import fs from 'fs'; import path from 'path'; import type { MarketContext } from '../types'; // Generates a self-contained HTML report saved to ./screener-report.html // Console output shows only the signal summary — full breakdown lives here. export class HtmlReporter { render( results: Record, marketContext: MarketContext, personalFinance: unknown = null, ): string { return this._buildHtml(results, marketContext, personalFinance); } generate( results: Record, marketContext: MarketContext, personalFinance: unknown = null, outputPath = './screener-report.html', ): string { const html = this._buildHtml(results, marketContext, personalFinance); fs.writeFileSync(outputPath, html, 'utf8'); return path.resolve(outputPath); } // ── HTML builder ──────────────────────────────────────────────────────────── _buildHtml(results, ctx, pf = null) { const b = ctx.benchmarks ?? {}; const all = [...results.STOCK, ...results.ETF, ...results.BOND]; return ` Market Screener — ${ctx.timestamp?.slice(0, 10) ?? ''}

📊 Market Screener

Date ${ctx.timestamp?.slice(0, 10) ?? '—'}
Rate ${ctx.rateRegime}
Volatility ${ctx.volatilityRegime}
${this._ctxCard('10Y Yield', (ctx.riskFreeRate?.toFixed(2) ?? '—') + '%')} ${this._ctxCard('VIX', ctx.vixLevel?.toFixed(1) ?? '—')} ${this._ctxCard('S&P 500', ctx.sp500Price?.toLocaleString() ?? '—')} ${this._ctxCard('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x')} ${this._ctxCard('Tech P/E', (b.techPE?.toFixed(1) ?? '—') + 'x')} ${this._ctxCard('REIT Yield', (b.reitYield?.toFixed(2) ?? '—') + '%')} ${this._ctxCard('IG Spread', (b.igSpread?.toFixed(2) ?? '—') + '%')}

Signal Summary

${all .sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal)) .map((r) => this._summaryRow(r)) .join('')}
TickerTypeSignalInflated VerdictFundamental Verdict
${['STOCK', 'ETF', 'BOND'] .map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : '')) .join('')} ${pf ? this._personalFinanceSection(pf) : ''} ${ results.ERROR?.length ? `

Errors

${results.ERROR.map((e) => ``).join('')}
TickerReason
${e.ticker}${e.message}
` : '' }
`; } // ── Section builders ──────────────────────────────────────────────────────── _assetSection(type, items, benchmarks) { const sorted = [...items].sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal)); const inflatedId = `${type}-inflated`; const fundamentalId = `${type}-fundamental`; const inflatedLabel = type === 'STOCK' ? `Market-Adjusted (P/E gate: ~${benchmarks.marketPE != null ? Math.round(benchmarks.marketPE * 1.5) : '—'}x from live data)` : 'Market-Adjusted'; return `

${type}S

