phase-7: code restructure

This commit is contained in:
Sai Kiran Vella
2026-06-05 22:05:55 -04:00
committed by saikiranvella
parent c160e65bd6
commit 357b0c0f6e
108 changed files with 8931 additions and 3434 deletions
+433 -161
View File
@@ -45,10 +45,9 @@ npm run ui:install # install UI dependencies (ui/ sub
```
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
screen.ts ← CLI screener entry point
finance.ts ← CLI personal finance entry point
server.ts ← Fastify API server entry point (imports buildApp from server/app.ts)
scripts/
summary-reporter.js ← custom node:test reporter (silent on pass, summary line at end)
@@ -57,66 +56,95 @@ 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
app.ts ← Fastify app factory (buildApp). Registers CORS + all controllers.
NOTE: lives at server/app.ts, NOT inside server/controllers/.
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
controllers/ ← HTTP only: parse request, call service, return response
screener.controller.ts ← POST /api/screen, GET /api/screen/catalysts
finance.controller.ts ← GET /api/finance/portfolio, POST|DELETE /api/finance/holdings
calls.controller.ts ← CRUD for market calls + GET /api/calls/calendar
analyze.controller.ts ← POST /api/analyze (LLM analysis for a ticker set)
screener/ ← core screening domain
ScreenerEngine.js ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data),
services/ ← business logic, no HTTP or I/O concerns
ScreenerEngine.ts ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data),
screenWithProgress() (CLI with stdout). Accepts { logger } option.
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
DataMapper.ts ← normalises Yahoo payload → flat asset data object.
Computes: DCF intrinsic value, analyst upside, 52W movement fields,
grossMargin, marketCap. Uses trailingPE. Preserves negative FCF.
RuleMerger.ts ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
BenchmarkProvider.ts ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext.
In-memory cache: 1 hr TTL. Resets on server restart.
MarketRegime.tsderives INFLATED gate overrides from live benchmarks + rate regime
CatalystAnalyst.tsfetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }.
LLMAnalyst.ts ← uses AnthropicClient to analyze headlines → summary, sentiment,
affectedIndustries, relatedTickers. Returns null if API key not set.
PersonalFinanceAnalyzer.ts ← net worth, cash vs investments, spending by category
PortfolioAdvisor.ts ← cross-references holdings with screener signals → hold/sell/add advice
index.ts ← barrel re-export (import services from here, not individual files)
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 }.
repositories/ ← data persistence only (JSON file read/write)
MarketCallRepository.ts ← persists market thesis entries to market-calls.json.
CRUD: list/get/create/delete.
PortfolioRepository.ts ← read/write portfolio.json. Methods: read, upsert, remove.
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.
clients/ ← external API connectors, one class per third-party system
YahooFinanceClient.ts ← wraps yahoo-finance2 v3, retry + backoff. Methods: fetchSummary,
fetchCalendarEvents, search. Typed via YahooFinanceLib interface.
SimpleFINClient.ts ← claims setup token → access URL, fetches /accounts via Basic Auth.
AnthropicClient.ts ← wraps Anthropic SDK. complete(system, user) → raw text response.
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
models/ ← domain entity classes with metrics + display logic
Asset.ts ← abstract base: ticker, currentPrice, type, formatting helpers
Stock.ts ← metrics + _mapToStandardSector (8 sectors) + _classifyMarketCap
(Mega/Large/Mid/Small/Micro) + _classifyGrowth (style classification).
Holds: valuation, quality, risk, 52W movement, analyst consensus, DCF.
Etf.ts ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets
Bond.ts ← metrics: ytm, duration, creditRating, creditRatingNumeric
reporters/
HtmlReporter.jsrender() → HTML string (server), generate() → writes file (CLI)
FinanceReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
scorers/ ← stateless pure scoring functions, no I/O
StockScorer.ts gate checks + weighted registry:
core: ROE, opMargin, margin, peg, revenue, fcf
expert: analyst consensus (inverted Yahoo 15 scale), DCF margin of safety
riskFlags: beta, 52W position, 52W momentum, analyst divergence, DCF divergence
EtfScorer.ts ← expense gate + registry (cost, yield, volume, fiveYearReturn)
BondScorer.ts ← credit gate + spread/duration scoring
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)
reporters/ ← HTML rendering, no business logic
HtmlReporter.ts ← render() → HTML string (server), generate() → writes file (CLI)
FinanceReporter.ts ← render() → HTML string (server), generate() → writes file (CLI)
config/
ScoringConfig.ts ← CREDIT_RATING_SCALE + ScoringRules (single source of truth for all
gates, weights, thresholds including analyst and dcf weights)
constants.ts ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER,
CAP_CATEGORY (Mega/Large/Mid/Small/Micro),
GROWTH_CATEGORY (High Growth/Growth/Stable/Value/Turnaround/Declining)
types/ ← all domain types, one file per domain
asset.model.ts ← Signal, AssetType, ScoreMode, ScoringRules, ScoreResult, AssetResult,
ScreenerResult, GateSet, WeightSet, ThresholdSet, RuleBlock, StockRules
market.model.ts ← RateRegime, VolatilityRegime, Benchmarks, MarketContext
portfolio.model.ts ← HoldingType, PortfolioHolding, PortfolioAdvice, AdviceRow
calls.model.ts ← TickerSnapshot, MarketCall, SnapshotEntry, CreateCallInput
finance.model.ts ← LLMAnalysis, CatalystStory, CalendarEvent, SimpleFINAccount,
SimpleFINTransaction, SimpleFINData, YahooNewsItem, YahooFinanceLib
logger.model.ts ← Logger
models.model.ts ← AssetData, StockData, StockMetrics, EtfData, EtfMetrics, BondData, BondMetrics
repositories.model.ts ← StoreData, PortfolioData
scorers.model.ts ← NumVal, SanitizedMetrics, SanitizedBondMetrics
services.model.ts ← BenchmarkProviderOptions, InflatedOverrides, ErrorResult, RuleSet,
ScreenerEngineOptions, CatalystResult, MappedData, and other service shapes
schemas.ts ← Fastify JSON Schema objects for request body validation
index.ts ← re-exports all of the above (use this barrel for imports)
types.ts ← thin barrel: export type * from './types/index.js'
utils/
Chunker.ts ← splits ticker list into batches
logger.ts ← noopLogger constant for silencing output in server context
ui/ ← SvelteKit dashboard (lives inside this repo, not a separate repo)
src/
styles/ ← global SCSS design-token system (Phase 4)
styles/ ← global SCSS design-token system
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
@@ -126,24 +154,30 @@ ui/ ← SvelteKit dashboard (lives inside this repo, not a
_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
api.ts ← backward-compat shim; re-exports from api/index.ts
api/
screener.ts ← screenTickers, fetchCatalysts, analyzeTickers
finance.ts ← fetchPortfolio, addHolding, removeHolding, fetchMarketContext
calls.ts ← fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar
index.ts ← barrel re-export
types.ts ← re-exports shared types from $types (server/types/); UI-only types defined here
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)
MarketContextStrip.svelte ← horizontal chip strip (used in screener)
AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table
AnalysisSidebar.svelte ← LLM analysis slide-over panel
VerdictPill.svelte ← verdict-pill span; props: label
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)
+page.ts ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount
+page.svelte ← main screener UI
+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)
market-calls.json ← persisted market thesis calls (written by MarketCallRepository)
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY
```
@@ -161,8 +195,11 @@ BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND)
uses trailingPE as primary; preserves negative FCF yield; infers bond duration
computes: DCF intrinsic value, analyst upside, 52W movement, grossMargin, marketCap
Asset subclass — Stock / Etf / Bond holds metrics + getDisplayMetrics()
Stock also derives capCategory (Mega/Large/Mid/Small/Micro)
and growthCategory (High Growth/Growth/Stable/Value/Turnaround/Declining)
RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style)
INFLATED mode: sector override + MarketRegime live gate overrides
@@ -182,7 +219,7 @@ ScreenerEngine — derives Signal from comparing both verdicts
| 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 |
| POST | `/api/screen` | Screen tickers. Body: `{ tickers: string[] }` (max 50). 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 |
@@ -195,6 +232,8 @@ ScreenerEngine — derives Signal from comparing both verdicts
CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`).
Request body validation uses Fastify JSON Schema, defined in `server/types/schemas.ts`.
---
## Scoring Modes
@@ -221,7 +260,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
## ScoringConfig Key Values
`server/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds.
`server/config/ScoringConfig.ts` — single source of truth for all gates, weights, thresholds.
**STOCK base gates (Fundamental mode):**
- `maxPERatio: 15` — Graham's actual rule (trailing P/E)
@@ -229,6 +268,12 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
- `maxDebtToEquity: 1.5` — most distress starts above 2x
- `minQuickRatio: 0.8` — below this signals real liquidity stress
**STOCK base weights:**
- `roe: 3`, `fcf: 3` — primary quality signals
- `opMargin: 2`, `margin: 2`, `peg: 2`, `revenue: 2` — secondary factors
- `analyst: 2` — Wall Street consensus (Yahoo 15 scale inverted; requires ≥3 analysts)
- `dcf: 2` — DCF margin of safety (positive = undervalued; only fires when FCF > 0)
**Sector overrides** (structural — apply in both modes):
| Sector | Key difference |
@@ -256,7 +301,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
## MarketRegime (INFLATED overrides)
`server/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime:
`server/services/MarketRegime.ts` derives gate overrides from live benchmarks and current rate regime:
| Gate | Formula (NORMAL rates) | Formula (HIGH rates) |
|---|---|---|
@@ -267,6 +312,76 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
| Bond minSpread | LQDTNX × 0.80 | LQDTNX × 0.90 |
| ETF maxExpenseRatio | 0.75% | 0.75% |
Rate regime thresholds: `< 2%` = LOW, `25%` = NORMAL, `> 5%` = HIGH (10Y Treasury yield).
**Known issue**: sharp break at 5% can flip the scoring regime between two back-to-back requests when the 10Y hovers near the threshold. Consider smoothing or making the threshold configurable via env var.
---
## Expert Scoring Features (Stock)
Added to `DataMapper.ts` (extraction) and `StockScorer.ts` (scoring).
All data comes from Yahoo's existing modules — no extra API calls required.
### DCF Intrinsic Value
Two-stage discounted cash flow model computed per stock with positive TTM FCF:
- **Stage 1**: FCF per share grows at the earnings/revenue growth rate for 5 years, discounted at 9.5% (4% risk-free + 5.5% equity risk premium)
- **Stage 2**: Terminal value at 2.5% perpetuity growth (Gordon Growth Model)
- Growth rate capped at 30%, floored at -5%
- Returns `dcfIntrinsicValue` ($ per share) and `dcfMarginOfSafety` (% undervaluation)
Scoring: ≥20% margin of safety → +dcf weight; 020% → +1; -20% to 0 → -1; < -20% → -dcf weight.
Only fires when FCF > 0. Risk flags trigger at ±30% divergence.
### Analyst Consensus
From `financialData.recommendationMean` (Yahoo scale: 1.0 = Strong Buy, 5.0 = Strong Sell):
- Requires ≥3 analysts to avoid noise from thin coverage
- ≤2.0 → full weight; ≤3.0 → +1; ≤4.0 → -1; >4.0 → -full weight
- `analystTargetPrice`, `analystUpside` (% to target), `numberOfAnalysts` surfaced in display
- Risk flags trigger at ≥25% upside or ≤-15% downside vs analyst target
### 52-Week Movement
Three fields replace the single `52W Pos` position metric:
| Field | Meaning |
|---|---|
| `52W Chg` | Total % price return over last 52 weeks (`ks['52WeekChange']`) |
| `From High` | % current price is below 52-week high (negative = below peak) |
| `From Low` | % current price is above 52-week low (positive = recovered) |
Risk flags: strong uptrend (≥+50%), significant drawdown (≤-30%), >20% off 52W high.
### Market Cap Segmentation
`Stock._classifyMarketCap()` derives `capCategory` from `price.marketCap`:
| Tier | Threshold |
|---|---|
| Mega Cap | > $200B |
| Large Cap | $10B $200B |
| Mid Cap | $2B $10B |
| Small Cap | $300M $2B |
| Micro Cap | < $300M |
### Growth / Style Classification
`Stock._classifyGrowth()` derives `growthCategory` from revenue growth, earnings growth, and dividend yield:
| Category | Condition |
|---|---|
| High Growth | revenueGrowth ≥ 15% OR earningsGrowth ≥ 20% |
| Growth | revenueGrowth 515% |
| Value | revenueGrowth < 5% AND dividendYield ≥ 3% |
| Stable | Low growth, modest or no dividend |
| Turnaround | earningsGrowth < 0% AND revenueGrowth ≥ 0% |
| Declining | revenueGrowth < -5% |
Both `Cap Tier` and `Style` appear in `getDisplayMetrics()` and the screener table.
---
## Sector Detection
@@ -292,8 +407,14 @@ GENERAL → fallback
- **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
- **grossMargin**: `financialData.grossMargins * 100` — exposed in display; not yet a scoring factor
- **marketCap**: `price.marketCap` — used for cap tier classification
- **analystRating**: `financialData.recommendationMean` (1=Strong Buy, 5=Strong Sell). `targetMeanPrice` and `numberOfAnalystOpinions` also extracted
- **52W movement**: `defaultKeyStatistics['52WeekChange']` for annual return; `From High`/`From Low` computed from `fiftyTwoWeekHigh`/`fiftyTwoWeekLow`
- **DCF growth rate**: uses `earningsGrowth` (TTM decimal) first, falls back to `revenueGrowth * 0.7`. Capped at 30%, floored at -5%
- **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
- **D/E ratio**: Yahoo returns `financialData.debtToEquity` as a percentage (e.g. 175.56 for 1.7556×); dividing by 100 normalises to the standard ratio
- **Quick ratio**: falls back to `currentRatio` when missing — known methodological issue; current ratio includes inventory and can overstate liquidity for retailers/manufacturers
---
@@ -301,31 +422,31 @@ GENERAL → fallback
- 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.
- DCF, analyst, and 52W scoring factors are all optional — they activate only when the underlying data is non-null.
---
## Logger Injection Pattern
Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context:
Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context. Pass `noopLogger` from `server/utils/logger.ts` to silence all output.
```js
Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.
```ts
// CLI (default) — writes to stdout
new ScreenerEngine()
// Server — fully silent
new ScreenerEngine({ logger: { write: () => {}, log: () => {}, warn: () => {} } })
new ScreenerEngine({ logger: noopLogger })
```
Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.
---
## Reporter Pattern
Both reporters have two methods:
```js
```ts
reporter.render(...) // → HTML string (use in server route responses)
reporter.generate(...) // → writes file to disk, returns path (use in CLI)
```
@@ -371,7 +492,7 @@ tests/
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
PortfolioAdvisor.test.js ← position gain/loss calc, advice signal mapping, BRK.B normalisation
LLMAnalyst.test.js ← markdown fence stripping, JSON parse correctness
```
@@ -381,121 +502,272 @@ Test output: silent on pass, shows only failures + one summary line (`scripts/su
**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 against `market-calls.json` is untested
- `MarketCallRepository.ts` — no tests; CRUD against `market-calls.json` is untested
- `LLMAnalyst.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 45 are pure UI; no server logic changed)
- API controllers (`server/controllers/`) — no integration tests; covered implicitly by manual testing only
- Expert scoring features (analyst, DCF, 52W) — not yet covered in `StockScorer.test.js`
- UI components — not tested at the unit level
---
## Conventions
## Architecture Guide
- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules.
This section is the single reference for where code lives and how to add features. Read this before touching any file.
### Server layer map
| Folder | Role | Rule |
|---|---|---|
| `server/app.ts` | Fastify bootstrap — wires DI, registers controllers | No business logic |
| `server/controllers/` | HTTP only — parse request, call service, return response | No business logic, no file I/O, no external API calls |
| `server/services/` | Business logic and orchestration | No HTTP concerns (`req`/`reply`), no direct file I/O |
| `server/repositories/` | Data persistence — JSON file read/write | No business logic; one class per data file |
| `server/clients/` | External API connectors — one class per third-party system | No business logic; only I/O and protocol handling |
| `server/models/` | Domain entity classes — hold metrics and `getDisplayMetrics()` | No I/O; pure data + formatting |
| `server/scorers/` | Stateless pure scoring functions | No I/O, no state; `score(metrics, rules, marketContext)` only |
| `server/reporters/` | HTML rendering | No business logic; `render()` → string, `generate()` → file |
| `server/config/` | Constants and scoring gates/weights | No logic; change numbers here, not in scorers |
| `server/types/` | TypeScript interfaces and types | No logic; one `*.model.ts` per domain |
| `server/utils/` | Shared pure utilities | No domain knowledge |
| `bin/` | CLI entry points | Call into services only; the only place `process.exit()` is allowed |
### UI layer map
| Path | Role |
|---|---|
| `ui/src/routes/*/+page.ts` | SvelteKit load functions — fetch data for the page |
| `ui/src/routes/*/+page.svelte` | Route component — composition only; minimal logic |
| `ui/src/lib/api/screener.ts` | Fetch wrappers for `/api/screen*` |
| `ui/src/lib/api/finance.ts` | Fetch wrappers for `/api/finance/*` |
| `ui/src/lib/api/calls.ts` | Fetch wrappers for `/api/calls/*` |
| `ui/src/lib/stores/` | Reactive state shared across components (Svelte 5 runes) |
| `ui/src/lib/components/` | Generic shared components (VerdictPill, SignalBadge, Spinner) |
| `ui/src/lib/portfolio/` | Portfolio-specific components |
| `ui/src/lib/calls/` | Calls-specific components |
| `ui/src/lib/types.ts` | Re-exports server types via `$types` alias; UI-only types defined here |
| `ui/src/lib/utils.ts` | Pure formatting and sorting helpers |
| `ui/src/styles/` | SCSS partials — global design tokens, layout, tables, forms |
### Where to put a new type
- Shared domain type (used by server + UI): `server/types/<domain>.model.ts` and re-export from `server/types/index.ts`
- UI-only type (component state, display shape): `ui/src/lib/types.ts`
- Private implementation detail used only within one file: inline in that file
### Where to put new code — decision table
| What you're adding | Where it goes |
|---|---|
| New API endpoint | `server/controllers/<domain>.controller.ts` + register in `server/app.ts` |
| Business logic for that endpoint | New method in `server/services/<Domain>.ts` |
| Call to a new external API | New class in `server/clients/<Service>Client.ts` |
| New data stored in a JSON file | New class in `server/repositories/<Domain>Repository.ts` |
| New scoring rule or gate value | `server/config/ScoringConfig.ts` |
| New market regime override | `server/services/MarketRegime.ts``getInflatedOverrides()` |
| New stock metric (mapped from Yahoo) | `server/services/DataMapper.ts``mapStockData()` + `StockData`/`StockMetrics` interfaces in `server/types/models.model.ts` + `server/models/Stock.ts` constructor + `getDisplayMetrics()` |
| New scoring factor | `server/config/ScoringConfig.ts` (add weight + threshold) + `server/scorers/StockScorer.ts` (add factor to array) |
| New UI page | `ui/src/routes/<name>/+page.ts` + `+page.svelte` |
| New UI fetch call | `ui/src/lib/api/<domain>.ts` + re-export from `api/index.ts` |
| Reactive state shared by >1 component | `ui/src/lib/stores/<domain>.store.ts` |
| New shared UI component | `ui/src/lib/components/` (generic) or domain subfolder |
| New global style | `ui/src/styles/_<domain>.scss` + `@use` in `app.scss` |
### Conventions
- Asset `type` (uppercased: `STOCK` / `ETF` / `BOND`) is the routing key across `DataMapper`, model classes, `SCORERS` map, and `ScoringRules`. Keep it consistent everywhere.
- 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 `server/`.
- `bin/server.js` starts Fastify; `server/server/` contains all route logic.
- `BenchmarkProvider` caches for 1 hour in memory — cache is lost on server restart. A persistent cache is planned (see Phase 8).
- All entry points live in `bin/`. Do not add logic there — they call into `services/` and controllers.
- **Never** call `process.exit()` inside `server/` — only `bin/` may do that.
- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `server/server/routes/screener.js` `serializeAssets()`).
- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `serializeAssets()` in `screener.controller.ts`).
- Controllers use constructor injection — dependencies are wired in `server/app.ts`, not created inside handlers.
- The `$types` alias in the UI resolves to `server/types/` — use it instead of duplicating type definitions.
- Ticker normalisation (`BRK.B → BRK-B`) currently only happens in `FinanceController.normalizeYahoo()`. Submitting `BRK.B` directly to `/api/screen` will fail. Fix target: move normalisation into `YahooFinanceClient.fetchSummary()`.
### Adding a new scoring metric — step-by-step
When adding a new data point that flows from Yahoo → scoring:
1. Extract field in `DataMapper.mapStockData()` from the Yahoo payload
2. Add field to `StockData` and `StockMetrics` interfaces in `server/types/models.model.ts`
3. Add field to `Stock` constructor assignment and `getDisplayMetrics()` in `server/models/Stock.ts`
4. Add weight + threshold to `ScoringConfig.ts` base weights/thresholds (and any relevant sector overrides)
5. Add the field to `SanitizedMetrics` and `_sanitize()` in `StockScorer.ts`
6. Add a factor entry to the `factors` array in `StockScorer.score()`
7. Add a test case to `StockScorer.test.js`
**Warning**: The `scorer.score(asset.metrics as never, ...)` cast in `ScreenerEngine._process()` bypasses TypeScript for the scorer dispatch. If you add a field to `StockMetrics` but forget to add it to `SanitizedMetrics`, the compiler will not catch it — the scorer will silently receive `undefined`. Always update `_sanitize()` when adding metrics.
---
## Architecture Roadmap
Planned improvements in priority order. Do not start a later phase before completing earlier ones.
### Phases 17 ✅ COMPLETE
### Phase 1 — Cleanup ✅ COMPLETE
All items completed. Additional features delivered alongside cleanup:
All phases of the original roadmap are done. Summary of what was delivered:
- Phase 14: CLI cleanup, shared utilities, TypeScript migration, SCSS design tokens
- Phase 5: `+page.svelte` decomposed into `AssetTable`, `AnalysisSidebar`, `MarketContextStrip`, `VerdictPill`
- Phase 6: Full TypeScript conversion across `server/` and `bin/`
- Phase 7a7b: Type domain split, API module split
- Phase 7f: Server layer restructured to layered architecture (controllers / services / repositories / clients / models / scorers)
- Phase 7g: Controllers converted to classes with DI, types moved to domain files, `YahooFinanceClient` properly typed
**Cleanup done:**
- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md`
- Deleted `server/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
**Pending UI work (Phase 7c7e):**
- 7c: Decompose `portfolio/+page.svelte` (751 lines) into `AddHoldingForm`, `InlineEditRow`, `AdviceTable`, `AccountsTable`; decompose `calls/+page.svelte` (385 lines) into `CallForm`, `CallCard`, `CalendarSection`
- 7d: Add `ui/src/lib/stores/` layer — `screener.store.ts`, `portfolio.store.ts`
- 7e: Extract inline `<style>` blocks from portfolio, calls, and AnalysisSidebar into `_forms.scss`, `_sidebar.scss`, `_calls.scss`, `_portfolio.scss`
**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 8 — Server Hardening & Quality
### Phase 2 — Extract Shared Utilities ✅ COMPLETE
Priority order. Complete earlier items before starting later ones.
**Done:**
- Created `ui/src/lib/utils.ts` — typed shared pure functions: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass`, `advClass`. Exports `Signal` type.
- Created `server/server/utils/logger.js` — shared `noopLogger` constant, imported by `screener.js`, `app.js`, `finance.js`, and `calls.js`
- Added TypeScript support to `ui/``tsconfig.json` extending SvelteKit's generated config, `typescript` and `svelte-check` added as dev dependencies
- All three pages (`+page.svelte`, `safe-buys/+page.svelte`, `portfolio/+page.svelte`) now import from `$lib/utils.js` instead of duplicating logic
#### 8a — Fix `as never` scorer dispatch in `ScreenerEngine`
### Phase 3 — Rename `src/` → `server/` ✅ COMPLETE
`scorer.score(asset.metrics as never, ...)` bypasses TypeScript. Replace with a properly typed discriminated union or per-type dispatch so the compiler can verify that `StockMetrics` matches `StockScorer.score()`'s signature. This is the highest-risk cast in the scoring pipeline.
**Done:**
- Renamed `src/` to `server/``src/server/` is now `server/server/`
- Updated all import paths in `bin/`, `tests/`, and `CLAUDE.md`
#### 8b — Inject dependencies into `ScreenerEngine` and `PortfolioAdvisor`
### Phase 4 — SCSS Migration ✅ COMPLETE
Both classes self-construct their `YahooFinanceClient` and `BenchmarkProvider` in the constructor, making unit testing impossible without monkey-patching. Target:
**Done:**
- Created `ui/src/styles/` with 7 partials + `app.scss` root (imported by `+layout.svelte`)
- `_tokens.scss` uses SCSS maps (`$bg`, `$borders`, `$text`, `$blues`, `$signals`) with `@each` loops to emit CSS custom properties — adding a token is one line in the map
- `_badges.scss` uses `@each` + `map.get` for verdict/sentiment color variants and `.text-*` helpers; shared `%pill-base` placeholder
- `_buttons.scss` uses `%btn-disabled` / `%btn-inline-flex` placeholders + nested `&:hover` / `&:disabled`
- `_section.scss`, `_table.scss`, `_layout.scss` use SCSS nesting throughout
- `.vpill` (safe-buys) unified with `.verdict-pill` (screener) — inconsistency resolved
- All component `<style>` blocks trimmed to component-unique rules only; `+layout.svelte` style block removed entirely
- Nav links now highlight immediately on click via `activePath` derived from `$navigating` (not `$page`)
- `+layout.svelte` nav-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 + removing `verdictShort`/`vClass` imports from safe-buys)
- `MarketContextStrip.svelte` — horizontal chip strip extracted from `+page.svelte`. Uses a `$derived` chips array so the template is declarative (no repeated markup blocks)
- `AssetTable.svelte` — full STOCK/ETF/BOND section: section-header, mode tabs (owns `mode` state internally), Analyze button, complete table per type. Props: `type`, `rows`, `analyzeLoading`, `onAnalyze`
- `AnalysisSidebar.svelte` — LLM slide-over panel. Props: `sidebar` (state object from parent), `onClose`. All `sb-*` styles live here
- `+page.js``export const ssr = false`; `load()` fetches catalysts then screens them. Component receives `data.results` + `data.catalystInput` as props — replaces `_booted` / `$effect` hack entirely
- `loadCatalysts()` split: initial load handled by `+page.js`, user-triggered refresh is `reloadCatalysts()` in the component
- `+page.svelte` reduced 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:
```ts
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 }
export class ScreenerEngine {
constructor(
private readonly client: YahooFinanceClient,
private readonly benchmarkProvider: BenchmarkProvider,
{ logger }: ScreenerEngineOptions = {},
) {}
}
```
SvelteKit supports TypeScript natively — components just need `<script lang="ts">`.
Wire in `server/app.ts`. This unblocks proper service-layer unit tests.
### 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.
#### 8c — Controller integration tests
Add one Fastify `inject()` smoke test per route using a fixture for `ScreenerEngine.screenTickers()`. Catches schema validation regressions and response shape changes without needing live Yahoo access. Target: `tests/screener.controller.test.js`, `tests/calls.controller.test.js`.
#### 8d — Repository tests
`MarketCallRepository` has zero test coverage. Add `tests/MarketCallRepository.test.js` using a temp file path (inject via constructor or env var) to test `list`, `create`, `delete`, and concurrent-write safety.
#### 8e — Ticker normalisation in `YahooFinanceClient`
`BRK.B → BRK-B` normalisation lives only in `FinanceController`. Move it to `YahooFinanceClient.fetchSummary()` so it applies to all callers including `/api/screen`.
```ts
async fetchSummary(ticker: string, ...): Promise<any> {
const normalized = ticker.replace(/\./g, '-');
return await this.lib.quoteSummary(normalized, { modules: YAHOO_MODULES });
}
```
#### 8f — Persistent benchmark cache
`BenchmarkProvider`'s 1-hour cache is in-memory only — cold start after every restart adds 24s latency to the first request. Write the cached `MarketContext` to `.benchmark-cache.json` (or a single-row SQLite table). Read it on boot; only re-fetch if stale.
#### 8g — Rate limiting + API key auth
Add `@fastify/rate-limit` on `/api/screen` and `/api/analyze` (e.g. 10 req/min per IP). Add a simple `Authorization: Bearer <key>` check against an `API_KEY` env var as middleware in `server/app.ts`. Both are single-digit line additions.
#### 8h — Extract `CalendarService`
`CallsController.calendar()` is 80+ lines of inline event construction, date parsing, and sorting — all inside a controller method. Extract to `server/services/CalendarService.ts` to make it testable and keep the controller under 50 lines.
#### 8i — SQLite migration for repositories
Both `market-calls.json` and `portfolio.json` use `writeFileSync` with no concurrency guard. Two concurrent writes within the same event loop tick will lose one write. Replace with `better-sqlite3` for both repositories: concurrent-write safe, atomic transactions, no extra infrastructure. At current portfolio sizes the footprint is trivial.
#### 8j — Cache `CatalystAnalyst` results
`CatalystAnalyst.run()` fires fresh Yahoo news queries on every `/api/screen/catalysts` call. Cache the result for 15 minutes. A new `CatalystAnalyst` instance is also created on each call inside `ScreenerController.catalysts()` — hoist it to a class-level singleton wired in `server/app.ts`.
#### 8k — Add expert scoring tests
Update `StockScorer.test.js` to cover the three new scoring factors: analyst consensus scoring (including the `numberOfAnalysts < 3` guard), DCF margin of safety scoring (positive/negative/null cases), and the new 52W risk flags.
---
## Adding a New Asset Type
1. Create a subclass of `Asset` in `server/screener/assets/` with a flat `metrics` object and `getDisplayMetrics()`.
2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `ScoringConfig.js`.
3. Add inflated overrides in `MarketRegime.getInflatedOverrides()`.
4. Create a Scorer in `server/screener/scorers/` exposing `score(metrics, rules, marketContext)`.
5. Add a mapper in `DataMapper.js`.
6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map.
7. Add the new type to `serializeAssets()` handling in `server/server/routes/screener.js`.
1. Create a subclass of `Asset` in `server/models/` with a flat `metrics` object and `getDisplayMetrics()`.
2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `server/config/ScoringConfig.ts`.
3. Add inflated overrides in `server/services/MarketRegime.ts``getInflatedOverrides()`.
4. Create a Scorer in `server/scorers/` exposing `score(metrics, rules, marketContext)`.
5. Add a mapper branch in `server/services/DataMapper.ts`.
6. Wire into `server/services/ScreenerEngine.ts`: add `case` in `_buildAsset`, entry in `SCORERS` map.
7. Add the new type to `serializeAssets()` in `server/controllers/screener.controller.ts`.
---
## Clean Architecture Pattern (Server-Side)
### Core Principles
1. **Types in `server/types/`** — Domain shapes only; one `*.model.ts` per domain
2. **Schemas in `server/types/schemas.ts`** — Fastify JSON Schema objects for request body validation
3. **Class-Based Implementation** — All `.ts` files (except types/schemas) are classes
4. **No Inline Interfaces** — All types in `server/types/`; private-only shapes may stay inline
5. **Module-Level Cleanliness** — No module-level constants or functions outside classes
6. **Direct Imports** — Import directly from source files; use `server/types/index.ts` barrel for types
7. **Service barrel**`server/services/index.ts` re-exports all services so controllers import from one path
### Implementation Pattern
#### Controllers (Classes with DI)
```typescript
export class ScreenerController {
constructor(private readonly engine: ScreenerEngine) {}
register(app: FastifyInstance): void {
app.post('/api/screen', { schema: screenSchema }, this.screen.bind(this));
}
private async screen(req: FastifyRequest) {
const tickers = (req.body as { tickers: string[] }).tickers.map(t => t.toUpperCase());
return this.engine.screenTickers(tickers);
}
}
```
#### Services (Instance methods, static helpers)
```typescript
export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000;
async getMarketContext(): Promise<MarketContext> { ... }
private static rateRegime(rate: number): string {
return rate < 2 ? 'LOW' : rate <= 5 ? 'NORMAL' : 'HIGH';
}
}
```
#### Scorers (Static-only classes)
```typescript
export class StockScorer {
static score(metrics: StockMetrics, rules: ScoringRules): ScoreResult { ... }
private static _sanitize(m: StockMetrics): SanitizedMetrics { ... }
}
```
### Import Guidelines
```typescript
// ✅ CORRECT
import { ScreenerEngine } from '../services/ScreenerEngine'; // direct for implementations
import { ScreenerEngine } from '../services'; // via barrel also acceptable
import type { StockMetrics } from '../types'; // always use types barrel
import { screenSchema } from '../types/schemas'; // direct for schemas
// ❌ WRONG
import type { StockMetrics } from '../types/models.model'; // use barrel instead
```
### Adding a New API Endpoint
1. Define types → `server/types/<domain>.model.ts`
2. Define schema → `server/types/schemas.ts`
3. Create service → `server/services/<Domain>Service.ts`
4. Wire controller → `server/controllers/<domain>.controller.ts`
5. Register → `server/app.ts`