phase-7_alpha: legacy code cleanup
This commit is contained in:
committed by
saikiranvella
parent
357b0c0f6e
commit
93aac355cc
+6
-3
@@ -5,6 +5,8 @@ import { FinanceController } from './controllers/finance.controller';
|
||||
import { CallsController } from './controllers/calls.controller';
|
||||
import { AnalyzeController } from './controllers/analyze.controller';
|
||||
import { ScreenerEngine } from './services/ScreenerEngine';
|
||||
import { BenchmarkProvider } from './services/BenchmarkProvider';
|
||||
import { PortfolioAdvisor } from './services/PortfolioAdvisor';
|
||||
import { LLMAnalyst } from './services/LLMAnalyst';
|
||||
import { CatalystAnalyst } from './services/CatalystAnalyst';
|
||||
import { YahooFinanceClient } from './clients/YahooFinanceClient';
|
||||
@@ -29,14 +31,15 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
||||
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
|
||||
});
|
||||
|
||||
const engine = new ScreenerEngine({ logger: noopLogger });
|
||||
|
||||
const yahoo = new YahooFinanceClient();
|
||||
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
|
||||
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
|
||||
const advisor = new PortfolioAdvisor(yahoo);
|
||||
const llm = new LLMAnalyst({ logger: noopLogger });
|
||||
const catalyst = new CatalystAnalyst({ logger: noopLogger });
|
||||
|
||||
new ScreenerController(engine).register(app);
|
||||
new FinanceController(engine, new PortfolioRepository()).register(app);
|
||||
new FinanceController(engine, new PortfolioRepository(), advisor).register(app);
|
||||
new CallsController(new MarketCallRepository(), engine, yahoo).register(app);
|
||||
new AnalyzeController(catalyst, llm).register(app);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export class FinanceController {
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly repo: PortfolioRepository,
|
||||
private readonly advisor: PortfolioAdvisor,
|
||||
) {}
|
||||
|
||||
private static normalizeYahoo(ticker: string): string {
|
||||
@@ -44,7 +45,7 @@ export class FinanceController {
|
||||
? await this.engine.screenTickers(screenable)
|
||||
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
|
||||
|
||||
const advice = await new PortfolioAdvisor().advise(holdings, results);
|
||||
const advice = await this.advisor.advise(holdings, results);
|
||||
return { advice, personalFinance, marketContext: results.marketContext };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
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<string, unknown[]>,
|
||||
marketContext: MarketContext,
|
||||
personalFinance: unknown = null,
|
||||
): string {
|
||||
return this._buildHtml(results, marketContext, personalFinance);
|
||||
}
|
||||
|
||||
generate(
|
||||
results: Record<string, unknown[]>,
|
||||
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 `<!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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,13 @@ export class BenchmarkProvider {
|
||||
private static pe(summary: any): number | null {
|
||||
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
|
||||
}
|
||||
private client: YahooFinanceClient;
|
||||
private cache: { data: MarketContext | null; expiresAt: number };
|
||||
private logger: Logger;
|
||||
|
||||
constructor({ logger }: BenchmarkProviderOptions = {}) {
|
||||
this.client = new YahooFinanceClient();
|
||||
constructor(
|
||||
private readonly client: YahooFinanceClient,
|
||||
{ logger }: BenchmarkProviderOptions = {},
|
||||
) {
|
||||
this.cache = { data: null, expiresAt: 0 };
|
||||
this.logger = logger ?? (console as unknown as Logger);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,7 @@ import type {
|
||||
} from '../types';
|
||||
|
||||
export class PortfolioAdvisor {
|
||||
private client: YahooFinanceClient;
|
||||
|
||||
constructor() {
|
||||
this.client = new YahooFinanceClient();
|
||||
}
|
||||
constructor(private readonly client: YahooFinanceClient) {}
|
||||
|
||||
async advise(
|
||||
holdings: PortfolioHolding[],
|
||||
|
||||
@@ -29,15 +29,13 @@ export class ScreenerEngine {
|
||||
private static readonly BATCH_SIZE = 5;
|
||||
private static readonly BATCH_DELAY_MS = 1000;
|
||||
|
||||
private client: YahooFinanceClient;
|
||||
private benchmarkProvider: BenchmarkProvider;
|
||||
private logger: Logger;
|
||||
|
||||
constructor({ logger }: ScreenerEngineOptions = {}) {
|
||||
this.client = new YahooFinanceClient();
|
||||
this.benchmarkProvider = new BenchmarkProvider({
|
||||
logger: logger ?? (console as unknown as Logger),
|
||||
});
|
||||
constructor(
|
||||
private readonly client: YahooFinanceClient,
|
||||
private readonly benchmarkProvider: BenchmarkProvider,
|
||||
{ logger }: ScreenerEngineOptions = {},
|
||||
) {
|
||||
this.logger = logger ?? {
|
||||
write: (msg: string) => process.stdout.write(msg),
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
|
||||
Reference in New Issue
Block a user