Phase 6: Expand market rates and enhance AI investment recommendations

- Rate fetcher now scrapes CD, Money Market, and High Yield Savings rates
  from Bankrate.com with pauses between fetches to avoid rate limiting
- Historical rate data is preserved (no longer deleted on each fetch)
- Database migration adds rate_type column and tenant ai_recommendations table
- Backend returns market rates grouped by type with latest-batch-only queries
- AI prompt now includes all three rate types for comprehensive analysis
- AI recommendations are saved per-tenant for retrieval on page load
- Frontend: "Market CD Rates" replaced with "Today's Market Rates" tabbed view
- Rates section is collapsible (expanded by default) to save screen space
- Saved recommendations load automatically with "Last Updated" timestamp

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 13:39:19 -05:00
parent d9bb9363dd
commit 2fed5d6ce1
7 changed files with 686 additions and 317 deletions

View File

@@ -316,6 +316,17 @@ export class TenantSchemaService {
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
)`, )`,
// AI Investment Recommendations (saved per tenant)
`CREATE TABLE "${s}".ai_recommendations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recommendations_json JSONB NOT NULL,
overall_assessment TEXT,
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Attachments (file storage for receipts/invoices) // Attachments (file storage for receipts/invoices)
`CREATE TABLE "${s}".attachments ( `CREATE TABLE "${s}".attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

View File

@@ -17,11 +17,23 @@ export class InvestmentPlanningController {
} }
@Get('cd-rates') @Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data' }) @ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
getCdRates() { getCdRates() {
return this.service.getCdRates(); return this.service.getCdRates();
} }
@Get('market-rates')
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
getMarketRates() {
return this.service.getMarketRates();
}
@Get('saved-recommendation')
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
getSavedRecommendation() {
return this.service.getSavedRecommendation();
}
@Post('recommendations') @Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' }) @ApiOperation({ summary: 'Get AI-powered investment recommendations' })
getRecommendations(@Req() req: any) { getRecommendations(@Req() req: any) {

View File

@@ -28,12 +28,13 @@ export interface InvestmentAccount {
current_value: string; current_value: string;
} }
export interface CdRate { export interface MarketRate {
bank_name: string; bank_name: string;
apy: string; apy: string;
min_deposit: string | null; min_deposit: string | null;
term: string; term: string;
term_months: number | null; term_months: number | null;
rate_type: string;
fetched_at: string; fetched_at: string;
} }
@@ -57,6 +58,15 @@ export interface AIResponse {
risk_notes: string[]; risk_notes: string[];
} }
export interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: string;
}
@Injectable() @Injectable()
export class InvestmentPlanningService { export class InvestmentPlanningService {
private readonly logger = new Logger(InvestmentPlanningService.name); private readonly logger = new Logger(InvestmentPlanningService.name);
@@ -139,40 +149,124 @@ export class InvestmentPlanningService {
} }
/** /**
* Fetch latest CD rates from the shared schema (cross-tenant market data). * Fetch latest market rates from the shared schema (cross-tenant market data).
* Uses DataSource directly since this queries the shared schema, not tenant. * Returns rates grouped by type, each showing only the most recent fetch batch.
*/ */
async getCdRates(): Promise<CdRate[]> { async getMarketRates(): Promise<{ cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] }> {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
try { try {
await queryRunner.connect(); await queryRunner.connect();
const rates = await queryRunner.query(
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at // For each rate type, get the latest batch (same fetched_at timestamp)
const fetchLatest = async (rateType: string): Promise<MarketRate[]> => {
return queryRunner.query(
`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
FROM shared.cd_rates FROM shared.cd_rates
WHERE rate_type = $1
AND fetched_at = (
SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type = $1
)
ORDER BY apy DESC ORDER BY apy DESC
LIMIT 25`, LIMIT 25`,
[rateType],
); );
return rates; };
const [cd, moneyMarket, highYieldSavings] = await Promise.all([
fetchLatest('cd'),
fetchLatest('money_market'),
fetchLatest('high_yield_savings'),
]);
return {
cd,
money_market: moneyMarket,
high_yield_savings: highYieldSavings,
};
} finally { } finally {
await queryRunner.release(); await queryRunner.release();
} }
} }
/**
* Backward-compatible: get only CD rates.
*/
async getCdRates(): Promise<MarketRate[]> {
const rates = await this.getMarketRates();
return rates.cd;
}
/**
* Get the latest saved AI recommendation for this tenant.
*/
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
try {
const rows = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
FROM ai_recommendations
ORDER BY created_at DESC
LIMIT 1`,
);
if (!rows || rows.length === 0) return null;
const row = rows[0];
const recData = row.recommendations_json || {};
return {
id: row.id,
recommendations: recData.recommendations || [],
overall_assessment: row.overall_assessment || recData.overall_assessment || '',
risk_notes: row.risk_notes || recData.risk_notes || [],
response_time_ms: row.response_time_ms || 0,
created_at: row.created_at,
};
} catch (err: any) {
// Table might not exist yet (pre-migration tenants)
this.logger.warn(`Could not load saved recommendations: ${err.message}`);
return null;
}
}
/**
* Save AI recommendation result to tenant schema.
*/
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
try {
await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
VALUES ($1, $2, $3, $4, $5)`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
userId || null,
elapsed,
],
);
} catch (err: any) {
// Non-critical — don't let storage failure break recommendations
this.logger.warn(`Could not save recommendation: ${err.message}`);
}
}
/** /**
* Orchestrate the AI recommendation flow: * Orchestrate the AI recommendation flow:
* 1. Gather all financial data (tenant-scoped) * 1. Gather all financial data (tenant-scoped)
* 2. Fetch CD rates (shared schema) * 2. Fetch all market rates (shared schema)
* 3. Build the prompt with all context * 3. Build the prompt with all context
* 4. Call the AI API * 4. Call the AI API
* 5. Parse and return structured recommendations * 5. Parse and return structured recommendations
* 6. Save to tenant storage for future retrieval
*/ */
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> { async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
this.debug('getAIRecommendations', 'Starting AI recommendation flow'); this.debug('getAIRecommendations', 'Starting AI recommendation flow');
const startTime = Date.now(); const startTime = Date.now();
const [snapshot, cdRates, monthlyForecast] = await Promise.all([ const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(), this.getFinancialSnapshot(),
this.getCdRates(), this.getMarketRates(),
this.getMonthlyForecast(), this.getMonthlyForecast(),
]); ]);
@@ -183,11 +277,13 @@ export class InvestmentPlanningService {
investment_accounts: snapshot.investment_accounts.length, investment_accounts: snapshot.investment_accounts.length,
budgets: snapshot.budgets.length, budgets: snapshot.budgets.length,
projects: snapshot.projects.length, projects: snapshot.projects.length,
cd_rates: cdRates.length, cd_rates: allRates.cd.length,
money_market_rates: allRates.money_market.length,
savings_rates: allRates.high_yield_savings.length,
forecast_months: monthlyForecast.datapoints.length, forecast_months: monthlyForecast.datapoints.length,
}); });
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast); const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages); const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
@@ -197,6 +293,9 @@ export class InvestmentPlanningService {
risk_notes_count: aiResponse.risk_notes?.length || 0, risk_notes_count: aiResponse.risk_notes?.length || 0,
}); });
// Save recommendation to tenant storage (fire-and-forget)
this.saveRecommendation(aiResponse, userId, elapsed).catch(() => {});
// Log AI usage to shared.ai_recommendation_log (fire-and-forget) // Log AI usage to shared.ai_recommendation_log (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {}); this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
@@ -345,9 +444,6 @@ export class InvestmentPlanningService {
/** /**
* Build a 12-month forward cash flow forecast for the AI. * Build a 12-month forward cash flow forecast for the AI.
* Mirrors the logic from ReportsService.getCashFlowForecast() but streamlined
* for AI context. Includes: assessment income schedule (regular + special),
* monthly budget income/expenses, investment maturities, and capital project costs.
*/ */
private async getMonthlyForecast() { private async getMonthlyForecast() {
const now = new Date(); const now = new Date();
@@ -551,7 +647,11 @@ export class InvestmentPlanningService {
// ── Private: AI Prompt Construction ── // ── Private: AI Prompt Construction ──
private buildPromptMessages(snapshot: any, cdRates: CdRate[], monthlyForecast: any) { private buildPromptMessages(
snapshot: any,
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
monthlyForecast: any,
) {
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot; const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
@@ -564,8 +664,10 @@ CRITICAL RULES:
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access. 4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
5. Operating funds should remain highly liquid (money market or high-yield savings only). 5. Operating funds should remain highly liquid (money market or high-yield savings only).
6. Respect the separation between operating funds and reserve funds. Never suggest commingling. 6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
7. Base your recommendations ONLY on the available CD rates and instruments provided. Do not reference rates or banks not in the provided data. 7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) provided. Do not reference rates or banks not in the provided data.
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due. 8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
RESPONSE FORMAT: RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema: Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
@@ -581,7 +683,7 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
"suggested_amount": 50000.00, "suggested_amount": 50000.00,
"suggested_term": "12 months", "suggested_term": "12 months",
"suggested_rate": 4.50, "suggested_rate": 4.50,
"bank_name": "Bank name from CD rates (if applicable)", "bank_name": "Bank name from market rates (if applicable)",
"rationale": "Financial reasoning for why this makes sense" "rationale": "Financial reasoning for why this makes sense"
} }
], ],
@@ -589,7 +691,7 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s
"risk_notes": ["Array of risk items or concerns to flag for the board"] "risk_notes": ["Array of risk items or concerns to flag for the board"]
} }
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`; IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
// Build the data context for the user prompt // Build the data context for the user prompt
const investmentsList = investment_accounts.length === 0 const investmentsList = investment_accounts.length === 0
@@ -616,11 +718,18 @@ IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority item
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`, `- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
).join('\n'); ).join('\n');
const cdRateLines = cdRates.length === 0 // Format market rates by type
? 'No CD rate data available. Rate fetcher may not have been run yet.' const formatRates = (rates: MarketRate[], typeLabel: string): string => {
: cdRates.map((r: CdRate) => if (rates.length === 0) return `No ${typeLabel} rate data available. Rate fetcher may not have been run yet.`;
`- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`, return rates.map((r: MarketRate) => {
).join('\n'); const termStr = r.term !== 'N/A' ? ` | Term: ${r.term}` : '';
return `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${termStr} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`;
}).join('\n');
};
const cdRateLines = formatRates(allRates.cd, 'CD');
const moneyMarketLines = formatRates(allRates.money_market, 'Money Market');
const savingsRateLines = formatRates(allRates.high_yield_savings, 'High Yield Savings');
// Format assessment schedule showing regular + special // Format assessment schedule showing regular + special
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0 const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
@@ -679,15 +788,25 @@ ${projectLines}
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities. This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
${forecastLines} ${forecastLines}
=== AVAILABLE CD RATES (Market Data) === === AVAILABLE MARKET RATES ===
--- CD Rates ---
${cdRateLines} ${cdRateLines}
--- Money Market Rates ---
${moneyMarketLines}
--- High Yield Savings Rates ---
${savingsRateLines}
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider: Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
1. Is there excess cash that could earn better returns in CDs? 1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
2. Are any current investments maturing soon that need reinvestment planning? 2. Are any current investments maturing soon that need reinvestment planning?
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low. 3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
4. Would a CD ladder strategy improve the yield while maintaining access to funds? 4. Would a CD ladder strategy improve the yield while maintaining access to funds?
5. Are operating and reserve funds properly separated in the investment strategy?`; 5. Are operating and reserve funds properly separated in the investment strategy?
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
return [ return [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },

View File

@@ -77,7 +77,8 @@ CREATE TABLE shared.invitations (
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
-- CD Rates (cross-tenant market data for investment recommendations) -- Market Rates (cross-tenant market data for investment recommendations)
-- Supports CD, Money Market, and High Yield Savings rate types
CREATE TABLE shared.cd_rates ( CREATE TABLE shared.cd_rates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bank_name VARCHAR(255) NOT NULL, bank_name VARCHAR(255) NOT NULL,
@@ -85,6 +86,7 @@ CREATE TABLE shared.cd_rates (
min_deposit DECIMAL(15,2), min_deposit DECIMAL(15,2),
term VARCHAR(100) NOT NULL, term VARCHAR(100) NOT NULL,
term_months INTEGER, term_months INTEGER,
rate_type VARCHAR(50) NOT NULL DEFAULT 'cd',
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url VARCHAR(500), source_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
@@ -121,6 +123,8 @@ CREATE INDEX idx_invitations_token ON shared.invitations(token);
CREATE INDEX idx_invitations_email ON shared.invitations(email); CREATE INDEX idx_invitations_email ON shared.invitations(email);
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC); CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC); CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
CREATE INDEX idx_cd_rates_type ON shared.cd_rates(rate_type);
CREATE INDEX idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC); CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
CREATE INDEX idx_login_history_user ON shared.login_history(user_id); CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC); CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);