${inflatedLabel}
Fundamental (Graham-style)
${this._table(type, sorted, 'inflated')}
${this._table(type, sorted, 'fundamental')}
`; } _table(type, items, mode) { const headers = this._headers(type, items, mode); const rows = items.map((r) => this._row(type, r, mode, headers)).join(''); return `${headers.map((h) => ``).join('')}${rows}
${h}
`; } // Collect only headers that have at least one non-null value across all items _headers(type, items, _mode) { const base = ['Ticker', 'Price', 'Verdict', 'Score']; if (type === 'STOCK') { const metricKeys = [ 'Sector', 'P/E', 'PEG', 'P/B', 'ROE%', 'OpMgn%', 'NetMgn%', 'Rev%', 'FCF Yld%', 'Div%', 'D/E', 'Quick', 'Beta', '52W Pos', 'P/FFO', ]; const present = metricKeys.filter((k) => items.some((r) => r.asset.getDisplayMetrics()[k] != null), ); return [...base, ...present, 'Risk Flags']; } if (type === 'ETF') return [...base, 'Expense', 'Yield', 'AUM', '5Y Ret']; if (type === 'BOND') return [...base, 'YTM', 'Duration', 'Rating']; return base; } _row(type, result, mode, headers) { const m = result.asset.getDisplayMetrics(); const bd = result[mode]?.audit?.breakdown ?? {}; const rf = result[mode]?.audit?.riskFlags ?? []; const v = result[mode]?.label ?? ''; const s = result[mode]?.scoreSummary ?? ''; const p = (key) => bd[key] != null ? `${bd[key] > 0 ? '✅' : '❌'}` : ''; const cells = { Ticker: `${m.Ticker}`, Price: `${m.Price}`, Verdict: `${v}`, Score: `${s}`, Sector: `${m.Sector ?? ''}`, 'P/E': `${m['P/E'] ?? ''}`, PEG: `${m.PEG != null ? m.PEG + ' ' + p('peg') : ''}`, 'P/B': `${m['P/B'] ?? ''}`, 'ROE%': `${m['ROE%'] != null ? m['ROE%'] + ' ' + p('roe') : ''}`, 'OpMgn%': `${m['OpMgn%'] != null ? m['OpMgn%'] + ' ' + p('opMargin') : ''}`, 'NetMgn%': `${m['NetMgn%'] != null ? m['NetMgn%'] + ' ' + p('margin') : ''}`, 'Rev%': `${m['Rev%'] != null ? m['Rev%'] + ' ' + p('revenue') : ''}`, 'FCF Yld%': `${m['FCF Yld%'] != null ? m['FCF Yld%'] + ' ' + p('fcf') : ''}`, 'Div%': `${m['Div%'] != null ? m['Div%'] + ' ' + p('yield') : ''}`, 'D/E': `${m['D/E'] ?? ''}`, Quick: `${m.Quick ?? ''}`, Beta: `${m.Beta ?? ''}`, '52W Pos': `${m['52W Pos'] ?? ''}`, 'P/FFO': `${m['P/FFO'] != null ? m['P/FFO'] + ' ' + p('pFFO') : ''}`, 'Risk Flags': `${rf.map((f) => `⚠ ${f}`).join('') || ''}`, // ETF Expense: `${m['Exp Ratio%'] != null ? m['Exp Ratio%'] + ' ' + p('cost') : ''}`, Yield: `${m['Yield%'] != null ? m['Yield%'] + ' ' + p('yield') : ''}`, AUM: `${m.AUM ?? ''}`, '5Y Ret': `${m['5Y Return%'] ?? ''}`, // BOND YTM: `${m['YTM%'] != null ? m['YTM%'] + ' ' + p('spread') : ''}`, Duration: `${m.Duration != null ? m.Duration + ' ' + p('duration') : ''}`, Rating: `${m.Rating ?? ''}`, }; return `${headers.map((h) => cells[h] ?? `—`).join('')}`; } _summaryRow(r) { return ` ${r.asset.ticker} ${r.asset.type} ${r.signal} ${r.inflated.label} ${r.fundamental.label} `; } // ── Helpers ───────────────────────────────────────────────────────────────── _ctxCard(label, value) { return `
${label}
${value}
`; } _verdictClass(label) { if (label?.startsWith('🟢')) return 'verdict-green'; if (label?.startsWith('🟡')) return 'verdict-yellow'; return 'verdict-red'; } _signalClass(signal) { if (signal?.includes('Strong')) return 'signal-strong'; if (signal?.includes('Momentum')) return 'signal-momentum'; if (signal?.includes('Neutral')) return 'signal-neutral'; if (signal?.includes('Speculation')) return 'signal-spec'; return 'signal-avoid'; } _personalFinanceSection(pf) { const fmt = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }).format(n); const sign = (n) => n >= 0 ? `${fmt(n)}` : `${fmt(n)}`; const accountRows = pf.accounts .map( (a) => ` ${a.name} ${a.type} ${a.org} ${sign(a.balance)} ${a.balanceDate} `, ) .join(''); const categoryRows = pf.categoryBreakdown .slice(0, 8) .map( (c) => ` ${c.category} ${fmt(c.amount)} ${c.pct}%
`, ) .join(''); return `

Personal Finance — SimpleFIN

${this._ctxCard('Net Worth', fmt(pf.netWorth))} ${this._ctxCard('Total Assets', fmt(pf.totalAssets))} ${this._ctxCard('Liabilities', fmt(pf.totalLiabilities))} ${this._ctxCard('Cash', `${fmt(pf.totalCash)} (${pf.cashPct}%)`)} ${this._ctxCard('Investments', `${fmt(pf.totalInvestments)} (${pf.investPct}%)`)} ${this._ctxCard('Monthly Income', fmt(pf.totalIncome))} ${this._ctxCard('Monthly Spend', fmt(pf.totalSpend))} ${pf.savingsRate != null ? this._ctxCard('Savings Rate', `${pf.savingsRate}%`) : ''}

Accounts

${accountRows}
AccountTypeInstitutionBalanceUpdated

Spending by Category (Last 30 Days)

${categoryRows}
CategoryAmount%Share
`; } _sigOrd(signal) { return ( { '✅ Strong Buy': 0, '⚡ Momentum': 1, '🔄 Neutral': 2, '⚠️ Speculation': 3, '❌ Avoid': 4, }[signal] ?? 5 ); } }