import fs from 'fs'; import path from 'path'; import type { MarketContext } from '../types'; export class FinanceReporter { render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string { return this._build(advice, personalFinance, marketContext); } generate( advice: unknown[], personalFinance: unknown, marketContext: MarketContext, outputPath = './finance-report.html', ): string { const html = this._build(advice, personalFinance, marketContext); fs.writeFileSync(outputPath, html, 'utf8'); return path.resolve(outputPath); } _build(advice: unknown, pf: unknown, ctx: unknown) { const date = new Date().toISOString().slice(0, 10); return ` Personal Finance — ${date}

💰 Personal Finance

Date ${date}
${pf ? this._netWorthSection(pf) : ''} ${this._portfolioSection(advice, ctx)} ${pf ? this._spendingSection(pf) : ''} ${pf ? this._accountsSection(pf) : ''}
`; } // ── Net worth ────────────────────────────────────────────────────────────── _netWorthSection(pf) { const f = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }).format(n); return `

Net Worth

${this._card('Net Worth', f(pf.netWorth), pf.netWorth >= 0 ? 'green' : 'red')} ${this._card('Total Assets', f(pf.totalAssets))} ${this._card('Liabilities', f(pf.totalLiabilities), 'red')} ${this._card('Cash & Savings', `${f(pf.totalCash)}`, null, `${pf.cashPct}% of assets`)} ${this._card('Investments', `${f(pf.totalInvestments)}`, null, `${pf.investPct}% of assets`)} ${pf.savingsRate != null ? this._card('Savings Rate', `${pf.savingsRate}%`, parseFloat(pf.savingsRate) > 20 ? 'green' : 'yellow') : ''} ${this._card('Monthly Income', f(pf.totalIncome))} ${this._card('Monthly Spend', f(pf.totalSpend))}
`; } // ── Portfolio with hold/sell advice ─────────────────────────────────────── _portfolioSection(advice, ctx) { const f = (n) => n != null ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(n) : '—'; const f2 = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }).format(n); const b = ctx?.benchmarks ?? {}; const stocks = advice.filter((a) => a.type !== 'crypto'); const crypto = advice.filter((a) => a.type === 'crypto'); const totalValue = advice.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0); const totalCost = advice.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0); const totalGL = totalValue - totalCost; const totalGLPct = totalCost > 0 ? ((totalGL / totalCost) * 100).toFixed(1) : null; const sourceColors = { Robinhood: '#22c55e', Vanguard: '#3b82f6', Fidelity: '#f59e0b', Coinbase: '#8b5cf6', }; const sourcePill = (s) => { const color = sourceColors[s] ?? '#64748b'; return `${s}`; }; const stockRows = stocks .map((a) => { const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; const advClass = this._adviceClass(a.advice); return ` ${a.ticker} ${sourcePill(a.source)} ${a.type} ${a.shares} ${f(a.costBasis)} ${f(parseFloat(a.currentPrice))} ${f(parseFloat(a.marketValue))} ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} ${a.signal ?? '—'} ${a.advice} ${a.reason} `; }) .join(''); const cryptoRows = crypto .map((a) => { const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; const advClass = this._adviceClass(a.advice); return ` ${a.ticker} ${sourcePill(a.source)} ${a.shares} ${f(a.costBasis)} ${f(parseFloat(a.currentPrice))} ${f(parseFloat(a.marketValue))} ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} ${a.advice} ${a.reason} `; }) .join(''); return `

Portfolio — Hold / Sell / Add Advice

${this._card('Total Value', f2(totalValue))} ${this._card('Total Cost', f2(totalCost))} ${this._card('Total G/L', f2(totalGL), totalGL >= 0 ? 'green' : 'red', totalGLPct != null ? totalGLPct + '%' : '')} ${this._card('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x', null, 'Live benchmark')}
${ stocks.length > 0 ? `

Stocks & ETFs

${stockRows}
TickerSourceTypeShares Cost BasisCurrentValue G/LSignalAdviceReason
` : '' } ${ crypto.length > 0 ? `

Crypto

${cryptoRows}
TickerSourceShares Cost BasisCurrentValue G/LAdviceNote
` : '' }
`; } // ── Spending breakdown ───────────────────────────────────────────────────── _spendingSection(pf) { const f = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(n); const rows = pf.categoryBreakdown .slice(0, 10) .map( (c) => ` ${c.category} ${f(c.amount)} ${c.pct}%
`, ) .join(''); return `

Spending by Category — Last 30 Days

${rows}
CategoryAmountShare
`; } // ── Accounts ─────────────────────────────────────────────────────────────── _accountsSection(pf) { const f = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(n); const rows = pf.accounts .map( (a) => ` ${a.name} ${a.type} ${a.org} ${f(a.balance)} ${a.balanceDate} `, ) .join(''); return `

Accounts

${rows}
AccountTypeInstitutionBalanceUpdated
`; } // ── Helpers ──────────────────────────────────────────────────────────────── _card(label, value, colorClass = null, sub = null) { return `
${label}
${value}
${sub ? `
${sub}
` : ''}
`; } _adviceClass(advice) { if (advice?.includes('🟢')) return 'advice-green'; if (advice?.includes('🟡')) return 'advice-yellow'; if (advice?.includes('🟠')) return 'advice-orange'; if (advice?.includes('🔴')) return 'advice-red'; return 'gray'; } }