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}
${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
| Ticker | Source | Type | Shares |
Cost Basis | Current | Value |
G/L | Signal | Advice | Reason |
${stockRows}
`
: ''
}
${
crypto.length > 0
? `
Crypto
| Ticker | Source | Shares |
Cost Basis | Current | Value |
G/L | Advice | Note |
${cryptoRows}
`
: ''
}
`;
}
// ── 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
| Category | Amount | Share | |
${rows}
`;
}
// ── 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
| Account | Type | Institution | Balance | Updated |
${rows}
`;
}
// ── 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';
}
}