phase-7_alpha: legacy code cleanup

This commit is contained in:
Sai Kiran Vella
2026-06-05 22:27:53 -04:00
parent 5185f03c12
commit a7108b448a
13 changed files with 31 additions and 983 deletions
+4 -33
View File
@@ -4,10 +4,7 @@ Guidance for working in this repository.
## Overview ## Overview
`market-screener` is a Node.js project with two modes: `market-screener` is a Node.js project consisting of a Fastify API server that powers the SvelteKit dashboard in the `ui/` subdirectory.
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
Every asset is scored under two lenses: 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 install # install dependencies
npm run dev # start API server (port 3000) + SvelteKit UI (port 5173) together npm run dev # start API server (port 3000) + SvelteKit UI (port 5173) together
npm run server # API server only (port 3000) 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 test # run all unit tests (node:test, zero external deps)
npm run test:watch # watch mode — uses verbose spec reporter npm run test:watch # watch mode — uses verbose spec reporter
npm run format # format all server/bin/tests with Prettier npm run format # format all server/bin/tests with Prettier
@@ -45,13 +38,8 @@ npm run ui:install # install UI dependencies (ui/ sub
``` ```
bin/ 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) 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/ prompts/
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow) 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) analyze.controller.ts ← POST /api/analyze (LLM analysis for a ticker set)
services/ ← business logic, no HTTP or I/O concerns services/ ← business logic, no HTTP or I/O concerns
ScreenerEngine.ts ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data), ScreenerEngine.ts ← orchestrates: fetch → score × 2. Method: screenTickers() → ScreenerResult.
screenWithProgress() (CLI with stdout). Accepts { logger } option. Accepts injected YahooFinanceClient + BenchmarkProvider + { logger } option.
DataMapper.ts ← normalises Yahoo payload → flat asset data object. DataMapper.ts ← normalises Yahoo payload → flat asset data object.
Computes: DCF intrinsic value, analyst upside, 52W movement fields, Computes: DCF intrinsic value, analyst upside, 52W movement fields,
grossMargin, marketCap. Uses trailingPE. Preserves negative FCF. grossMargin, marketCap. Uses trailingPE. Preserves negative FCF.
@@ -109,10 +97,6 @@ server/
EtfScorer.ts ← expense gate + registry (cost, yield, volume, fiveYearReturn) EtfScorer.ts ← expense gate + registry (cost, yield, volume, fiveYearReturn)
BondScorer.ts ← credit gate + spread/duration scoring 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/ config/
ScoringConfig.ts ← CREDIT_RATING_SCALE + ScoringRules (single source of truth for all ScoringConfig.ts ← CREDIT_RATING_SCALE + ScoringRules (single source of truth for all
gates, weights, thresholds including analyst and dcf weights) 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 ScreenerEngine — derives Signal from comparing both verdicts
├── CLI path: screenWithProgress() → HtmlReporter.generate() → screener-report.html
└── API path: screenTickers() → JSON (with serialized displayMetrics) → SvelteKit UI └── 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 ## SimpleFIN Auth Flow
1. User gets a Setup Token from https://beta-bridge.simplefin.org 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`. 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. **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/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/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/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/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/types/` | TypeScript interfaces and types | No logic; one `*.model.ts` per domain |
| `server/utils/` | Shared pure utilities | No domain knowledge | | `server/utils/` | Shared pure utilities | No domain knowledge |
-85
View File
@@ -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<void> {
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<Record<string, number>>((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);
});
-84
View File
@@ -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<void> {
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);
+3 -5
View File
@@ -3,18 +3,16 @@
"version": "2.0.0", "version": "2.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "tsx bin/screen.ts",
"server": "tsx bin/server.ts", "server": "tsx bin/server.ts",
"dev": "concurrently -n api,ui -c cyan,magenta \"tsx bin/server.ts\" \"npm run dev --prefix ui\"", "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", "ui:install": "npm install --prefix ui --legacy-peer-deps",
"finance": "tsx bin/finance.ts",
"typecheck": "tsc --noEmit", "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", "test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts",
"lint": "eslint . --ext .ts,.js", "lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix", "lint:fix": "eslint . --ext .ts,.js --fix",
"format": "prettier --write \"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\" \"scripts/**/*.ts\"", "format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
"prepare": "husky" "prepare": "husky"
}, },
"lint-staged": { "lint-staged": {
-48
View File
@@ -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<TestEvent>,
): AsyncGenerator<string> {
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`;
}
+6 -3
View File
@@ -5,6 +5,8 @@ import { FinanceController } from './controllers/finance.controller';
import { CallsController } from './controllers/calls.controller'; import { CallsController } from './controllers/calls.controller';
import { AnalyzeController } from './controllers/analyze.controller'; import { AnalyzeController } from './controllers/analyze.controller';
import { ScreenerEngine } from './services/ScreenerEngine'; import { ScreenerEngine } from './services/ScreenerEngine';
import { BenchmarkProvider } from './services/BenchmarkProvider';
import { PortfolioAdvisor } from './services/PortfolioAdvisor';
import { LLMAnalyst } from './services/LLMAnalyst'; import { LLMAnalyst } from './services/LLMAnalyst';
import { CatalystAnalyst } from './services/CatalystAnalyst'; import { CatalystAnalyst } from './services/CatalystAnalyst';
import { YahooFinanceClient } from './clients/YahooFinanceClient'; import { YahooFinanceClient } from './clients/YahooFinanceClient';
@@ -29,14 +31,15 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173', origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
}); });
const engine = new ScreenerEngine({ logger: noopLogger });
const yahoo = new YahooFinanceClient(); 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 llm = new LLMAnalyst({ logger: noopLogger });
const catalyst = new CatalystAnalyst({ logger: noopLogger }); const catalyst = new CatalystAnalyst({ logger: noopLogger });
new ScreenerController(engine).register(app); 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 CallsController(new MarketCallRepository(), engine, yahoo).register(app);
new AnalyzeController(catalyst, llm).register(app); new AnalyzeController(catalyst, llm).register(app);
+2 -1
View File
@@ -10,6 +10,7 @@ export class FinanceController {
constructor( constructor(
private readonly engine: ScreenerEngine, private readonly engine: ScreenerEngine,
private readonly repo: PortfolioRepository, private readonly repo: PortfolioRepository,
private readonly advisor: PortfolioAdvisor,
) {} ) {}
private static normalizeYahoo(ticker: string): string { private static normalizeYahoo(ticker: string): string {
@@ -44,7 +45,7 @@ export class FinanceController {
? await this.engine.screenTickers(screenable) ? await this.engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any }; : { 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 }; return { advice, personalFinance, marketContext: results.marketContext };
} }
-308
View File
@@ -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 &amp; 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';
}
}
-400
View File
@@ -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
);
}
}
+4 -3
View File
@@ -25,12 +25,13 @@ export class BenchmarkProvider {
private static pe(summary: any): number | null { private static pe(summary: any): number | null {
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null; return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
} }
private client: YahooFinanceClient;
private cache: { data: MarketContext | null; expiresAt: number }; private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger; private logger: Logger;
constructor({ logger }: BenchmarkProviderOptions = {}) { constructor(
this.client = new YahooFinanceClient(); private readonly client: YahooFinanceClient,
{ logger }: BenchmarkProviderOptions = {},
) {
this.cache = { data: null, expiresAt: 0 }; this.cache = { data: null, expiresAt: 0 };
this.logger = logger ?? (console as unknown as Logger); this.logger = logger ?? (console as unknown as Logger);
} }
+1 -5
View File
@@ -11,11 +11,7 @@ import type {
} from '../types'; } from '../types';
export class PortfolioAdvisor { export class PortfolioAdvisor {
private client: YahooFinanceClient; constructor(private readonly client: YahooFinanceClient) {}
constructor() {
this.client = new YahooFinanceClient();
}
async advise( async advise(
holdings: PortfolioHolding[], holdings: PortfolioHolding[],
+5 -7
View File
@@ -29,15 +29,13 @@ export class ScreenerEngine {
private static readonly BATCH_SIZE = 5; private static readonly BATCH_SIZE = 5;
private static readonly BATCH_DELAY_MS = 1000; private static readonly BATCH_DELAY_MS = 1000;
private client: YahooFinanceClient;
private benchmarkProvider: BenchmarkProvider;
private logger: Logger; private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) { constructor(
this.client = new YahooFinanceClient(); private readonly client: YahooFinanceClient,
this.benchmarkProvider = new BenchmarkProvider({ private readonly benchmarkProvider: BenchmarkProvider,
logger: logger ?? (console as unknown as Logger), { logger }: ScreenerEngineOptions = {},
}); ) {
this.logger = logger ?? { this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg), write: (msg: string) => process.stdout.write(msg),
log: (...args: unknown[]) => console.log(...args), log: (...args: unknown[]) => console.log(...args),
+6 -1
View File
@@ -3,9 +3,14 @@ import assert from 'node:assert/strict';
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor'; import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
import { SIGNAL } from '../server/config/constants'; import { SIGNAL } from '../server/config/constants';
import type { PortfolioHolding } from '../server/types'; 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. // 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). // Minimal holding shape used by _position and _advice (only costBasis/shares matter).
const holding = (costBasis: number, shares: number): PortfolioHolding => ({ const holding = (costBasis: number, shares: number): PortfolioHolding => ({