phase-2: extract shared utils

This commit is contained in:
Kazuma
2026-06-04 11:06:30 -04:00
committed by Kazuma
parent 96e48aebe5
commit f5a338fc4e
49 changed files with 299 additions and 120 deletions
+392
View File
@@ -0,0 +1,392 @@
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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Market Screener — ${ctx.timestamp?.slice(0, 10) ?? ''}</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: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
a { color: inherit; text-decoration: none; }
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
.header-meta { display: flex; gap: 24px; margin-left: auto; }
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; }
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
.content { padding: 24px 32px; }
.ctx-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px; }
.ctx-card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
.ctx-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.ctx-value { font-size: 18px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
.section { margin-bottom: 40px; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #1e293b; margin-bottom: 16px; }
.tab { padding: 8px 20px; cursor: pointer; border-bottom: 2px solid transparent; font-size: 12px; font-weight: 600; color: #64748b; transition: color 0.15s; }
.tab.active { color: #e2e8f0; border-bottom-color: #3b82f6; }
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; transition: background 0.1s; }
tbody tr:hover { background: #1e293b; }
tbody td { padding: 10px 12px; vertical-align: middle; white-space: nowrap; }
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
.price { color: #94a3b8; font-variant-numeric: tabular-nums; }
.sector { font-size: 11px; color: #64748b; background: #1e293b; padding: 2px 8px; border-radius: 4px; }
.score { font-weight: 700; font-variant-numeric: tabular-nums; }
.verdict-green { color: #4ade80; }
.verdict-yellow { color: #facc15; }
.verdict-red { color: #f87171; }
.signal-strong { color: #4ade80; font-weight: 700; }
.signal-momentum{ color: #60a5fa; font-weight: 700; }
.signal-neutral { color: #94a3b8; }
.signal-spec { color: #fb923c; font-weight: 700; }
.signal-avoid { color: #f87171; font-weight: 700; }
.pass { color: #4ade80; }
.fail { color: #f87171; }
.flag { color: #fb923c; font-size: 11px; display: block; margin-top: 2px; }
.risk-flags { display: flex; flex-direction: column; gap: 2px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.no-data { color: #334155; }
</style>
</head>
<body>
<div class="header">
<h1>📊 Market Screener</h1>
<div class="header-meta">
<div class="pill">Date <span>${ctx.timestamp?.slice(0, 10) ?? '—'}</span></div>
<div class="pill">Rate <span>${ctx.rateRegime}</span></div>
<div class="pill">Volatility <span>${ctx.volatilityRegime}</span></div>
</div>
</div>
<div class="content">
<div class="ctx-grid">
${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) ?? '—') + '%')}
</div>
<div class="section">
<h2>Signal Summary</h2>
<table>
<thead><tr><th>Ticker</th><th>Type</th><th>Signal</th><th>Inflated Verdict</th><th>Fundamental Verdict</th></tr></thead>
<tbody>${all
.sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal))
.map((r) => this._summaryRow(r))
.join('')}</tbody>
</table>
</div>
${['STOCK', 'ETF', 'BOND']
.map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : ''))
.join('')}
${pf ? this._personalFinanceSection(pf) : ''}
${
results.ERROR?.length
? `
<div class="section">
<h2>Errors</h2>
<table>
<thead><tr><th>Ticker</th><th>Reason</th></tr></thead>
<tbody>${results.ERROR.map((e) => `<tr><td class="ticker">${e.ticker}</td><td class="verdict-red">${e.message}</td></tr>`).join('')}</tbody>
</table>
</div>`
: ''
}
</div>
<script>
document.querySelectorAll('.tabs').forEach((tabs) => {
tabs.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
const section = tabs.closest('.section');
tabs.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
section.querySelectorAll('.tab-content').forEach((c) => c.classList.remove('active'));
tab.classList.add('active');
section.querySelector('#' + tab.dataset.target).classList.add('active');
});
});
});
</script>
</body>
</html>`;
}
// ── 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 `
<div class="section">
<h2>${type}S</h2>
<div class="tabs">
<div class="tab active" data-target="${inflatedId}">${inflatedLabel}</div>
<div class="tab" data-target="${fundamentalId}">Fundamental (Graham-style)</div>
</div>
<div id="${inflatedId}" class="tab-content active">
${this._table(type, sorted, 'inflated')}
</div>
<div id="${fundamentalId}" class="tab-content">
${this._table(type, sorted, 'fundamental')}
</div>
</div>`;
}
_table(type, items, mode) {
const headers = this._headers(type, items, mode);
const rows = items.map((r) => this._row(type, r, mode, headers)).join('');
return `<table>
<thead><tr>${headers.map((h) => `<th>${h}</th>`).join('')}</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// 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
? `<span class="${bd[key] > 0 ? 'pass' : 'fail'}">${bd[key] > 0 ? '✅' : '❌'}</span>`
: '';
const cells = {
Ticker: `<td class="ticker">${m.Ticker}</td>`,
Price: `<td class="price">${m.Price}</td>`,
Verdict: `<td class="${this._verdictClass(v)}">${v}</td>`,
Score: `<td class="score">${s}</td>`,
Sector: `<td><span class="sector">${m.Sector ?? ''}</span></td>`,
'P/E': `<td>${m['P/E'] ?? '<span class="no-data">—</span>'}</td>`,
PEG: `<td>${m.PEG != null ? m.PEG + ' ' + p('peg') : '<span class="no-data">—</span>'}</td>`,
'P/B': `<td>${m['P/B'] ?? '<span class="no-data">—</span>'}</td>`,
'ROE%': `<td>${m['ROE%'] != null ? m['ROE%'] + ' ' + p('roe') : '<span class="no-data">—</span>'}</td>`,
'OpMgn%': `<td>${m['OpMgn%'] != null ? m['OpMgn%'] + ' ' + p('opMargin') : '<span class="no-data">—</span>'}</td>`,
'NetMgn%': `<td>${m['NetMgn%'] != null ? m['NetMgn%'] + ' ' + p('margin') : '<span class="no-data">—</span>'}</td>`,
'Rev%': `<td>${m['Rev%'] != null ? m['Rev%'] + ' ' + p('revenue') : '<span class="no-data">—</span>'}</td>`,
'FCF Yld%': `<td>${m['FCF Yld%'] != null ? m['FCF Yld%'] + ' ' + p('fcf') : '<span class="no-data">—</span>'}</td>`,
'Div%': `<td>${m['Div%'] != null ? m['Div%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
'D/E': `<td>${m['D/E'] ?? '<span class="no-data">—</span>'}</td>`,
Quick: `<td>${m.Quick ?? '<span class="no-data">—</span>'}</td>`,
Beta: `<td>${m.Beta ?? '<span class="no-data">—</span>'}</td>`,
'52W Pos': `<td>${m['52W Pos'] ?? '<span class="no-data">—</span>'}</td>`,
'P/FFO': `<td>${m['P/FFO'] != null ? m['P/FFO'] + ' ' + p('pFFO') : '<span class="no-data">—</span>'}</td>`,
'Risk Flags': `<td class="risk-flags">${rf.map((f) => `<span class="flag">⚠ ${f}</span>`).join('') || '<span class="no-data">—</span>'}</td>`,
// ETF
Expense: `<td>${m['Exp Ratio%'] != null ? m['Exp Ratio%'] + ' ' + p('cost') : '<span class="no-data">—</span>'}</td>`,
Yield: `<td>${m['Yield%'] != null ? m['Yield%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
AUM: `<td>${m.AUM ?? '<span class="no-data">—</span>'}</td>`,
'5Y Ret': `<td>${m['5Y Return%'] ?? '<span class="no-data">—</span>'}</td>`,
// BOND
YTM: `<td>${m['YTM%'] != null ? m['YTM%'] + ' ' + p('spread') : '<span class="no-data">—</span>'}</td>`,
Duration: `<td>${m.Duration != null ? m.Duration + ' ' + p('duration') : '<span class="no-data">—</span>'}</td>`,
Rating: `<td>${m.Rating ?? '<span class="no-data">—</span>'}</td>`,
};
return `<tr>${headers.map((h) => cells[h] ?? `<td>—</td>`).join('')}</tr>`;
}
_summaryRow(r) {
return `<tr>
<td class="ticker">${r.asset.ticker}</td>
<td><span class="sector">${r.asset.type}</span></td>
<td class="${this._signalClass(r.signal)}">${r.signal}</td>
<td class="${this._verdictClass(r.inflated.label)}">${r.inflated.label}</td>
<td class="${this._verdictClass(r.fundamental.label)}">${r.fundamental.label}</td>
</tr>`;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
_ctxCard(label, value) {
return `<div class="ctx-card"><div class="ctx-label">${label}</div><div class="ctx-value">${value}</div></div>`;
}
_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
? `<span class="verdict-green">${fmt(n)}</span>`
: `<span class="verdict-red">${fmt(n)}</span>`;
const accountRows = pf.accounts
.map(
(a) => `
<tr>
<td class="ticker">${a.name}</td>
<td><span class="sector">${a.type}</span></td>
<td class="price">${a.org}</td>
<td style="text-align:right">${sign(a.balance)}</td>
<td class="price" style="text-align:right">${a.balanceDate}</td>
</tr>`,
)
.join('');
const categoryRows = pf.categoryBreakdown
.slice(0, 8)
.map(
(c) => `
<tr>
<td>${c.category}</td>
<td style="text-align:right">${fmt(c.amount)}</td>
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
<td>
<div style="background:#1e293b;border-radius:4px;height:8px;width:100%;max-width:120px">
<div style="background:#3b82f6;border-radius:4px;height:8px;width:${c.pct}%"></div>
</div>
</td>
</tr>`,
)
.join('');
return `
<div class="section">
<h2>Personal Finance — SimpleFIN</h2>
<div class="ctx-grid" style="margin-bottom:24px">
${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}%`) : ''}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
<div>
<h2 style="margin-bottom:12px">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>${accountRows}</tbody>
</table>
</div>
<div>
<h2 style="margin-bottom:12px">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">%</th><th>Share</th></tr></thead>
<tbody>${categoryRows}</tbody>
</table>
</div>
</div>
</div>`;
}
_sigOrd(signal) {
return (
{
'✅ Strong Buy': 0,
'⚡ Momentum': 1,
'🔄 Neutral': 2,
'⚠️ Speculation': 3,
'❌ Avoid': 4,
}[signal] ?? 5
);
}
}