25 KiB
CLAUDE.md
Guidance for working in this repository.
Overview
market-screener is a Node.js project with two modes:
- CLI — screens stocks, ETFs, and bonds via
npm start, generates HTML reports - 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
npm install # install dependencies
npm run dev # start API server (port 3000) + SvelteKit UI (port 5173) together
npm run server # API server only (port 3000)
npm start # CLI: Yahoo news → catalyst tickers → screener-report.html
npm start -- watch # CLI: default watchlist
npm start -- AAPL MSFT VOO # CLI: specific tickers
npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html
npm test # run all unit tests (node:test, zero external deps)
npm run test:watch # watch mode — uses verbose spec reporter
npm run format # format all server/bin/tests with Prettier
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)
server/
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/
styles/ ← global SCSS design-token system (Phase 4)
app.scss ← root file — @use all partials
_tokens.scss ← CSS custom properties generated from SCSS maps ($bg, $text, $blues, $signals…)
_reset.scss ← box-sizing reset + body base
_layout.scss ← shell, nav, .links, main, nav-progress, .loading-area
_section.scss ← .section, .section-header, .count, .mode-tabs, .error-banner
_table.scss ← table, thead/tbody, .table-wrap, .col-ticker, .ticker, .num, .tag
_buttons.scss ← button base, .btn-primary, .btn-catalyst, .btn-ghost, .btn-screen, .btn-analyze
_badges.scss ← .verdict-pill, .sentiment-pill, .text-* helpers (SCSS @each maps)
lib/
api.js ← typed fetch wrappers for all API routes
utils.ts ← shared pure functions: sigOrd, sorted, verdictShort, vClass, fmtPE, fmt…
MarketContext.svelte ← collapsible card-grid context (used in portfolio + safe-buys)
MarketContextStrip.svelte ← horizontal chip strip (used in screener — Phase 5)
AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table (Phase 5)
AnalysisSidebar.svelte ← LLM analysis slide-over panel (Phase 5)
VerdictPill.svelte ← verdict-pill span; props: label (Phase 5)
SignalBadge.svelte ← signal emoji + label badge
Spinner.svelte ← sm: dot-pulse | md/lg: chart-line animation
routes/
+page.js ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount (Phase 5)
+page.svelte ← main screener UI (~230 lines after Phase 5 decomposition)
+layout.svelte ← shell, nav, nav-progress bar, nav-overlay with Spinner
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
server/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 pricemaxDebtToEquity: 1.5— most distress starts above 2xminQuickRatio: 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 gateminFiveYearReturn: 8.0%— S&P long-run floorminVolume: 1,000,000ADV
BOND gates:
minCreditRating: 7(BBB = investment-grade floor)minSpread: 1.5%above risk-freemaxDuration: 7years
MarketRegime (INFLATED overrides)
server/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) overforwardPE(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-2liquidity penalty on every ETF
Missing Data Convention
- Missing metrics use
null(not0) in_sanitize. Gate checks skipnullrather than auto-failing. pegRatiofalls back totrailingPE / earningsGrowthwhen Yahoo doesn't provide it.quickRatiofalls back tocurrentRatiowhen missing.
Logger Injection Pattern
Classes that produce output accept an optional { logger } constructor option so they work cleanly in server context:
// 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:
reporter.render(...) // → HTML string (use in server route responses)
reporter.generate(...) // → writes file to disk, returns path (use in CLI)
SimpleFIN Auth Flow
- User gets a Setup Token from https://beta-bridge.simplefin.org
SimpleFINClient.init()base64-decodes it → POSTs once to claim Access URLonAccessUrlClaimedcallback is called with the URL — CLI usessaveAccessUrlToEnv(), server stores elsewhere- All subsequent requests use Access URL with
Authorization: Basicheader (not embedded in URL)
portfolio.json Format
{
"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.
Coverage gaps (known):
MarketCallStore.js— no tests; CRUD againstmarket-calls.jsonis untestedLLMAnalyst.test.js— tests a local copy of the fence-stripping regex rather than importing from source; will silently drift if the regex changes- API routes (
server/server/routes/) — no integration tests; covered implicitly by manual testing only - UI components — not tested at the unit level (Phases 4–5 are pure UI; no server logic changed)
Conventions
- Asset
type(uppercased) is the routing key across DataMapper, asset classes,SCORERSmap, and ScoringRules. - Prefer adjusting
ScoringConfigorMarketRegimeover 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 intoserver/. bin/server.jsstarts Fastify;server/server/contains all route logic.- Never call
process.exit()insideserver/— onlybin/may do that. - Class instances don't survive
JSON.stringify. CallgetDisplayMetrics()server-side before returning from API routes (seeserver/server/routes/screener.jsserializeAssets()).
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
server/server/routes/analyze.js(orphaned route file) - Removed dead
analysisstate,analysisOpenstate, and "🤖 AI Market Analysis" panel from+page.svelte - Fixed
.gitignore—portfolio.json,market-calls.json,.envare 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.Bdot-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 ✅ COMPLETE
Done:
- Created
ui/src/lib/utils.ts— typed shared pure functions:sigOrd,sorted,verdictShort,vClass,fmtPE,fmt,fmtShort,glClass,advClass. ExportsSignaltype. - Created
server/server/utils/logger.js— sharednoopLoggerconstant, imported byscreener.js,app.js,finance.js, andcalls.js - Added TypeScript support to
ui/—tsconfig.jsonextending SvelteKit's generated config,typescriptandsvelte-checkadded as dev dependencies - All three pages (
+page.svelte,safe-buys/+page.svelte,portfolio/+page.svelte) now import from$lib/utils.jsinstead of duplicating logic
Phase 3 — Rename src/ → server/ ✅ COMPLETE
Done:
- Renamed
src/toserver/—src/server/is nowserver/server/ - Updated all import paths in
bin/,tests/, andCLAUDE.md
Phase 4 — SCSS Migration ✅ COMPLETE
Done:
- Created
ui/src/styles/with 7 partials +app.scssroot (imported by+layout.svelte) _tokens.scssuses SCSS maps ($bg,$borders,$text,$blues,$signals) with@eachloops to emit CSS custom properties — adding a token is one line in the map_badges.scssuses@each+map.getfor verdict/sentiment color variants and.text-*helpers; shared%pill-baseplaceholder_buttons.scssuses%btn-disabled/%btn-inline-flexplaceholders + nested&:hover/&:disabled_section.scss,_table.scss,_layout.scssuse SCSS nesting throughout.vpill(safe-buys) unified with.verdict-pill(screener) — inconsistency resolved- All component
<style>blocks trimmed to component-unique rules only;+layout.sveltestyle block removed entirely - Nav links now highlight immediately on click via
activePathderived from$navigating(not$page) +layout.sveltenav-overlay uses<Spinner>component instead of legacy CSS spinner
Note: sass must be installed in ui/ (npm install -D sass --legacy-peer-deps). Map keys that are CSS color names ('green', 'red', 'blue', etc.) must be quoted to avoid Sass color-value interpolation warnings.
Phase 5 — Decompose +page.svelte ✅ COMPLETE
Done:
VerdictPill.svelte— wraps<span class="verdict-pill {vClass(label)}">. Used in screener summary, detail tables, and safe-buys (replacing inline spans + removingverdictShort/vClassimports from safe-buys)MarketContextStrip.svelte— horizontal chip strip extracted from+page.svelte. Uses a$derivedchips array so the template is declarative (no repeated markup blocks)AssetTable.svelte— full STOCK/ETF/BOND section: section-header, mode tabs (ownsmodestate internally), Analyze button, complete table per type. Props:type,rows,analyzeLoading,onAnalyzeAnalysisSidebar.svelte— LLM slide-over panel. Props:sidebar(state object from parent),onClose. Allsb-*styles live here+page.js—export const ssr = false;load()fetches catalysts then screens them. Component receivesdata.results+data.catalystInputas props — replaces_booted/$effecthack entirelyloadCatalysts()split: initial load handled by+page.js, user-triggered refresh isreloadCatalysts()in the component+page.sveltereduced from ~600 lines to ~230 lines
Phase 6 — TypeScript
Convert server first (no framework coupling), then $lib/utils, then Svelte components.
Define shared types first:
type Signal = '✅ Strong Buy' | '⚡ Momentum' | '🔄 Neutral' | '⚠️ Speculation' | '❌ Avoid'
type AssetType = 'STOCK' | 'ETF' | 'BOND'
type ScoreMode = 'inflated' | 'fundamental'
interface ScreenerResult { STOCK, ETF, BOND, ERROR, marketContext }
interface MarketContext { sp500Price, riskFreeRate, vixLevel, rateRegime, benchmarks }
interface LLMAnalysis { summary, sentiment, affectedIndustries, relatedTickers }
interface MarketCall { id, title, quarter, date, thesis, tickers, snapshot }
interface PortfolioHolding { ticker, shares, costBasis, source, type }
SvelteKit supports TypeScript natively — components just need <script lang="ts">.
Not Planned
- npm workspaces / monorepo — current
ui/subdirectory structure works; high friction for low gain at this scale - Database — JSON files are sufficient at current portfolio size; Yahoo Finance rate limiting is the real bottleneck, not storage. Revisit with SQLite only if portfolio grows to 500+ holdings with frequent concurrent reads.
Adding a New Asset Type
- Create a subclass of
Assetinserver/screener/assets/with a flatmetricsobject andgetDisplayMetrics(). - Add a per-type entry (
gates/weights/thresholds) toScoringRulesinScoringConfig.js. - Add inflated overrides in
MarketRegime.getInflatedOverrides(). - Create a Scorer in
server/screener/scorers/exposingscore(metrics, rules, marketContext). - Add a mapper in
DataMapper.js. - Wire into
ScreenerEngine: addcasein_buildAsset, entry inSCORERSmap. - Add the new type to
serializeAssets()handling inserver/server/routes/screener.js.