diff --git a/CLAUDE.md b/CLAUDE.md index 630196b..31a5332 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,7 @@ Guidance for working in this repository. ## Overview -`market-screener` is a Node.js project with two modes: - -1. **CLI** — screens stocks, ETFs, and bonds via `npm start`, generates HTML reports -2. **Fastify API server** — powers the SvelteKit dashboard in the `ui/` subdirectory +`market-screener` is a Node.js project consisting of a Fastify API server that powers the SvelteKit dashboard in the `ui/` subdirectory. Every asset is scored under two lenses: @@ -26,10 +23,6 @@ ES module project (`"type": "module"`); use `import`/`export`, not `require`. npm install # install dependencies npm run dev # start API server (port 3000) + SvelteKit UI (port 5173) together npm run server # API server only (port 3000) -npm start # CLI: Yahoo news → catalyst tickers → screener-report.html -npm start -- watch # CLI: default watchlist -npm start -- AAPL MSFT VOO # CLI: specific tickers -npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html npm test # run all unit tests (node:test, zero external deps) npm run test:watch # watch mode — uses verbose spec reporter npm run format # format all server/bin/tests with Prettier @@ -45,13 +38,8 @@ npm run ui:install # install UI dependencies (ui/ sub ``` bin/ - screen.ts ← CLI screener entry point - finance.ts ← CLI personal finance entry point server.ts ← Fastify API server entry point (imports buildApp from server/app.ts) -scripts/ - summary-reporter.js ← custom node:test reporter (silent on pass, summary line at end) - prompts/ catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow) @@ -66,8 +54,8 @@ server/ analyze.controller.ts ← POST /api/analyze (LLM analysis for a ticker set) services/ ← business logic, no HTTP or I/O concerns - ScreenerEngine.ts ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data), - screenWithProgress() (CLI with stdout). Accepts { logger } option. + ScreenerEngine.ts ← orchestrates: fetch → score × 2. Method: screenTickers() → ScreenerResult. + Accepts injected YahooFinanceClient + BenchmarkProvider + { logger } option. DataMapper.ts ← normalises Yahoo payload → flat asset data object. Computes: DCF intrinsic value, analyst upside, 52W movement fields, grossMargin, marketCap. Uses trailingPE. Preserves negative FCF. @@ -109,10 +97,6 @@ server/ EtfScorer.ts ← expense gate + registry (cost, yield, volume, fiveYearReturn) BondScorer.ts ← credit gate + spread/duration scoring - reporters/ ← HTML rendering, no business logic - HtmlReporter.ts ← render() → HTML string (server), generate() → writes file (CLI) - FinanceReporter.ts ← render() → HTML string (server), generate() → writes file (CLI) - config/ ScoringConfig.ts ← CREDIT_RATING_SCALE + ScoringRules (single source of truth for all gates, weights, thresholds including analyst and dcf weights) @@ -208,7 +192,6 @@ Scorer × 2 — StockScorer / EtfScorer / BondScorer, fully stateless ↓ ScreenerEngine — derives Signal from comparing both verdicts ↓ - ├── CLI path: screenWithProgress() → HtmlReporter.generate() → screener-report.html └── API path: screenTickers() → JSON (with serialized displayMetrics) → SvelteKit UI ``` @@ -442,17 +425,6 @@ new ScreenerEngine({ logger: noopLogger }) --- -## Reporter Pattern - -Both reporters have two methods: - -```ts -reporter.render(...) // → HTML string (use in server route responses) -reporter.generate(...) // → writes file to disk, returns path (use in CLI) -``` - ---- - ## SimpleFIN Auth Flow 1. User gets a Setup Token from https://beta-bridge.simplefin.org @@ -497,7 +469,7 @@ tests/ ``` Pre-commit hook runs `lint-staged` (Prettier) then `npm test`. Pre-push hook runs `npm test`. -Test output: silent on pass, shows only failures + one summary line (`scripts/summary-reporter.js`). +Test output uses the built-in `spec` reporter. **Key unit:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation. @@ -525,7 +497,6 @@ This section is the single reference for where code lives and how to add feature | `server/clients/` | External API connectors — one class per third-party system | No business logic; only I/O and protocol handling | | `server/models/` | Domain entity classes — hold metrics and `getDisplayMetrics()` | No I/O; pure data + formatting | | `server/scorers/` | Stateless pure scoring functions | No I/O, no state; `score(metrics, rules, marketContext)` only | -| `server/reporters/` | HTML rendering | No business logic; `render()` → string, `generate()` → file | | `server/config/` | Constants and scoring gates/weights | No logic; change numbers here, not in scorers | | `server/types/` | TypeScript interfaces and types | No logic; one `*.model.ts` per domain | | `server/utils/` | Shared pure utilities | No domain knowledge | diff --git a/bin/finance.ts b/bin/finance.ts deleted file mode 100644 index e04653e..0000000 --- a/bin/finance.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * bin/finance.ts — Personal Finance CLI - */ - -import 'dotenv/config'; -import { existsSync, readFileSync } from 'fs'; -import { SimpleFINClient, saveAccessUrlToEnv } from '../server/clients/SimpleFINClient'; -import { FinanceReporter } from '../server/reporters/FinanceReporter'; -import { PersonalFinanceAnalyzer } from '../server/services/PersonalFinanceAnalyzer'; -import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor'; -import { ScreenerEngine } from '../server/services/ScreenerEngine'; -import type { PortfolioHolding } from '../server/types'; - -const PORTFOLIO_PATH = './portfolio.json'; - -async function main(): Promise { - if (!existsSync(PORTFOLIO_PATH)) - throw new Error('portfolio.json not found — edit it with your holdings and re-run.'); - - const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as { - holdings: PortfolioHolding[]; - }; - - const byType = holdings.reduce>((acc, h) => { - const t = h.type ?? 'stock'; - acc[t] = (acc[t] ?? 0) + 1; - return acc; - }, {}); - console.log( - `📋 Portfolio: ${holdings.length} positions — ${Object.entries(byType) - .map(([t, n]) => `${n} ${t}`) - .join(', ')}\n`, - ); - - // ── SimpleFIN accounts (optional) - let personalFinance = null; - if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) { - try { - process.stdout.write('💰 Fetching SimpleFIN accounts...'); - const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv }); - await client.init(); - const { accounts } = await client.getAccounts(); - personalFinance = new PersonalFinanceAnalyzer().analyze(accounts); - process.stdout.write(` ${accounts.length} accounts loaded\n`); - } catch (err) { - process.stdout.write(` skipped — ${(err as Error).message}\n`); - } - } else { - console.log('ℹ Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n'); - } - - // ── Screen stocks & ETFs - const screenableTickers = holdings - .filter((h) => (h.type ?? 'stock') !== 'crypto') - .map((h) => h.ticker.toUpperCase()); - - let results = { - STOCK: [] as any[], - ETF: [] as any[], - BOND: [] as any[], - ERROR: [] as any[], - marketContext: {} as any, - }; - if (screenableTickers.length > 0) { - process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`); - results = (await new ScreenerEngine().screenTickers(screenableTickers)) as any; - process.stdout.write(' done\n'); - } - - process.stdout.write('💡 Generating portfolio advice...'); - const advice = await new PortfolioAdvisor().advise(holdings, results); - process.stdout.write(' done\n'); - - const reportPath = new FinanceReporter().generate( - advice as any, - personalFinance, - results.marketContext, - ); - console.log(`\n✅ Finance report: ${reportPath}\n`); -} - -main().catch((err) => { - console.error('Failed:', (err as Error).message); - process.exit(1); -}); diff --git a/bin/screen.ts b/bin/screen.ts deleted file mode 100644 index cf65e65..0000000 --- a/bin/screen.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * bin/screen.ts — Market Screener CLI - * - * Fetches today's catalyst tickers from Yahoo Finance news, - * screens them under both Market-Adjusted and Fundamental lenses, - * and saves a full HTML report. - * - * Usage: - * npm start → Yahoo news → catalyst tickers → screen - * npm start -- watch → default watchlist - * npm start -- AAPL MSFT VOO → specific tickers - */ - -import 'dotenv/config'; -import { CatalystAnalyst } from '../server/services/CatalystAnalyst'; -import { ScreenerEngine } from '../server/services/ScreenerEngine'; -import { HtmlReporter } from '../server/reporters/HtmlReporter'; - -const DEFAULT_WATCHLIST: string[] = [ - 'PLTR', - 'AAPL', - 'MSFT', - 'TSLA', - 'O', - 'VOO', - 'QQQ', - 'BND', - 'LQD', - 'TLT', - 'IEF', - 'SHY', - 'GOVT', - 'AGG', - 'MUB', -]; - -async function main(): Promise { - const args = process.argv.slice(2); - let tickers: string[] = []; - - if (args.length > 0 && args[0] !== 'watch') { - tickers = args.map((t) => t.toUpperCase()); - console.log(`📋 Screening: ${tickers.join(', ')}\n`); - } else if (args[0] === 'watch') { - tickers = DEFAULT_WATCHLIST; - console.log(`📋 Screening default watchlist (${tickers.length} tickers)\n`); - } else { - try { - const { tickers: newsTickers, stories } = await new CatalystAnalyst().run(); - if (newsTickers.length === 0) { - console.warn("⚠ No tickers in today's news — using default watchlist\n"); - tickers = DEFAULT_WATCHLIST; - } else { - tickers = newsTickers; - console.log("\n📰 Stories driving today's screen:"); - stories.slice(0, 5).forEach((s) => { - const tags = s.tickers.slice(0, 3).join(', '); - console.log(` • ${s.title}${tags ? ` [${tags}]` : ''}`); - }); - console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`); - } - } catch (err) { - console.warn( - `⚠ Catalyst analysis failed (${(err as Error).message}) — using default watchlist\n`, - ); - tickers = DEFAULT_WATCHLIST; - } - } - - try { - const { STOCK, ETF, BOND, ERROR, marketContext } = - await new ScreenerEngine().screenWithProgress(tickers); - const reportPath = new HtmlReporter().generate( - { STOCK, ETF, BOND, ERROR } as any, - marketContext, - ); - console.log(`\n✅ Done — report saved to: ${reportPath}\n`); - } catch (err) { - console.error('Screener failed:', (err as Error).message); - process.exit(1); - } -} - -main().catch(console.error); diff --git a/package.json b/package.json index a0b1357..f57bb31 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,16 @@ "version": "2.0.0", "type": "module", "scripts": { - "start": "tsx bin/screen.ts", "server": "tsx bin/server.ts", "dev": "concurrently -n api,ui -c cyan,magenta \"tsx bin/server.ts\" \"npm run dev --prefix ui\"", "ui:install": "npm install --prefix ui --legacy-peer-deps", - "finance": "tsx bin/finance.ts", "typecheck": "tsc --noEmit", - "test": "tsx --test --test-reporter=./scripts/summary-reporter.ts tests/*.test.ts", + "test": "tsx --test --test-reporter=spec tests/*.test.ts", "test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts", "lint": "eslint . --ext .ts,.js", "lint:fix": "eslint . --ext .ts,.js --fix", - "format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"", - "format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"", + "format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"", + "format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"", "prepare": "husky" }, "lint-staged": { diff --git a/scripts/summary-reporter.ts b/scripts/summary-reporter.ts deleted file mode 100644 index 2a42f0c..0000000 --- a/scripts/summary-reporter.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Minimal test reporter: silent on pass, prints failures in full, ends with one summary line. -import type { TestEvent } from 'node:test/reporters'; - -interface Failure { - name: string; - reason: string; -} - -export default async function* summaryReporter( - source: AsyncIterable, -): AsyncGenerator { - const failures: Failure[] = []; - let passed = 0, - failed = 0, - totalMs = 0; - - for await (const event of source) { - // Skip file-level wrapper events (name ends in .ts) — only count individual tests. - if ((event.data as { name?: string })?.name?.endsWith('.ts')) continue; - - if (event.type === 'test:pass') { - passed++; - totalMs += (event.data as { details?: { duration_ms?: number } }).details?.duration_ms ?? 0; - } else if (event.type === 'test:fail') { - failed++; - totalMs += (event.data as { details?: { duration_ms?: number } }).details?.duration_ms ?? 0; - const err = ( - event.data as { details?: { error?: { cause?: { message?: string }; message?: string } } } - ).details?.error; - failures.push({ - name: (event.data as { name?: string }).name ?? 'unknown', - reason: err?.cause?.message ?? err?.message ?? 'unknown', - }); - } - } - - if (failures.length) { - yield '\nFailed tests:\n'; - for (const f of failures) yield ` ❌ ${f.name}\n ${f.reason}\n`; - yield '\n'; - } - - const status = failed === 0 ? '✅' : '❌'; - const time = (totalMs / 1000).toFixed(2); - yield `${status} ${passed + failed} tests: ${passed} passed`; - if (failed) yield `, ${failed} failed`; - yield ` (${time}s)\n`; -} diff --git a/server/app.ts b/server/app.ts index 749a7db..2bdce6e 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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); diff --git a/server/controllers/finance.controller.ts b/server/controllers/finance.controller.ts index 6aaef5a..899bab2 100644 --- a/server/controllers/finance.controller.ts +++ b/server/controllers/finance.controller.ts @@ -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 }; } diff --git a/server/reporters/FinanceReporter.ts b/server/reporters/FinanceReporter.ts deleted file mode 100644 index 1cbeb50..0000000 --- a/server/reporters/FinanceReporter.ts +++ /dev/null @@ -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 ` - - - - -Personal Finance — ${date} - - - -
-

