import fs from 'fs';
import path from 'path';
// 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 {
// Returns the HTML string — useful for server responses.
render(results, marketContext, personalFinance = null) {
return this._buildHtml(results, marketContext, personalFinance);
}
// Writes to disk and returns the absolute path — used by the CLI.
generate(results, marketContext, personalFinance = null, outputPath = './screener-report.html') {
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) ?? ''}
${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
| Ticker | Type | Signal | Inflated Verdict | Fundamental Verdict |
${all
.sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal))
.map((r) => this._summaryRow(r))
.join('')}
${['STOCK', 'ETF', 'BOND']
.map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : ''))
.join('')}
${pf ? this._personalFinanceSection(pf) : ''}
${
results.ERROR?.length
? `
Errors
| Ticker | Reason |
${results.ERROR.map((e) => `| ${e.ticker} | ${e.message} |
`).join('')}
`
: ''
}
`;
}
// ── 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) => `| ${h} | `).join('')}
${rows}
`;
}
// 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 ``;
}
_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
| Account | Type | Institution | Balance | Updated |
${accountRows}
Spending by Category (Last 30 Days)
| Category | Amount | % | Share |
${categoryRows}
`;
}
_sigOrd(signal) {
return (
{
'✅ Strong Buy': 0,
'⚡ Momentum': 1,
'🔄 Neutral': 2,
'⚠️ Speculation': 3,
'❌ Avoid': 4,
}[signal] ?? 5
);
}
}