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:
@@ -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(),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
FROM shared.cd_rates
|
const fetchLatest = async (rateType: string): Promise<MarketRate[]> => {
|
||||||
ORDER BY apy DESC
|
return queryRunner.query(
|
||||||
LIMIT 25`,
|
`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
|
||||||
);
|
FROM shared.cd_rates
|
||||||
return rates;
|
WHERE rate_type = $1
|
||||||
|
AND fetched_at = (
|
||||||
|
SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type = $1
|
||||||
|
)
|
||||||
|
ORDER BY apy DESC
|
||||||
|
LIMIT 25`,
|
||||||
|
[rateType],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
36
db/migrations/007-market-rates.sql
Normal file
36
db/migrations/007-market-rates.sql
Normal 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 $$;
|
||||||
@@ -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'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 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>
|
</Group>
|
||||||
{ratesLoading ? (
|
|
||||||
<Center py="lg">
|
<Collapse in={ratesExpanded}>
|
||||||
<Loader />
|
{ratesLoading ? (
|
||||||
</Center>
|
<Center py="lg">
|
||||||
) : (
|
<Loader />
|
||||||
<Table striped highlightOnHover>
|
</Center>
|
||||||
<Table.Thead>
|
) : (
|
||||||
<Table.Tr>
|
<Tabs defaultValue="cd">
|
||||||
<Table.Th>Bank</Table.Th>
|
<Tabs.List>
|
||||||
<Table.Th ta="right">APY</Table.Th>
|
<Tabs.Tab value="cd">
|
||||||
<Table.Th>Term</Table.Th>
|
CDs {(marketRates?.cd?.length || 0) > 0 && (
|
||||||
<Table.Th ta="right">Min Deposit</Table.Th>
|
<Badge size="xs" variant="light" ml={4}>{marketRates?.cd?.length}</Badge>
|
||||||
</Table.Tr>
|
)}
|
||||||
</Table.Thead>
|
</Tabs.Tab>
|
||||||
<Table.Tbody>
|
<Tabs.Tab value="money_market">
|
||||||
{cdRates.map((r, i) => (
|
Money Market {(marketRates?.money_market?.length || 0) > 0 && (
|
||||||
<Table.Tr key={i}>
|
<Badge size="xs" variant="light" ml={4}>{marketRates?.money_market?.length}</Badge>
|
||||||
<Table.Td fw={500}>{r.bank_name}</Table.Td>
|
)}
|
||||||
<Table.Td ta="right" fw={700} c="green">
|
</Tabs.Tab>
|
||||||
{parseFloat(r.apy).toFixed(2)}%
|
<Tabs.Tab value="high_yield_savings">
|
||||||
</Table.Td>
|
High Yield Savings {(marketRates?.high_yield_savings?.length || 0) > 0 && (
|
||||||
<Table.Td>{r.term}</Table.Td>
|
<Badge size="xs" variant="light" ml={4}>{marketRates?.high_yield_savings?.length}</Badge>
|
||||||
<Table.Td ta="right" ff="monospace">
|
)}
|
||||||
{r.min_deposit
|
</Tabs.Tab>
|
||||||
? `$${parseFloat(r.min_deposit).toLocaleString()}`
|
</Tabs.List>
|
||||||
: '-'}
|
|
||||||
</Table.Td>
|
<Tabs.Panel value="cd" pt="sm">
|
||||||
</Table.Tr>
|
<RateTable rates={marketRates?.cd || []} showTerm={true} />
|
||||||
))}
|
</Tabs.Panel>
|
||||||
{cdRates.length === 0 && (
|
<Tabs.Panel value="money_market" pt="sm">
|
||||||
<Table.Tr>
|
<RateTable rates={marketRates?.money_market || []} showTerm={false} />
|
||||||
<Table.Td colSpan={4}>
|
</Tabs.Panel>
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
<Tabs.Panel value="high_yield_savings" pt="sm">
|
||||||
No CD rates available. Run the fetch-cd-rates script to populate market data.
|
<RateTable rates={marketRates?.high_yield_savings || []} showTerm={false} />
|
||||||
</Text>
|
</Tabs.Panel>
|
||||||
</Table.Td>
|
</Tabs>
|
||||||
</Table.Tr>
|
)}
|
||||||
)}
|
</Collapse>
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</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 "Get AI Recommendations" 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.
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to a Bankrate URL and scrape rate data.
|
||||||
|
* Reuses an existing browser instance.
|
||||||
|
*/
|
||||||
|
async function fetchRatesFromPage(
|
||||||
|
browser: Browser,
|
||||||
|
sourceUrl: string,
|
||||||
|
rateType: string,
|
||||||
|
label: string,
|
||||||
|
): Promise<MarketRate[]> {
|
||||||
|
const page: Page = await browser.newPage();
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Launching headless browser...');
|
console.log(`\n--- Fetching ${label} ---`);
|
||||||
browser = await puppeteer.launch({
|
console.log(`Navigating to ${sourceUrl}...`);
|
||||||
headless: true,
|
await page.goto(sourceUrl, {
|
||||||
args: [
|
|
||||||
'--no-sandbox',
|
|
||||||
'--disable-setuid-sandbox',
|
|
||||||
'--disable-dev-shm-usage',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await browser.newPage();
|
|
||||||
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',
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Navigating to ${BANKRATE_URL}...`);
|
|
||||||
await page.goto(BANKRATE_URL, {
|
|
||||||
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,24 +277,27 @@ 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) ||
|
||||||
/no\s*min/i.test(bankName) ||
|
/no\s*min/i.test(bankName) ||
|
||||||
/^\$/.test(bankName) ||
|
/^\$/.test(bankName) ||
|
||||||
bankName.length < 4
|
bankName.length < 4
|
||||||
) {
|
) {
|
||||||
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 {
|
||||||
@@ -286,13 +305,14 @@ async function fetchRates(): Promise<CdRate[]> {
|
|||||||
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',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
if (rates.length === 0) {
|
let totalStored = 0;
|
||||||
console.warn('');
|
|
||||||
console.warn('WARNING: No CD rates were extracted from Bankrate.');
|
for (let i = 0; i < RATE_SOURCES.length; i++) {
|
||||||
console.warn(
|
const source = RATE_SOURCES[i];
|
||||||
'This likely means Bankrate changed their page structure.',
|
|
||||||
);
|
// Pause between fetches to avoid rate limiting (skip for first)
|
||||||
console.warn(
|
if (i > 0) {
|
||||||
'Review the page DOM and update selectors in fetch-cd-rates.ts.',
|
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) {
|
||||||
|
console.warn(`\nWARNING: No ${source.label} rates were extracted.`);
|
||||||
|
console.warn('This may mean Bankrate changed their page structure.');
|
||||||
|
continue; // Don't abort the whole run — try other rate types
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nExtracted ${rates.length} ${source.label}:`);
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user