phase-7_alpha: legacy code cleanup
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
Reference in New Issue
Block a user