From b75e8bda72597a5e16f52be0824b0756963701f3 Mon Sep 17 00:00:00 2001 From: Kazuma Date: Thu, 4 Jun 2026 01:36:28 -0400 Subject: [PATCH] phase-1: optimize code --- .claude/launch.json | 11 + .env.example | 11 + .gitignore | 17 +- .husky/pre-commit | 2 + .husky/pre-push | 1 + .prettierrc | 12 + CLAUDE.md | 464 ++++++ Market_News_Analysis.md | 46 - README.md | 239 ++- bin/finance.js | 84 + bin/screen.js | 83 + bin/server.js | 14 + index.js | 32 - markdown.md | 47 - package-lock.json | 1406 +++++++++++++++- package.json | 30 +- prompts/catalyst-analysis.md | 165 ++ scripts/summary-reporter.js | 37 + src/analyst/CatalystAnalyst.js | 53 + src/analyst/LLMAnalyst.js | 78 + src/api/BenchmarkProvider.js | 45 - src/calls/MarketCallStore.js | 80 + src/config/ScoringConfig.js | 222 ++- src/config/constants.js | 53 + src/core/assets/Asset.js | 23 - src/core/assets/Stock.js | 60 - src/core/engine/ScoringEngine.js | 49 - src/core/engine/ScreenerEngine.js | 122 -- src/core/scorers/BondScorer.js | 63 - src/core/scorers/EtfScorer.js | 62 - src/core/scorers/StockScorer.js | 107 -- src/finance/PersonalFinanceAnalyzer.js | 62 + src/finance/PortfolioAdvisor.js | 167 ++ src/finance/clients/SimpleFINClient.js | 201 +++ src/market/BenchmarkProvider.js | 73 + src/market/MarketRegime.js | 63 + .../yahooClient.js => market/YahooClient.js} | 12 +- src/reporters/FinanceReporter.js | 304 ++++ src/reporters/HtmlReporter.js | 392 +++++ src/screener/Chunker.js | 4 + src/screener/DataMapper.js | 153 ++ src/screener/RuleMerger.js | 33 + src/screener/ScreenerEngine.js | 141 ++ src/screener/assets/Asset.js | 19 + src/{core => screener}/assets/Bond.js | 11 +- src/{core => screener}/assets/Etf.js | 14 +- src/screener/assets/Stock.js | 134 ++ src/screener/scorers/BondScorer.js | 40 + src/screener/scorers/EtfScorer.js | 36 + src/screener/scorers/StockScorer.js | 157 ++ src/server/app.js | 77 + src/server/routes/calls.js | 187 +++ src/server/routes/finance.js | 111 ++ src/server/routes/screener.js | 59 + src/utils/Chunker.js | 7 - src/utils/DataMapper.js | 58 - src/utils/RulesMerger.js | 37 - tests/BondScorer.test.js | 61 + tests/DataMapper.test.js | 149 ++ tests/EtfScorer.test.js | 54 + tests/LLMAnalyst.test.js | 47 + tests/MarketRegime.test.js | 69 + tests/PortfolioAdvisor.test.js | 92 ++ tests/RuleMerger.test.js | 66 + tests/ScoringConfig.test.js | 41 + tests/StockScorer.test.js | 81 + ui/CLAUDE.md | 170 ++ ui/README.md | 114 ++ ui/package-lock.json | 1456 +++++++++++++++++ ui/package.json | 17 + ui/src/app.css | 7 + ui/src/app.html | 12 + ui/src/lib/MarketContext.svelte | 191 +++ ui/src/lib/SignalBadge.svelte | 29 + ui/src/lib/Spinner.svelte | 139 ++ ui/src/lib/api.js | 96 ++ ui/src/routes/+layout.js | 1 + ui/src/routes/+layout.svelte | 132 ++ ui/src/routes/+page.svelte | 858 ++++++++++ ui/src/routes/calls/+page.js | 8 + ui/src/routes/calls/+page.svelte | 420 +++++ ui/src/routes/calls/[id]/+page.js | 5 + ui/src/routes/calls/[id]/+page.svelte | 202 +++ ui/src/routes/portfolio/+page.js | 8 + ui/src/routes/portfolio/+page.svelte | 795 +++++++++ ui/src/routes/safe-buys/+page.js | 60 + ui/src/routes/safe-buys/+page.svelte | 368 +++++ ui/svelte.config.js | 5 + ui/vite.config.js | 11 + 89 files changed, 11189 insertions(+), 845 deletions(-) create mode 100644 .claude/launch.json create mode 100644 .env.example create mode 100755 .husky/pre-commit create mode 100755 .husky/pre-push create mode 100644 .prettierrc create mode 100644 CLAUDE.md delete mode 100644 Market_News_Analysis.md create mode 100644 bin/finance.js create mode 100644 bin/screen.js create mode 100644 bin/server.js delete mode 100644 index.js delete mode 100644 markdown.md create mode 100644 prompts/catalyst-analysis.md create mode 100644 scripts/summary-reporter.js create mode 100644 src/analyst/CatalystAnalyst.js create mode 100644 src/analyst/LLMAnalyst.js delete mode 100644 src/api/BenchmarkProvider.js create mode 100644 src/calls/MarketCallStore.js create mode 100644 src/config/constants.js delete mode 100644 src/core/assets/Asset.js delete mode 100644 src/core/assets/Stock.js delete mode 100644 src/core/engine/ScoringEngine.js delete mode 100644 src/core/engine/ScreenerEngine.js delete mode 100644 src/core/scorers/BondScorer.js delete mode 100644 src/core/scorers/EtfScorer.js delete mode 100644 src/core/scorers/StockScorer.js create mode 100644 src/finance/PersonalFinanceAnalyzer.js create mode 100644 src/finance/PortfolioAdvisor.js create mode 100644 src/finance/clients/SimpleFINClient.js create mode 100644 src/market/BenchmarkProvider.js create mode 100644 src/market/MarketRegime.js rename src/{api/yahooClient.js => market/YahooClient.js} (66%) create mode 100644 src/reporters/FinanceReporter.js create mode 100644 src/reporters/HtmlReporter.js create mode 100644 src/screener/Chunker.js create mode 100644 src/screener/DataMapper.js create mode 100644 src/screener/RuleMerger.js create mode 100644 src/screener/ScreenerEngine.js create mode 100644 src/screener/assets/Asset.js rename src/{core => screener}/assets/Bond.js (60%) rename src/{core => screener}/assets/Etf.js (56%) create mode 100644 src/screener/assets/Stock.js create mode 100644 src/screener/scorers/BondScorer.js create mode 100644 src/screener/scorers/EtfScorer.js create mode 100644 src/screener/scorers/StockScorer.js create mode 100644 src/server/app.js create mode 100644 src/server/routes/calls.js create mode 100644 src/server/routes/finance.js create mode 100644 src/server/routes/screener.js delete mode 100644 src/utils/Chunker.js delete mode 100644 src/utils/DataMapper.js delete mode 100644 src/utils/RulesMerger.js create mode 100644 tests/BondScorer.test.js create mode 100644 tests/DataMapper.test.js create mode 100644 tests/EtfScorer.test.js create mode 100644 tests/LLMAnalyst.test.js create mode 100644 tests/MarketRegime.test.js create mode 100644 tests/PortfolioAdvisor.test.js create mode 100644 tests/RuleMerger.test.js create mode 100644 tests/ScoringConfig.test.js create mode 100644 tests/StockScorer.test.js create mode 100644 ui/CLAUDE.md create mode 100644 ui/README.md create mode 100644 ui/package-lock.json create mode 100644 ui/package.json create mode 100644 ui/src/app.css create mode 100644 ui/src/app.html create mode 100644 ui/src/lib/MarketContext.svelte create mode 100644 ui/src/lib/SignalBadge.svelte create mode 100644 ui/src/lib/Spinner.svelte create mode 100644 ui/src/lib/api.js create mode 100644 ui/src/routes/+layout.js create mode 100644 ui/src/routes/+layout.svelte create mode 100644 ui/src/routes/+page.svelte create mode 100644 ui/src/routes/calls/+page.js create mode 100644 ui/src/routes/calls/+page.svelte create mode 100644 ui/src/routes/calls/[id]/+page.js create mode 100644 ui/src/routes/calls/[id]/+page.svelte create mode 100644 ui/src/routes/portfolio/+page.js create mode 100644 ui/src/routes/portfolio/+page.svelte create mode 100644 ui/src/routes/safe-buys/+page.js create mode 100644 ui/src/routes/safe-buys/+page.svelte create mode 100644 ui/svelte.config.js create mode 100644 ui/vite.config.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..ed36181 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "market-screener-ui", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "ui"], + "port": 5173 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..53bc189 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# ── SimpleFIN personal finance ─────────────────────────────────────────────── +# +# FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org +# (Settings → Connect an app → copy the token) +# +SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3Jn... +# +# AFTER FIRST RUN: the Access URL is written here automatically. +# Remove SIMPLEFIN_SETUP_TOKEN once this appears. +# +# SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin diff --git a/.gitignore b/.gitignore index b512c09..d3c60b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,16 @@ -node_modules \ No newline at end of file +node_modules +ui/node_modules + +# Sensitive data — never commit +portfolio.json +market-calls.json +.env +.env.* + +# Build outputs +ui/.svelte-kit +ui/build + +# Reports +screener-report.html +finance-report.html \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..1c0ebca --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npx lint-staged +npm test diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..72c4429 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +npm test diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ca34704 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1927fe2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,464 @@ +# CLAUDE.md + +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 + +Every asset is scored under two lenses: + +- **Market-Adjusted** — gates derived from live Yahoo benchmarks (SPY P/E, XLK P/E, XLRE yield, LQD spread). Reflects what is acceptable in today's market. +- **Fundamental** — strict Graham/value-investing gates from `ScoringConfig`. Reflects genuine value regardless of market conditions. + +The comparison produces a **Signal** (Strong Buy / Momentum / Speculation / Neutral / Avoid). + +ES module project (`"type": "module"`); use `import`/`export`, not `require`. + +--- + +## Commands + +```bash +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 src/bin/tests with Prettier +npm run format:check # check formatting without writing (used in CI/pre-commit) +npm run ui:install # install UI dependencies (ui/ subdirectory) +``` + +`npm run dev` runs both the API server and the SvelteKit UI (in `ui/`) concurrently. Run `npm run ui:install` once before first use. + +--- + +## Project Structure + +``` +bin/ + screen.js ← CLI screener entry point + finance.js ← CLI personal finance entry point + import-portfolio.js ← broker CSV importer + server.js ← Fastify API server entry point + +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) + +src/ + config/ + ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth) + constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER + + market/ ← Yahoo Finance data layer + YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff + BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext + MarketRegime.js ← derives INFLATED gate overrides from live benchmarks + rate regime + + screener/ ← core screening domain + ScreenerEngine.js ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data), + screenWithProgress() (CLI with stdout). Accepts { logger } option. + DataMapper.js ← normalises Yahoo payload → flat asset data object + NOTE: uses trailingPE (not forwardPE). Preserves negative FCF. + Infers bond duration from category string. Maps ETF volume. + RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode) + Chunker.js ← splits ticker list into batches + assets/ + Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers + Stock.js ← metrics + _mapToStandardSector (8 sectors detected) + Etf.js ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets + Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric + scorers/ + StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf) + EtfScorer.js ← expense gate + registry (cost, yield, volume, fiveYearReturn) + BondScorer.js ← credit gate + spread/duration scoring + + analyst/ + CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }. + LLMAnalyst.js ← uses Claude Haiku (ANTHROPIC_API_KEY) to analyze headlines → summary, + sentiment (BULLISH/NEUTRAL/BEARISH), affectedIndustries, relatedTickers. + Returns null gracefully if API key is not set. Accepts { logger }. + + calls/ + MarketCallStore.js ← persists quarterly market thesis entries to market-calls.json. + Each call stores: title, quarter, date, thesis, tickers[], snapshot{} + (price + signal per ticker at creation time). CRUD: list/get/create/delete. + + finance/ + clients/ + SimpleFINClient.js ← claims setup token → access URL, fetches /accounts via Basic Auth header + (NOT embedded credentials in URL). Accepts { logger, onAccessUrlClaimed }. + PersonalFinanceAnalyzer.js ← net worth, cash vs investments, spending by category + PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice + + reporters/ + HtmlReporter.js ← render() → HTML string (server), generate() → writes file (CLI) + FinanceReporter.js ← render() → HTML string (server), generate() → writes file (CLI) + + server/ + app.js ← Fastify app factory (buildApp). Registers CORS + routes. + routes/ + screener.js ← POST /api/screen, GET /api/screen/catalysts + Serializes asset.getDisplayMetrics() before JSON response. + finance.js ← GET /api/finance/portfolio, GET /api/finance/market-context + calls.js ← CRUD for market calls + GET /api/calls/calendar (earnings/dividend events) + +ui/ ← SvelteKit dashboard (lives inside this repo, not a separate repo) + src/routes/ + +page.svelte ← main screener UI + calls/ ← market calls list + detail views + portfolio/ ← portfolio advice view + safe-buys/ ← filtered strong-buy view + +market-calls.json ← persisted market thesis calls (written by MarketCallStore) +portfolio.json ← user's holdings: ticker, shares, costBasis, source, type +.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY +``` + +--- + +## Data Flow + +``` +Yahoo Finance API + ↓ +BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD + builds marketContext { sp500Price, riskFreeRate, vixLevel, + rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } } + ↓ +DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND) + uses trailingPE as primary; preserves negative FCF yield; infers bond duration + ↓ +Asset subclass — Stock / Etf / Bond holds metrics + getDisplayMetrics() + ↓ +RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style) + INFLATED mode: sector override + MarketRegime live gate overrides + ↓ +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 +``` + +--- + +## API Routes (Fastify) + +| Method | Path | Description | +|---|---|---| +| GET | `/health` | Health check | +| POST | `/api/screen` | Screen tickers. Body: `{ tickers: string[] }`. Returns `{ STOCK, ETF, BOND, ERROR, marketContext }` with `asset.displayMetrics` pre-serialized | +| GET | `/api/screen/catalysts` | Yahoo news → `{ tickers, stories }` | +| GET | `/api/finance/portfolio` | Portfolio advice + optional SimpleFIN data | +| GET | `/api/finance/market-context` | Live benchmark data only | +| GET | `/api/calls` | List all market calls (newest first) | +| GET | `/api/calls/:id` | Get one call + re-screened current prices for comparison | +| POST | `/api/analyze` | Fetch Yahoo news for specific tickers + run LLM analysis. Body: `{ tickers: string[] }`. Returns `{ analysis }` | +| POST | `/api/calls` | Create a market call; snapshots current prices. Body: `{ title, quarter, thesis, tickers[], date? }` | +| DELETE | `/api/calls/:id` | Delete a market call | +| GET | `/api/calls/calendar` | Earnings + dividend calendar. Query: `?tickers=AAPL,MSFT` (omit for all call tickers) | + +CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`). + +--- + +## Scoring Modes + +| Mode | P/E Gate (general) | P/E Gate (tech) | Source | +|---|---|---|---| +| FUNDAMENTAL | 15x | 35x | ScoringConfig (true Graham) | +| INFLATED | S&P 500 P/E × 1.5 | XLK P/E × 1.3 | Live SPY/XLK data | + +**Rate regime effect on INFLATED mode:** +- HIGH rate regime: P/E multiplier compresses to 1.2× (vs 1.5× in NORMAL) +- HIGH rate regime: REIT yield floor tightens (0.95× vs 0.85×) +- HIGH rate regime: bond spread demand increases (0.90× vs 0.80×) + +| Signal | Meaning | +|---|---| +| ✅ Strong Buy | Passes both fundamental AND inflated gates | +| ⚡ Momentum | Passes inflated, holds fundamentally | +| ⚠️ Speculation | Passes inflated, fails fundamental | +| 🔄 Neutral | Hold territory in one or both lenses | +| ❌ Avoid | Fails both | + +--- + +## ScoringConfig Key Values + +`src/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds. + +**STOCK base gates (Fundamental mode):** +- `maxPERatio: 15` — Graham's actual rule (trailing P/E) +- `maxPegGate: 1.0` — Lynch standard: PEG > 1.0 means paying full price +- `maxDebtToEquity: 1.5` — most distress starts above 2x +- `minQuickRatio: 0.8` — below this signals real liquidity stress + +**Sector overrides** (structural — apply in both modes): + +| Sector | Key difference | +|---|---| +| TECHNOLOGY | D/E up to 2.0, P/E up to 35x, FCF weight raised | +| REIT | P/E and PEG disabled (9999), scored on yield + P/FFO | +| FINANCIAL | D/E disabled, scored on ROE + P/B, maxPriceToBook 1.5x | +| ENERGY | FCF weight 4, yield weight 3, opMargin primary | +| HEALTHCARE | Revenue growth primary, P/E up to 25x | +| COMMUNICATION | FCF weight 4, P/E up to 25x (META, GOOGL, NFLX) | +| CONSUMER_STAPLES | Margin/ROE focus, low revenue growth expectations | +| CONSUMER_DISCRETIONARY | Revenue growth primary, P/E up to 25x | + +**ETF gates:** +- `maxExpenseRatio: 0.2%` — hard gate +- `minFiveYearReturn: 8.0%` — S&P long-run floor +- `minVolume: 1,000,000` ADV + +**BOND gates:** +- `minCreditRating: 7` (BBB = investment-grade floor) +- `minSpread: 1.5%` above risk-free +- `maxDuration: 7` years + +--- + +## MarketRegime (INFLATED overrides) + +`src/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime: + +| Gate | Formula (NORMAL rates) | Formula (HIGH rates) | +|---|---|---| +| Stock maxPERatio | SPY trailing P/E × 1.5 | SPY trailing P/E × 1.2 | +| Tech maxPERatio | XLK P/E × 1.3 | XLK P/E × 1.3 | +| Tech maxPegGate | XLK P/E ÷ 15 | XLK P/E ÷ 15 | +| REIT minYield | XLRE yield × 0.85 | XLRE yield × 0.95 | +| Bond minSpread | LQD−TNX × 0.80 | LQD−TNX × 0.90 | +| ETF maxExpenseRatio | 0.75% | 0.75% | + +--- + +## Sector Detection + +`Stock._mapToStandardSector()` maps Yahoo Finance `sector`/`industry` strings to internal constants. +Order matters — more specific matches first: + +``` +TECHNOLOGY → "technology", "electronic", "semiconductor", "software" +REIT → "real estate", "reit" +FINANCIAL → "financial", "bank", "insurance", "asset management" +ENERGY → "energy", "oil", "gas", "petroleum" +HEALTHCARE → "health", "biotech", "pharmaceutical", "medical" +COMMUNICATION→ "communication", "media", "entertainment", "telecom" +CONSUMER_STAPLES → "consumer defensive", "consumer staples", "household", "beverage", "food" +CONSUMER_DISCRETIONARY → "consumer cyclical", "consumer discretionary", "retail", "apparel", "auto" +GENERAL → fallback +``` + +--- + +## DataMapper Notes + +- **peRatio**: prefers `trailingPE` (audited) over `forwardPE` (analyst estimate, ~10-15% optimistic) +- **FCF yield**: `freeCashflow !== 0` (not `> 0`) — negative FCF preserved so cash-burning companies fail the gate, not silently skip it +- **Bond duration**: inferred from category string ("Short-Term" → 2y, "Intermediate" → 5y, "Long" → 18y, default 6y). Yahoo does not expose effective duration in the modules we fetch. +- **ETF volume**: `summaryDetail.averageVolume` — was missing before, causing the `-2` liquidity penalty on every ETF + +--- + +## Missing Data Convention + +- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` rather than auto-failing. +- `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it. +- `quickRatio` falls back to `currentRatio` when missing. + +--- + +## Logger Injection Pattern + +Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context: + +```js +// CLI (default) — writes to stdout +new ScreenerEngine() + +// Server — fully silent +new ScreenerEngine({ logger: { write: () => {}, log: () => {}, warn: () => {} } }) +``` + +Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`. + +--- + +## Reporter Pattern + +Both reporters have two methods: + +```js +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 +2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim Access URL +3. `onAccessUrlClaimed` callback is called with the URL — CLI uses `saveAccessUrlToEnv()`, server stores elsewhere +4. All subsequent requests use Access URL with `Authorization: Basic` header (not embedded in URL) + +--- + +## portfolio.json Format + +```json +{ + "holdings": [ + { "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" }, + { "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" }, + { "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" } + ] +} +``` + +`type` values: `stock`, `etf`, `crypto`. Crypto is priced via Yahoo (BTC-USD style) but not fundamentally scored. + +--- + +## Tests + +Uses Node's built-in test runner (`node:test` + `node:assert/strict`) — no test framework needed. + +``` +tests/ + ScoringConfig.test.js ← gate values (P/E 15x, PEG 1.0, QuickRatio 0.8), sector overrides + RuleMerger.test.js ← FUNDAMENTAL vs INFLATED modes, sector merging + MarketRegime.test.js ← inflated overrides including HIGH/NORMAL rate regime variants + StockScorer.test.js ← gate failures, scoring labels, risk flags + EtfScorer.test.js ← expense gate, volume penalty, 5Y return scoring + BondScorer.test.js ← credit gate, spread/duration scoring, unit handling + DataMapper.test.js ← type detection, PEG computation, trailing PE preference, + negative FCF, ETF volume, bond duration inference + PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping, BRK.B dot-notation normalisation + LLMAnalyst.test.js ← markdown fence stripping, JSON parse correctness +``` + +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`). + +**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. + +--- + +## Conventions + +- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules. +- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers. +- BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch. +- All entry points live in `bin/`. Do not add logic to entry points — they call into `src/`. +- `bin/server.js` starts Fastify; `src/server/` contains all route logic. +- **Never** call `process.exit()` inside `src/` — only `bin/` may do that. +- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `src/server/routes/screener.js` `serializeAssets()`). + +--- + +## Architecture Roadmap + +Planned improvements in priority order. Do not start a later phase before completing earlier ones. + +### Phase 1 — Cleanup ✅ COMPLETE +All items completed. Additional features delivered alongside cleanup: + +**Cleanup done:** +- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md` +- Deleted `src/server/routes/analyze.js` (orphaned route file) +- Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte` +- Fixed `.gitignore` — `portfolio.json`, `market-calls.json`, `.env` are now excluded from git + +**Features added during Phase 1:** +- `POST /api/analyze` — per-tab LLM analysis with sidebar (✦ Analyze button on each asset section) +- `POST /api/finance/holdings` + `DELETE /api/finance/holdings/:ticker` — add/edit/delete holdings via UI +- Portfolio page: inline row editing, optimistic UI updates, sortable columns, collapsible market context with tooltips, P&L summary card tooltips +- Holdings can be added/edited/deleted via the portfolio UI (manual entry replaces CSV importer) +- `BRK.B` dot-notation tickers now normalised to Yahoo Finance format (`BRK.B → BRK-B`) +- Market graph drawing-line animation replaces generic spinner (lg/md); dot-pulse for sm (buttons) +- Portfolio page loads client-side (`$effect`) to avoid blocking navigation +- Catalyst page auto-loads on mount; LLM analysis only runs on explicit ✦ Analyze click + +**Pending (deferred to later):** +- LLM Analysis button on portfolio page (analyse holdings against current news) + +### Phase 2 — Extract Shared Utilities +- Create `ui/src/lib/utils.ts` with all pure functions currently duplicated across pages: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass` +- Create `src/server/utils/logger.js` with shared `noopLogger` constant (currently copy-pasted in `screener.js` and `app.js`) + +### Phase 3 — Rename `src/` → `server/` +- Rename the directory and update all import paths in `bin/`, internal routes, and `CLAUDE.md` +- Makes the API layer unambiguous — `src/` conventionally implies "all project source" + +### Phase 4 — SCSS Migration +Replace per-component ` + + +
+