View File

@@ -0,0 +1,36 @@
-- Migration: Expand cd_rates for multiple market rate types + tenant AI recommendation storage
-- Phase 6: AI Features Part 2
-- 1) Add rate_type column to shared.cd_rates to support CD, Money Market, and High Yield Savings
ALTER TABLE shared.cd_rates
ADD COLUMN IF NOT EXISTS rate_type VARCHAR(50) DEFAULT 'cd' NOT NULL;
-- Index for filtering by rate type
CREATE INDEX IF NOT EXISTS idx_cd_rates_type ON shared.cd_rates(rate_type);
-- Composite index for getting latest rates by type efficiently
CREATE INDEX IF NOT EXISTS idx_cd_rates_type_fetched ON shared.cd_rates(rate_type, fetched_at DESC);
-- 2) Create ai_recommendations table in each existing tenant schema
-- This stores saved AI investment recommendations per tenant
-- For new tenants, this is handled by tenant-schema.service.ts
DO $$
DECLARE
tenant_schema TEXT;
BEGIN
FOR tenant_schema IN
SELECT schema_name FROM shared.organizations WHERE schema_name IS NOT NULL
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I.ai_recommendations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
recommendations_json JSONB NOT NULL,
overall_assessment TEXT,
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
)', tenant_schema
);
END LOOP;
END $$;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Title, Title,
Text, Text,
@@ -16,6 +16,9 @@ import {
Divider, Divider,
Accordion, Accordion,
Paper, Paper,
Tabs,
Collapse,
ActionIcon,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconBulb, IconBulb,
@@ -27,6 +30,8 @@ import {
IconRefresh, IconRefresh,
IconCoin, IconCoin,
IconPigMoney, IconPigMoney,
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
@@ -59,15 +64,22 @@ interface FinancialSnapshot {
}>; }>;
} }
interface CdRate { interface MarketRate {
bank_name: string; bank_name: string;
apy: string; apy: string;
min_deposit: string | null; min_deposit: string | null;
term: string; term: string;
term_months: number | null; term_months: number | null;
rate_type: string;
fetched_at: string; fetched_at: string;
} }
interface MarketRatesResponse {
cd: MarketRate[];
money_market: MarketRate[];
high_yield_savings: MarketRate[];
}
interface Recommendation { interface Recommendation {
type: string; type: string;
priority: 'high' | 'medium' | 'low'; priority: 'high' | 'medium' | 'low';
@@ -88,6 +100,15 @@ interface AIResponse {
risk_notes: string[]; risk_notes: string[];
} }
interface SavedRecommendation {
id: string;
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
response_time_ms: number;
created_at: string;
}
// ── Helpers ── // ── Helpers ──
const fmt = (v: number) => const fmt = (v: number) =>
@@ -117,10 +138,198 @@ const typeLabels: Record<string, string> = {
general: 'General', general: 'General',
}; };
// ── Component ── // ── Rate Table Component ──
function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean }) {
if (rates.length === 0) {
return (
<Text ta="center" c="dimmed" py="lg">
No rates available. Run the market rate fetcher to populate data.
</Text>
);
}
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Bank</Table.Th>
<Table.Th ta="right">APY</Table.Th>
{showTerm && <Table.Th>Term</Table.Th>}
<Table.Th ta="right">Min Deposit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rates.map((r, i) => (
<Table.Tr key={i}>
<Table.Td fw={500}>{r.bank_name}</Table.Td>
<Table.Td ta="right" fw={700} c="green">
{parseFloat(r.apy).toFixed(2)}%
</Table.Td>
{showTerm && <Table.Td>{r.term}</Table.Td>}
<Table.Td ta="right" ff="monospace">
{r.min_deposit
? `$${parseFloat(r.min_deposit).toLocaleString()}`
: '-'}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
// ── Recommendations Display Component ──
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
return (
<Stack>
{/* Last Updated timestamp */}
{lastUpdated && (
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
)}
{/* Overall Assessment */}
<Alert color="blue" variant="light" title="Overall Assessment">
<Text size="sm">{aiResult.overall_assessment}</Text>
</Alert>
{/* Risk Notes */}
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
<Alert
color="yellow"
variant="light"
title="Risk Notes"
icon={<IconAlertTriangle />}
>
<Stack gap={4}>
{aiResult.risk_notes.map((note, i) => (
<Text key={i} size="sm">
{note}
</Text>
))}
</Stack>
</Alert>
)}
{/* Recommendation Cards */}
{aiResult.recommendations.length > 0 ? (
<Accordion variant="separated">
{aiResult.recommendations.map((rec, i) => {
const Icon = typeIcons[rec.type] || IconBulb;
return (
<Accordion.Item key={i} value={`rec-${i}`}>
<Accordion.Control>
<Group>
<ThemeIcon
variant="light"
color={priorityColors[rec.priority] || 'gray'}
size="md"
>
<Icon size={16} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={600}>{rec.title}</Text>
<Badge
size="xs"
color={priorityColors[rec.priority]}
>
{rec.priority}
</Badge>
<Badge size="xs" variant="light">
{typeLabels[rec.type] || rec.type}
</Badge>
<Badge
size="xs"
variant="dot"
color={
rec.fund_type === 'reserve'
? 'violet'
: rec.fund_type === 'operating'
? 'blue'
: 'gray'
}
>
{rec.fund_type}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={2}>
{rec.summary}
</Text>
</div>
{rec.suggested_amount != null && (
<Text fw={700} ff="monospace" c="green" size="lg">
{fmt(rec.suggested_amount)}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm">{rec.details}</Text>
{(rec.suggested_term ||
rec.suggested_rate != null ||
rec.bank_name) && (
<Paper withBorder p="sm" radius="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{rec.suggested_term && (
<div>
<Text size="xs" c="dimmed">
Suggested Term
</Text>
<Text fw={600}>{rec.suggested_term}</Text>
</div>
)}
{rec.suggested_rate != null && (
<div>
<Text size="xs" c="dimmed">
Target Rate
</Text>
<Text fw={600}>
{rec.suggested_rate}% APY
</Text>
</div>
)}
{rec.bank_name && (
<div>
<Text size="xs" c="dimmed">
Bank
</Text>
<Text fw={600}>{rec.bank_name}</Text>
</div>
)}
</SimpleGrid>
</Paper>
)}
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</Alert>
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text ta="center" c="dimmed" py="lg">
No specific recommendations at this time.
</Text>
)}
</Stack>
);
}
// ── Main Component ──
export function InvestmentPlanningPage() { export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null); const [aiResult, setAiResult] = useState<AIResponse | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [ratesExpanded, setRatesExpanded] = useState(true);
// Load financial snapshot on mount // Load financial snapshot on mount
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({ const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
@@ -131,15 +340,36 @@ export function InvestmentPlanningPage() {
}, },
}); });
// Load CD rates on mount // Load market rates (all types) on mount
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({ const { data: marketRates, isLoading: ratesLoading } = useQuery<MarketRatesResponse>({
queryKey: ['investment-planning-cd-rates'], queryKey: ['investment-planning-market-rates'],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get('/investment-planning/cd-rates'); const { data } = await api.get('/investment-planning/market-rates');
return data; return data;
}, },
}); });
// Load saved recommendation on mount
const { data: savedRec } = useQuery<SavedRecommendation | null>({
queryKey: ['investment-planning-saved-recommendation'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
},
});
// Populate AI results from saved recommendation on load
useEffect(() => {
if (savedRec && !aiResult) {
setAiResult({
recommendations: savedRec.recommendations,
overall_assessment: savedRec.overall_assessment,
risk_notes: savedRec.risk_notes,
});
setLastUpdated(savedRec.created_at);
}
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
// AI recommendation (on-demand) // AI recommendation (on-demand)
const aiMutation = useMutation({ const aiMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -148,6 +378,7 @@ export function InvestmentPlanningPage() {
}, },
onSuccess: (data) => { onSuccess: (data) => {
setAiResult(data); setAiResult(data);
setLastUpdated(new Date().toISOString());
if (data.recommendations.length > 0) { if (data.recommendations.length > 0) {
notifications.show({ notifications.show({
message: `Generated ${data.recommendations.length} investment recommendations`, message: `Generated ${data.recommendations.length} investment recommendations`,
@@ -173,6 +404,23 @@ export function InvestmentPlanningPage() {
const s = snapshot?.summary; const s = snapshot?.summary;
// Determine the latest fetched_at timestamp across all rate types
const allRatesList = [
...(marketRates?.cd || []),
...(marketRates?.money_market || []),
...(marketRates?.high_yield_savings || []),
];
const latestFetchedAt = allRatesList.length > 0
? allRatesList.reduce((latest, r) =>
new Date(r.fetched_at) > new Date(latest.fetched_at) ? r : latest,
).fetched_at
: null;
const totalRateCount =
(marketRates?.cd?.length || 0) +
(marketRates?.money_market?.length || 0) +
(marketRates?.high_yield_savings?.length || 0);
return ( return (
<Stack> <Stack>
{/* Page Header */} {/* Page Header */}
@@ -312,57 +560,71 @@ export function InvestmentPlanningPage() {
</Card> </Card>
)} )}
{/* ── Section 3: Market CD Rates ── */} {/* ── Section 3: Today's Market Rates (Collapsible with Tabs) ── */}
<Card withBorder p="lg"> <Card withBorder p="lg">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb={ratesExpanded ? 'md' : 0}>
<Title order={4}>Market CD Rates</Title> <Group gap="xs">
{cdRates.length > 0 && ( <Title order={4}>Today&apos;s Market Rates</Title>
<Text size="xs" c="dimmed"> {totalRateCount > 0 && (
Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()} <Badge size="sm" variant="light" color="gray">
</Text> {totalRateCount} rates
</Badge>
)} )}
</Group> </Group>
<Group gap="xs">
{latestFetchedAt && (
<Text size="xs" c="dimmed">
Last fetched: {new Date(latestFetchedAt).toLocaleString()}
</Text>
)}
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setRatesExpanded((v) => !v)}
title={ratesExpanded ? 'Collapse rates' : 'Expand rates'}
>
{ratesExpanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
</ActionIcon>
</Group>
</Group>
<Collapse in={ratesExpanded}>
{ratesLoading ? ( {ratesLoading ? (
<Center py="lg"> <Center py="lg">
<Loader /> <Loader />
</Center> </Center>
) : ( ) : (
<Table striped highlightOnHover> <Tabs defaultValue="cd">
<Table.Thead> <Tabs.List>
<Table.Tr> <Tabs.Tab value="cd">
<Table.Th>Bank</Table.Th> CDs {(marketRates?.cd?.length || 0) > 0 && (
<Table.Th ta="right">APY</Table.Th> <Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
<Table.Th>Term</Table.Th>
<Table.Th ta="right">Min Deposit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{cdRates.map((r, i) => (
<Table.Tr key={i}>
<Table.Td fw={500}>{r.bank_name}</Table.Td>
<Table.Td ta="right" fw={700} c="green">
{parseFloat(r.apy).toFixed(2)}%
</Table.Td>
<Table.Td>{r.term}</Table.Td>
<Table.Td ta="right" ff="monospace">
{r.min_deposit
? `$${parseFloat(r.min_deposit).toLocaleString()}`
: '-'}
</Table.Td>
</Table.Tr>
))}
{cdRates.length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text ta="center" c="dimmed" py="lg">
No CD rates available. Run the fetch-cd-rates script to populate market data.
</Text>
</Table.Td>
</Table.Tr>
)} )}
</Table.Tbody> </Tabs.Tab>
</Table> <Tabs.Tab value="money_market">
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
)} )}
</Tabs.Tab>
<Tabs.Tab value="high_yield_savings">
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
)}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="cd" pt="sm">
<RateTable rates={marketRates?.cd || []} showTerm={true} />
</Tabs.Panel>
<Tabs.Panel value="money_market" pt="sm">
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
</Tabs.Panel>
<Tabs.Panel value="high_yield_savings" pt="sm">
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
</Tabs.Panel>
</Tabs>
)}
</Collapse>
</Card> </Card>
<Divider /> <Divider />
@@ -409,137 +671,7 @@ export function InvestmentPlanningPage() {
{/* Results */} {/* Results */}
{aiResult && !aiMutation.isPending && ( {aiResult && !aiMutation.isPending && (
<Stack> <RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
{/* Overall Assessment */}
<Alert color="blue" variant="light" title="Overall Assessment">
<Text size="sm">{aiResult.overall_assessment}</Text>
</Alert>
{/* Risk Notes */}
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
<Alert
color="yellow"
variant="light"
title="Risk Notes"
icon={<IconAlertTriangle />}
>
<Stack gap={4}>
{aiResult.risk_notes.map((note, i) => (
<Text key={i} size="sm">
{note}
</Text>
))}
</Stack>
</Alert>
)}
{/* Recommendation Cards */}
{aiResult.recommendations.length > 0 ? (
<Accordion variant="separated">
{aiResult.recommendations.map((rec, i) => {
const Icon = typeIcons[rec.type] || IconBulb;
return (
<Accordion.Item key={i} value={`rec-${i}`}>
<Accordion.Control>
<Group>
<ThemeIcon
variant="light"
color={priorityColors[rec.priority] || 'gray'}
size="md"
>
<Icon size={16} />
</ThemeIcon>
<div style={{ flex: 1 }}>
<Group gap="xs">
<Text fw={600}>{rec.title}</Text>
<Badge
size="xs"
color={priorityColors[rec.priority]}
>
{rec.priority}
</Badge>
<Badge size="xs" variant="light">
{typeLabels[rec.type] || rec.type}
</Badge>
<Badge
size="xs"
variant="dot"
color={
rec.fund_type === 'reserve'
? 'violet'
: rec.fund_type === 'operating'
? 'blue'
: 'gray'
}
>
{rec.fund_type}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt={2}>
{rec.summary}
</Text>
</div>
{rec.suggested_amount != null && (
<Text fw={700} ff="monospace" c="green" size="lg">
{fmt(rec.suggested_amount)}
</Text>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm">{rec.details}</Text>
{(rec.suggested_term ||
rec.suggested_rate != null ||
rec.bank_name) && (
<Paper withBorder p="sm" radius="sm">
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{rec.suggested_term && (
<div>
<Text size="xs" c="dimmed">
Suggested Term
</Text>
<Text fw={600}>{rec.suggested_term}</Text>
</div>
)}
{rec.suggested_rate != null && (
<div>
<Text size="xs" c="dimmed">
Target Rate
</Text>
<Text fw={600}>
{rec.suggested_rate}% APY
</Text>
</div>
)}
{rec.bank_name && (
<div>
<Text size="xs" c="dimmed">
Bank
</Text>
<Text fw={600}>{rec.bank_name}</Text>
</div>
)}
</SimpleGrid>
</Paper>
)}
<Alert variant="light" color="gray" title="Rationale">
<Text size="sm">{rec.rationale}</Text>
</Alert>
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : (
<Text ta="center" c="dimmed" py="lg">
No specific recommendations at this time.
</Text>
)}
</Stack>
)} )}
{/* Empty State */} {/* Empty State */}
@@ -552,7 +684,7 @@ export function InvestmentPlanningPage() {
AI-Powered Investment Analysis AI-Powered Investment Analysis
</Text> </Text>
<Text c="dimmed" size="sm" maw={500} mx="auto"> <Text c="dimmed" size="sm" maw={500} mx="auto">
Click "Get AI Recommendations" to analyze your accounts, cash flow, Click &quot;Get AI Recommendations&quot; to analyze your accounts, cash flow,
budget, and capital projects against current market rates. The AI will budget, and capital projects against current market rates. The AI will
suggest specific investment moves to maximize interest income while suggest specific investment moves to maximize interest income while
maintaining adequate liquidity. maintaining adequate liquidity.

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env tsx #!/usr/bin/env tsx
/** /**
* CD Rate Fetcher Script * Market Rate Fetcher Script
* *
* Scrapes the top CD rates from Bankrate.com and stores them in the * Scrapes the top CD, Money Market, and High Yield Savings rates from
* shared.cd_rates table in PostgreSQL. Designed to run standalone via cron. * Bankrate.com and stores them in the shared.cd_rates table in PostgreSQL.
* Designed to run standalone via cron (once per day).
* *
* Bankrate renders rate data dynamically via JavaScript, so this script * Historical data is preserved — each fetch adds new rows with the current
* uses Puppeteer (headless Chrome) to fully render the page before scraping. * timestamp. The application queries only the latest batch per rate type.
* *
* Usage: * Usage:
* cd scripts * cd scripts
@@ -20,20 +21,39 @@
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
import { resolve } from 'path'; import { resolve } from 'path';
import { Pool } from 'pg'; import { Pool } from 'pg';
import puppeteer, { type Browser } from 'puppeteer'; import puppeteer, { type Browser, type Page } from 'puppeteer';
// Load .env from project root // Load .env from project root
dotenv.config({ path: resolve(__dirname, '..', '.env') }); dotenv.config({ path: resolve(__dirname, '..', '.env') });
const BANKRATE_URL = 'https://www.bankrate.com/banking/cds/cd-rates/';
const MAX_RATES = 25; const MAX_RATES = 25;
interface CdRate { // Rate source configurations
const RATE_SOURCES = [
{
type: 'cd',
label: 'CD Rates',
url: 'https://www.bankrate.com/banking/cds/cd-rates/',
},
{
type: 'high_yield_savings',
label: 'High Yield Savings',
url: 'https://www.bankrate.com/banking/savings/best-high-yield-interests-savings-accounts/',
},
{
type: 'money_market',
label: 'Money Market',
url: 'https://www.bankrate.com/banking/money-market/rates/',
},
];
interface MarketRate {
bank_name: string; bank_name: string;
apy: number; apy: number;
min_deposit: number | null; min_deposit: number | null;
term: string; term: string;
term_months: number | null; term_months: number | null;
rate_type: string;
} }
/** /**
@@ -71,35 +91,36 @@ function parseApy(raw: string): number {
} }
/** /**
* Launch headless Chrome, navigate to Bankrate, and scrape CD rate data. * Pause execution for a given number of milliseconds.
*/ */
async function fetchRates(): Promise<CdRate[]> { function sleep(ms: number): Promise<void> {
let browser: Browser | null = null; return new Promise((resolve) => setTimeout(resolve, ms));
}
try { /**
console.log('Launching headless browser...'); * Navigate to a Bankrate URL and scrape rate data.
browser = await puppeteer.launch({ * Reuses an existing browser instance.
headless: true, */
args: [ async function fetchRatesFromPage(
'--no-sandbox', browser: Browser,
'--disable-setuid-sandbox', sourceUrl: string,
'--disable-dev-shm-usage', rateType: string,
], label: string,
}); ): Promise<MarketRate[]> {
const page: Page = await browser.newPage();
const page = await browser.newPage();
await page.setUserAgent( await page.setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
); );
console.log(`Navigating to ${BANKRATE_URL}...`); try {
await page.goto(BANKRATE_URL, { console.log(`\n--- Fetching ${label} ---`);
console.log(`Navigating to ${sourceUrl}...`);
await page.goto(sourceUrl, {
waitUntil: 'networkidle2', waitUntil: 'networkidle2',
timeout: 60000, timeout: 60000,
}); });
// Wait for rate content to render // Wait for rate content to render
// Bankrate uses various table/card patterns; we'll try multiple selectors
console.log('Waiting for rate data to render...'); console.log('Waiting for rate data to render...');
await page.waitForSelector( await page.waitForSelector(
'table, [data-testid*="rate"], .brc-table, [class*="ComparisonTable"], [class*="rate-table"]', 'table, [data-testid*="rate"], .brc-table, [class*="ComparisonTable"], [class*="rate-table"]',
@@ -109,9 +130,9 @@ async function fetchRates(): Promise<CdRate[]> {
}); });
// Extra wait for dynamic content // Extra wait for dynamic content
await new Promise((resolve) => setTimeout(resolve, 3000)); await sleep(3000);
// Scroll down to load all content (rate tables may be below the fold) // Scroll down to load all content
console.log('Scrolling to load all content...'); console.log('Scrolling to load all content...');
await page.evaluate(async () => { await page.evaluate(async () => {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@@ -120,10 +141,10 @@ async function fetchRates(): Promise<CdRate[]> {
} }
window.scrollTo(0, 0); window.scrollTo(0, 0);
}); });
await new Promise((resolve) => setTimeout(resolve, 2000)); await sleep(2000);
// Extract rate data from the page using multiple strategies // Extract rate data from the page
const rates = await page.evaluate((maxRates: number) => { const rawRates = await page.evaluate((maxRates: number) => {
const results: Array<{ const results: Array<{
bank_name: string; bank_name: string;
apy_raw: string; apy_raw: string;
@@ -131,12 +152,11 @@ async function fetchRates(): Promise<CdRate[]> {
term_raw: string; term_raw: string;
}> = []; }> = [];
// Strategy 1: Look for detailed bank comparison tables with named banks // Strategy 1: Look for detailed bank comparison tables
// These typically have 4+ columns: Bank, APY, Min Deposit, Term
const tables = document.querySelectorAll('table'); const tables = document.querySelectorAll('table');
for (const table of tables) { for (const table of tables) {
const rows = table.querySelectorAll('tbody tr'); const rows = table.querySelectorAll('tbody tr');
if (rows.length < 3) continue; // Skip small tables if (rows.length < 3) continue;
for (const row of rows) { for (const row of rows) {
const cells = row.querySelectorAll('td, th'); const cells = row.querySelectorAll('td, th');
@@ -146,7 +166,6 @@ async function fetchRates(): Promise<CdRate[]> {
const apyCell = texts.find((t) => /\d+\.\d+\s*%/.test(t)); const apyCell = texts.find((t) => /\d+\.\d+\s*%/.test(t));
if (!apyCell) continue; if (!apyCell) continue;
// Bank name: look for a cell with a real name (not just number/percent/dollar)
const bankCell = texts.find( const bankCell = texts.find(
(t) => (t) =>
t.length > 3 && t.length > 3 &&
@@ -156,7 +175,6 @@ async function fetchRates(): Promise<CdRate[]> {
!/^\d+\s*(month|year)/i.test(t), !/^\d+\s*(month|year)/i.test(t),
); );
// Also try to find the bank name from links or images in the row
const linkEl = row.querySelector('a[href*="review"], a[href*="bank"], img[alt]'); const linkEl = row.querySelector('a[href*="review"], a[href*="bank"], img[alt]');
const linkName = linkEl?.textContent?.trim() || (linkEl as HTMLImageElement)?.alt || ''; const linkName = linkEl?.textContent?.trim() || (linkEl as HTMLImageElement)?.alt || '';
@@ -173,10 +191,10 @@ async function fetchRates(): Promise<CdRate[]> {
if (results.length >= maxRates) break; if (results.length >= maxRates) break;
} }
if (results.length >= 5) break; // Found a good table if (results.length >= 5) break;
} }
// Strategy 2: Look for card/list layouts with bank names and rates // Strategy 2: Look for card/list layouts
if (results.length < 5) { if (results.length < 5) {
const cardSelectors = [ const cardSelectors = [
'[class*="product"]', '[class*="product"]',
@@ -199,14 +217,12 @@ async function fetchRates(): Promise<CdRate[]> {
const apyMatch = text.match(/([\d.]+)\s*%/); const apyMatch = text.match(/([\d.]+)\s*%/);
if (!apyMatch) continue; if (!apyMatch) continue;
// Try to find bank name from heading, link, or image alt text
const nameEl = const nameEl =
card.querySelector( card.querySelector(
'h2, h3, h4, h5, strong, [class*="name"], [class*="bank"], [class*="title"], a[href*="review"], img[alt]', 'h2, h3, h4, h5, strong, [class*="name"], [class*="bank"], [class*="title"], a[href*="review"], img[alt]',
); );
let bankName = nameEl?.textContent?.trim() || (nameEl as HTMLImageElement)?.alt || ''; let bankName = nameEl?.textContent?.trim() || (nameEl as HTMLImageElement)?.alt || '';
// Skip if the "name" is just a rate or term
if (!bankName || bankName.length < 3 || /^\d/.test(bankName) || bankName.includes('%')) continue; if (!bankName || bankName.length < 3 || /^\d/.test(bankName) || bankName.includes('%')) continue;
const depositMatch = text.match(/\$[\d,]+/); const depositMatch = text.match(/\$[\d,]+/);
@@ -261,16 +277,18 @@ async function fetchRates(): Promise<CdRate[]> {
return results; return results;
}, MAX_RATES); }, MAX_RATES);
console.log(`Raw extraction found ${rates.length} rate entries.`); console.log(`Raw extraction found ${rawRates.length} rate entries.`);
// Parse and normalize the scraped data // Parse and normalize
const parsed: CdRate[] = rates const isTermProduct = rateType === 'cd';
const parsed: MarketRate[] = rawRates
.map((r) => { .map((r) => {
let bankName = r.bank_name.replace(/\s+/g, ' ').trim(); let bankName = r.bank_name.replace(/\s+/g, ' ').trim();
const term = r.term_raw || 'N/A'; const term = isTermProduct ? (r.term_raw || 'N/A') : 'N/A';
// If the bank name looks like a term or deposit info, it's a // For CDs: if bank name looks like a term, label it descriptively
// summary card — label it more descriptively using the term if (isTermProduct) {
const termText = r.term_raw || bankName; const termText = r.term_raw || bankName;
if ( if (
/^\d+\s*(month|year)/i.test(bankName) || /^\d+\s*(month|year)/i.test(bankName) ||
@@ -280,19 +298,21 @@ async function fetchRates(): Promise<CdRate[]> {
) { ) {
bankName = `Top CD Rate - ${termText.replace(/^\d+/, (m: string) => m + ' ')}`.replace(/\s+/g, ' ').trim(); bankName = `Top CD Rate - ${termText.replace(/^\d+/, (m: string) => m + ' ')}`.replace(/\s+/g, ' ').trim();
} }
}
return { return {
bank_name: bankName, bank_name: bankName,
apy: parseApy(r.apy_raw), apy: parseApy(r.apy_raw),
min_deposit: parseMinDeposit(r.min_deposit_raw), min_deposit: parseMinDeposit(r.min_deposit_raw),
term, term,
term_months: parseTermMonths(r.term_raw || bankName), term_months: isTermProduct ? parseTermMonths(r.term_raw || bankName) : null,
rate_type: rateType,
}; };
}) })
.filter((r) => r.bank_name && r.apy > 0); .filter((r) => r.bank_name && r.apy > 0);
// Deduplicate by bank name + term (keep highest APY) // Deduplicate by bank name + term (keep highest APY)
const seen = new Map<string, CdRate>(); const seen = new Map<string, MarketRate>();
for (const rate of parsed) { for (const rate of parsed) {
const key = `${rate.bank_name}|${rate.term}`; const key = `${rate.bank_name}|${rate.term}`;
const existing = seen.get(key); const existing = seen.get(key);
@@ -305,16 +325,16 @@ async function fetchRates(): Promise<CdRate[]> {
.sort((a, b) => b.apy - a.apy) .sort((a, b) => b.apy - a.apy)
.slice(0, MAX_RATES); .slice(0, MAX_RATES);
} finally { } finally {
if (browser) { await page.close();
await browser.close();
}
} }
} }
/** /**
* Store scraped rates into shared.cd_rates, replacing all previous data. * Store scraped rates into shared.cd_rates.
* Historical data is preserved — we no longer delete previous rows.
* Each fetch batch shares a common fetched_at timestamp per rate_type.
*/ */
async function storeRates(rates: CdRate[]): Promise<void> { async function storeRates(rates: MarketRate[], sourceUrl: string): Promise<void> {
const connectionString = const connectionString =
process.env.DATABASE_URL || process.env.DATABASE_URL ||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance'; 'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
@@ -325,30 +345,28 @@ async function storeRates(rates: CdRate[]): Promise<void> {
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
// Clear previous batch (we only keep the latest fetch)
await client.query('DELETE FROM shared.cd_rates');
const now = new Date().toISOString(); const now = new Date().toISOString();
for (const rate of rates) { for (const rate of rates) {
await client.query( await client.query(
`INSERT INTO shared.cd_rates `INSERT INTO shared.cd_rates
(bank_name, apy, min_deposit, term, term_months, fetched_at, source_url) (bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at, source_url)
VALUES ($1, $2, $3, $4, $5, $6, $7)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[ [
rate.bank_name, rate.bank_name,
rate.apy, rate.apy,
rate.min_deposit, rate.min_deposit,
rate.term, rate.term,
rate.term_months, rate.term_months,
rate.rate_type,
now, now,
BANKRATE_URL, sourceUrl,
], ],
); );
} }
await client.query('COMMIT'); await client.query('COMMIT');
console.log(`Successfully stored ${rates.length} CD rates at ${now}`); console.log(` Stored ${rates.length} ${rates[0]?.rate_type || ''} rates at ${now}`);
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
throw err; throw err;
@@ -362,41 +380,78 @@ async function storeRates(rates: CdRate[]): Promise<void> {
* Main entry point. * Main entry point.
*/ */
async function main() { async function main() {
console.log('=== CD Rate Fetcher ==='); console.log('=== Market Rate Fetcher ===');
console.log(`Fetching top CD rates from Bankrate.com...`); console.log(`Fetching rates from Bankrate.com...`);
console.log(`Time: ${new Date().toISOString()}`); console.log(`Time: ${new Date().toISOString()}`);
console.log(''); console.log(`Rate types: ${RATE_SOURCES.map((s) => s.label).join(', ')}`);
let browser: Browser | null = null;
try { try {
const rates = await fetchRates(); console.log('\nLaunching headless browser...');
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
],
});
let totalStored = 0;
for (let i = 0; i < RATE_SOURCES.length; i++) {
const source = RATE_SOURCES[i];
// Pause between fetches to avoid rate limiting (skip for first)
if (i > 0) {
const pauseSeconds = 8 + Math.floor(Math.random() * 5); // 8-12 seconds
console.log(`\nPausing ${pauseSeconds} seconds before next fetch...`);
await sleep(pauseSeconds * 1000);
}
try {
const rates = await fetchRatesFromPage(browser, source.url, source.type, source.label);
if (rates.length === 0) { if (rates.length === 0) {
console.warn(''); console.warn(`\nWARNING: No ${source.label} rates were extracted.`);
console.warn('WARNING: No CD rates were extracted from Bankrate.'); console.warn('This may mean Bankrate changed their page structure.');
console.warn( continue; // Don't abort the whole run — try other rate types
'This likely means Bankrate changed their page structure.', }
);
console.warn( console.log(`\nExtracted ${rates.length} ${source.label}:`);
'Review the page DOM and update selectors in fetch-cd-rates.ts.', console.log('\u2500'.repeat(80));
for (const r of rates) {
const termStr = r.term !== 'N/A' ? r.term.padEnd(15) : ''.padEnd(15);
console.log(
` ${r.bank_name.padEnd(35)} ${String(r.apy + '%').padEnd(8)} ${termStr} ${r.min_deposit != null ? '$' + r.min_deposit.toLocaleString() : 'N/A'}`,
); );
}
console.log('\u2500'.repeat(80));
console.log(`\nStoring ${source.label} to database...`);
await storeRates(rates, source.url);
totalStored += rates.length;
} catch (err: any) {
console.error(`\nERROR fetching ${source.label}: ${err.message}`);
// Continue to next rate type
}
}
if (totalStored === 0) {
console.warn('\nWARNING: No rates were stored for any type.');
console.warn('Review Bankrate page structure and update selectors.');
process.exit(1); process.exit(1);
} }
console.log(`\nExtracted ${rates.length} rates:`); console.log(`\nDone. Total rates stored: ${totalStored}`);
console.log('─'.repeat(70));
for (const r of rates) {
console.log(
` ${r.bank_name.padEnd(30)} ${String(r.apy + '%').padEnd(8)} ${r.term.padEnd(15)} ${r.min_deposit != null ? '$' + r.min_deposit.toLocaleString() : 'N/A'}`,
);
}
console.log('─'.repeat(70));
console.log('\nStoring to database...');
await storeRates(rates);
console.log('Done.');
} catch (err) { } catch (err) {
console.error('\nFATAL ERROR:', err); console.error('\nFATAL ERROR:', err);
process.exit(1); process.exit(1);
} finally {
if (browser) {
await browser.close();
}
} }
} }