💰 Personal Finance

-
Date ${date}
-
-
- - ${pf ? this._netWorthSection(pf) : ''} - - ${this._portfolioSection(advice, ctx)} - - ${pf ? this._spendingSection(pf) : ''} - - ${pf ? this._accountsSection(pf) : ''} - -
- -`; - } - - // ── Net worth ────────────────────────────────────────────────────────────── - - _netWorthSection(pf) { - const f = (n) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(n); - return ` -
-

Net Worth

-
- ${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))} -
-
`; - } - - // ── 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 `${s}`; - }; - - const stockRows = stocks - .map((a) => { - const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; - const advClass = this._adviceClass(a.advice); - return ` - ${a.ticker} - ${sourcePill(a.source)} - ${a.type} - ${a.shares} - ${f(a.costBasis)} - ${f(parseFloat(a.currentPrice))} - ${f(parseFloat(a.marketValue))} - ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} - ${a.signal ?? '—'} - ${a.advice} - ${a.reason} - `; - }) - .join(''); - - const cryptoRows = crypto - .map((a) => { - const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; - const advClass = this._adviceClass(a.advice); - return ` - ${a.ticker} - ${sourcePill(a.source)} - ${a.shares} - ${f(a.costBasis)} - ${f(parseFloat(a.currentPrice))} - ${f(parseFloat(a.marketValue))} - ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} - ${a.advice} - ${a.reason} - `; - }) - .join(''); - - return ` -
-

