309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
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 `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Personal Finance — ${date}</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; font-size: 13px; }
|
|
h1 { font-size: 20px; font-weight: 600; }
|
|
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
|
|
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
|
|
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; margin-left: auto; }
|
|
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
|
|
.content { padding: 24px 32px; }
|
|
.section { margin-bottom: 40px; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
.card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
|
|
.card-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.card-value { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
|
|
.card-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
|
|
tbody tr { border-bottom: 1px solid #1a2233; }
|
|
tbody tr:hover { background: #1e293b; }
|
|
tbody td { padding: 10px 12px; vertical-align: middle; }
|
|
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
|
|
.green { color: #4ade80; }
|
|
.yellow { color: #facc15; }
|
|
.orange { color: #fb923c; }
|
|
.red { color: #f87171; }
|
|
.gray { color: #64748b; }
|
|
.advice-green { color: #4ade80; font-weight: 600; }
|
|
.advice-yellow { color: #facc15; font-weight: 600; }
|
|
.advice-orange { color: #fb923c; font-weight: 600; }
|
|
.advice-red { color: #f87171; font-weight: 600; }
|
|
.reason { color: #94a3b8; font-size: 11px; }
|
|
.bar-bg { background: #1e293b; border-radius: 4px; height: 8px; }
|
|
.bar-fill { background: #3b82f6; border-radius: 4px; height: 8px; }
|
|
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>💰 Personal Finance</h1>
|
|
<div class="pill">Date <span>${date}</span></div>
|
|
</div>
|
|
<div class="content">
|
|
|
|
${pf ? this._netWorthSection(pf) : ''}
|
|
|
|
${this._portfolioSection(advice, ctx)}
|
|
|
|
${pf ? this._spendingSection(pf) : ''}
|
|
|
|
${pf ? this._accountsSection(pf) : ''}
|
|
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// ── Net worth ──────────────────────────────────────────────────────────────
|
|
|
|
_netWorthSection(pf) {
|
|
const f = (n) =>
|
|
new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
maximumFractionDigits: 0,
|
|
}).format(n);
|
|
return `
|
|
<div class="section">
|
|
<h2>Net Worth</h2>
|
|
<div class="grid">
|
|
${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))}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── 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 `<span style="background:${color}22;color:${color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">${s}</span>`;
|
|
};
|
|
|
|
const stockRows = stocks
|
|
.map((a) => {
|
|
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
|
|
const advClass = this._adviceClass(a.advice);
|
|
return `<tr>
|
|
<td class="ticker">${a.ticker}</td>
|
|
<td>${sourcePill(a.source)}</td>
|
|
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
|
|
<td>${a.shares}</td>
|
|
<td>${f(a.costBasis)}</td>
|
|
<td>${f(parseFloat(a.currentPrice))}</td>
|
|
<td>${f(parseFloat(a.marketValue))}</td>
|
|
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
|
<td class="gray" style="font-size:11px">${a.signal ?? '—'}</td>
|
|
<td class="${advClass}">${a.advice}</td>
|
|
<td class="reason">${a.reason}</td>
|
|
</tr>`;
|
|
})
|
|
.join('');
|
|
|
|
const cryptoRows = crypto
|
|
.map((a) => {
|
|
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
|
|
const advClass = this._adviceClass(a.advice);
|
|
return `<tr>
|
|
<td class="ticker">${a.ticker}</td>
|
|
<td>${sourcePill(a.source)}</td>
|
|
<td>${a.shares}</td>
|
|
<td>${f(a.costBasis)}</td>
|
|
<td>${f(parseFloat(a.currentPrice))}</td>
|
|
<td>${f(parseFloat(a.marketValue))}</td>
|
|
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
|
<td class="${advClass}">${a.advice}</td>
|
|
<td class="reason">${a.reason}</td>
|
|
</tr>`;
|
|
})
|
|
.join('');
|
|
|
|
return `
|
|
<div class="section">
|
|
<h2>Portfolio — Hold / Sell / Add Advice</h2>
|
|
<div class="grid" style="margin-bottom:16px">
|
|
${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')}
|
|
</div>
|
|
|
|
${
|
|
stocks.length > 0
|
|
? `
|
|
<h2 style="margin-bottom:10px">Stocks & ETFs</h2>
|
|
<table>
|
|
<thead><tr>
|
|
<th>Ticker</th><th>Source</th><th>Type</th><th>Shares</th>
|
|
<th>Cost Basis</th><th>Current</th><th>Value</th>
|
|
<th>G/L</th><th>Signal</th><th>Advice</th><th>Reason</th>
|
|
</tr></thead>
|
|
<tbody>${stockRows}</tbody>
|
|
</table>`
|
|
: ''
|
|
}
|
|
|
|
${
|
|
crypto.length > 0
|
|
? `
|
|
<h2 style="margin-top:24px;margin-bottom:10px">Crypto</h2>
|
|
<table>
|
|
<thead><tr>
|
|
<th>Ticker</th><th>Source</th><th>Shares</th>
|
|
<th>Cost Basis</th><th>Current</th><th>Value</th>
|
|
<th>G/L</th><th>Advice</th><th>Note</th>
|
|
</tr></thead>
|
|
<tbody>${cryptoRows}</tbody>
|
|
</table>`
|
|
: ''
|
|
}
|
|
</div>`;
|
|
}
|
|
|
|
// ── 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) => `
|
|
<tr>
|
|
<td>${c.category}</td>
|
|
<td style="text-align:right">${f(c.amount)}</td>
|
|
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
|
|
<td style="width:120px">
|
|
<div class="bar-bg"><div class="bar-fill" style="width:${Math.min(c.pct, 100)}%"></div></div>
|
|
</td>
|
|
</tr>`,
|
|
)
|
|
.join('');
|
|
|
|
return `
|
|
<div class="section">
|
|
<h2>Spending by Category — Last 30 Days</h2>
|
|
<table>
|
|
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">Share</th><th></th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Accounts ───────────────────────────────────────────────────────────────
|
|
|
|
_accountsSection(pf) {
|
|
const f = (n) =>
|
|
new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
}).format(n);
|
|
const rows = pf.accounts
|
|
.map(
|
|
(a) => `
|
|
<tr>
|
|
<td class="ticker">${a.name}</td>
|
|
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
|
|
<td class="gray">${a.org}</td>
|
|
<td style="text-align:right" class="${a.balance >= 0 ? 'green' : 'red'}">${f(a.balance)}</td>
|
|
<td class="gray" style="text-align:right">${a.balanceDate}</td>
|
|
</tr>`,
|
|
)
|
|
.join('');
|
|
|
|
return `
|
|
<div class="section">
|
|
<h2>Accounts</h2>
|
|
<table>
|
|
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th style="text-align:right">Balance</th><th style="text-align:right">Updated</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
_card(label, value, colorClass = null, sub = null) {
|
|
return `<div class="card">
|
|
<div class="card-label">${label}</div>
|
|
<div class="card-value ${colorClass ? colorClass : ''}">${value}</div>
|
|
${sub ? `<div class="card-sub">${sub}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
_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';
|
|
}
|
|
}
|