💰 Personal Finance

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

Net Worth

+
+ ${this._card('Net Worth', f(pf.netWorth), pf.netWorth >= 0 ? 'green' : 'red')} + ${this._card('Total Assets', f(pf.totalAssets))} + ${this._card('Liabilities', f(pf.totalLiabilities), 'red')} + ${this._card('Cash & Savings', `${f(pf.totalCash)}`, null, `${pf.cashPct}% of assets`)} + ${this._card('Investments', `${f(pf.totalInvestments)}`, null, `${pf.investPct}% of assets`)} + ${pf.savingsRate != null ? this._card('Savings Rate', `${pf.savingsRate}%`, parseFloat(pf.savingsRate) > 20 ? 'green' : 'yellow') : ''} + ${this._card('Monthly Income', f(pf.totalIncome))} + ${this._card('Monthly Spend', f(pf.totalSpend))} +
+
`; + } + + // ── Portfolio with hold/sell advice ─────────────────────────────────────── + + _portfolioSection(advice, ctx) { + const f = (n) => + n != null + ? new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(n) + : '—'; + const f2 = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(n); + const b = ctx?.benchmarks ?? {}; + + const stocks = advice.filter((a) => a.type !== 'crypto'); + const crypto = advice.filter((a) => a.type === 'crypto'); + + const totalValue = advice.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0); + const totalCost = advice.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0); + const totalGL = totalValue - totalCost; + const totalGLPct = totalCost > 0 ? ((totalGL / totalCost) * 100).toFixed(1) : null; + + const sourceColors = { + Robinhood: '#22c55e', + Vanguard: '#3b82f6', + Fidelity: '#f59e0b', + Coinbase: '#8b5cf6', + }; + const sourcePill = (s) => { + const color = sourceColors[s] ?? '#64748b'; + return `${s}`; + }; + + const stockRows = stocks + .map((a) => { + const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; + const advClass = this._adviceClass(a.advice); + return ` + ${a.ticker} + ${sourcePill(a.source)} + ${a.type} + ${a.shares} + ${f(a.costBasis)} + ${f(parseFloat(a.currentPrice))} + ${f(parseFloat(a.marketValue))} + ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} + ${a.signal ?? '—'} + ${a.advice} + ${a.reason} + `; + }) + .join(''); + + const cryptoRows = crypto + .map((a) => { + const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red'; + const advClass = this._adviceClass(a.advice); + return ` + ${a.ticker} + ${sourcePill(a.source)} + ${a.shares} + ${f(a.costBasis)} + ${f(parseFloat(a.currentPrice))} + ${f(parseFloat(a.marketValue))} + ${a.gainLossPct != null ? a.gainLossPct + '%' : '—'} + ${a.advice} + ${a.reason} + `; + }) + .join(''); + + return ` +
+

Portfolio — Hold / Sell / Add Advice

+
+ ${this._card('Total Value', f2(totalValue))} + ${this._card('Total Cost', f2(totalCost))} + ${this._card('Total G/L', f2(totalGL), totalGL >= 0 ? 'green' : 'red', totalGLPct != null ? totalGLPct + '%' : '')} + ${this._card('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x', null, 'Live benchmark')} +
+ + ${ + stocks.length > 0 + ? ` +

Stocks & ETFs

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

Crypto

+ + + + + + + ${cryptoRows} +
TickerSourceSharesCost BasisCurrentValueG/LAdviceNote
` + : '' + } +
`; + } + + // ── Spending breakdown ───────────────────────────────────────────────────── + + _spendingSection(pf) { + const f = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(n); + const rows = pf.categoryBreakdown + .slice(0, 10) + .map( + (c) => ` + + ${c.category} + ${f(c.amount)} + ${c.pct}% + +
+ + `, + ) + .join(''); + + return ` +
+

Spending by Category — Last 30 Days

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

Accounts

+ + + ${rows} +
AccountTypeInstitutionBalanceUpdated
+
`; + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + _card(label, value, colorClass = null, sub = null) { + return `
+
${label}
+
${value}
+ ${sub ? `
${sub}
` : ''} +
`; + } + + _adviceClass(advice) { + if (advice?.includes('🟢')) return 'advice-green'; + if (advice?.includes('🟡')) return 'advice-yellow'; + if (advice?.includes('🟠')) return 'advice-orange'; + if (advice?.includes('🔴')) return 'advice-red'; + return 'gray'; + } +} diff --git a/src/reporters/HtmlReporter.js b/src/reporters/HtmlReporter.js new file mode 100644 index 0000000..a782f05 --- /dev/null +++ b/src/reporters/HtmlReporter.js @@ -0,0 +1,392 @@ +import fs from 'fs'; +import path from 'path'; + +// Generates a self-contained HTML report saved to ./screener-report.html +// Console output shows only the signal summary — full breakdown lives here. + +export class HtmlReporter { + // Returns the HTML string — useful for server responses. + render(results, marketContext, personalFinance = null) { + return this._buildHtml(results, marketContext, personalFinance); + } + + // Writes to disk and returns the absolute path — used by the CLI. + generate(results, marketContext, personalFinance = null, outputPath = './screener-report.html') { + const html = this._buildHtml(results, marketContext, personalFinance); + fs.writeFileSync(outputPath, html, 'utf8'); + return path.resolve(outputPath); + } + + // ── HTML builder ──────────────────────────────────────────────────────────── + + _buildHtml(results, ctx, pf = null) { + const b = ctx.benchmarks ?? {}; + const all = [...results.STOCK, ...results.ETF, ...results.BOND]; + + return ` + + + + +Market Screener — ${ctx.timestamp?.slice(0, 10) ?? ''} + + + + +
+

📊 Market Screener

+
+
Date ${ctx.timestamp?.slice(0, 10) ?? '—'}
+
Rate ${ctx.rateRegime}
+
Volatility ${ctx.volatilityRegime}
+
+
+ +
+ +
+ ${this._ctxCard('10Y Yield', (ctx.riskFreeRate?.toFixed(2) ?? '—') + '%')} + ${this._ctxCard('VIX', ctx.vixLevel?.toFixed(1) ?? '—')} + ${this._ctxCard('S&P 500', ctx.sp500Price?.toLocaleString() ?? '—')} + ${this._ctxCard('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x')} + ${this._ctxCard('Tech P/E', (b.techPE?.toFixed(1) ?? '—') + 'x')} + ${this._ctxCard('REIT Yield', (b.reitYield?.toFixed(2) ?? '—') + '%')} + ${this._ctxCard('IG Spread', (b.igSpread?.toFixed(2) ?? '—') + '%')} +
+ +
+

Signal Summary

+ + + ${all + .sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal)) + .map((r) => this._summaryRow(r)) + .join('')} +
TickerTypeSignalInflated VerdictFundamental Verdict
+
+ + ${['STOCK', 'ETF', 'BOND'] + .map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : '')) + .join('')} + + ${pf ? this._personalFinanceSection(pf) : ''} + + ${ + results.ERROR?.length + ? ` +
+

Errors

+ + + ${results.ERROR.map((e) => ``).join('')} +
TickerReason
${e.ticker}${e.message}
+
` + : '' + } + +
+ + + +`; + } + + // ── Section builders ──────────────────────────────────────────────────────── + + _assetSection(type, items, benchmarks) { + const sorted = [...items].sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal)); + const inflatedId = `${type}-inflated`; + const fundamentalId = `${type}-fundamental`; + + const inflatedLabel = + type === 'STOCK' + ? `Market-Adjusted (P/E gate: ~${benchmarks.marketPE != null ? Math.round(benchmarks.marketPE * 1.5) : '—'}x from live data)` + : 'Market-Adjusted'; + + return ` +
+

${type}S