Portfolio — Hold / Sell / Add Advice

-
- ${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')} -
- - ${ - stocks.length > 0 - ? ` -

Stocks & ETFs

- - - - - - - ${stockRows} -
TickerSourceTypeSharesCost BasisCurrentValueG/LSignalAdviceReason
` - : '' - } - - ${ - crypto.length > 0 - ? ` -

Crypto

- - - - - - - ${cryptoRows} -
TickerSourceSharesCost BasisCurrentValueG/LAdviceNote
` - : '' - } -
`; - } - - // ── 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) => ` - - ${c.category} - ${f(c.amount)} - ${c.pct}% - -
- - `, - ) - .join(''); - - return ` -
-

Spending by Category — Last 30 Days

- - - ${rows} -
CategoryAmountShare
-
`; - } - - // ── Accounts ─────────────────────────────────────────────────────────────── - - _accountsSection(pf) { - const f = (n) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(n); - const rows = pf.accounts - .map( - (a) => ` - - ${a.name} - ${a.type} - ${a.org} - ${f(a.balance)} - ${a.balanceDate} - `, - ) - .join(''); - - return ` -
-

Accounts

- - - ${rows} -
AccountTypeInstitutionBalanceUpdated
-
`; - } - - // ── Helpers ──────────────────────────────────────────────────────────────── - - _card(label, value, colorClass = null, sub = null) { - return `
-
${label}
-
${value}
- ${sub ? `
${sub}
` : ''} -
`; - } - - _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'; - } -} diff --git a/server/reporters/HtmlReporter.ts b/server/reporters/HtmlReporter.ts deleted file mode 100644 index 187cd43..0000000 --- a/server/reporters/HtmlReporter.ts +++ /dev/null @@ -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, - 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 - ); - } -} diff --git a/server/services/BenchmarkProvider.ts b/server/services/BenchmarkProvider.ts index 1f8c190..f9734e2 100644 --- a/server/services/BenchmarkProvider.ts +++ b/server/services/BenchmarkProvider.ts @@ -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); } diff --git a/server/services/PortfolioAdvisor.ts b/server/services/PortfolioAdvisor.ts index d1edb1b..f11a7ee 100644 --- a/server/services/PortfolioAdvisor.ts +++ b/server/services/PortfolioAdvisor.ts @@ -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[], diff --git a/server/services/ScreenerEngine.ts b/server/services/ScreenerEngine.ts index 4493ad5..3ff2338 100644 --- a/server/services/ScreenerEngine.ts +++ b/server/services/ScreenerEngine.ts @@ -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), diff --git a/tests/PortfolioAdvisor.test.ts b/tests/PortfolioAdvisor.test.ts index cf69293..362e8bf 100644 --- a/tests/PortfolioAdvisor.test.ts +++ b/tests/PortfolioAdvisor.test.ts @@ -3,9 +3,14 @@ import assert from 'node:assert/strict'; import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor'; import { SIGNAL } from '../server/config/constants'; import type { PortfolioHolding } from '../server/types'; +import type { YahooFinanceClient } from '../server/clients/YahooFinanceClient'; + +// _cryptoPrices is the only method that uses the client; all other private +// methods under test are pure calculations that never touch it. +const stubClient = {} as unknown as YahooFinanceClient; // Cast to any to access private methods — tests exercise internal behaviour directly. -const advisor = new PortfolioAdvisor() as any; +const advisor = new PortfolioAdvisor(stubClient) as any; // Minimal holding shape used by _position and _advice (only costBasis/shares matter). const holding = (costBasis: number, shares: number): PortfolioHolding => ({