phase-2: extract shared utils
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user