+
+
${inflatedLabel}
+
Fundamental (Graham-style)
+
+
+ ${this._table(type, sorted, 'inflated')} +
+
+ ${this._table(type, sorted, 'fundamental')} +
+
`; + } + + _table(type, items, mode) { + const headers = this._headers(type, items, mode); + const rows = items.map((r) => this._row(type, r, mode, headers)).join(''); + return ` + ${headers.map((h) => ``).join('')} + ${rows} +
${h}
`; + } + + // Collect only headers that have at least one non-null value across all items + _headers(type, items, mode) { + const base = ['Ticker', 'Price', 'Verdict', 'Score']; + if (type === 'STOCK') { + const metricKeys = [ + 'Sector', + 'P/E', + 'PEG', + 'P/B', + 'ROE%', + 'OpMgn%', + 'NetMgn%', + 'Rev%', + 'FCF Yld%', + 'Div%', + 'D/E', + 'Quick', + 'Beta', + '52W Pos', + 'P/FFO', + ]; + const present = metricKeys.filter((k) => + items.some((r) => r.asset.getDisplayMetrics()[k] != null), + ); + return [...base, ...present, 'Risk Flags']; + } + if (type === 'ETF') return [...base, 'Expense', 'Yield', 'AUM', '5Y Ret']; + if (type === 'BOND') return [...base, 'YTM', 'Duration', 'Rating']; + return base; + } + + _row(type, result, mode, headers) { + const m = result.asset.getDisplayMetrics(); + const bd = result[mode]?.audit?.breakdown ?? {}; + const rf = result[mode]?.audit?.riskFlags ?? []; + const v = result[mode]?.label ?? ''; + const s = result[mode]?.scoreSummary ?? ''; + const p = (key) => + bd[key] != null + ? `${bd[key] > 0 ? '✅' : '❌'}` + : ''; + + const cells = { + Ticker: `${m.Ticker}`, + Price: `${m.Price}`, + Verdict: `${v}`, + Score: `${s}`, + Sector: `${m.Sector ?? ''}`, + 'P/E': `${m['P/E'] ?? ''}`, + PEG: `${m.PEG != null ? m.PEG + ' ' + p('peg') : ''}`, + 'P/B': `${m['P/B'] ?? ''}`, + 'ROE%': `${m['ROE%'] != null ? m['ROE%'] + ' ' + p('roe') : ''}`, + 'OpMgn%': `${m['OpMgn%'] != null ? m['OpMgn%'] + ' ' + p('opMargin') : ''}`, + 'NetMgn%': `${m['NetMgn%'] != null ? m['NetMgn%'] + ' ' + p('margin') : ''}`, + 'Rev%': `${m['Rev%'] != null ? m['Rev%'] + ' ' + p('revenue') : ''}`, + 'FCF Yld%': `${m['FCF Yld%'] != null ? m['FCF Yld%'] + ' ' + p('fcf') : ''}`, + 'Div%': `${m['Div%'] != null ? m['Div%'] + ' ' + p('yield') : ''}`, + 'D/E': `${m['D/E'] ?? ''}`, + Quick: `${m.Quick ?? ''}`, + Beta: `${m.Beta ?? ''}`, + '52W Pos': `${m['52W Pos'] ?? ''}`, + 'P/FFO': `${m['P/FFO'] != null ? m['P/FFO'] + ' ' + p('pFFO') : ''}`, + 'Risk Flags': `${rf.map((f) => `⚠ ${f}`).join('') || ''}`, + // ETF + Expense: `${m['Exp Ratio%'] != null ? m['Exp Ratio%'] + ' ' + p('cost') : ''}`, + Yield: `${m['Yield%'] != null ? m['Yield%'] + ' ' + p('yield') : ''}`, + AUM: `${m.AUM ?? ''}`, + '5Y Ret': `${m['5Y Return%'] ?? ''}`, + // BOND + YTM: `${m['YTM%'] != null ? m['YTM%'] + ' ' + p('spread') : ''}`, + Duration: `${m.Duration != null ? m.Duration + ' ' + p('duration') : ''}`, + Rating: `${m.Rating ?? ''}`, + }; + + return `${headers.map((h) => cells[h] ?? `—`).join('')}`; + } + + _summaryRow(r) { + return ` + ${r.asset.ticker} + ${r.asset.type} + ${r.signal} + ${r.inflated.label} + ${r.fundamental.label} + `; + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + _ctxCard(label, value) { + return `
${label}
${value}
`; + } + + _verdictClass(label) { + if (label?.startsWith('🟢')) return 'verdict-green'; + if (label?.startsWith('🟡')) return 'verdict-yellow'; + return 'verdict-red'; + } + + _signalClass(signal) { + if (signal?.includes('Strong')) return 'signal-strong'; + if (signal?.includes('Momentum')) return 'signal-momentum'; + if (signal?.includes('Neutral')) return 'signal-neutral'; + if (signal?.includes('Speculation')) return 'signal-spec'; + return 'signal-avoid'; + } + + _personalFinanceSection(pf) { + const fmt = (n) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(n); + const sign = (n) => + n >= 0 + ? `${fmt(n)}` + : `${fmt(n)}`; + + const accountRows = pf.accounts + .map( + (a) => ` + + ${a.name} + ${a.type} + ${a.org} + ${sign(a.balance)} + ${a.balanceDate} + `, + ) + .join(''); + + const categoryRows = pf.categoryBreakdown + .slice(0, 8) + .map( + (c) => ` + + ${c.category} + ${fmt(c.amount)} + ${c.pct}% + +
+
+
+ + `, + ) + .join(''); + + return ` +
+

Personal Finance — SimpleFIN

+ +
+ ${this._ctxCard('Net Worth', fmt(pf.netWorth))} + ${this._ctxCard('Total Assets', fmt(pf.totalAssets))} + ${this._ctxCard('Liabilities', fmt(pf.totalLiabilities))} + ${this._ctxCard('Cash', `${fmt(pf.totalCash)} (${pf.cashPct}%)`)} + ${this._ctxCard('Investments', `${fmt(pf.totalInvestments)} (${pf.investPct}%)`)} + ${this._ctxCard('Monthly Income', fmt(pf.totalIncome))} + ${this._ctxCard('Monthly Spend', fmt(pf.totalSpend))} + ${pf.savingsRate != null ? this._ctxCard('Savings Rate', `${pf.savingsRate}%`) : ''} +
+ +
+
+

Accounts

+ + + ${accountRows} +
AccountTypeInstitutionBalanceUpdated
+
+
+

Spending by Category (Last 30 Days)

+ + + ${categoryRows} +
CategoryAmount%Share
+
+
+
`; + } + + _sigOrd(signal) { + return ( + { + '✅ Strong Buy': 0, + '⚡ Momentum': 1, + '🔄 Neutral': 2, + '⚠️ Speculation': 3, + '❌ Avoid': 4, + }[signal] ?? 5 + ); + } +} diff --git a/src/screener/Chunker.js b/src/screener/Chunker.js new file mode 100644 index 0000000..8ca736c --- /dev/null +++ b/src/screener/Chunker.js @@ -0,0 +1,4 @@ +export const chunkArray = (array, size) => + Array.from({ length: Math.ceil(array.length / size) }, (_, i) => + array.slice(i * size, i * size + size), + ); diff --git a/src/screener/DataMapper.js b/src/screener/DataMapper.js new file mode 100644 index 0000000..ddc6745 --- /dev/null +++ b/src/screener/DataMapper.js @@ -0,0 +1,153 @@ +export const mapToStandardFormat = (ticker, summary) => { + const quoteType = summary.price?.quoteType; + const category = (summary.assetProfile?.category || '').toLowerCase(); + const yieldVal = summary.summaryDetail?.trailingAnnualDividendYield ?? 0; + // Logic to determine type + const isBond = + category.includes('bond') || + category.includes('fixed income') || + category.includes('treasury') || + (quoteType === 'ETF' && yieldVal > 0.02 && category === ''); // Heuristic fallback + if (quoteType === 'ETF') { + return isBond + ? { + type: 'BOND', + ticker, + ...mapBondData(summary), + } + : { + type: 'ETF', + ticker, + ...mapEtfData(summary), + }; + } + // Default to STOCK (covers 'EQUITY' or missing types) + return { + type: 'STOCK', + ticker, + ...mapStockData(summary), + }; +}; + +const mapStockData = (summary) => { + const fd = summary.financialData ?? {}; + const ks = summary.defaultKeyStatistics ?? {}; + const sd = summary.summaryDetail ?? {}; + const pr = summary.price ?? {}; + + const currentPrice = pr.regularMarketPrice ?? 0; + const sharesOutstanding = ks.sharesOutstanding ?? 0; + const operatingCashflow = fd.operatingCashflow ?? 0; + const freeCashflow = fd.freeCashflow ?? 0; + + // P/FFO proxy (price / operating cash flow per share) — used for REIT scoring + const pFFO = + operatingCashflow > 0 && sharesOutstanding > 0 + ? currentPrice / (operatingCashflow / sharesOutstanding) + : null; + + // FCF yield = free cash flow per share / price. + // Negative FCF is preserved (not nulled) — a company burning cash should fail the gate, + // not be silently skipped as "no data". + const fcfYield = + freeCashflow !== 0 && sharesOutstanding > 0 && currentPrice > 0 + ? (freeCashflow / sharesOutstanding / currentPrice) * 100 + : null; + + // PEG computation: use Yahoo's value first; fall back to trailingPE / earningsGrowth + // earningsGrowth from Yahoo is a decimal (e.g. 0.15 = 15%), convert to whole number first + const yahoosPEG = ks.pegRatio ?? null; + const trailingPE = sd.trailingPE ?? null; + const earningsGrowth = fd.earningsGrowth != null ? fd.earningsGrowth * 100 : null; // now in % + const computedPEG = + trailingPE != null && earningsGrowth > 0 ? +(trailingPE / earningsGrowth).toFixed(2) : null; + const pegRatio = yahoosPEG ?? computedPEG; // prefer Yahoo's, fall back to computed + + // Quick ratio — fall back to currentRatio when quickRatio is missing + const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null; + + return { + // Valuation — trailing PE is the audited number; forward PE is an analyst estimate + // (historically 10-15% optimistic). Use trailing as primary for fundamental mode. + peRatio: trailingPE ?? ks.forwardPE, + trailingPE, + pegRatio, + priceToBook: ks.priceToBook ?? null, + evToEbitda: ks.enterpriseToEbitda ?? null, + + // Profitability + netProfitMargin: fd.profitMargins != null ? fd.profitMargins * 100 : null, + operatingMargin: fd.operatingMargins != null ? fd.operatingMargins * 100 : null, + returnOnEquity: fd.returnOnEquity != null ? fd.returnOnEquity * 100 : null, + + // Growth + revenueGrowth: fd.revenueGrowth != null ? fd.revenueGrowth * 100 : null, + earningsGrowth, + + // Financial health + debtToEquity: fd.debtToEquity != null ? fd.debtToEquity / 100 : null, + quickRatio, + + // Cash flow + fcfYield, + pFFO, + + // Income + dividendYield: + sd.trailingAnnualDividendYield != null ? sd.trailingAnnualDividendYield * 100 : null, + + // Risk & momentum + beta: sd.beta ?? null, + week52High: sd.fiftyTwoWeekHigh ?? null, + week52Low: sd.fiftyTwoWeekLow ?? null, + + currentPrice, + assetProfile: summary.assetProfile || {}, + }; +}; + +const mapEtfData = (summary) => ({ + expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100, + totalAssets: summary.summaryDetail?.totalAssets ?? 0, + yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100, + // fiveYearAverageReturn is annualised total return — valid proxy for performance vs peers. + fiveYearReturn: (summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0) * 100, + // averageVolume from summaryDetail is average daily trading volume — used for liquidity gate. + volume: summary.summaryDetail?.averageVolume ?? summary.price?.averageVolume ?? 0, + currentPrice: summary.price?.regularMarketPrice ?? 0, +}); + +/** + * Infer credit rating from ETF category string (Yahoo Finance doesn't expose + * bond credit ratings directly). Defaults to BBB (investment grade) when unknown. + */ +const inferCreditRating = (category) => { + const cat = (category || '').toLowerCase(); + if (cat.includes('government') || cat.includes('treasury')) return 'AAA'; + if (cat.includes('muni')) return 'AA'; + if (cat.includes('high yield') || cat.includes('junk')) return 'BB'; + if (cat.includes('corporate') || cat.includes('investment grade')) return 'A'; + return 'BBB'; // conservative default +}; + +// Infers approximate effective duration (years) from bond ETF category name. +// Buckets match standard industry classifications (short < 3y, intermediate 3-7y, long > 10y). +const inferDuration = (category) => { + const cat = (category || '').toLowerCase(); + if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2; + if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5; + if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18; + if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4; + return 6; // conservative default — typical aggregate bond fund duration +}; + +const mapBondData = (summary) => ({ + yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100, + // KNOWN LIMITATION: Yahoo Finance does not expose effective duration via the modules + // we fetch (assetProfile, financialData, defaultKeyStatistics, price, summaryDetail). + // The `fundProfile` module has duration for some funds but requires a separate fetch. + // We use the ETF category name to infer a rough duration bucket as a proxy. + duration: inferDuration(summary.assetProfile?.category), + creditRating: inferCreditRating(summary.assetProfile?.category), + currentPrice: summary.price?.regularMarketPrice ?? 0, +}); diff --git a/src/screener/RuleMerger.js b/src/screener/RuleMerger.js new file mode 100644 index 0000000..95ce0e1 --- /dev/null +++ b/src/screener/RuleMerger.js @@ -0,0 +1,33 @@ +import { ScoringRules } from '../config/ScoringConfig.js'; +import { MarketRegime } from '../market/MarketRegime.js'; +import { SCORE_MODE } from '../config/constants.js'; + +export const RuleMerger = { + getRulesForAsset(type, metrics, marketContext = {}, mode = SCORE_MODE.FUNDAMENTAL) { + const base = ScoringRules[type]; + if (!base) throw new Error(`No rules configured for asset type: ${type}`); + + let rules = JSON.parse(JSON.stringify(base)); + + if (type === 'STOCK' && metrics.sector) { + const override = base.SECTOR_OVERRIDE?.[metrics.sector.toUpperCase()]; + if (override) { + rules.gates = { ...rules.gates, ...override.gates }; + rules.weights = { ...rules.weights, ...override.weights }; + rules.thresholds = { ...rules.thresholds, ...override.thresholds }; + } + } + delete rules.SECTOR_OVERRIDE; + + if (mode === SCORE_MODE.INFLATED) { + const { gates, thresholds } = new MarketRegime(marketContext).getInflatedOverrides( + type, + metrics.sector, + ); + rules.gates = { ...rules.gates, ...gates }; + rules.thresholds = { ...rules.thresholds, ...thresholds }; + } + + return rules; + }, +}; diff --git a/src/screener/ScreenerEngine.js b/src/screener/ScreenerEngine.js new file mode 100644 index 0000000..e592968 --- /dev/null +++ b/src/screener/ScreenerEngine.js @@ -0,0 +1,141 @@ +import { YahooClient } from '../market/YahooClient.js'; +import { BenchmarkProvider } from '../market/BenchmarkProvider.js'; +import { mapToStandardFormat } from './DataMapper.js'; +import { chunkArray } from './Chunker.js'; +import { RuleMerger } from './RuleMerger.js'; +import { Stock } from './assets/Stock.js'; +import { Etf } from './assets/Etf.js'; +import { Bond } from './assets/Bond.js'; +import { StockScorer } from './scorers/StockScorer.js'; +import { EtfScorer } from './scorers/EtfScorer.js'; +import { BondScorer } from './scorers/BondScorer.js'; +import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js'; + +const SCORERS = { + [ASSET_TYPE.STOCK]: StockScorer, + [ASSET_TYPE.ETF]: EtfScorer, + [ASSET_TYPE.BOND]: BondScorer, +}; + +export class ScreenerEngine { + // logger: object with .write() / .log() — defaults to a console shim so CLI behaviour is unchanged. + // Pass a no-op logger ({ write: () => {}, log: () => {} }) in server context. + constructor({ logger } = {}) { + this.client = new YahooClient(); + this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? console }); + this.logger = logger ?? { + write: (msg) => process.stdout.write(msg), + log: (...args) => console.log(...args), + }; + } + + // Pure data method — returns structured results. Safe to use in a server route. + async screenTickers(tickers) { + const marketContext = await this.benchmarkProvider.getMarketContext(); + const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; + for (const chunk of chunkArray(tickers, 5)) { + const batch = await Promise.all(chunk.map((t) => this._fetch(t))); + batch.forEach((data) => this._process(data, marketContext, results)); + await new Promise((r) => setTimeout(r, 1000)); + } + return { ...results, marketContext }; + } + + // CLI helper — emits progress to logger, returns structured results. + // The caller (bin/screen.js) is responsible for writing the report. + async screenWithProgress(tickers) { + this.logger.write('⏳ Fetching market context...'); + const marketContext = await this.benchmarkProvider.getMarketContext(); + this.logger.write(' done\n'); + + const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] }; + const chunks = chunkArray(tickers, 5); + let processed = 0; + + for (const chunk of chunks) { + const batch = await Promise.all(chunk.map((t) => this._fetch(t))); + batch.forEach((data) => this._process(data, marketContext, results)); + processed += chunk.length; + this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`); + await new Promise((r) => setTimeout(r, 1000)); + } + + this.logger.write('\n'); + return { ...results, marketContext }; + } + + async _fetch(ticker) { + try { + const summary = await this.client.fetchSummary(ticker); + if (!summary?.price) throw new Error('Empty response from Yahoo'); + return mapToStandardFormat(ticker, summary); + } catch (err) { + return { isError: true, ticker: ticker.toUpperCase(), message: err.message }; + } + } + + _process(data, marketContext, results) { + if (data.isError) { + results.ERROR.push(data); + return; + } + try { + const asset = this._buildAsset(data); + const scorer = SCORERS[asset.type]; + if (!scorer) throw new Error(`No scorer for type: ${asset.type}`); + + const fundamental = scorer.score( + asset.metrics, + RuleMerger.getRulesForAsset( + asset.type, + asset.metrics, + marketContext, + SCORE_MODE.FUNDAMENTAL, + ), + marketContext, + ); + const inflated = scorer.score( + asset.metrics, + RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED), + marketContext, + ); + + results[asset.type].push({ + asset, + fundamental, + inflated, + signal: this._signal(fundamental.label, inflated.label), + }); + } catch (err) { + results.ERROR.push({ + ticker: (data.ticker || 'UNKNOWN').toUpperCase(), + message: err.message, + }); + } + } + + _buildAsset(data) { + switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) { + case ASSET_TYPE.BOND: + return new Bond(data); + case ASSET_TYPE.ETF: + return new Etf(data); + default: + return new Stock(data); + } + } + + _signal(fundamentalLabel, inflatedLabel) { + const green = (l) => l.startsWith('🟢'); + const yellow = (l) => l.startsWith('🟡'); + if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY; + if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM; + if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION; + if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL; + return SIGNAL.AVOID; + } + + signalOrder(signal) { + return SIGNAL_ORDER[signal] ?? 5; + } +} diff --git a/src/screener/assets/Asset.js b/src/screener/assets/Asset.js new file mode 100644 index 0000000..83c5689 --- /dev/null +++ b/src/screener/assets/Asset.js @@ -0,0 +1,19 @@ +export class Asset { + constructor(data) { + this.ticker = (data.ticker || 'UNKNOWN').toUpperCase(); + this.currentPrice = data.currentPrice || 0; + this.type = (data.type || 'STOCK').toUpperCase(); + } + + formatCurrency(val) { + return val ? `$${val.toFixed(2)}` : 'N/A'; + } + + formatLargeNumber(num) { + if (!num) return 'N/A'; + if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`; + if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`; + if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`; + return num.toString(); + } +} diff --git a/src/core/assets/Bond.js b/src/screener/assets/Bond.js similarity index 60% rename from src/core/assets/Bond.js rename to src/screener/assets/Bond.js index 2ec59c7..24f5afb 100644 --- a/src/core/assets/Bond.js +++ b/src/screener/assets/Bond.js @@ -1,18 +1,21 @@ +import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js'; import { Asset } from './Asset.js'; export class Bond extends Asset { constructor(data) { super(data); - // Store metrics in a flat object for the ScoringEngine + const creditRating = data.creditRating || 'BBB'; + const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7; + this.metrics = { ytm: parseFloat(data.yieldToMaturity) || 0, duration: parseFloat(data.duration) || 0, - creditRating: data.creditRating || 'N/A', + creditRating, + creditRatingNumeric, }; } - // Helper for dashboard display getDisplayMetrics() { return { Ticker: this.ticker, @@ -20,7 +23,7 @@ export class Bond extends Asset { Price: this.formatCurrency(this.currentPrice), 'YTM%': `${this.metrics.ytm.toFixed(2)}%`, Duration: this.metrics.duration.toFixed(1), - Rating: this.metrics.creditRating, + Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`, }; } } diff --git a/src/core/assets/Etf.js b/src/screener/assets/Etf.js similarity index 56% rename from src/core/assets/Etf.js rename to src/screener/assets/Etf.js index e46c6e6..1096e95 100644 --- a/src/core/assets/Etf.js +++ b/src/screener/assets/Etf.js @@ -3,28 +3,24 @@ import { Asset } from './Asset.js'; export class Etf extends Asset { constructor(data) { super(data); - - // Store metrics in a flat object for the ScoringEngine this.metrics = { - expRatio: parseFloat(data.expenseRatio) || 0, + expenseRatio: parseFloat(data.expenseRatio) || 0, totalAssets: parseFloat(data.totalAssets) || 0, yield: parseFloat(data.yield) || 0, + volume: parseFloat(data.volume) || 0, + fiveYearReturn: parseFloat(data.fiveYearReturn) || 0, }; - - // Keep performance metrics for display only - this.fiveYearReturn = parseFloat(data.fiveYearReturn) || 0; } - // Helper for dashboard display getDisplayMetrics() { return { Ticker: this.ticker, Type: 'ETF', Price: this.formatCurrency(this.currentPrice), - 'Exp Ratio%': `${this.metrics.expRatio.toFixed(2)}%`, + 'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`, 'Yield%': `${this.metrics.yield.toFixed(2)}%`, AUM: this.formatLargeNumber(this.metrics.totalAssets), - '5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`, + '5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`, }; } } diff --git a/src/screener/assets/Stock.js b/src/screener/assets/Stock.js new file mode 100644 index 0000000..5da38cc --- /dev/null +++ b/src/screener/assets/Stock.js @@ -0,0 +1,134 @@ +import { Asset } from './Asset.js'; + +export class Stock extends Asset { + constructor(data) { + super(data); + // console.log('Data:', data); + this.sector = this._mapToStandardSector(data || {}); + + this.metrics = { + sector: this.sector, + // Valuation + peRatio: data.peRatio ?? null, + pegRatio: data.pegRatio ?? null, + priceToBook: data.priceToBook ?? null, + // Profitability + netProfitMargin: data.netProfitMargin ?? null, + operatingMargin: data.operatingMargin ?? null, + returnOnEquity: data.returnOnEquity ?? null, + // Growth + revenueGrowth: data.revenueGrowth ?? null, + earningsGrowth: data.earningsGrowth ?? null, + // Financial health + debtToEquity: data.debtToEquity ?? null, + quickRatio: data.quickRatio ?? null, + // Cash flow + fcfYield: data.fcfYield ?? null, + pFFO: data.pFFO ?? null, + // Income + dividendYield: data.dividendYield ?? null, + // Risk & momentum + beta: data.beta ?? null, + week52High: data.week52High ?? null, + week52Low: data.week52Low ?? null, + currentPrice: data.currentPrice ?? 0, + }; + } + + _mapToStandardSector(data) { + const profile = data.assetProfile || {}; + const industry = (profile.industry || '').toLowerCase(); + const sector = (profile.sector || '').toLowerCase(); + const combined = `${industry} ${sector}`; + + // Yahoo Finance sector/industry strings mapped to our internal sector constants. + // Order matters — more specific matches first. + if ( + combined.includes('technology') || + combined.includes('electronic') || + combined.includes('semiconductor') || + combined.includes('software') + ) + return 'TECHNOLOGY'; + if (combined.includes('real estate') || combined.includes('reit')) return 'REIT'; + if ( + combined.includes('financial') || + combined.includes('bank') || + combined.includes('insurance') || + combined.includes('asset management') + ) + return 'FINANCIAL'; + if ( + combined.includes('energy') || + combined.includes('oil') || + combined.includes('gas') || + combined.includes('petroleum') + ) + return 'ENERGY'; + if ( + combined.includes('health') || + combined.includes('biotech') || + combined.includes('pharmaceutical') || + combined.includes('medical') + ) + return 'HEALTHCARE'; + // Yahoo calls this "Communication Services" — covers META, GOOGL, NFLX, DIS, T + if ( + combined.includes('communication') || + combined.includes('media') || + combined.includes('entertainment') || + combined.includes('telecom') + ) + return 'COMMUNICATION'; + if ( + combined.includes('consumer defensive') || + combined.includes('consumer staples') || + combined.includes('household') || + combined.includes('beverage') || + combined.includes('food') + ) + return 'CONSUMER_STAPLES'; + if ( + combined.includes('consumer cyclical') || + combined.includes('consumer discretionary') || + combined.includes('retail') || + combined.includes('apparel') || + combined.includes('auto') + ) + return 'CONSUMER_DISCRETIONARY'; + + return 'GENERAL'; + } + + getDisplayMetrics() { + const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null); + const m = this.metrics; + const w52pos = + m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 + ? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%' + : null; + + // Only include fields that have actual data — null fields are omitted + const display = { + Ticker: this.ticker, + Price: this.formatCurrency(this.currentPrice), + Sector: this.sector, + }; + if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1); + if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2); + if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2); + if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%'); + if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%'); + if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%'); + if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%'); + if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%'); + if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%'); + if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2); + if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2); + if (m.beta != null) display['Beta'] = fmt(m.beta, 2); + if (w52pos != null) display['52W Pos'] = w52pos; + if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1); + + return display; + } +} diff --git a/src/screener/scorers/BondScorer.js b/src/screener/scorers/BondScorer.js new file mode 100644 index 0000000..a29f03d --- /dev/null +++ b/src/screener/scorers/BondScorer.js @@ -0,0 +1,40 @@ +export const BondScorer = { + score(m, rules, context) { + const { gates, weights, thresholds } = rules; + const metrics = this._sanitize(m); + const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100; + + if (metrics.creditRatingNumeric < gates.minCreditRating) { + return { + label: '🔴 Avoid', + scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`, + audit: { passedGates: false }, + }; + } + + // Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%) + const spreadPct = (metrics.ytm - riskFreeRate) * 100; + + const breakdown = { + spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2, + duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1, + }; + const score = Object.values(breakdown).reduce((a, b) => a + b, 0); + + return { + label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid', + scoreSummary: `Score: ${score}`, + audit: { breakdown }, + }; + }, + + _sanitize(m) { + const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0; + return { + ytm: pct(m.ytm), + duration: parseFloat(m.duration) || 0, + creditRating: m.creditRating || 'BBB', + creditRatingNumeric: m.creditRatingNumeric ?? 7, + }; + }, +}; diff --git a/src/screener/scorers/EtfScorer.js b/src/screener/scorers/EtfScorer.js new file mode 100644 index 0000000..87bf1db --- /dev/null +++ b/src/screener/scorers/EtfScorer.js @@ -0,0 +1,36 @@ +export const EtfScorer = { + score(m, rules) { + const { gates, weights, thresholds } = rules; + const metrics = { + expenseRatio: parseFloat(m.expenseRatio) || 0, + yield: parseFloat(m.yield) || 0, + volume: parseFloat(m.volume) || 0, + fiveYearReturn: parseFloat(m.fiveYearReturn) || 0, + }; + + if (metrics.expenseRatio > gates.maxExpenseRatio) { + return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' }; + } + + const breakdown = { + cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3, + yield: metrics.yield >= thresholds.minYield ? weights.yield : -1, + vol: metrics.volume >= (thresholds.minVolume ?? 1000000) ? 0 : -2, + // 5Y return: strong long-term performance vs the ~10% S&P average is rewarded + fiveYearReturn: + thresholds.minFiveYearReturn != null + ? metrics.fiveYearReturn >= thresholds.minFiveYearReturn + ? (weights.fiveYearReturn ?? 1) + : -1 + : 0, + }; + + const score = Object.values(breakdown).reduce((a, b) => a + b, 0); + + return { + label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield', + scoreSummary: `Score: ${score}`, + audit: { passedGates: true, breakdown }, + }; + }, +}; diff --git a/src/screener/scorers/StockScorer.js b/src/screener/scorers/StockScorer.js new file mode 100644 index 0000000..f45c4c1 --- /dev/null +++ b/src/screener/scorers/StockScorer.js @@ -0,0 +1,157 @@ +import { SIGNAL } from '../../config/constants.js'; + +const n = (v) => { + const f = parseFloat(v); + return !isNaN(f) && f !== 0 ? f : null; +}; + +const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1); +const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1); + +export const StockScorer = { + score(metrics, rules) { + const { gates, weights, thresholds } = rules; + const m = this._sanitize(metrics); + + const failures = [ + m.debtToEquity != null && + m.debtToEquity > gates.maxDebtToEquity && + `D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`, + m.quickRatio != null && + m.quickRatio < gates.minQuickRatio && + `Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`, + m.peRatio != null && + m.peRatio > gates.maxPERatio && + `P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`, + m.pegRatio != null && + m.pegRatio > gates.maxPegGate && + `PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`, + m.priceToBook != null && + gates.maxPriceToBook && + m.priceToBook > gates.maxPriceToBook && + `P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`, + ].filter(Boolean); + + if (failures.length > 0) { + return { + label: '🔴 REJECT', + scoreSummary: `Gate failed: ${failures.join(' | ')}`, + audit: { passedGates: false, failures }, + }; + } + + const factors = [ + { + key: 'roe', + active: weights.roe > 0 && m.returnOnEquity != null, + fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe), + }, + { + key: 'opMargin', + active: weights.opMargin > 0 && m.operatingMargin != null, + fn: () => + scoreValue( + m.operatingMargin, + thresholds.opMarginHigh, + thresholds.opMarginMed, + weights.opMargin, + ), + }, + { + key: 'margin', + active: weights.margin > 0 && m.netProfitMargin != null, + fn: () => + scoreValue( + m.netProfitMargin, + thresholds.marginHigh, + thresholds.marginMed, + weights.margin, + ), + }, + { + key: 'peg', + active: weights.peg > 0 && m.pegRatio != null, + fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg), + }, + { + key: 'revenue', + active: weights.revenue > 0 && m.revenueGrowth != null, + fn: () => + scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue), + }, + { + key: 'fcf', + active: weights.fcf > 0 && m.fcfYield != null, + fn: () => + scoreValue(m.fcfYield, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf), + }, + { + key: 'yield', + active: (weights.yield ?? 0) > 0 && m.dividendYield != null, + fn: () => (m.dividendYield >= (thresholds.minYield ?? 4) ? weights.yield : -1), + }, + { + key: 'pFFO', + active: (weights.pFFO ?? 0) > 0 && m.pFFO != null, + fn: () => (m.pFFO <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2), + }, + { + key: 'priceToBook', + active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null, + fn: () => scoreValue(1 / m.priceToBook, 1 / 1.0, 1 / 2.0, weights.priceToBook), + }, + ]; + + const breakdown = {}; + const totalScore = factors.reduce((sum, f) => { + if (!f.active) return sum; + breakdown[f.key] = f.fn(); + return sum + breakdown[f.key]; + }, 0); + + const riskFlags = [ + m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`, + m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`, + m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade', + m.week52Position != null && + m.week52Position < 0.1 && + 'Near 52-week low — potential opportunity', + ].filter(Boolean); + + return { + label: this._label(totalScore), + scoreSummary: `Score: ${totalScore}`, + audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null }, + }; + }, + + _label(score) { + if (score >= 8) return '🟢 BUY (High Conviction)'; + if (score >= 4) return '🟢 BUY (Speculative)'; + if (score >= 0) return '🟡 HOLD'; + return '🔴 REJECT'; + }, + + _sanitize(m) { + const w52 = + m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 + ? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low) + : null; + return { + debtToEquity: n(m.debtToEquity), + quickRatio: n(m.quickRatio), + peRatio: n(m.peRatio), + pegRatio: n(m.pegRatio), + priceToBook: n(m.priceToBook), + netProfitMargin: n(m.netProfitMargin), + operatingMargin: n(m.operatingMargin), + returnOnEquity: n(m.returnOnEquity), + revenueGrowth: n(m.revenueGrowth), + fcfYield: n(m.fcfYield), + dividendYield: n(m.dividendYield), + pFFO: n(m.pFFO), + beta: n(m.beta), + week52Position: w52, + }; + }, +}; diff --git a/src/server/app.js b/src/server/app.js new file mode 100644 index 0000000..8737cb1 --- /dev/null +++ b/src/server/app.js @@ -0,0 +1,77 @@ +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import screenerRoutes from './routes/screener.js'; +import financeRoutes from './routes/finance.js'; +import callsRoutes from './routes/calls.js'; +import { YahooClient } from '../market/YahooClient.js'; +import { LLMAnalyst } from '../analyst/LLMAnalyst.js'; + +const noopLogger = { write: () => {}, log: () => {}, warn: () => {} }; + +export async function buildApp({ logger = true } = {}) { + const app = Fastify({ logger }); + + await app.register(cors, { + origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173', + }); + + await app.register(screenerRoutes); + await app.register(financeRoutes); + await app.register(callsRoutes); + + // POST /api/analyze — fetch Yahoo news for tickers and run LLM analysis + app.post('/api/analyze', { + schema: { + body: { + type: 'object', + required: ['tickers'], + properties: { + tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 }, + }, + }, + }, + handler: async (req, reply) => { + if (!process.env.ANTHROPIC_API_KEY) { + return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' }); + } + + const tickers = req.body.tickers.map((t) => t.toUpperCase()); + const client = new YahooClient(); + const llm = new LLMAnalyst({ logger: noopLogger }); + + const seen = new Map(); + await Promise.all( + tickers.slice(0, 10).map(async (ticker) => { + try { + const { news = [] } = await client.yf.search(ticker, { newsCount: 3, quotesCount: 0 }); + for (const s of news) { + if (!seen.has(s.title)) { + seen.set(s.title, { + title: s.title, + publisher: s.publisher, + link: s.link, + relatedTickers: s.relatedTickers ?? [], + }); + } + } + } catch { + /* skip */ + } + }), + ); + + const stories = [...seen.values()].slice(0, 15); + + if (!stories.length) { + return reply.code(200).send({ analysis: null, reason: 'no_stories' }); + } + + const analysis = await llm.analyze(stories, tickers); + return { analysis }; + }, + }); + + app.get('/health', async () => ({ status: 'ok' })); + + return app; +} diff --git a/src/server/routes/calls.js b/src/server/routes/calls.js new file mode 100644 index 0000000..501bf9e --- /dev/null +++ b/src/server/routes/calls.js @@ -0,0 +1,187 @@ +import { MarketCallStore } from '../../calls/MarketCallStore.js'; +import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; +import { YahooClient } from '../../market/YahooClient.js'; +import { chunkArray } from '../../screener/Chunker.js'; + +const noopLogger = { write: () => {}, log: () => {}, warn: () => {} }; +const store = new MarketCallStore(); + +// Takes a screener result entry and flattens it to a snapshot record +const toSnapshot = (r) => { + if (!r) return null; + const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {}; + return { + price: r.asset?.currentPrice ?? null, + signal: r.signal ?? null, + inflatedVerdict: r.inflated?.label ?? null, + fundamentalVerdict: r.fundamental?.label ?? null, + pe: m['P/E'] ?? null, + roe: m['ROE%'] ?? null, + fcf: m['FCF Yld%'] ?? null, + }; +}; + +export default async function callsRoutes(app) { + // GET /api/calls — list all market calls (newest first) + app.get('/api/calls', async () => { + return { calls: store.list() }; + }); + + // GET /api/calls/:id — get one call + enrich with current prices for comparison + app.get('/api/calls/:id', async (req, reply) => { + const call = store.get(req.params.id); + if (!call) return reply.code(404).send({ error: 'Call not found' }); + + // Re-screen the tickers to get current prices for comparison + let current = {}; + if (call.tickers.length > 0) { + try { + const engine = new ScreenerEngine({ logger: noopLogger }); + const results = await engine.screenTickers(call.tickers); + const all = [...results.STOCK, ...results.ETF, ...results.BOND]; + for (const r of all) { + current[r.asset.ticker] = toSnapshot(r); + } + } catch { + // Non-fatal — return call without current prices + } + } + + return { ...call, current }; + }); + + // POST /api/calls — create a new market call and snapshot current prices + app.post('/api/calls', { + schema: { + body: { + type: 'object', + required: ['title', 'quarter', 'thesis', 'tickers'], + properties: { + title: { type: 'string', minLength: 3 }, + quarter: { type: 'string', minLength: 2 }, + date: { type: 'string' }, + thesis: { type: 'string', minLength: 10 }, + tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 }, + }, + }, + }, + handler: async (req, reply) => { + const { title, quarter, date, thesis, tickers } = req.body; + const upperTickers = tickers.map((t) => t.toUpperCase()); + + // Snapshot current screener data for each ticker + let snapshot = {}; + try { + const engine = new ScreenerEngine({ logger: noopLogger }); + const results = await engine.screenTickers(upperTickers); + const all = [...results.STOCK, ...results.ETF, ...results.BOND]; + for (const r of all) { + snapshot[r.asset.ticker] = toSnapshot(r); + } + } catch (err) { + app.log.warn('Could not snapshot prices for market call:', err.message); + } + + const call = store.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot }); + return reply.code(201).send(call); + }, + }); + + // DELETE /api/calls/:id + app.delete('/api/calls/:id', async (req, reply) => { + const deleted = store.delete(req.params.id); + if (!deleted) return reply.code(404).send({ error: 'Call not found' }); + return { ok: true }; + }); + + // GET /api/calls/calendar?tickers=AAPL,MSFT (or omit to use all call tickers) + // Returns upcoming earnings dates, ex-dividend dates and dividend dates per ticker. + // Fetched in parallel batches of 5 with rate-limit delay. + app.get('/api/calls/calendar', async (req) => { + const client = new YahooClient(); + + // Resolve tickers: from query param, or aggregate all unique tickers across all calls + let tickers; + if (req.query.tickers) { + tickers = req.query.tickers + .split(',') + .map((t) => t.trim().toUpperCase()) + .filter(Boolean); + } else { + const allCalls = store.list(); + const set = new Set(allCalls.flatMap((c) => c.tickers)); + tickers = [...set]; + } + + if (tickers.length === 0) return { events: [] }; + + // Fetch calendarEvents in parallel batches + const results = {}; + for (const batch of chunkArray(tickers, 5)) { + await Promise.all( + batch.map(async (ticker) => { + const cal = await client.fetchCalendarEvents(ticker); + if (cal) results[ticker] = cal; + }), + ); + await new Promise((r) => setTimeout(r, 500)); + } + + // Flatten into a sorted event list + const events = []; + const now = Date.now(); + + for (const [ticker, cal] of Object.entries(results)) { + // Upcoming earnings dates + for (const dateVal of cal.earnings?.earningsDate ?? []) { + const d = new Date(dateVal); + events.push({ + ticker, + type: 'earnings', + date: d.toISOString().slice(0, 10), + label: 'Earnings', + detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed', + epsEstimate: cal.earnings.earningsAverage ?? null, + revEstimate: cal.earnings.revenueAverage ?? null, + isPast: d.getTime() < now, + }); + } + + // Ex-dividend date + if (cal.exDividendDate) { + const d = new Date(cal.exDividendDate); + events.push({ + ticker, + type: 'exdividend', + date: d.toISOString().slice(0, 10), + label: 'Ex-Dividend', + detail: null, + isPast: d.getTime() < now, + }); + } + + // Dividend payment date + if (cal.dividendDate) { + const d = new Date(cal.dividendDate); + events.push({ + ticker, + type: 'dividend', + date: d.toISOString().slice(0, 10), + label: 'Dividend', + detail: null, + isPast: d.getTime() < now, + }); + } + } + + // Sort: upcoming first, then past + events.sort((a, b) => { + if (a.isPast !== b.isPast) return a.isPast ? 1 : -1; + return a.isPast + ? new Date(b.date) - new Date(a.date) // most recent past first + : new Date(a.date) - new Date(b.date); // soonest upcoming first + }); + + return { events, tickers }; + }); +} diff --git a/src/server/routes/finance.js b/src/server/routes/finance.js new file mode 100644 index 0000000..fcbb78b --- /dev/null +++ b/src/server/routes/finance.js @@ -0,0 +1,111 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; +import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js'; +import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js'; +import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js'; + +const noopLogger = { write: () => {}, log: () => {}, warn: () => {} }; +const PORTFOLIO_PATH = './portfolio.json'; + +export default async function financeRoutes(app) { + // GET /api/finance/portfolio + // Returns: { advice, personalFinance, marketContext } + app.get('/api/finance/portfolio', async (req, reply) => { + if (!existsSync(PORTFOLIO_PATH)) { + return reply.code(404).send({ error: 'portfolio.json not found' }); + } + + const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); + + // SimpleFIN is optional — omit if not configured + let personalFinance = null; + if (process.env.SIMPLEFIN_ACCESS_URL) { + const client = new SimpleFINClient({ logger: noopLogger }); + const { accounts } = await client.getAccounts(); + personalFinance = new PersonalFinanceAnalyzer().analyse(accounts); + } + + // Normalize dot-notation tickers to Yahoo Finance format (BRK.B → BRK-B) + const normalizeYahoo = (t) => t.toUpperCase().replace(/\./g, '-'); + + const screenable = holdings + .filter((h) => (h.type ?? 'stock') !== 'crypto') + .map((h) => normalizeYahoo(h.ticker)); + + const engine = new ScreenerEngine({ logger: noopLogger }); + const results = + screenable.length > 0 + ? await engine.screenTickers(screenable) + : { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} }; + + const advice = await new PortfolioAdvisor().advise(holdings, results); + + return { advice, personalFinance, marketContext: results.marketContext }; + }); + + // POST /api/finance/holdings + // Add or update a single holding in portfolio.json. + // Body: { ticker, shares, costBasis, type, source } + app.post('/api/finance/holdings', { + schema: { + body: { + type: 'object', + required: ['ticker', 'shares'], + properties: { + ticker: { type: 'string', minLength: 1, maxLength: 10 }, + shares: { type: 'number', exclusiveMinimum: 0 }, + costBasis: { type: 'number', minimum: 0 }, + type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] }, + source: { type: 'string' }, + }, + }, + }, + handler: async (req, reply) => { + const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body; + const normalized = ticker.toUpperCase().trim(); + + const portfolio = existsSync(PORTFOLIO_PATH) + ? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) + : { holdings: [] }; + + const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized); + + const entry = { ticker: normalized, shares, costBasis, type, source }; + + if (idx >= 0) { + portfolio.holdings[idx] = entry; // update existing + } else { + portfolio.holdings.push(entry); // add new + } + + writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8'); + return reply.code(201).send(entry); + }, + }); + + // DELETE /api/finance/holdings/:ticker + // Remove a holding from portfolio.json. + app.delete('/api/finance/holdings/:ticker', async (req, reply) => { + const ticker = req.params.ticker.toUpperCase(); + + if (!existsSync(PORTFOLIO_PATH)) + return reply.code(404).send({ error: 'portfolio.json not found' }); + + const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); + const before = portfolio.holdings.length; + portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker); + + if (portfolio.holdings.length === before) + return reply.code(404).send({ error: 'Holding not found' }); + + writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8'); + return { ok: true }; + }); + + // GET /api/finance/market-context + // Returns live benchmark data without running a full screen + app.get('/api/finance/market-context', async () => { + const engine = new ScreenerEngine({ logger: noopLogger }); + return engine.benchmarkProvider.getMarketContext(); + }); +} diff --git a/src/server/routes/screener.js b/src/server/routes/screener.js new file mode 100644 index 0000000..0d6c3ae --- /dev/null +++ b/src/server/routes/screener.js @@ -0,0 +1,59 @@ +import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; + +const noopLogger = { write: () => {}, log: () => {}, warn: () => {} }; + +// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the +// server so the browser receives plain serializable objects. +const serializeAssets = (arr) => + arr.map((r) => ({ + ...r, + asset: { + ticker: r.asset.ticker, + type: r.asset.type, + currentPrice: r.asset.currentPrice, + metrics: r.asset.metrics, + displayMetrics: r.asset.getDisplayMetrics(), + }, + })); + +export default async function screenerRoutes(app) { + // Shared engine — BenchmarkProvider caches for 1 hour across requests. + const engine = new ScreenerEngine({ logger: noopLogger }); + + // POST /api/screen + // Body: { tickers: string[] } + // Returns: { STOCK, ETF, BOND, ERROR, marketContext } + app.post('/api/screen', { + schema: { + body: { + type: 'object', + required: ['tickers'], + properties: { + tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 }, + }, + }, + }, + handler: async (req) => { + const tickers = req.body.tickers.map((t) => t.toUpperCase()); + const results = await engine.screenTickers(tickers); + return { + ...results, + STOCK: serializeAssets(results.STOCK), + ETF: serializeAssets(results.ETF), + BOND: serializeAssets(results.BOND), + }; + }, + }); + + // GET /api/screen/catalysts + // Returns: { tickers, stories, analysis? } + // analysis is present only when ANTHROPIC_API_KEY is set. + app.get('/api/screen/catalysts', async () => { + const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js'); + + const catalyst = new CatalystAnalyst({ logger: noopLogger }); + const { tickers, stories } = await catalyst.run(); + + return { tickers, stories }; + }); +} diff --git a/src/utils/Chunker.js b/src/utils/Chunker.js deleted file mode 100644 index 5d70edb..0000000 --- a/src/utils/Chunker.js +++ /dev/null @@ -1,7 +0,0 @@ -export const chunkArray = (array, size) => { - const result = []; - for (let i = 0; i < array.length; i += size) { - result.push(array.slice(i, i + size)); - } - return result; -}; diff --git a/src/utils/DataMapper.js b/src/utils/DataMapper.js deleted file mode 100644 index 6199b70..0000000 --- a/src/utils/DataMapper.js +++ /dev/null @@ -1,58 +0,0 @@ -export const mapToStandardFormat = (ticker, summary) => { - const quoteType = summary.price?.quoteType; - const category = (summary.assetProfile?.category || '').toLowerCase(); - const yieldVal = summary.summaryDetail?.trailingAnnualDividendYield ?? 0; - // Logic to determine type - const isBond = - category.includes('bond') || - category.includes('fixed income') || - category.includes('treasury') || - (quoteType === 'ETF' && yieldVal > 0.02 && category === ''); // Heuristic fallback - if (quoteType === 'ETF') { - return isBond - ? { - type: 'BOND', - ticker, - ...mapBondData(summary), - } - : { - type: 'ETF', - ticker, - ...mapEtfData(summary), - }; - } - // Default to STOCK (covers 'EQUITY' or missing types) - return { - type: 'STOCK', - ticker, - ...mapStockData(summary), - }; -}; - -const mapStockData = (summary) => ({ - quickRatio: summary.financialData?.quickRatio ?? 0, - debtToEquity: (summary.financialData?.debtToEquity ?? 0) / 100, - fcfGrowth: - (summary.financialData?.freeCashflow ?? 0) > 0 ? 'positive' : 'negative', - revenueGrowth: (summary.financialData?.revenueGrowth ?? 0) * 100, - netProfitMargin: (summary.financialData?.profitMargins ?? 0) * 100, - pegRatio: summary.defaultKeyStatistics?.pegRatio ?? 0, - peRatio: summary.defaultKeyStatistics?.forwardPE ?? 0, - currentPrice: summary.price?.regularMarketPrice ?? 0, - assetProfile: summary.assetProfile || {}, -}); - -const mapEtfData = (summary) => ({ - expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100, - totalAssets: summary.summaryDetail?.totalAssets ?? 0, - yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100, - fiveYearReturn: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0, - currentPrice: summary.price?.regularMarketPrice ?? 0, -}); - -const mapBondData = (summary) => ({ - yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100, - duration: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0, - creditRating: summary.assetProfile?.governanceEpochDate ? 'Rated' : 'N/A', - currentPrice: summary.price?.regularMarketPrice ?? 0, -}); diff --git a/src/utils/RulesMerger.js b/src/utils/RulesMerger.js deleted file mode 100644 index eed20d4..0000000 --- a/src/utils/RulesMerger.js +++ /dev/null @@ -1,37 +0,0 @@ -import { ScoringRules } from '../config/ScoringConfig.js'; - -/** - * RuleMerger ensures that we apply sector-specific overrides - * to base asset rules without polluting the individual Asset or Scorer logic. - */ -export const RuleMerger = { - getRulesForAsset(type, metrics) { - // 1. Start with a deep clone of the base rules for this asset type (STOCK, ETF, etc.) - const baseRules = ScoringRules[type]; - if (!baseRules) throw new Error(`No configuration found for type: ${type}`); - - let finalRules = JSON.parse(JSON.stringify(baseRules)); - - // 2. If it's a stock and we have a sector, merge the overrides - if (type === 'STOCK' && metrics.sector) { - const sectorKey = metrics.sector.toUpperCase(); - const overrides = baseRules.SECTOR_OVERRIDE?.[sectorKey]; - - if (overrides) { - // Merge gates, weights, and thresholds deeply - finalRules.gates = { ...finalRules.gates, ...overrides.gates }; - finalRules.weights = { ...finalRules.weights, ...overrides.weights }; - finalRules.thresholds = { - ...finalRules.thresholds, - ...overrides.thresholds, - }; - } - } - - // 3. Cleanup: Remove the override configuration from the final object - // so the Scorer works with a clean, flat rule set. - delete finalRules.SECTOR_OVERRIDE; - - return finalRules; - }, -}; diff --git a/tests/BondScorer.test.js b/tests/BondScorer.test.js new file mode 100644 index 0000000..95a1983 --- /dev/null +++ b/tests/BondScorer.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { BondScorer } from '../src/screener/scorers/BondScorer.js'; + +// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it. +// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation. + +const rules = { + gates: { minCreditRating: 7 }, + weights: { yieldSpread: 3, duration: 2 }, + thresholds: { minSpread: 1.0, maxDuration: 10 }, +}; +const ctx = { riskFreeRate: 4.5 }; + +test('rejects bond below investment-grade floor', () => { + const result = BondScorer.score( + { ytm: 8.0, duration: 5, creditRating: 'BB', creditRatingNumeric: 6 }, + rules, + ctx, + ); + assert.equal(result.label, '🔴 Avoid'); + assert(result.scoreSummary.includes('Gate failed')); +}); + +test('attractive for wide spread and short duration', () => { + // ytm=6.5%, riskFree=4.5% → spreadPct=(0.065-0.045)*100=2.0% >= minSpread 1.0% + const result = BondScorer.score( + { ytm: 6.5, duration: 4, creditRating: 'AA', creditRatingNumeric: 9 }, + rules, + ctx, + ); + assert.equal(result.label, '🟢 Attractive'); +}); + +test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %', () => { + const result = BondScorer.score( + { ytm: 6.5, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 }, + rules, + ctx, + ); + assert.equal(result.audit.breakdown.spread, rules.weights.yieldSpread); +}); + +test('fails spread when yield barely above risk-free', () => { + // ytm=4.7%, riskFree=4.5% → spreadPct=0.2% < minSpread 1.0% + const result = BondScorer.score( + { ytm: 4.7, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 }, + rules, + ctx, + ); + assert.equal(result.audit.breakdown.spread, -2); +}); + +test('penalises long duration', () => { + const result = BondScorer.score( + { ytm: 6.5, duration: 15, creditRating: 'AA', creditRatingNumeric: 9 }, + rules, + ctx, + ); + assert.equal(result.audit.breakdown.duration, -1); +}); diff --git a/tests/DataMapper.test.js b/tests/DataMapper.test.js new file mode 100644 index 0000000..b299093 --- /dev/null +++ b/tests/DataMapper.test.js @@ -0,0 +1,149 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapToStandardFormat } from '../src/screener/DataMapper.js'; + +const base = { + price: { quoteType: 'EQUITY', regularMarketPrice: 150 }, + assetProfile: { sector: 'Technology', industry: 'Software', category: '' }, + financialData: { + quickRatio: 1.2, + debtToEquity: 150, + freeCashflow: 5e9, + revenueGrowth: 0.15, + profitMargins: 0.25, + operatingMargins: 0.3, + returnOnEquity: 0.2, + earningsGrowth: 0.12, + operatingCashflow: 8e9, + }, + defaultKeyStatistics: { pegRatio: null, forwardPE: 28, sharesOutstanding: 1e9, priceToBook: 12 }, + summaryDetail: { + trailingAnnualDividendYield: 0.005, + trailingPE: 30, + beta: 1.2, + fiftyTwoWeekHigh: 200, + fiftyTwoWeekLow: 120, + }, +}; + +test('maps EQUITY quote type to STOCK', () => { + const result = mapToStandardFormat('AAPL', base); + assert.equal(result.type, 'STOCK'); + assert.equal(result.ticker, 'AAPL'); +}); + +test('computes PEG from trailingPE / earningsGrowth when Yahoo returns null', () => { + const result = mapToStandardFormat('AAPL', base); + const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12% + assert.equal(result.pegRatio, expected); +}); + +test('uses Yahoo pegRatio when available', () => { + const summary = { + ...base, + defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 }, + }; + const result = mapToStandardFormat('AAPL', summary); + assert.equal(result.pegRatio, 1.5); +}); + +test('debtToEquity is divided by 100', () => { + const result = mapToStandardFormat('AAPL', base); + assert.equal(result.debtToEquity, 1.5); // 150 / 100 +}); + +test('maps ETF quoteType to ETF', () => { + const etfSummary = { + ...base, + price: { ...base.price, quoteType: 'ETF' }, + assetProfile: { category: 'Large Blend' }, + }; + const result = mapToStandardFormat('VOO', etfSummary); + assert.equal(result.type, 'ETF'); +}); + +test('classifies bond ETF from category keyword', () => { + const bondSummary = { + ...base, + price: { ...base.price, quoteType: 'ETF' }, + assetProfile: { category: 'Intermediate-Term Bond' }, + }; + const result = mapToStandardFormat('BND', bondSummary); + assert.equal(result.type, 'BOND'); +}); + +test('FCF yield is computed when data available', () => { + const result = mapToStandardFormat('AAPL', base); + assert.notEqual(result.fcfYield, null); + assert(result.fcfYield > 0); +}); + +test('peRatio prefers trailingPE over forwardPE', () => { + // trailingPE=30 in summaryDetail, forwardPE=28 in defaultKeyStatistics + const result = mapToStandardFormat('AAPL', base); + assert.equal(result.peRatio, 30); // trailing should win +}); + +test('negative FCF yield is preserved, not nulled', () => { + const negativeFcf = { + ...base, + financialData: { ...base.financialData, freeCashflow: -2e9 }, + }; + const result = mapToStandardFormat('AAPL', negativeFcf); + assert.notEqual(result.fcfYield, null); + assert(result.fcfYield < 0, 'negative FCF should produce negative yield, not null'); +}); + +test('ETF maps volume from summaryDetail', () => { + const etfSummary = { + ...base, + price: { ...base.price, quoteType: 'ETF' }, + assetProfile: { category: 'Large Blend' }, + summaryDetail: { + ...base.summaryDetail, + averageVolume: 5000000, + expenseRatio: 0.0003, + trailingAnnualDividendYield: 0.013, + }, + defaultKeyStatistics: { fiveYearAverageReturn: 0.12 }, + }; + const result = mapToStandardFormat('VOO', etfSummary); + assert.equal(result.volume, 5000000); +}); + +test('bond duration inferred from category — intermediate maps to 5y', () => { + const bondSummary = { + ...base, + price: { ...base.price, quoteType: 'ETF' }, + assetProfile: { category: 'Intermediate-Term Bond' }, + summaryDetail: { yield: 0.045 }, + defaultKeyStatistics: {}, + }; + const result = mapToStandardFormat('BND', bondSummary); + assert.equal(result.duration, 5); +}); + +test('bond duration inferred from category — short-term maps to 2y', () => { + const bondSummary = { + ...base, + price: { ...base.price, quoteType: 'ETF' }, + assetProfile: { category: 'Short-Term Bond' }, + summaryDetail: { yield: 0.05 }, + defaultKeyStatistics: {}, + }; + const result = mapToStandardFormat('SHY', bondSummary); + assert.equal(result.duration, 2); +}); + +test('metrics are null (not 0) when data missing', () => { + const sparse = { + price: { quoteType: 'EQUITY', regularMarketPrice: 100 }, + financialData: {}, + defaultKeyStatistics: {}, + summaryDetail: {}, + assetProfile: {}, + }; + const result = mapToStandardFormat('X', sparse); + assert.equal(result.pegRatio, null); + assert.equal(result.quickRatio, null); +}); diff --git a/tests/EtfScorer.test.js b/tests/EtfScorer.test.js new file mode 100644 index 0000000..ee88542 --- /dev/null +++ b/tests/EtfScorer.test.js @@ -0,0 +1,54 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EtfScorer } from '../src/screener/scorers/EtfScorer.js'; + +const rules = { + gates: { maxExpenseRatio: 0.5 }, + weights: { yield: 2, lowCost: 3 }, + thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 }, +}; + +test('rejects ETF with expense ratio above gate', () => { + const result = EtfScorer.score({ expenseRatio: 0.8, yield: 2.0 }, rules); + assert.equal(result.label, '🔴 REJECT'); +}); + +test('efficient label for low-cost, high-yield ETF', () => { + const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules); + assert.equal(result.label, '🟢 Efficient'); +}); + +test('neutral when yield is below threshold', () => { + const result = EtfScorer.score({ expenseRatio: 0.03, yield: 0.4, volume: 1000000 }, rules); + assert.equal(result.label, '🟡 Neutral'); +}); + +test('audit breakdown includes cost, yield, vol keys', () => { + const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules); + assert(result.audit.breakdown.cost != null); + assert(result.audit.breakdown.yield != null); + assert(result.audit.breakdown.vol != null); +}); + +test('penalises ETF with volume below liquidity floor', () => { + const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 100000 }, rules); + assert(result.audit.breakdown.vol < 0, 'low-volume ETF should receive negative vol score'); +}); + +test('scores 5Y return when threshold configured', () => { + const rulesWithReturn = { + ...rules, + weights: { ...rules.weights, fiveYearReturn: 2 }, + thresholds: { ...rules.thresholds, minFiveYearReturn: 8.0 }, + }; + const good = EtfScorer.score( + { expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 10 }, + rulesWithReturn, + ); + const poor = EtfScorer.score( + { expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 5 }, + rulesWithReturn, + ); + assert(good.audit.breakdown.fiveYearReturn > 0, 'strong 5Y return should score positively'); + assert(poor.audit.breakdown.fiveYearReturn < 0, 'weak 5Y return should score negatively'); +}); diff --git a/tests/LLMAnalyst.test.js b/tests/LLMAnalyst.test.js new file mode 100644 index 0000000..0cef405 --- /dev/null +++ b/tests/LLMAnalyst.test.js @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// Test the markdown fence stripping logic in isolation — +// we don't instantiate LLMAnalyst (requires Anthropic SDK + API key). +// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim() + +function stripFences(raw) { + return raw + .replace(/^```(?:json)?\s*/i, '') + .replace(/```\s*$/i, '') + .trim(); +} + +const VALID_JSON = + '{"summary":"test","sentiment":"BULLISH","affectedIndustries":[],"relatedTickers":[]}'; + +test('stripFences: passes clean JSON through unchanged', () => { + assert.equal(stripFences(VALID_JSON), VALID_JSON); +}); + +test('stripFences: strips ```json ... ``` fences', () => { + const wrapped = '```json\n' + VALID_JSON + '\n```'; + assert.equal(stripFences(wrapped), VALID_JSON); +}); + +test('stripFences: strips ``` ... ``` fences (no language tag)', () => { + const wrapped = '```\n' + VALID_JSON + '\n```'; + assert.equal(stripFences(wrapped), VALID_JSON); +}); + +test('stripFences: result is valid parseable JSON', () => { + const wrapped = '```json\n' + VALID_JSON + '\n```'; + const parsed = JSON.parse(stripFences(wrapped)); + assert.equal(parsed.sentiment, 'BULLISH'); + assert.equal(parsed.summary, 'test'); +}); + +test('stripFences: handles no trailing newline before closing fence', () => { + const wrapped = '```json\n' + VALID_JSON + '```'; + assert.equal(stripFences(wrapped), VALID_JSON); +}); + +test('stripFences: case-insensitive fence tag', () => { + const wrapped = '```JSON\n' + VALID_JSON + '\n```'; + assert.equal(stripFences(wrapped), VALID_JSON); +}); diff --git a/tests/MarketRegime.test.js b/tests/MarketRegime.test.js new file mode 100644 index 0000000..8ea7a44 --- /dev/null +++ b/tests/MarketRegime.test.js @@ -0,0 +1,69 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { MarketRegime } from '../src/market/MarketRegime.js'; +import { SECTOR, ASSET_TYPE } from '../src/config/constants.js'; + +const regime = (benchmarks, extra = {}) => new MarketRegime({ benchmarks, ...extra }); + +test('stock inflated P/E = marketPE × 1.5', () => { + const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL); + assert.equal(gates.maxPERatio, Math.round(24 * 1.5)); // 36 +}); + +test('tech inflated P/E = techPE × 1.3', () => { + const { gates } = regime({ techPE: 40 }).getInflatedOverrides( + ASSET_TYPE.STOCK, + SECTOR.TECHNOLOGY, + ); + assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52 +}); + +test('REIT inflated minYield = reitYield × 0.85 in NORMAL rate regime', () => { + const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'NORMAL' }).getInflatedOverrides( + ASSET_TYPE.STOCK, + SECTOR.REIT, + ); + assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40 +}); + +test('REIT inflated minYield = reitYield × 0.95 in HIGH rate regime', () => { + const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'HIGH' }).getInflatedOverrides( + ASSET_TYPE.STOCK, + SECTOR.REIT, + ); + assert.equal(thresholds.minYield, +(4.0 * 0.95).toFixed(2)); // 3.80 +}); + +test('bond inflated minSpread = igSpread × 0.80 in NORMAL rate regime', () => { + const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'NORMAL' }).getInflatedOverrides( + ASSET_TYPE.BOND, + SECTOR.GENERAL, + ); + assert.equal(thresholds.minSpread, +(1.5 * 0.8).toFixed(2)); // 1.20 +}); + +test('bond inflated minSpread = igSpread × 0.90 in HIGH rate regime', () => { + const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'HIGH' }).getInflatedOverrides( + ASSET_TYPE.BOND, + SECTOR.GENERAL, + ); + assert.equal(thresholds.minSpread, +(1.5 * 0.9).toFixed(2)); // 1.35 +}); + +test('GENERAL stock P/E multiplier compresses to 1.2× in HIGH rate regime', () => { + const { gates } = regime({ marketPE: 25 }, { rateRegime: 'HIGH' }).getInflatedOverrides( + ASSET_TYPE.STOCK, + SECTOR.GENERAL, + ); + assert.equal(gates.maxPERatio, Math.round(25 * 1.2)); // 30 +}); + +test('ETF inflated loosens expense gate to 0.75', () => { + const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF); + assert.equal(gates.maxExpenseRatio, 0.75); +}); + +test('falls back to defaults when benchmarks missing', () => { + const { gates } = new MarketRegime({}).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL); + assert.equal(gates.maxPERatio, Math.round(22 * 1.5)); // default marketPE = 22 +}); diff --git a/tests/PortfolioAdvisor.test.js b/tests/PortfolioAdvisor.test.js new file mode 100644 index 0000000..8bc52d5 --- /dev/null +++ b/tests/PortfolioAdvisor.test.js @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js'; +import { SIGNAL } from '../src/config/constants.js'; + +const advisor = new PortfolioAdvisor(); + +test('_position: computes gain/loss correctly', () => { + const pos = advisor._position({ costBasis: 100, shares: 10 }, 150); + assert.equal(pos.gainLossPct, '50.0'); + assert.equal(pos.marketValue, '1500.00'); + assert.equal(pos.totalCost, '1000.00'); +}); + +test('_position: returns null gainLoss when price unavailable', () => { + const pos = advisor._position({ costBasis: 100, shares: 10 }, null); + assert.equal(pos.gainLossPct, null); + assert.equal(pos.marketValue, null); +}); + +test('_advice: Strong Buy → Hold & Add', () => { + const { action } = advisor._advice(SIGNAL.STRONG_BUY, { costBasis: 100, shares: 10 }, 150); + assert.equal(action, '🟢 Hold & Add'); +}); + +test('_advice: Avoid + loss → Sell (Cut Loss)', () => { + const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 150, shares: 10 }, 100); + assert.equal(action, '🔴 Sell (Cut Loss)'); +}); + +test('_advice: Avoid + profit → Sell (Take Profits)', () => { + const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 100, shares: 10 }, 150); + assert.equal(action, '🔴 Sell (Take Profits)'); +}); + +test('_advice: Speculation + >20% gain → Reduce Position', () => { + const { action } = advisor._advice(SIGNAL.SPECULATION, { costBasis: 100, shares: 10 }, 125); + assert.equal(action, '🟠 Reduce Position'); +}); + +test('_cryptoAdvice: no price → No price data', () => { + const { action } = advisor._cryptoAdvice({ costBasis: 100, shares: 1 }, null); + assert.equal(action, '⚪ No price data'); +}); + +test('_cryptoAdvice: >100% gain → Consider taking profits', () => { + const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000); + assert.equal(action, '🟠 Consider taking profits'); +}); + +// ── Result map dot-notation normalisation (BRK.B / BRK-B) ─────────────────── + +test('advise: BRK-B screener result matches BRK.B holding', async () => { + const mockResult = { + asset: { ticker: 'BRK-B', currentPrice: 500 }, + signal: SIGNAL.STRONG_BUY, + inflated: { label: '🟢 BUY (High Conviction)' }, + fundamental: { label: '🟢 BUY (High Conviction)' }, + }; + const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] }; + const holding = { + ticker: 'BRK.B', + shares: 1, + costBasis: 400, + type: 'stock', + source: 'Robinhood', + }; + + const advice = await advisor.advise([holding], screenedResults); + // Should match and return a real signal, not "Not screened" + assert.equal(advice[0].signal, SIGNAL.STRONG_BUY); +}); + +test('advise: BRK.B screener result matches BRK-B holding', async () => { + const mockResult = { + asset: { ticker: 'BRK.B', currentPrice: 500 }, + signal: SIGNAL.STRONG_BUY, + inflated: { label: '🟢 BUY (High Conviction)' }, + fundamental: { label: '🟢 BUY (High Conviction)' }, + }; + const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] }; + const holding = { + ticker: 'BRK-B', + shares: 1, + costBasis: 400, + type: 'stock', + source: 'Robinhood', + }; + + const advice = await advisor.advise([holding], screenedResults); + assert.equal(advice[0].signal, SIGNAL.STRONG_BUY); +}); diff --git a/tests/RuleMerger.test.js b/tests/RuleMerger.test.js new file mode 100644 index 0000000..49eff00 --- /dev/null +++ b/tests/RuleMerger.test.js @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { RuleMerger } from '../src/screener/RuleMerger.js'; +import { SCORE_MODE } from '../src/config/constants.js'; + +const ctx = { + benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 }, +}; + +test('FUNDAMENTAL mode returns Graham-style P/E gate', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'GENERAL' }, + ctx, + SCORE_MODE.FUNDAMENTAL, + ); + assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x + assert.equal(rules.gates.maxPegGate, 1.0); // updated: Lynch PEG standard +}); + +test('INFLATED mode loosens P/E gate from live SPY data', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'GENERAL' }, + ctx, + SCORE_MODE.INFLATED, + ); + assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37 + assert(rules.gates.maxPERatio > 15, 'Inflated P/E should exceed fundamental 15x'); +}); + +test('INFLATED tech P/E gate uses XLK benchmark', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'TECHNOLOGY' }, + ctx, + SCORE_MODE.INFLATED, + ); + assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42 +}); + +test('Sector override applied before inflated overrides', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'REIT' }, + ctx, + SCORE_MODE.FUNDAMENTAL, + ); + assert.equal(rules.gates.maxPERatio, 9999); + assert.equal(rules.weights.yield, 5); + assert.equal(rules.weights.margin, 0); +}); + +test('SECTOR_OVERRIDE is deleted from returned rules', () => { + const rules = RuleMerger.getRulesForAsset( + 'STOCK', + { sector: 'GENERAL' }, + ctx, + SCORE_MODE.FUNDAMENTAL, + ); + assert.equal(rules.SECTOR_OVERRIDE, undefined); +}); + +test('throws for unknown asset type', () => { + assert.throws(() => RuleMerger.getRulesForAsset('CRYPTO', {}, ctx), /No rules configured/); +}); diff --git a/tests/ScoringConfig.test.js b/tests/ScoringConfig.test.js new file mode 100644 index 0000000..867da09 --- /dev/null +++ b/tests/ScoringConfig.test.js @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CREDIT_RATING_SCALE, ScoringRules } from '../src/config/ScoringConfig.js'; + +test('CREDIT_RATING_SCALE covers full spectrum', () => { + assert.equal(CREDIT_RATING_SCALE.AAA, 10); + assert.equal(CREDIT_RATING_SCALE.BBB, 7); + assert.equal(CREDIT_RATING_SCALE.BB, 6); + assert.equal(CREDIT_RATING_SCALE.D, 1); +}); + +test('STOCK base gates are fundamental (Graham-style)', () => { + const { gates } = ScoringRules.STOCK; + assert.equal(gates.maxPERatio, 15); // Graham's actual rule: 15x trailing earnings + assert.equal(gates.maxPegGate, 1.0); // Lynch standard: PEG > 1.0 is paying full price + assert.equal(gates.minQuickRatio, 0.8); // below 0.8 signals liquidity stress +}); + +test('REIT sector override zeroes out irrelevant weights', () => { + const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT; + assert.equal(reit.weights.margin, 0); + assert.equal(reit.weights.peg, 0); + assert.equal(reit.weights.revenue, 0); + assert.equal(reit.weights.yield, 5); +}); + +test('REIT gates disable P/E and PEG', () => { + const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT; + assert.equal(reit.gates.maxPERatio, 9999); + assert.equal(reit.gates.maxPegGate, 9999); +}); + +test('TECHNOLOGY gates are realistic for mega-cap', () => { + const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY; + assert.equal(tech.gates.maxDebtToEquity, 2.0); + assert.equal(tech.gates.minQuickRatio, 0.8); +}); + +test('BOND requires investment-grade floor (BBB = 7)', () => { + assert.equal(ScoringRules.BOND.gates.minCreditRating, 7); +}); diff --git a/tests/StockScorer.test.js b/tests/StockScorer.test.js new file mode 100644 index 0000000..88e3d00 --- /dev/null +++ b/tests/StockScorer.test.js @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { StockScorer } from '../src/screener/scorers/StockScorer.js'; + +const baseRules = { + gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 }, + weights: { margin: 2, opMargin: 2, roe: 3, peg: 2, revenue: 2, fcf: 2 }, + thresholds: { + marginHigh: 20, + marginMed: 10, + opMarginHigh: 20, + opMarginMed: 10, + roeHigh: 20, + roeMed: 10, + pegHigh: 1.0, + pegMed: 1.5, + revHigh: 15, + revMed: 5, + fcfHigh: 5, + fcfMed: 2, + }, +}; + +const pass = { + peRatio: 15, + pegRatio: 1.2, + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 22, + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 16, + fcfYield: 6, +}; + +test('rejects on high D/E', () => { + const result = StockScorer.score({ ...pass, debtToEquity: 4.0 }, baseRules); + assert.equal(result.label, '🔴 REJECT'); + assert(result.scoreSummary.includes('D/E')); +}); + +test('rejects on high P/E', () => { + const result = StockScorer.score({ ...pass, peRatio: 25 }, baseRules); + assert.equal(result.label, '🔴 REJECT'); + assert(result.scoreSummary.includes('P/E')); +}); + +test('rejects on high PEG', () => { + const result = StockScorer.score({ ...pass, pegRatio: 2.0 }, baseRules); + assert.equal(result.label, '🔴 REJECT'); +}); + +test('skips gate when metric is null (missing data)', () => { + const result = StockScorer.score({ ...pass, pegRatio: null, peRatio: null }, baseRules); + assert.notEqual(result.label, '🔴 REJECT'); +}); + +test('high-conviction BUY on strong metrics', () => { + const result = StockScorer.score(pass, baseRules); + assert.equal(result.label, '🟢 BUY (High Conviction)'); +}); + +test('audit breakdown contains scored factors', () => { + const result = StockScorer.score(pass, baseRules); + assert(result.audit.passedGates); + assert(result.audit.breakdown.roe != null); + assert(result.audit.breakdown.margin != null); +}); + +test('beta > 1.5 surfaces as risk flag', () => { + const result = StockScorer.score({ ...pass, beta: 2.0 }, baseRules); + assert(result.audit.riskFlags?.some((f) => f.includes('High volatility'))); +}); + +test('near 52-week high surfaces as risk flag', () => { + const result = StockScorer.score( + { ...pass, week52High: 200, week52Low: 100, currentPrice: 195 }, + baseRules, + ); + assert(result.audit.riskFlags?.some((f) => f.includes('52-week high'))); +}); diff --git a/ui/CLAUDE.md b/ui/CLAUDE.md new file mode 100644 index 0000000..eab7fa3 --- /dev/null +++ b/ui/CLAUDE.md @@ -0,0 +1,170 @@ +# CLAUDE.md + +Guidance for working in this repository. + +## Overview + +`market-screener-ui` is a SvelteKit 5 single-page application (CSR, no SSR) that serves as the interactive dashboard for the `market_screener` Fastify API. + +- All data comes from the API at `http://localhost:3000` (proxied through Vite in dev) +- No SSR — `+layout.js` exports `ssr = false` +- Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) + +--- + +## Commands + +```bash +npm install # install dependencies (SvelteKit, Vite, Svelte 5) +npm run dev # dev server on port 5173 +npm run build # production build → .svelte-kit/output +npm run preview # preview production build +``` + +To run the full stack, use `npm run dev` from the **API repo** (`market_screener/`) instead — it starts both servers together using `concurrently`. + +--- + +## Architecture + +### No SSR +`src/routes/+layout.js` exports `ssr = false`. All data fetching happens in the browser. This avoids Svelte 5 SSR compatibility issues and makes sense for a live-data dashboard. + +### API Proxy +`vite.config.js` proxies `/api/*` → `http://localhost:3000` in dev. In production, configure your reverse proxy (nginx/Caddy) to do the same. + +### Data Loading +- **Screener page** (`/`): data loaded client-side on button click via `$lib/api.js` +- **Portfolio page** (`/portfolio`): data loaded via SvelteKit `+page.js` `load()` function — this fires on navigation and is the correct SvelteKit pattern for CSR page data + +**Do not use `onMount` for initial data fetching** — use `load()` in `+page.js` instead. `onMount` does not reliably fire in SvelteKit CSR for page-level data. + +--- + +## Project Structure + +``` +src/ + app.html ← HTML shell + app.css ← Global reset + body styles (no :global() in .svelte files) + routes/ + +layout.js ← exports ssr = false + +layout.svelte ← nav bar (Screener / Portfolio links) + +page.svelte ← Screener page + portfolio/ + +page.js ← load() function — fetches /api/finance/portfolio + +page.svelte ← Portfolio + SimpleFIN page + + lib/ + api.js ← All fetch calls to the Fastify API + SignalBadge.svelte ← Signal pill component (Strong Buy / Avoid / etc.) + MarketContext.svelte ← Benchmark strip component + +.claude/ + launch.json ← Preview server config for Claude Code + +vite.config.js ← Vite config with /api proxy +svelte.config.js ← SvelteKit config (adapter-auto) +``` + +--- + +## Key Files + +### `src/lib/api.js` +All API calls in one place. If the API base URL changes, change it here only. + +```js +screenTickers(tickers) // POST /api/screen +fetchCatalysts() // GET /api/screen/catalysts +fetchPortfolio() // GET /api/finance/portfolio +fetchMarketContext() // GET /api/finance/market-context +``` + +### `src/routes/+page.svelte` (Screener) +- Ticker input pre-filled with a default watchlist +- `screen()` calls API and stores results in `$state` +- `loadCatalysts()` fetches news tickers then **immediately calls `screen()`** — one click, full results +- `results` is `null` until first screen — nothing renders below the toolbar +- `verdictShort()` abbreviates long verdict strings (`"🟢 BUY (High Conviction)"` → `"Strong"`) + +### `src/routes/portfolio/+page.svelte` +- Receives `data` from `+page.js` load function via `let { data } = $props()` +- Shows `data.error` if load failed, `data.advice` for holdings, `data.personalFinance` for SimpleFIN section + +--- + +## Svelte 5 Patterns Used + +```svelte + +let loading = $state(false); + + +const totalGL = $derived(totalValue - totalCost); + + +const cards = $derived.by(() => { ... return [...] }); + + +let { ctx } = $props(); +let { data } = $props(); + + + + + +{@const mode = getTab(type)} +``` + +--- + +## API Response Shape + +The Fastify API serializes asset class instances before sending — `asset.getDisplayMetrics()` is called server-side and included as `asset.displayMetrics`. In the browser, use `r.asset.displayMetrics` directly (not `r.asset.getDisplayMetrics()` which doesn't exist on plain JSON objects). + +```js +// Screener response shape +{ + STOCK: [{ asset: { ticker, type, currentPrice, metrics, displayMetrics }, fundamental, inflated, signal }], + ETF: [...], + BOND: [...], + ERROR: [...], + marketContext: { sp500Price, riskFreeRate, vixLevel, rateRegime, volatilityRegime, benchmarks } +} +``` + +--- + +## Styling Conventions + +- Dark theme throughout: page background `#0f1117`, card sections `#0d1117`/`#111827` +- All colors are CSS custom values inline (no CSS variables yet — keep consistent with existing palette) +- Tables: `width: max-content; min-width: 100%` inside a `.table-wrap { overflow-x: auto }` container +- First column sticky: `position: sticky; left: 0; background: inherit` +- Verdict pills: `.verdict-pill.green/yellow/red` — colored background tint + text +- Monospace font for the ticker input field +- `white-space: nowrap` on `tbody td` — tables scroll horizontally, not wrap + +**Color palette:** +``` +page bg: #0f1117 +card bg: #0d1117 / #111827 (header rows) +border: #1e293b +muted: #64748b / #475569 +text: #e2e8f0 / #f1f5f9 +green: #4ade80 (bg tint: #14532d33) +yellow: #facc15 (bg tint: #71350033) +red: #f87171 (bg tint: #450a0a33) +blue accent: #2563eb / #3b82f6 +``` + +--- + +## Conventions + +- Do not use `:global()` in ` diff --git a/ui/src/lib/SignalBadge.svelte b/ui/src/lib/SignalBadge.svelte new file mode 100644 index 0000000..52ad5a3 --- /dev/null +++ b/ui/src/lib/SignalBadge.svelte @@ -0,0 +1,29 @@ + + +{signal ?? '—'} + + diff --git a/ui/src/lib/Spinner.svelte b/ui/src/lib/Spinner.svelte new file mode 100644 index 0000000..e2c2cb5 --- /dev/null +++ b/ui/src/lib/Spinner.svelte @@ -0,0 +1,139 @@ + + +{#if size === 'sm'} + + + + +{:else} + +
+ + + {#if label} + {label} + {/if} +
+{/if} + + diff --git a/ui/src/lib/api.js b/ui/src/lib/api.js new file mode 100644 index 0000000..60ace6b --- /dev/null +++ b/ui/src/lib/api.js @@ -0,0 +1,96 @@ +const BASE = '/api'; + +export async function screenTickers(tickers) { + const res = await fetch(`${BASE}/screen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tickers }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchCatalysts() { + const res = await fetch(`${BASE}/screen/catalysts`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function analyzeTickers(tickers) { + const res = await fetch(`${BASE}/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tickers }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchPortfolio() { + const res = await fetch(`${BASE}/finance/portfolio`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function addHolding(holding) { + const res = await fetch(`${BASE}/finance/holdings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(holding), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function removeHolding(ticker) { + const res = await fetch(`${BASE}/finance/holdings/${ticker}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchMarketContext() { + const res = await fetch(`${BASE}/finance/market-context`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +// ── Market Calls ────────────────────────────────────────────────────────────── + +export async function fetchCalls() { + const res = await fetch(`${BASE}/calls`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchCall(id) { + const res = await fetch(`${BASE}/calls/${id}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function createCall(payload) { + const res = await fetch(`${BASE}/calls`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deleteCall(id) { + const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchCallsCalendar(tickers = null) { + const url = tickers?.length + ? `${BASE}/calls/calendar?tickers=${tickers.join(',')}` + : `${BASE}/calls/calendar`; + const res = await fetch(url); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} diff --git a/ui/src/routes/+layout.js b/ui/src/routes/+layout.js new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/ui/src/routes/+layout.js @@ -0,0 +1 @@ +export const ssr = false; diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte new file mode 100644 index 0000000..4927685 --- /dev/null +++ b/ui/src/routes/+layout.svelte @@ -0,0 +1,132 @@ + + +
+ + + + {#if $navigating} + + {/if} + +
+ {#if $navigating} + + + {:else} + {@render children()} + {/if} +
+
+ + diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte new file mode 100644 index 0000000..94b909e --- /dev/null +++ b/ui/src/routes/+page.svelte @@ -0,0 +1,858 @@ + + +
+ + +
+
+ + + {#if screenedAt} + Last screened {screenedAt} + {/if} +
+ + {#if searchOpen} +
+ e.key === 'Enter' && screen()} + /> + +
+ {/if} +
+ + {#if error} +
⚠ {error}
+ {/if} + + {#if loading || loadingCats} +
+ +
+ {/if} + + {#if ctx} + +
+
+ 10Y + {ctx.riskFreeRate?.toFixed(2)}% +
+
+ VIX + {ctx.vixLevel?.toFixed(1)} +
+
+ S&P + {ctx.sp500Price?.toLocaleString()} +
+
+ S&P P/E + {fmtPE(ctx.benchmarks?.marketPE?.toFixed(1))} +
+
+ Tech P/E + {fmtPE(ctx.benchmarks?.techPE?.toFixed(1))} +
+
+ REIT Yld + {ctx.benchmarks?.reitYield?.toFixed(2)}% +
+
+ IG Sprd + {ctx.benchmarks?.igSpread?.toFixed(2)}% +
+
+ Rates + {ctx.rateRegime} +
+
+ Vol + {ctx.volatilityRegime} +
+
+ + +
+
+

Signal Summary

+ {allAssets.length} assets +
+
+ + + + + + + + + + + + {#each allAssets as r} + + + + + + + + {/each} + +
TickerTypeSignalMkt-AdjustedFundamental
{r.asset.ticker}{r.asset.type} + + {verdictShort(r.inflated.label)} + + + + {verdictShort(r.fundamental.label)} + +
+
+
+ + + {#each ['STOCK', 'ETF', 'BOND'] as type} + {#if results[type]?.length} + {@const count = results[type].length} +
+
+

{type}S

+ {count} +
+ + +
+ +
+ +
+ + + + + + + + {#if type === 'STOCK'} + + + + + {:else if type === 'ETF'} + + {:else} + + {/if} + + + + {#each sorted(results[type]) as r} + {@const mode = getTab(type)} + {@const m = r.asset.displayMetrics ?? {}} + {@const v = r[mode]} + + + + + + {#if type === 'STOCK'} + + + + + + + + + {:else if type === 'ETF'} + + + + + {:else} + + + + {/if} + + {/each} + +
TickerPriceVerdictScoreSectorP/EPEGROE%OpMgn%FCF%D/EFlagsExpenseYieldAUM5Y RetYTMDurationRating
{r.asset.ticker}{m.Price ?? '—'} + + {verdictShort(v.label)} + + {v.scoreSummary}{m.Sector ?? '—'}{m['P/E'] ?? '—'}{m['PEG'] ?? '—'}{m['ROE%'] ?? '—'}{m['OpMgn%'] ?? '—'}{m['FCF Yld%'] ?? '—'}{m['D/E'] ?? '—'} + {#each v.audit?.riskFlags ?? [] as flag} + ⚠ {flag} + {/each} + {m['Exp Ratio%'] ?? '—'}{m['Yield%'] ?? '—'}{m['AUM'] ?? '—'}{m['5Y Return%'] ?? '—'}{m['YTM%'] ?? '—'}{m['Duration'] ?? '—'}{m['Rating'] ?? '—'}
+
+
+ {/if} + {/each} + + {#if results.ERROR?.length} +
+

Failed {results.ERROR.length}

+
+ {#each results.ERROR as e} +
{e.ticker} {e.message}
+ {/each} +
+
+ {/if} + {/if} +
+ + +{#if sidebar.open} + + + +{/if} + + diff --git a/ui/src/routes/calls/+page.js b/ui/src/routes/calls/+page.js new file mode 100644 index 0000000..9ba6f7d --- /dev/null +++ b/ui/src/routes/calls/+page.js @@ -0,0 +1,8 @@ +export async function load({ fetch }) { + const [callsRes, calRes] = await Promise.all([fetch('/api/calls'), fetch('/api/calls/calendar')]); + + const { calls } = callsRes.ok ? await callsRes.json() : { calls: [] }; + const { events } = calRes.ok ? await calRes.json() : { events: [] }; + + return { calls, events }; +} diff --git a/ui/src/routes/calls/+page.svelte b/ui/src/routes/calls/+page.svelte new file mode 100644 index 0000000..a2b5584 --- /dev/null +++ b/ui/src/routes/calls/+page.svelte @@ -0,0 +1,420 @@ + + +
+ + + + {#if showForm} +
+

New Market Call

+
{ e.preventDefault(); submit(); }}> +
+ + + +
+ + + {#if formError} +
⚠ {formError}
+ {/if} + +
+
+ {/if} + + + {#if (data.events ?? []).length > 0} +
+
+

📅 Upcoming Events

+ {upcoming.length} upcoming + {#if past.length > 0} + {past.length} recent + {/if} +
+
+ {#each upcoming as ev} +
+
{ev.date}
+
+ {ev.ticker} + + {eventIcon(ev.type)} {ev.label} + {#if ev.detail}· {ev.detail}{/if} + + {#if ev.epsEstimate != null} + EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)} + {/if} +
+
+ {/each} + {#if past.length > 0} +
— Past —
+ {#each past as ev} +
+
{ev.date}
+
+ {ev.ticker} + + {eventIcon(ev.type)} {ev.label} + +
+
+ {/each} + {/if} +
+
+ {/if} + + + {#if data.error} +
⚠ {data.error}
+ {:else if data.calls.length === 0} +
No market calls yet. Create your first one to start tracking.
+ {:else} + {#each data.calls as call} +
+
+
+ {call.title} +
+ {call.quarter} + {call.date} + {call.tickers.length} tickers +
+
+ +
+ +
+

{call.thesis}

+ + {#if Object.keys(call.snapshot ?? {}).length} +
+ {#each call.tickers as ticker} + {@const snap = call.snapshot[ticker]} + {#if snap} + +
{ticker}
+
${snap.price?.toFixed(2) ?? '—'}
+
+ {snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'} +
+
+ {/if} + {/each} +
+ View performance → + {/if} +
+
+ {/each} + {/if} +
+ + diff --git a/ui/src/routes/calls/[id]/+page.js b/ui/src/routes/calls/[id]/+page.js new file mode 100644 index 0000000..a468725 --- /dev/null +++ b/ui/src/routes/calls/[id]/+page.js @@ -0,0 +1,5 @@ +export async function load({ fetch, params }) { + const res = await fetch(`/api/calls/${params.id}`); + if (!res.ok) return { error: await res.text() }; + return res.json(); +} diff --git a/ui/src/routes/calls/[id]/+page.svelte b/ui/src/routes/calls/[id]/+page.svelte new file mode 100644 index 0000000..b23aaa5 --- /dev/null +++ b/ui/src/routes/calls/[id]/+page.svelte @@ -0,0 +1,202 @@ + + +
+ {#if data?.error} +
⚠ {data.error}
+ + {:else if data} + + +
+
+ {data.quarter} + {data.date} + ({daysSince(data.date)} days ago) +
+

{data.title}

+

{data.thesis}

+
+ + +
+
+

Performance since call date

+ {tickers.length} tickers +
+ +
+ + + + + + + + + + + + + + + {#each tickers as ticker} + {@const snap = snapshot[ticker]} + {@const cur = current[ticker]} + {@const ret = pctChange(snap?.price, cur?.price)} + = 10} class:worst={ret != null && ret <= -10}> + + + + + + + + + + {/each} + +
TickerCall PriceNowReturnCall SignalNow SignalCall VerdictNow Verdict
{ticker}{fmt(snap?.price)}{fmt(cur?.price)}{fmtPct(ret)} + {#if snap?.signal} + {snap.signal} + {:else} + + {/if} + + {#if cur?.signal} + {cur.signal} + {:else} + + {/if} + + {#if snap?.inflatedVerdict} + + {snap.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()} + + {:else} + + {/if} + + {#if cur?.inflatedVerdict} + + {cur.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()} + + {:else} + + {/if} +
+
+
+ {/if} +
+ + diff --git a/ui/src/routes/portfolio/+page.js b/ui/src/routes/portfolio/+page.js new file mode 100644 index 0000000..f9610a3 --- /dev/null +++ b/ui/src/routes/portfolio/+page.js @@ -0,0 +1,8 @@ +// Disable SSR — data is fetched client-side in the component so navigation +// is instant instead of blocking until all Yahoo Finance calls resolve. +export const ssr = false; +export const prerender = false; + +export function load() { + return {}; +} diff --git a/ui/src/routes/portfolio/+page.svelte b/ui/src/routes/portfolio/+page.svelte new file mode 100644 index 0000000..e74ca9e --- /dev/null +++ b/ui/src/routes/portfolio/+page.svelte @@ -0,0 +1,795 @@ + + +
+ {#if loading} +
+ +
+ + {:else if loadError} +
{loadError}
+ + {:else if data?.advice} + +
+ + {#if refreshing} + Updating prices… + {/if} +
+ + + {#if formOpen} +
+
Add Holding
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {#if formError} +
⚠ {formError}
+ {/if} +
+ {/if} + + {#if data.marketContext} + + {/if} + + +
+
+
+ Total Value + + ? + Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position. + +
+
{fmtShort(totalValue)}
+
+
+
+ Total Cost + + ? + Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered. + +
+
{fmtShort(totalCost)}
+
+
+
+ Total G/L + + ? + Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down. + +
+
{fmtShort(totalGL)}
+
+
+ + +
+

Holdings — Hold / Sell / Add Advice

+ + + + + + + + + + + + + + + + {#each sortedAdvice as a} + {@const isEditing = inlineEdit?.ticker === a.ticker} + + + + + + + + + + + + + + {/each} + +
toggleSort('ticker')}>Ticker {sortIcon('ticker')} toggleSort('type')}>Type {sortIcon('type')} toggleSort('shares')}>Shares {sortIcon('shares')} toggleSort('cost')}>Cost {sortIcon('cost')} toggleSort('current')}>Current {sortIcon('current')} toggleSort('value')}>Value {sortIcon('value')} toggleSort('gl')}>G/L {sortIcon('gl')} toggleSort('signal')}>Signal {sortIcon('signal')}AdviceReason
{a.ticker} + {#if isEditing} + + {:else} + {a.type} + {/if} + + {#if isEditing} + + {:else} + {a.shares} + {/if} + + {#if isEditing} + + {:else} + {fmt(a.costBasis)} + {/if} + {fmt(parseFloat(a.currentPrice))}{fmt(parseFloat(a.marketValue))}{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}{#if a.signal}{:else}{/if}{a.advice}{a.reason} + {#if isEditing} + + + {:else} + + + {/if} +
+
+ + + {#if data.personalFinance} + {@const pf = data.personalFinance} +
+
+
Net Worth
+
{fmtShort(pf.netWorth)}
+
+
+
Total Assets
+
{fmtShort(pf.totalAssets)}
+
+
+
Liabilities
+
{fmtShort(pf.totalLiabilities)}
+
+
+
Cash ({pf.cashPct}%)
+
{fmtShort(pf.totalCash)}
+
+
+
Investments ({pf.investPct}%)
+
{fmtShort(pf.totalInvestments)}
+
+ {#if pf.savingsRate != null} +
+
Savings Rate
+
{pf.savingsRate}%
+
+ {/if} +
+
Monthly Income
+
{fmtShort(pf.totalIncome)}
+
+
+
Monthly Spend
+
{fmtShort(pf.totalSpend)}
+
+
+ +
+
+

Accounts

+ + + + {#each pf.accounts as a} + + + + + + + {/each} + +
AccountTypeInstitutionBalance
{a.name}{a.type}{a.org}{fmt(a.balance)}
+
+ +
+

Spending — Last 30 Days

+ + + + {#each pf.categoryBreakdown.slice(0, 10) as c} + + + + + + + {/each} + +
CategoryAmount%Share
{c.category}{fmt(c.amount)}{c.pct}% +
+
+
+
+
+
+ {/if} + + {/if} +
+ + diff --git a/ui/src/routes/safe-buys/+page.js b/ui/src/routes/safe-buys/+page.js new file mode 100644 index 0000000..b7a5624 --- /dev/null +++ b/ui/src/routes/safe-buys/+page.js @@ -0,0 +1,60 @@ +// Curated watchlist of well-established, low-cost ETFs and investment-grade bond funds. +// Screened for Strong Buy signal under both Market-Adjusted and Fundamental lenses. +const SAFE_WATCHLIST = [ + // ── Broad Market ETFs + 'VOO', // S&P 500 — Vanguard (0.03%) + 'IVV', // S&P 500 — iShares (0.03%) + 'VTI', // Total US Market — Vanguard (0.03%) + 'SPY', // S&P 500 — SPDR (0.0945%) + 'QQQ', // Nasdaq-100 — Invesco (0.20%) + 'VEA', // Developed Markets ex-US — Vanguard + 'VWO', // Emerging Markets — Vanguard + + // ── Dividend / Quality ETFs + 'VIG', // Dividend Appreciation — Vanguard + 'SCHD', // Dividend — Schwab (0.06%) + 'DGRO', // Dividend Growth — iShares + 'VYM', // High Dividend Yield — Vanguard + + // ── Sector ETFs (established) + 'XLK', // Technology + 'XLV', // Healthcare + 'XLF', // Financials + 'XLE', // Energy + + // ── Investment-Grade Bond ETFs + 'BND', // Total Bond Market — Vanguard + 'AGG', // US Aggregate Bond — iShares + 'LQD', // IG Corporate Bond — iShares + 'VCIT', // Intermediate Corp Bond — Vanguard + + // ── Treasury ETFs + 'TLT', // 20+ Year Treasury — iShares + 'IEF', // 7-10 Year Treasury — iShares + 'SHY', // 1-3 Year Treasury — iShares + 'GOVT', // US Treasury — iShares + 'SGOV', // 0-3 Month T-Bill — iShares + + // ── Municipal / TIPS + 'MUB', // Muni Bond — iShares + 'TIP', // TIPS — iShares +]; + +export async function load({ fetch }) { + const res = await fetch('/api/screen', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tickers: SAFE_WATCHLIST }), + }); + + if (!res.ok) + return { ETF: [], BOND: [], ERROR: [], marketContext: null, error: await res.text() }; + + const data = await res.json(); + return { + ETF: data.ETF ?? [], + BOND: data.BOND ?? [], + ERROR: data.ERROR ?? [], + marketContext: data.marketContext ?? null, + }; +} diff --git a/ui/src/routes/safe-buys/+page.svelte b/ui/src/routes/safe-buys/+page.svelte new file mode 100644 index 0000000..1788b19 --- /dev/null +++ b/ui/src/routes/safe-buys/+page.svelte @@ -0,0 +1,368 @@ + + +
+ + + {#if data.error} +
⚠ {data.error}
+ {/if} + + {#if data.marketContext} + + {/if} + + + {#if strongEtfs.length || strongBonds.length} +
+ ✅ Strong Buy + Pass both Market-Adjusted and Fundamental gates +
+ + {#if strongEtfs.length} +
+
+

ETFs

+ {strongEtfs.length} +
+
+ + + + + + + + + + + + + + + + {#each sorted(strongEtfs) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + + {/each} + +
TickerPriceMkt-AdjGrahamExpenseYieldAUM5Y RetScore
{r.asset.ticker}{m.Price ?? '—'}{verdictShort(r.inflated.label)}{verdictShort(r.fundamental.label)}{m['Exp Ratio%'] ?? '—'}{m['Yield%'] ?? '—'}{m['AUM'] ?? '—'}{m['5Y Return%'] ?? '—'}{r.inflated.scoreSummary}
+
+
+ {/if} + + {#if strongBonds.length} +
+
+

Bond ETFs

+ {strongBonds.length} +
+
+ + + + + + + + + + + + + + + {#each sorted(strongBonds) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + {/each} + +
TickerPriceMkt-AdjGrahamYTMDurationRatingScore
{r.asset.ticker}{m.Price ?? '—'}{verdictShort(r.inflated.label)}{verdictShort(r.fundamental.label)}{m['YTM%'] ?? '—'}{m['Duration'] ?? '—'}{m['Rating'] ?? '—'}{r.inflated.scoreSummary}
+
+
+ {/if} + {:else} +
+ No assets currently pass both gates — market conditions may be elevated. + Check the Watch List below for assets passing at least one mode. +
+ {/if} + + + {#if watchEtfs.length || watchBonds.length} +
+ 👀 Watch List + Pass one gate — monitor for entry +
+ + {#if watchEtfs.length} +
+
+

ETFs

+ {watchEtfs.length} +
+
+ + + + + + + + + + + + + + + + {#each sorted(watchEtfs) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + + {/each} + +
TickerPriceSignalMkt-AdjGrahamExpenseYieldAUM5Y Ret
{r.asset.ticker}{m.Price ?? '—'}{verdictShort(r.inflated.label)}{verdictShort(r.fundamental.label)}{m['Exp Ratio%'] ?? '—'}{m['Yield%'] ?? '—'}{m['AUM'] ?? '—'}{m['5Y Return%'] ?? '—'}
+
+
+ {/if} + + {#if watchBonds.length} +
+
+

Bond ETFs

+ {watchBonds.length} +
+
+ + + + + + + + + + + + + + + {#each sorted(watchBonds) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + {/each} + +
TickerPriceSignalMkt-AdjGrahamYTMDurationRating
{r.asset.ticker}{m.Price ?? '—'}{verdictShort(r.inflated.label)}{verdictShort(r.fundamental.label)}{m['YTM%'] ?? '—'}{m['Duration'] ?? '—'}{m['Rating'] ?? '—'}
+
+
+ {/if} + {/if} +
+ + diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..df45697 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,5 @@ +import adapter from '@sveltejs/adapter-auto'; + +export default { + kit: { adapter: adapter() }, +}; diff --git a/ui/vite.config.js b/ui/vite.config.js new file mode 100644 index 0000000..1bfb950 --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + proxy: { + '/api': 'http://127.0.0.1:3000', + }, + }, +});