Files
market_screener/CLAUDE.md
T
Sai Kiran Vella 96a752ecf7 phase-9: domain-driven architecture complete
- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance
- Migrated 58 TypeScript files to domain-driven structure
- Updated CLAUDE.md with new architecture documentation
- Added .gitignore rules for .md files (except CLAUDE.md)
- Removed unused CatalystAnalyst import from app.ts
- Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions
- Verified no sensitive data in git history
- Server code compiles cleanly with TypeScript strict mode
2026-06-06 18:18:22 -04:00

118 KiB
Raw Blame History

CLAUDE.md

Guidance for working in this repository.

📋 See PHASES.md for the complete Phase 9-16+ roadmap, architecture summaries, and production readiness checklists.

Overview

market-screener is a Node.js project consisting of a Fastify API server that powers the SvelteKit dashboard in the ui/ subdirectory. Evolved to support day trading: real-time news webhooks, LLM-driven stock analysis with prompt caching, multi-user authentication, and Discord alerts for price movements.

Two Scoring Lenses (Original)

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).

Day Trading Mode (New)

The app now supports real-time trading workflows:

  • News webhooks (Polygon.io) — ingest market news instantly
  • Price monitoring (Alpaca/Interactive Brokers) — detect 5%+ dips
  • LLM analysis with prompt caching — analyze opportunities in real-time
  • Multi-user auth — JWT-based, role-based portfolio access
  • Discord notifications — alerts for trading signals
  • Trade journal — log decisions + outcomes for performance analysis

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 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.

Day trading features (when added):

npm run queue:worker                          # start BullMQ workers (job processing)
npm run webhook:test                          # test webhook signature validation locally
npm run migrate:db                            # run schema migrations (when switching SQLite → Postgres)

Project Structure (Phase 9: Domain-Driven)

bin/
  server.ts              ← Fastify API server entry point (imports buildApp from server/app.ts)

prompts/
  catalyst-analysis.md   ← daily catalyst analysis playbook (LLM prompt + workflow)

server/
  app.ts                 ← Fastify app factory (buildApp). Registers CORS + all controllers.
  types.ts               ← Barrel export: export * from domains/shared/types

  domains/               ← Domain-driven architecture (Phase 9+)
    
    shared/              ← Infrastructure & cross-domain utilities
      adapters/
        YahooFinanceClient.ts
        AnthropicClient.ts
        SimpleFINClient.ts
      services/
        BenchmarkProvider.ts   ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext
        CatalystAnalyst.ts     ← fetches Yahoo Finance news, extracts relatedTickers
        LLMAnalyst.ts          ← analyzes headlines → summary, sentiment, industries, tickers
        CatalystCache.ts       ← 15-minute cache for catalyst analysis (Phase 8j)
      entities/
        Asset.ts, Stock.ts, Etf.ts, Bond.ts
      persistence/
        MarketCallRepository.ts
        PortfolioRepository.ts
      config/
        constants.ts           ← SIGNAL, SCORE_MODE, ASSET_TYPE, REGIME, CAP_CATEGORY, etc.
      scoring/
        ScoringConfig.ts       ← gates, weights, thresholds (single source of truth)
        MarketRegime.ts        ← derives INFLATED overrides from live benchmarks
      db/
        DatabaseConnection.ts  ← SQLite wrapper with audit logging
        DatabaseInitializer.ts ← schema, migrations, legacy JSON migration
        QueryAudit.ts
        queries.constant.ts
      utils/
        logger.ts
        Chunker.ts
        QueryBuilder.ts
        sanitizer.ts
      types/
        *.model.ts             ← all TypeScript types (asset, market, portfolio, finance, etc.)
      index.ts                 ← public API barrel
    
    screener/              ← Stock/ETF/Bond filtering & scoring
      screener.controller.ts   ← POST /api/screen, GET /api/screen/catalysts
      analyze.controller.ts    ← POST /api/analyze (LLM analysis)
      ScreenerEngine.ts        ← orchestrates: fetch → score × 2
      PersonalFinanceAnalyzer.ts ← net worth, cash vs investments analysis
      scorers/
        StockScorer.ts
        EtfScorer.ts
        BondScorer.ts
      transform/
        DataMapper.ts          ← normalises Yahoo payload → flat asset data
        RuleMerger.ts          ← merges base rules + sector overrides
      index.ts
    
    portfolio/             ← Holdings management & investment advice
      finance.controller.ts    ← GET /api/finance/portfolio, POST|DELETE /api/finance/holdings
      PortfolioAdvisor.ts      ← cross-references holdings with signals
      index.ts
    
    calls/                 ← Market call tracking & earnings calendar
      calls.controller.ts      ← CRUD for market calls + GET /api/calls/calendar
      CalendarService.ts       ← earnings calendar logic
      index.ts
    
    finance/               ← Portfolio reporting
      finance.controller.ts    ← portfolio metrics endpoint
      index.ts

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.

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

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

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 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.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) 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.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-screener.db ← SQLite database (created on first boot). Contains holdings + market_calls tables. Legacy portfolio.json / market-calls.json are auto-migrated on first boot and renamed to *.json.migrated. .env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY, API_KEY (optional — enables Bearer auth on all routes)


---

## 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 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 ↓ Scorer × 2 — StockScorer / EtfScorer / BondScorer, fully stateless ↓ ScreenerEngine — derives Signal from comparing both verdicts ↓ └── 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[] }` (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 |
| 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`).

Request body validation uses Fastify JSON Schema, defined in `server/types/schemas.ts`.

---

## 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.ts` — 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

**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 |
|---|---|
| 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)

`server/services/MarketRegime.ts` 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 | 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

`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
- **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

---

## 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.
- 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. Pass `noopLogger` from `server/utils/logger.ts` to silence all output.

Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.

```ts
// CLI (default) — writes to stdout
new ScreenerEngine()

// Server — fully silent
new ScreenerEngine({ logger: noopLogger })

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)

Holdings Format

Holdings are stored in the holdings table in market-screener.db. To seed initial data, add holdings via the Portfolio UI or by inserting into the database directly.

type values: stock, etf, bond, crypto. Crypto is priced via Yahoo (BTC-USD style) but not fundamentally scored.

If you have an existing portfolio.json, it will be auto-migrated to SQLite on first boot and renamed to portfolio.json.migrated.


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 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 uses the built-in spec reporter.

Key unit: ytm in Bond.metrics is stored as a percentage (e.g. 6.5 = 6.5%). BondScorer._sanitize divides by 100 before spread calculation.

Coverage gaps (known):

  • MarketCallRepository.ts — covered by tests/MarketCallRepository.test.ts using in-memory SQLite
  • 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 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

Architecture Guide

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/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.tsgetInflatedOverrides()
New stock metric (mapped from Yahoo) server/services/DataMapper.tsmapStockData() + 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 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 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) happens in YahooFinanceClient.normalise() and applies to all callers via fetchSummary() and fetchCalendarEvents().

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

Phases 17 COMPLETE

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

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

Phase 8 — Server Hardening & Quality

Priority order. Complete earlier items before starting later ones.

8a — Fix as never scorer dispatch in ScreenerEngine

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.

8b — Inject dependencies into ScreenerEngine and PortfolioAdvisor

Both classes self-construct their YahooFinanceClient and BenchmarkProvider in the constructor, making unit testing impossible without monkey-patching. Target:

export class ScreenerEngine {
  constructor(
    private readonly client: YahooFinanceClient,
    private readonly benchmarkProvider: BenchmarkProvider,
    { logger }: ScreenerEngineOptions = {},
  ) {}
}

Wire in server/app.ts. This unblocks proper service-layer unit tests.

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.

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

@fastify/rate-limit registered globally in server/app.ts (global: false, opt-in per route). /api/screen, /api/screen/catalysts, and /api/analyze each carry config: { rateLimit: { max: 10, timeWindow: '1 minute' } }. API key enforced via onRequest hook when API_KEY env var is set (Authorization: Bearer <key>); /health and OPTIONS are exempt. Requires npm install after adding @fastify/rate-limit to dependencies (done in package.json).

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.

8l — Anthropic prompt caching for LLMAnalyst

LLMAnalyst.analyze() sends a large system prompt on every /api/analyze call. Enabling Anthropic prompt caching would cache the static system prompt across calls, reducing latency and token costs significantly.

Target: add cache_control: { type: 'ephemeral' } to the system prompt message block in AnthropicClient.complete() (or in LLMAnalyst.analyze() if the system prompt is built there). Use the anthropic-beta: prompt-caching-2024-07-31 header. The cache has a 5-minute TTL and applies to the longest common prefix of consecutive requests — ideal for the static analysis instructions that never change between calls.

See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching


Phase 9 — Subdomain Restructure: Server Layer Organization

Goal: Reorganize server/ from a flat layer-based structure to a domain-driven structure. This improves navigation, reduces cognitive load when onboarding, and makes feature ownership clearer.

Timeline: 3 weeks. Complete items in order; each is a self-contained commit with passing tests.

9a — Create shared infrastructure layer

Create the server/domains/shared/ hierarchy with type-safe foundations:

server/domains/shared/
  ├── entities/              (models + their types together)
  │   ├── Asset.ts
  │   ├── Stock.ts
  │   ├── Etf.ts
  │   ├── Bond.ts
  │   └── index.ts
  ├── adapters/              (external API wrappers, renamed from "clients")
  │   ├── YahooFinanceAdapter.ts     (was YahooFinanceClient)
  │   ├── AnthropicAdapter.ts        (was AnthropicClient)
  │   ├── SimpleFINAdapter.ts        (was SimpleFINClient)
  │   └── index.ts
  ├── services/              (cross-domain services)
  │   ├── BenchmarkProvider.ts
  │   ├── CatalystAnalyst.ts
  │   ├── LLMAnalyst.ts
  │   └── index.ts
  ├── scoring/               (rules + regime management)
  │   ├── ScoringConfig.ts
  │   ├── GateValidator.ts           (NEW — shared gate-check logic)
  │   ├── MarketRegime.ts
  │   └── index.ts
  ├── persistence/           (SQLite stores, renamed from "repositories")
  │   ├── MarketCallStore.ts         (was MarketCallRepository)
  │   ├── PortfolioStore.ts          (was PortfolioRepository)
  │   └── index.ts
  ├── types/                 (all domain types)
  │   ├── asset.model.ts
  │   ├── finance.model.ts
  │   ├── market.model.ts
  │   ├── portfolio.model.ts
  │   ├── calls.model.ts
  │   ├── logger.model.ts
  │   ├── models.model.ts
  │   ├── [...other models]
  │   └── index.ts
  ├── config/                (constants, not business logic)
  │   ├── constants.ts
  │   └── index.ts
  ├── utils/                 (pure utilities, no domain knowledge)
  │   ├── logger.ts
  │   ├── Chunker.ts
  │   ├── sanitizer.ts
  │   └── index.ts
  ├── db/                    (database initialization)
  │   └── index.ts
  ├── schemas.ts             (Fastify request validation)
  └── index.ts               (barrel: export all public APIs)

Steps:

  1. Create all directories and index.ts barrels (copy exports from existing files)
  2. Move files per the mapping above
  3. Update relative import paths in all moved files
  4. Run npm test — all existing tests should pass with no functional changes
  5. Verify npm run dev boots successfully

Commit: refactor: create server/domains/shared hierarchy


9b — Extract screener domain

Group all screener-related logic into one subdirectory:

server/domains/screener/
  ├── ScreenerController.ts
  ├── ScreenerEngine.ts
  ├── PersonalFinanceAnalyzer.ts
  ├── scorers/
  │   ├── StockScorer.ts
  │   ├── EtfScorer.ts
  │   ├── BondScorer.ts
  │   └── index.ts
  ├── transform/
  │   ├── DataMapper.ts
  │   ├── RuleMerger.ts
  │   └── index.ts
  └── index.ts

Steps:

  1. Create server/domains/screener/ structure
  2. Move files from server/ (controller, engine, analyzer) into this domain
  3. Move server/scorers/ into server/domains/screener/scorers/
  4. Move DataMapper.ts and RuleMerger.ts to server/domains/screener/transform/
  5. Update imports: all now point to ../shared/ for utilities/types/adapters
  6. Update server/app.ts to import from domains/screener
  7. Run npm test — verify all screener tests pass

Commit: refactor: extract screener domain


9c — Extract portfolio domain

server/domains/portfolio/
  ├── PortfolioController.ts
  ├── PortfolioAdvisor.ts
  ├── persistence/
  │   ├── PortfolioStore.ts
  │   └── index.ts
  └── index.ts

Steps:

  1. Create server/domains/portfolio/ structure
  2. Move files + dependency on shared adapter/services
  3. Update imports in controller + advisor to point to ../shared/
  4. Verify portfolio routes work with the new import paths
  5. Run npm test

Commit: refactor: extract portfolio domain


9d — Extract calls domain

server/domains/calls/
  ├── CallsController.ts
  ├── CalendarService.ts         (extract from CallsController if not done in Phase 8h)
  ├── persistence/
  │   ├── MarketCallStore.ts
  │   └── index.ts
  └── index.ts

Steps:

  1. Create server/domains/calls/ structure
  2. Move CallsController and MarketCallRepository
  3. If Phase 8h is not done, extract calendar logic into CalendarService.ts now
  4. Update imports
  5. Run npm test + verify /api/calls/* routes

Commit: refactor: extract calls domain


9e — Extract finance domain

Minimal domain — just the controller, since BenchmarkProvider stays in shared:

server/domains/finance/
  ├── FinanceController.ts
  └── index.ts

Steps:

  1. Create server/domains/finance/
  2. Move FinanceController (was finance.controller.ts)
  3. Update to import BenchmarkProvider from ../shared/services
  4. Verify /api/finance/* routes work
  5. Run npm test

Commit: refactor: extract finance domain


9f — Clean up old server/ directories

Now that all code is in domains/, remove the old flat structure:

rm -rf server/controllers/
rm -rf server/services/
rm -rf server/repositories/
rm -rf server/clients/
rm -rf server/models/
rm -rf server/scorers/
rm -rf server/config/
rm -rf server/types/
rm -rf server/utils/

(These now exist under server/domains/shared/ and individual domains.)

Update server/app.ts:

import { buildApp } from './app.ts';

// Controllers from domains
import { ScreenerController } from './domains/screener';
import { PortfolioController } from './domains/portfolio';
import { CallsController } from './domains/calls';
import { FinanceController } from './domains/finance';

// Shared services for wiring
import {
  YahooFinanceAdapter,
  AnthropicAdapter,
  BenchmarkProvider,
  // ... other imports from domains/shared
} from './domains/shared';

Steps:

  1. Delete the 8 old directories
  2. Verify all imports in app.ts and remaining files point to domains/
  3. Run full test suite: npm test
  4. Run npm run dev and manually check all API routes
  5. Verify npm run format:check passes

Commit: refactor: remove old flat server layer structure

After this commit, server/ directory tree looks like:

server/
  ├── app.ts                          ← Fastify bootstrap (unchanged role, updated imports)
  ├── domains/
  │   ├── shared/                     ← Shared infrastructure
  │   ├── screener/                   ← Screener feature domain
  │   ├── portfolio/                  ← Portfolio feature domain
  │   ├── calls/                      ← Market calls feature domain
  │   └── finance/                    ← Finance reporting domain
  ├── db/                             ← Database init (moved to domains/shared/db, but link from server/db can stay for backward compat)
  └── types.ts                        ← Thin barrel: export type * from './domains/shared/types/index.js'

9g — Update documentation in CLAUDE.md

Replace the old "Server layer map" section with the new structure:

### Server layer map (Phase 9+)

All server logic lives under `server/domains/`:

| Domain | Folder | Role | Key Files |
|---|---|---|---|
| Screener | `screener/` | Stock/ETF/Bond filtering | `ScreenerEngine.ts`, `scorers/`, `transform/` |
| Portfolio | `portfolio/` | Holdings mgmt + advice | `PortfolioAdvisor.ts`, `PortfolioStore.ts` |
| Calls | `calls/` | Market call tracking | `CallsController.ts`, `CalendarService.ts` |
| Finance | `finance/` | Portfolio metrics + reporting | `FinanceController.ts` |
| Shared | `shared/` | Adapters, services, types, config | `adapters/`, `services/`, `scoring/`, `entities/` |

**New conventions:**
- Import from domain `index.ts` barrels: `import { ScreenerEngine } from '../screener'`
- Shared types via barrel: `import type { Stock } from '../shared'`
- Adapters now called "adapters" (was "clients"); entities grouped with models
- Repositories renamed to "stores" (`PortfolioStore`, `MarketCallStore`)

Update the "Where to put new code — decision table" to reference domain folders:

What you're adding Where it goes
New API endpoint server/domains/<domain>/<Domain>Controller.ts + register in server/app.ts
Business logic for that endpoint New method in server/domains/<domain>/<Service>.ts
Call to a new external API New class in server/domains/shared/adapters/<Service>Adapter.ts
New data stored in a database table New class in server/domains/<domain>/persistence/<Entity>Store.ts
New scoring rule or gate value server/domains/shared/scoring/ScoringConfig.ts
Shared utility across domains server/domains/shared/services/<Service>.ts

Commit: docs: update CLAUDE.md with Phase 9 architecture


9h — Smoke test all routes

Create a simple integration smoke test that verifies all major routes still work after the restructure. This isn't comprehensive (Phase 8c adds real integration tests), but catches import errors and missing registrations.

# tests/integration.smoke.test.js (1015 min effort)
import test from 'node:test';
import { buildApp } from '../server/app.ts';

test('POST /api/screen works', async () => {
  const app = await buildApp();
  const res = await app.inject({
    method: 'POST',
    url: '/api/screen',
    payload: { tickers: ['AAPL'] },
  });
  assert.equal(res.statusCode, 200);
  const body = JSON.parse(res.body);
  assert(body.STOCK || body.ETF || body.BOND);
});

// ... one quick test per major endpoint

Commit: test: add smoke tests for Phase 9 restructure


Migration Checklist

  • 9a: Create shared hierarchy + run tests
  • 9b: Extract screener domain
  • 9c: Extract portfolio domain
  • 9d: Extract calls domain
  • 9e: Extract finance domain
  • 9f: Delete old directories, update app.ts
  • 9g: Update CLAUDE.md documentation
  • 9h: Add smoke tests + verify npm run dev locally
  • Final: Merge as one feature branch (all 9a9h commits)

Backward Compatibility

No breaking changes to the API or public types. File structure is internal — clients see the same routes and response shapes.

Benefits After Phase 9

  1. Navigation: New developers see which file owns which API endpoint at a glance.
  2. Code discovery: "Where's the screener logic?" → server/domains/screener/. "Where are the database stores?" → server/domains/shared/persistence/.
  3. Onboarding time: Cut in half. The folder structure is the feature map.
  4. Feature isolation: Adding a new domain (e.g., server/domains/watchlist/) is now a standard pattern — just follow the template.
  5. Import hygiene: Shared code stays in shared/; feature-specific code stays in domains. No circular imports.
  6. Testability: Each domain can be tested independently (Phase 8b + 8c will plug into this structure cleanly).

Phase 10 — UI Component Restructure & Clarity

Goal: Mirror Phase 9 server restructure at the UI layer. Organize Svelte components by domain, split utility files, and improve navigability.

Timeline: 1 week. Follow the same pattern as Phase 9a9h.

10a — Create lib/components/ structure

ui/src/lib/components/
  ├── shared/
  │   ├── Spinner.svelte
  │   ├── VerdictPill.svelte
  │   ├── SignalBadge.svelte
  │   ├── MarketContextStrip.svelte
  │   ├── MarketContext.svelte
  │   └── index.ts
  ├── layouts/
  │   ├── SidebarLayout.svelte
  │   ├── TableLayout.svelte
  │   └── index.ts
  ├── screener/
  │   ├── AssetTable.svelte
  │   ├── AnalysisSidebar.svelte
  │   └── index.ts
  ├── portfolio/
  │   ├── AddHoldingForm.svelte
  │   ├── AdviceTable.svelte
  │   ├── AccountsTable.svelte
  │   └── index.ts
  └── calls/
      ├── CallForm.svelte
      ├── CallCard.svelte
      ├── CalendarSection.svelte
      └── index.ts

10b — Split lib/utils.ts into lib/utils/

lib/utils/
  ├── formatting.ts    (fmtPE, fmt, fmtShort, fmtPct)
  ├── sorting.ts       (sigOrd, sorted)
  ├── verdicts.ts      (verdictShort, vClass)
  └── index.ts         (barrel re-export)

10c — Split lib/types.ts into lib/types/

lib/types/
  ├── ui.types.ts          (AssetDisplayMetrics, SidebarState)
  ├── portfolio.types.ts   (AdviceRow, HoldingFormData)
  ├── screener.types.ts    (if screener-specific types exist)
  ├── shared.ts            (re-exports from $types/*)
  └── index.ts             (barrel re-export)

10d — Update all imports in routes + stores

  1. Fix all import statements in routes/ to use new paths
  2. Run npm run build + verify no broken links
  3. Commit: refactor: update imports for Phase 10 restructure

10e — Extract reusable layout components

  1. Create SidebarLayout.svelte — used by AnalysisSidebar
  2. Create TableLayout.svelte — used by portfolio + calls
  3. Reduces component duplication
  4. Commit: refactor: extract reusable layout components

Commit: refactor: UI Phase 10 — component restructure complete

Benefits:

  • New devs instantly find components by domain
  • Utilities grouped by responsibility (easier to locate)
  • Types clearly separated (UI-only vs. shared)
  • Consistent with server Phase 9 — unified mental model

Phase 10.5 — Professional-Grade Screener UI (Institutional Research Tool)

Goal: Build a professional-grade screener interface that shows complete investment research capabilities. User sees institutional-quality tools from day one, learns mastery through using professional workflows — not through simplified interfaces. Same tool grows them from newbie to pro, not by hiding information but by organizing it.

Philosophy: Don't teach beginners by simplifying. Teach by showing complete information, organizing it clearly, and measuring outcomes. User of any proficiency level gets:

  • Advanced filtering (multi-criteria, saved presets, numeric ranges)
  • Forensic tearsheet detail (all metrics, decision framework, peer comparison)
  • Visible decision logic (which gates pass/fail, why verdict is what it is)
  • Performance tracking (backtest signals, decision logging, attribution analysis)

Timeline: 4-6 weeks (after Phase 10).

10.5a — UI Architecture: Three-Layer Layout

Sidebar (280px) | Main Table (flex) | Tearsheet Panel (420px)
────────────────┼──────────────────┼──────────────────────
Advanced        │ Compact table    │ Forensic detail
filters         │ 10 columns only  │ Full metrics
(left)          │ (ticker, price,  │ Peer comparison
                │ verdict, score,  │ Decision framework
Quick presets   │ P/E, ROE, 52W,  │ Risk breakdown
                │ DCF, flags,      │ Threshold sensitivity
                │ action)          │ (right side-panel)

Key principle: Main table is scannable (minimal), tearsheet is comprehensive (on-demand).

10.5b — Sidebar: Advanced Filtering

Filter Group (Verdict, Market Cap, Sector):
  • Preset buttons: All, Strong Buy, Buy, Hold, Avoid
  • Can multi-select (eventually)

Custom Filters:
  • P/E Range: 10-25 (numeric input, optional)
  • ROE Min: >15% (numeric input)
  • 52W Dip %: 5+ (numeric input, configurable)
  • Debt/Equity Max: 2.0 (numeric input)

Quick Presets (saved screeners):
  • "Value Trap Screen" (low P/E, declining revenue, high D/E)
  • "Growth at Fair Price" (P/E < PEG, ROE > 20%, FCF positive)
  • "Dip Opportunity" (52W dip >5%, verdict not Avoid, analyst Buy)
  • "My Watchlist" (user-curated list)

**Future enhancement (Phase 10.5e):** User saves custom presets (SQL persistence).

10.5c — Main Table: Minimal, Scannable

10 columns (monospace numbers, right-aligned):

Ticker Price Verdict Score P/E ROE 52W DCF Flags Menu
AAPL $189.50 Strong Buy 8.2 28.5x 95.2% +18.5% +22%
MSFT $425.30 Buy 7.1 32.1x 48.5% +12.3% +15%
NVDA $892.15 Hold 6.5 68.2x 61.5% +85.2% -8% Peak
XYZ $28.75 Avoid 2.1 15.8x -5.2% -42.1% -45% Decline

Properties:

  • Sticky header (always visible when scrolling)
  • Hover row → slight background highlight
  • Click row → opens tearsheet
  • Monospace numbers (scannable, professional)
  • Color-coded metrics (positive = green, negative = red, neutral = gray)
  • Verdict pills with icons (✓ Strong Buy, → Buy, ⊙ Hold, ✕ Avoid)
  • Flags show warnings only (Peak, Decline, etc) — clean
  • Header row: "Filtered: 247 | Strong Buy: 12 | Buy: 34"
  • Sort columns: click header to sort (P/E low→high, ROE high→low, etc)

10.5d — Tearsheet Panel: Professional Research

Right-side slide-in panel (420px, animates in 0.2s). Sticky header, scrollable body.

Header:

NVDA — NVIDIA Corp        $892.15        [X]

Body sections:

  1. Core Metrics (4-grid, color-coded cards):

    P/E Ratio: 68.5x          ROE: 61.5%
    (vs mkt avg 18x)          (exceptional)
    
    FCF Yield: 2.1%           52W Chg: +85.2%
    (strong)                  (from low)
    
  2. Valuation Context (comparison table):

    Metric  | NVDA  | Sector | S&P500
    ────────┼───────┼────────┼────────
    P/E     | 68.2x | 24.5x  | 18.0x
    PEG     | 2.8   | 1.2    | 1.0
    ROE     | 61.5% | 15.2%  | 12.8%
    
  3. Decision Framework (gate-by-gate breakdown):

    ✓ Quality gate (ROE > 15%)           PASS
    ✗ Valuation gate (P/E < 35x)         FAIL (68x)
    ✗ Value gate (PEG < 1.0)             FAIL (2.8)
    ⚠ Dip gate (52W -5%)                 FAIL (+85%, at peak)
    
    Result: Hold (2/4 gates, borderline)
    
  4. Risk Breakdown (ranked, quantified):

    ⚠ Active Risk Flags
    • Valuation: 68x P/E vs 18x market (278% premium)
    • Momentum: +85% from 52W low (at peak, reversal risk)
    • Growth dependency: Needs 25%+ growth to justify multiple
    • Macro: AI capex cycle uncertainty Q2-Q3 2026
    
  5. Threshold Sensitivity (what-if scenarios):

    If P/E compresses to 50x:     Stock -27% to $650
    If growth slows to 15%:       Stock -35% to $580
    If rates rise 100bps:         Stock -15% to $760
    
  6. Peer Comparison:

    Stock  | P/E  | Growth | ROE
    ───────┼──────┼────────┼──────
    NVDA   | 68x  | 25%    | 61.5%
    MSFT   | 32x  | 12%    | 48.5%
    GOOG   | 23x  | 8%     | 18.5%
    
  7. CTA Row (bottom):

    [Add to Watchlist]  [Decision Log]
    

Design principles:

  • Uppercase section headers (institutional style)
  • All numbers in monospace (scannable)
  • Color for meaning only (green = good, red = bad, black = neutral)
  • Subtle borders between sections (0.5px, tertiary color)
  • No decoration, no gradients, no shadows
  • Typography: 12px body, 16px section title, 18px metric value

10.5e — Decision Logging & Backtest (Phase 10.5 Extensions)

CTA Button: "Decision Log" → Opens modal with:

Decision Log for NVDA

Your thesis:
  [Text area: "Bought at $892, thesis is AI cycle, monitor growth guidance"]
  
Entry date: 2026-06-06
Entry price: $892.15
Suggested position: 3% of portfolio

Track these 30/60/90 days:
  □ Did dip thesis play out (stock up 10%+)?
  □ Did analyst estimates revise (up or down)?
  □ Did margins stay stable (within ±2%)?
  □ Did revenue guidance hold?

[Save Decision]

After 30 days, shows:

NVDA Decision Review (30 days later)

Your thesis: "AI cycle"
Outcome: Stock +18%, analyst estimates +5% EPS, margins stable
Result: Thesis intact, position profitable

Learnings:
  • Your P/E premium call was wrong (stock appreciated despite 68x P/E)
  • Analyst revisions more important than absolute P/E
  • Your "margin stability" tracking was useful signal

This builds pattern recognition. After 20-30 decisions logged + reviewed, user starts seeing what actually predicts returns.

Backtest view (future, Phase 10.5e):

Signal Accuracy Over Time

Strong Buy signals:  12 | Correct: 8 (67%) | Avg return: +18%
Buy signals:        34 | Correct: 21 (62%) | Avg return: +8%
Hold signals:       56 | Correct: 38 (68%) | Avg return: +2%
Avoid signals:      28 | Correct: 25 (89%) | Avg return: -12%

Best signal: Strong Buy + Dip >10% + Analyst Buy (76% win rate)
Worst signal: Hold + At Peak (48% win rate)

Your decisions vs signal:
  • How often you followed recommendations
  • When you deviated, what happened
  • Attribution: Was it luck or skill?

10.5f — Implementation (Phased)

Phase 10.5a (Week 1-2): Core UI

  • Sidebar filters + preset buttons
  • Main table (10 columns, sortable)
  • Tearsheet panel (slides in on row click)
  • Color coding + professional styling
  • Sticky header, monospace numbers

Phase 10.5b (Week 2-3): Tearsheet Sections

  • Core metrics cards
  • Valuation comparison table
  • Decision framework (gate breakdown)
  • Risk breakdown with quantified risks
  • Threshold sensitivity (if-then scenarios)
  • Peer comparison

Phase 10.5c (Week 3-4): Interactivity

  • Column sorting (click header)
  • Filter application (sidebar controls table)
  • Tear sheet smooth animation
  • Responsive layout (sidebar collapses on mobile)

Phase 10.5d (Week 4-5): Decision Logging

  • Decision Log modal
  • Save thesis + entry date/price
  • Track 30/60/90 day outcomes
  • Simple review modal (did thesis play out?)

Phase 10.5e (Week 5-6): Backtest Dashboard (Optional, can defer)

  • Signal accuracy rates
  • Win rate by signal type
  • User decision attribution
  • Correlation between signals and actual outcomes

10.5g — Key Features for Pro Growth

What teaches mastery:

  1. Decision Framework visible — User sees which gates matter for which sectors

    • After 10 decisions: "I notice 'Dip' gate almost always works"
    • After 20 decisions: "Growth gates matter more for tech, quality gates matter more for staples"
    • After 50 decisions: "I'm refining my thresholds"
  2. Threshold sensitivity shows downside — User stops buying at peaks

    • "If P/E → 50x, this stock -27%"
    • "If growth slows, this stock -35%"
    • User thinks: "Is my thesis strong enough to weather that?"
  3. Peer comparison normalizes expectations — User stops overpaying for "quality"

    • "NVDA 68x P/E but MSFT 32x P/E with similar quality"
    • "Understand the premium. Can it sustain?"
  4. Decision logging forces reflection — User learns what actually works

    • After first 5: "I bought too many at peaks"
    • After 15: "My 'Dip' picks outperform my 'Strong Buy' picks"
    • After 30: "I should weight dip % higher, P/E lower"
  5. Backtest shows signal accuracy — User moves from gut to data

    • "My thesis was right 18% of time, wrong 82%"
    • "Strong Buy + Dip is 67% accurate, Hold is 48% accurate"
    • "Which of MY filters actually predict returns?"

10.5h — Design Language (Professional, Minimalist)

  • Palette: Black text, white surfaces, semantic colors (green success, red danger, amber warning)
  • Typography: Monospace for numbers (Arial Mono, 11px), sans-serif for labels (Anthropic Sans, 12px)
  • Spacing: 12px gaps, 16px padding (tight, professional)
  • No decoration: Flat design, 0.5px borders only, no shadows/gradients/icons (icons only in Tabler outline)
  • Dark mode native: All colors use CSS variables (--color-text-primary, --color-background-secondary, etc.)
  • Sticky elements: Header always visible, sidebar sticky on desktop

10.5i — Mobile Responsiveness

  • Desktop (>1200px): Sidebar | Table | Tearsheet (3-column layout)
  • Tablet (768-1200px): Sidebar collapses to icon panel | Table (full width) | Tearsheet overlays
  • Mobile (<768px): Sidebar in drawer | Table scrolls horizontally | Tearsheet = full-screen modal

10.5j — Comprehensive Free Data Stack (Zero Cost, Zero Redundancy)


Phase 10.6 — Portfolio Integration: Market Analysis → Action

Goal: Connect screener signals + market context to portfolio decisions. Guide users through complete workflow: Find stock → Understand market backdrop → Size position → Track thesis → Measure outcome.

10.6a — Market-Aware Position Sizing

Calculate recommended position size (not user's job):

  • Stock verdict (Strong Buy = larger, Hold = smaller)
  • Market regime (High rates = trim growth, favor value)
  • Sector momentum (Hot = reduce exposure, cold = add exposure)
  • Portfolio allocation (already 22% tech = cap new tech at 2%)

Display: "Recommended: 2-4% of your portfolio" Show dollar amount: "If you have $100k, buy $2,000-$4,000"

10.6b — Portfolio Dashboard: Integrated View

Single screen shows all three:

  1. Holdings: Current positions + P&L
  2. Allocation vs Target: Visual breakdown (overweight/underweight)
  3. Market Context: Fed, VIX, sector trends
  4. Screener Signals: How many Strong Buys/Holds/Avoids you own
  5. Recommended Action: What to do (trim/add/rebalance/do nothing)

10.6c — Screener-Portfolio Bridge

In screener table, add column: "Your Holdings"

  • "You own 2% | +$1,000 gain"
  • "Verdict changed from Strong Buy → Hold (consider trimming)"
  • Alert: "Your thesis on this changed (verdict downgraded)"

10.6d — Thesis Journal (Simplified)

When adding position to portfolio:

  1. Why I'm buying (pick ONE reason)
  2. What I'll watch (pick 1-2 metrics)
  3. Review date (auto 30 days)

After 30 days: "Did your thesis hold?"

  • Stock price: Up/Down/Flat
  • Analyst consensus: Upgraded/Same/Downgraded
  • Thesis status: Still valid / Partially broken / Completely wrong

Track accuracy over time (learning loop).

10.6e — Rebalancing Advisor

Monitor allocation vs target. When screener verdict changes on existing holding:

  • Alert user
  • Show impact on portfolio
  • Suggest specific action (trim this, add that)

When market context shifts (Fed decision, rate change):

  • Recalculate position attractiveness
  • Recommend sector rotation (take profits in expensive growth, add cheap value)

Phase 10.7 — Newbie UX: Progressive Disclosure

Goal: Professional tool with newbie-friendly interface. Same power, different experience. Default simple, reveal detail on demand. Plain language always.

Core principle: Don't simplify the tool. Simplify the interface.

10.7a — Screener Entry: Strategy-Based (Not Filter-Based)

Instead of showing filters (P/E range, ROE min, dip %, etc):

Ask: "What are you looking for?"

Options:
  ○ Solid companies at good prices (Balanced)
     → Auto-applies: Quality gate PASS + Reasonable valuation
     
  ○ Hot stocks with momentum (Momentum)
     → Auto-applies: Positive 52W momentum + analyst upgrades
     
  ○ Beaten-down bargains (Value)
     → Auto-applies: Low P/E + High dividend yield
     
  ○ Let me customize filters (Advanced)
     → Shows full filter panel for pros

Why it works:

  • Newbies pick a strategy, not filters
  • Tool auto-applies appropriate gates
  • Advanced users still have access to full customization
  • Feels like guidance, not overwhelming data

10.7b — Table View: Plain Language Explanations

Minimal table (Ticker | Price | Verdict | Why? )

Clicking "" shows plain-language explanation:

WHY IS AAPL A STRONG BUY?

3 Key Reasons:

1️⃣ GREAT COMPANY
   Apple makes money really well.
   Profit margins: 46% (excellent)
   Debt: Manageable
   Cash: Strong
   
   💡 What this means: You're buying a healthy business
   Quality Score: 95/100

2️⃣ REASONABLE PRICE
   Cost: 28.5x yearly earnings
   Peers: 18x-32x
   Conclusion: Fair price (not cheap, not expensive)
   
   💡 What this means: You're not overpaying
   Value Score: 85/100

3️⃣ GOOD TIMING
   Price: 5% below 52-week high
   Analysts: Think it'll go up 12%
   Market: Fed paused rate hikes (good for stocks)
   
   💡 What this means: Now is an OK time to buy
   Timing Score: 80/100

═══════════════════════════════════════
VERDICT: Strong Buy ✓
Confidence: 8/10 (Pretty confident)

[Learn More] [Add to Portfolio] [Not Ready]

Features:

  • Plain language (no jargon)
  • Icons + visual hierarchy
  • "What this means" translation
  • Clear CTA buttons

10.7c — Buy Decision Helper

When user considers adding a stock:

YOU'RE CONSIDERING: TSLA - $241.85

QUICK ASSESSMENT:
┌─────────────────────────────┐
│ IS THIS A GOOD BUY?         │
│ Company Quality: ★★★★★     │
│ Price Level:     ★★★☆☆     │
│ Timing:          ★★★★☆     │
│ OVERALL:         ★★★★☆     │
└─────────────────────────────┘

💡 4 stars = Recommended
   You'll probably make money

HOW MUCH SHOULD YOU BUY?

Based on your portfolio:
  Recommended: 2-4% of your money
  
  If you have $100,000:
    Buy $2,000-$4,000 worth
    = About 9-18 shares

Why 2-4%?
  Don't put too much in ONE stock.
  Spread money across many = lower risk.

YOUR DECISION:

  ✓ [Add 2%]  - Safe (recommended)
  ✓ [Add 3%]  - Moderate confidence
  ✓ [Add 4%]  - High conviction
  ✗ [More than 4%] - Too risky, not recommended
  ○ [Skip, Wait for Dip] - Maybe later at better price

Why it works:

  • Star rating is intuitive
  • Position size auto-calculated (not user's job)
  • Concrete dollars (not abstract %)
  • Clear "safe" path highlighted
  • Option to wait shown upfront

10.7d — Portfolio Status View (Not Analysis)

Instead of complex metrics, show status + guidance:

YOUR PORTFOLIO STATUS: ✓ HEALTHY

YOUR BREAKDOWN:
┌──────────────────────────────────┐
│ Tech     ████████████░░░░░░ 22%  │
│ Target:  20% (2% over)           │
│                                   │
│ Healthcare ██████░░░░░░░░░░░ 18% │
│ Target:  15% (3% over)           │
│                                   │
│ Other  ███████████░░░░░░░░ 60%  │
│ Target: 65% (5% under)           │
└──────────────────────────────────┘

💡 WHAT THIS MEANS:
   You own too much Tech & Healthcare
   Not enough in Everything Else
   This is FINE (not dangerous) but...

⚠️  WHAT COULD HAPPEN:
   If Tech drops 10% → Your portfolio drops 8%
   If Everything Else drops 10% → You feel 6%
   = You're slightly overexposed to tech risk

✅ WHAT TO DO (Pick one):

   Option A: TAKE PROFITS (Beginner-friendly)
   Sell 2% of tech stocks
   You lock in gains + reduce risk
   Takes: 5 minutes
   [Do This] ← Recommended

   Option B: BUY OTHER SECTORS (Intermediate)
   Add money to Healthcare & Other
   Diversify without selling winners
   Takes: 30 minutes
   [Learn How]

   Option C: DO NOTHING (Advanced)
   Your allocation is acceptable
   Monitor quarterly
   [I'm Comfortable]

Why it works:

  • Visual (bars are immediately clear)
  • Explains impact (concrete numbers)
  • Gives multiple options (not prescriptive)
  • Recommends safest for beginners
  • Supports different experience levels

10.7e — Market Context: Status Light + Impact

Instead of raw data, use traffic light system:

🟢 MARKET HEALTH: Good
   The market is calm. Stocks are fairly priced.
   Good time to invest (not too risky).

⚠️  WHAT TO KNOW: Interest rates are high
   Banks benefit (lending is expensive)
   Growth stocks suffer (future profits worth less)
   
   Impact on YOUR portfolio:
   • Your tech stocks: Slightly risky 📉
   • Your bank stocks: Doing well 📈

💡 WHAT TO DO:
   Nothing urgent, but...
   If adding money: Prefer banks & stable companies
   If rebalancing: Trim expensive tech, add value stocks

Three layers:

  1. Status indicator (🟢🟡🔴)
  2. Plain explanation (why market is here)
  3. Impact on their portfolio + action

10.7f — Thesis Logging: Simple Checklist

Not open-ended journal. Simple form:

YOU BOUGHT TSLA: $241.85 (4% of portfolio)

WHY ARE YOU BUYING?
(Pick ONE reason):

  ○ Strong growth company (earnings growing fast)
  ○ Undervalued (price low vs business)
  ○ Sector tailwind (industry heating up)
  ○ I just liked it (no specific reason)

[I chose: "Strong growth company"]

WHAT WILL YOU WATCH?
(Pick 1-2 metrics):

  ☐ Stock price (is it going up?)
  ☐ Earnings (beating estimates?)
  ☐ Analyst ratings (still bullish?)
  ☐ Sector news (industry still growing?)

[I'll watch: Stock price, Analyst ratings]

═══════════════════════════════════
IN 30 DAYS, WE'LL CHECK:
  ✓ Did stock price go up?
  ✓ Any analyst downgrades?
  ✓ Still bullish on sector?

You'll learn: "Did my pick work?"

Why it works:

  • Simple checklist (not overwhelming)
  • Focuses on 1-2 metrics (not 10+)
  • Built-in review schedule (30-day check-in)
  • Learning reinforced without lecturing

10.7g — After Buying: 30-Day Check-In

System auto-reminds user after 30 days:

HOW DID YOUR TSLA PICK WORK?

Your thesis: "Strong growth company"

CHECK YOUR METRICS:

Stock Price:
  Entry: $241.85
  Now: $255.00
  Change: +5.3% ✓ (Good!)
  
  Your thesis depends on: Stock going UP
  Status: On track ✓

Analyst Ratings:
  Were: 75% Buy
  Now: 78% Buy
  Change: Upgraded ✓
  
  Your thesis depends on: Bullish consensus
  Status: Intact ✓

═══════════════════════════════════
RESULT: Your thesis is WORKING ✓

2/2 metrics on track.
Keep holding OR take some profits.

[Keep Holding] [Take 20% Profit] [Exit All]

Why it works:

  • Automated reminder (user doesn't forget)
  • Shows actual vs predicted
  • Clear thesis validation
  • Suggests next action
  • Builds learning habit

10.7h — Newbie Mode vs Pro Mode (Toggle)

Newbie Mode (default):

  • Simplified screener entry (strategy-based)
  • Plain language explanations
  • Auto position sizing
  • Status lights (not data)
  • Guided workflows

Pro Mode (toggle in settings):

  • Full filter control
  • All metrics visible
  • Raw data + charts
  • Advanced analysis
  • Complete transparency

Same tool, two interfaces.

User can toggle anytime:

Settings > Experience Level
  ○ Newbie (Simplified, guided)
  ○ Intermediate (Mixed)
  ○ Pro (Full control, no guardrails)

Phase 10.8 — Earnings Calendar: Context, Not Destination

Strategic Principle: Calendar data should be integrated contextually into decision workflows, NOT a standalone navigation tab.

Why NOT a Calendar Tab:

Low discoverability: Users browse screener, find stock, then want to know when it reports. They don't open a separate calendar tab.

Out-of-context data: Earnings dates alone = reference data. Earnings dates WITH screener verdict + thesis = actionable intelligence.

Navigation friction: Breaks user flow. Instead of stock → tearsheet, forces stock → calendar tab → search → check.

Redundant: Same data appears in 3 places (screener, portfolio, calendar), creating maintenance debt.

Wrong mental model: Calendar is reference. Your platform is decision-focused.

Better Approach: Earnings as Decision Context

10.8a — Earnings in Screener Tearsheet (Primary)

When user clicks stock, earnings section shows:

UPCOMING EVENTS:
├── Earnings: July 30, 2026 (18 days away)
│   ├── EPS estimate: $6.50
│   ├── Historical beat rate: 65% (beats estimate 65% of time)
│   ├── Avg price move on earnings: +3% (beat), -2% (miss)
│   └── Timing decision: "Buy now before earnings?" or "Wait to see results?"
│
├── Ex-dividend: June 15 (6 days away)
│   └── Dividend: $0.24/share
│
└── Analyst call: Post-earnings July 30
    └── Action: "Watch call for forward guidance"

Why here:

  • Stock + earnings together (context matters)
  • Timing-based decision ("Should I buy before or after earnings?")
  • Thesis-aware ("If earnings beat, thesis validates")
  • Newbie-friendly ("What does earnings mean for my decision?")

Implementation:

  • Fetch from Finnhub (earnings, estimates, surprise data)
  • Calculate historical beat rate (from historical data)
  • Show avg price move (from options implied volatility or historical moves)
  • Provide decision guidance ("Reduce position before surprise risk" vs "Hold through catalyst")

10.8b — Earnings in Portfolio (Secondary)

Portfolio holdings view shows upcoming events for YOUR positions:

YOUR HOLDINGS - UPCOMING CATALYSTS:
├── AAPL: Earnings July 30 (18 days) | Your position: 2% | Verdict: Hold
│   ✓ Your thesis: iPhone growth → Watch if guidance raised
│   ✓ Decision: Consider adding if earnings beat + guidance raised
│   ⚠ Risk: If revenue guidance disappoints, -5-10% likely
│
├── MSFT: Earnings July 24 (12 days) | Your position: 3% | Verdict: Buy
│   ✓ Your thesis: Cloud growth → Watch cloud revenue % of total
│   ✓ Decision: Set buy limit if beaten down after earnings
│   ⚠ Risk: If azure growth slows <25% YoY, re-evaluate
│
└── NVDA: Earnings August 3 (28 days) | Your position: 2% | Verdict: Hold (at peak)
    ⚠ Your thesis: AI cycle resilience → At risk from guidance miss
    ✓ Decision: Consider taking 50% profit BEFORE earnings (reduce risk)
    ✓ Reason: Stock at peak, earnings miss would be -15-20%

Why here:

  • Shows when YOUR holdings report (not just all earnings)
  • Thesis-specific tracking ("What earnings data validates/breaks your thesis?")
  • Risk management ("Reduce position before surprise events")
  • Decision-oriented ("What should you do given upcoming earnings?")

Implementation:

  • Overlay screener verdicts on portfolio holdings
  • Show decision guidance based on thesis + verdict combo
  • Risk warnings for stocks at peaks or with weak guidance expectations
  • Reminders to take profits before uncertain catalysts

10.8c — Earnings Discovery Widget (Optional, Tertiary)

Optional light calendar feature in screener header (NOT main nav):

SCREENER HEADER:
  Filtered: 247 | Strong Buy: 12 | Buy: 34 | Hold: 56
  
  📅 25 earnings this week in your screened results
     └── [View by day] [View by verdict]

Clicking shows:

EARNINGS IN YOUR SCREENED STOCKS:

This Week:
├── Monday 6/9: 5 reporting
│   ├── TSLA (Strong Buy) - earnings 4:30pm ET
│   ├── NFLX (Buy) - earnings 5pm ET
│   └── 3 others
│
├── Tuesday 6/10: 8 reporting
│   ├── NVDA (Hold) - earnings 5pm ET
│   └── 7 others
│
└── Wednesday 6/11: 12 reporting

Why here:

  • Proactive discovery ("Which stocks I'm looking at report soon?")
  • Not competing with main nav (sub-feature in header)
  • Contextual to screener (only shows stocks user is actually screening)
  • Decision support ("Should I wait to see earnings before buying?")

Implementation:

  • Light modal/dropdown (not full page)
  • Filter by verdict (show "Strong Buys reporting this week")
  • Link back to stock tearsheet (click to see full earnings context)

10.8d — What NOT to Build

Standalone "Calendar" nav tab

  • Creates navigation bloat
  • Out-of-context data (just dates, no decisions)
  • Low usage (users don't proactively browse earnings separate from stocks)
  • Redundant (data already in screener + portfolio)

10.8e — Earnings Calendar in Thesis Journal

When user logs a thesis, earnings become a key tracking metric:

THESIS JOURNAL:
You bought: AAPL @ $189.50

Why: "iPhone 18 launch cycle"

Key metrics to track:
  ☑ Stock price (investor signal)
  ☑ Analyst ratings (consensus shift)
  ☑ Earnings (thesis validation)
    └── Next earnings: July 30 (18 days)
    └── What to watch: Revenue guidance + iPhone % of revenue
    └── What breaks thesis: If iPhone <40% of revenue (market share loss)

AUTOMATED TRACKING:
30-day reminder: Did stock move in line with your thesis?
  → Earnings will occur on July 30
  → Check: Did earnings beat/miss? Did guidance hold?
  → Update: Thesis status (intact / shaken / broken)

Why this works:

  • Earnings as thesis validation (not just date reference)
  • Newbie learning ("This is why earnings matter for my pick")
  • Decision trigger ("If earnings break my thesis, I exit")

Strategic Summary: Earnings Calendar Architecture (TABULAR)

Component Where Why Cost
Earnings dates + estimates Screener tearsheet Context for timing decisions Low (Finnhub API)
Earnings + thesis tracking Portfolio view Manage existing positions Low (already integrated)
Historical beat rate Screener tearsheet Estimate surprise probability Low (historical data)
Earnings in thesis journal Decision logging Validate thesis over time Low (metadata tracking)
Earnings discovery widget Screener header badge Proactive browsing (nice-to-have) Medium (optional)
Standalone calendar tab DONT BUILD Navigation clutter, low value N/A

Key Principle: Calendar is context for decisions, not a destination.

10.8f — Design Note: Revisit Earnings Display Format

⚠️ DESIGN REVIEW NEEDED:

Current plan shows earnings integrated across three locations (tearsheet, portfolio, journal). Consider:

  1. Information consistency: Does earnings data show the same way in all three places, or should each context adapt it?

    • Screener tearsheet: "Earnings July 30 (18 days). Beat prob: 65%"
    • Portfolio: "AAPL earnings July 30. Your thesis: iPhone growth. Risk: Guidance miss"
    • Journal: "Earnings is key metric #3 to track for outcome"
  2. Visual hierarchy: How prominent should earnings be vs. other data?

    • Screener: Part of "Upcoming Events" section (secondary)
    • Portfolio: Inline with risk management (primary)
    • Journal: One of 3-4 tracked metrics (equal weight)
  3. Mobile responsiveness: Earnings data may not fit on small screens.

    • Desktop: Full table with all fields
    • Mobile: Abbreviated ("July 30" not "July 30, 2026 - 4:30 PM ET")

Recommendation: Implement Phase 10.5 + 10.6, then review actual user behavior before finalizing visual design. Adjust based on what users actually click on and spend time looking at.


Phase 10.9 — Strong Buys: Professional Dip Opportunity Monitor

Goal: Flag quality stocks ("too big to fail") when they drop 5%+ from 52W high, with market analysis of why they dipped.

Use case: "AAPL dropped 5% today. Was it company fundamentals or macro headwind? If macro, it's a dip to buy."

NOT a newbie feature. This is professional dip-buying opportunity detection.

10.9a — Strong Buys Data Structure

Field Source Purpose
Ticker Yahoo Finance Stock identifier
Current Price Yahoo Finance (real-time daily fetch) Entry price today
52W High Yahoo Finance Reference point for dip %
Dip % Calculated: (high - current) / high Triggers display if ≥5%
Screener Verdict ScreenerEngine Is it Strong Buy or just cheap?
Screener Score ScreenerEngine Quality ranking (8.2/10 vs 5.5/10)
Dip Reason Market Context Analysis Macro (Fed), sector rotation, catalyst, or company issue
Market Context Daily fetched (Fed, sector trends, catalysts) Why did it drop? Is it temporary?
Your Play LLM analysis What should you do? Buy the dip or wait?
Recommended Action Position sizing logic "Add 2-4% to portfolio"

10.9b — Fetching Mechanism (Daily Update)

Daily Job (EOD or Morning):

Step 1: Get "Too Big to Fail" Stock Universe
  ├── Tier 1: Mega-cap only (10 stocks)
  │   └── AAPL, MSFT, NVDA, GOOG, AMZN, TSLA, META, BERKB, etc
  ├── Tier 2: Large-cap (50 stocks)
  │   └── >$10B market cap, curated quality
  ├── Tier 3: User's custom watchlist
  │   └── User-added stocks to monitor
  └── Total: ~150 stocks to screen

Step 2: Fetch Prices + 52W High (One Yahoo batch call)
  └── Returns: current price, 52W high, volume, etc.

Step 3: Filter Dips ≥5% from 52W High
  └── Calculate: (52W high - current price) / 52W high
  └── Keep only: dip % ≥ 5%
  └── Example: AAPL 52W=$210, today=$189 → 9.76% ✓

Step 4: Run Screener on Dipped Stocks (One screener call)
  └── ScreenerEngine.screenTickers(dipped_tickers)
  └── Returns: verdict, score, market analysis

Step 5: Analyze Why Dipped (Use cached market context)
  ├── Macro factor (Fed held rates → growth punished)
  ├── Sector rotation (Capital flowing from A to B)
  ├── Catalyst (Ex-dividend, earnings coming)
  └── Company issue (Earnings miss, guidance cut)

Step 6: Combine + Cache (TTL 24 hours)
  └── Store as: { ticker, price, dip%, verdict, reason, thesis, action }
  └── Next update: Tomorrow same time

Step 7: API Serves from Cache (Zero latency)
  └── GET /api/strong-buys → returns cached results

10.9c — UI: Tabular Display of Dip Opportunities

Ticker Price Dip % Verdict Why It Dipped Your Play Action
AAPL $189.50 -9.76% Strong Buy (8.2) Fed rates high (macro headwind, not company issue) Buy the dip. iPhone growth intact. Temporary. [+2-4%]
JPM $215.30 -7.2% Strong Buy (7.8) Sector rotation (capital → tech from banks) Defensive play. Banks undervalued. Patient entry. [+3%]
MSFT $425.30 -3.1% Buy (7.1) Minor pullback (no major catalyst) Not yet dip enough. Watch for 5%+. [Skip]

Table Features:

  • Sortable by: Dip %, Verdict, Your Play
  • Click row → full tearsheet with thesis + market analysis
  • Daily refresh: Updates each morning/EOD
  • Dip threshold configurable: 5% (default) → 10% → 15%

10.9d — Configuration (User Control)

Settings > Strong Buys Monitor:
  
  Stock Universe:
    ☑ Mega-cap (10 stocks)
    ☑ Large-cap (50 stocks)
    ☑ My Watchlist (custom)
  
  Dip Threshold:
    ○ 5% (Aggressive - most opportunities)
    ○ 10% (Balanced)
    ○ 15% (Conservative - only major dips)
  
  Update Frequency:
    ○ Daily morning (9:30 AM)
    ● Daily EOD (4:00 PM)
    ○ 4x daily (more frequent updates - future)
  
  Alerts (Future):
    ☐ Email when dip detected
    ☐ Discord webhook
    ☐ Push notification

10.9e — Design Note: Revisit Tabular Format

⚠️ DESIGN REVIEW NEEDED:

The tabular format above is functional but may be dense for quick scanning. Consider:

  1. Card-based alternative (cleaner, easier to scan):

    ┌──────────────────────────────────┐
    │ AAPL | $189.50 | -9.76%          │
    │ Strong Buy (8.2/10)              │
    ├──────────────────────────────────┤
    │ WHY: Fed rates high (macro)      │
    │ NOT: Company fundamentals        │
    ├──────────────────────────────────┤
    │ PLAY: Buy the dip                │
    │ Add: 2-4% to portfolio           │
    │ [Add to Portfolio] [Analyze]     │
    └──────────────────────────────────┘
    
  2. Compact table (current proposal):

    • Pro: All info visible at once
    • Con: Wide table, may require horizontal scroll on mobile
  3. Hybrid approach (desktop table + mobile cards):

    • Desktop: Full table
    • Mobile: Card view

Recommendation: Review after Phase 10.9a implementation. Gather user feedback on what works better.


Phase 10.8 — Earnings Calendar: Context, Not Destination (TABULAR)

Philosophy: Build professional-grade screener using only FREE sources. Layer specialized APIs intelligently — no bloated $99-$200/mo subscriptions. Each source has ONE clear job (no duplication).

Architecture Principle:

  • Yahoo Finance (via YahooFinanceClient): Stock metrics only (what you already have)
  • yfinance: Per-ticker enrichment only (news, earnings dates)
  • Finnhub FREE: Earnings calendar + estimates only
  • Alpha Vantage FREE: Market context only (macro trends)
  • API Ninjas FREE: Earnings backup only (redundancy layer)
  • Your LLM: Intelligence layer (turns data into insights)

The Stack (All Free, No Redundancy):

  1. Yahoo Finance (via YahooFinanceClient) — METRICS ONLY

    • Core metrics: P/E, ROE, FCF, D/E, analyst ratings, market cap, 52W high/low
    • Insider activity, institutional holdings
    • Already integrated in your ScreenerEngine
    • Role: Calculate screener scores + verdicts
    • NOT used for: News (use yfinance instead), earnings calendar (use Finnhub instead)
  2. yfinance Library — ENRICHMENT ONLY

    • Per-ticker news articles (top 5-10)
    • Earnings dates (historical + future)
    • Dividend history, options chain
    • Role: Fetch stock-specific news for tearsheet
    • NOT used for: Fundamental metrics (already have from Yahoo)
    • Why yfinance not Yahoo direct: Optimized for news extraction, cleaner interface
  3. Finnhub FREE Tier — EARNINGS CALENDAR + ESTIMATES

    • Upcoming earnings calendar (3-month lookahead)
    • EPS consensus estimates
    • Earnings surprise data (actual vs. estimated)
    • Role: Reliable earnings dates + estimates for decision triggers
    • NOT used for: Stock prices or fundamentals (have from Yahoo)
    • Why Finnhub: More reliable for future events than Yahoo
  4. Alpha Vantage FREE Tier — MARKET CONTEXT + SENTIMENT

    • Daily market news headlines (macro-focused)
    • AI sentiment analysis (positive/neutral/negative)
    • Ticker mention extraction (which stocks in headlines)
    • Keyword search (Fed decisions, economic data, sector trends)
    • Role: Provide market backdrop + macro sentiment for screener header
    • NOT used for: Stock-specific data (use yfinance instead)
    • Why separate: Completely different data type (macro, not micro)
  5. API Ninjas FREE Tier — EARNINGS BACKUP

    • Upcoming earnings dates (100 requests/month free)
    • Filter by exchange, date range, ticker
    • Role: Redundancy layer (if Finnhub API hits rate limits)
    • NOT used for: Primary source (Finnhub is primary)
    • Why backup: Ensures reliability, handles spikes
  6. Your LLM (Claude) — INTELLIGENCE LAYER

    • News sentiment analysis (beyond Alpha Vantage baseline)
    • Decision framework generation ("which gates pass/fail, why?")
    • Risk narrative scoring (quantify uncertainties)
    • Thesis validation (does news confirm your thesis?)
    • Role: Turn raw data into actionable insights
    • NOT used for: Data fetching (all data from external sources)

Data Flow in Tearsheet:

Step 1: User screens stocks
  → ScreenerEngine uses YahooFinanceClient (your existing code)
  → Returns: 247 stocks with scores, verdicts, metrics

Step 2: Metrics cached in memory/state
  → No additional API calls needed
  → Display compact table instantly

Step 3: User clicks row → Tearsheet opens
  → All metrics already loaded (from Step 1)

Step 4: Fetch per-ticker enrichment (on-demand, parallel)
  → yfinance.Ticker(ticker).news → top 5 articles
  → Finnhub earnings/{ticker} → next earnings + estimates
  → Alpha Vantage called once daily (cached) for market context

Step 5: Process with LLM (if enabled)
  → Analyze yfinance news → sentiment
  → Validate thesis against news + fundamentals
  → Generate decision framework

Step 6: Display complete tearsheet
  ├── Core Metrics (Yahoo, cached from screener)
  ├── Valuation Context (Yahoo peer comparison)
  ├── Decision Framework (gates pass/fail, from ScoringConfig)
  ├── Recent News (yfinance + LLM sentiment)
  ├── Upcoming Events (Finnhub earnings + estimates)
  ├── Market Context (Alpha Vantage + sentiment)
  ├── Risk Breakdown (LLM analysis)
  ├── Peer Comparison (Yahoo data)
  └── CTA (Decision Log, Analyze)

Integration Timeline (Phased):

Phase 1 (Week 1): Add yfinance News Enrichment

  • Create endpoint: GET /api/screen/:ticker/news
  • Returns top 5 yfinance articles for ticker
  • Display in tearsheet "Recent News" section
  • Cost: 0 (yfinance free)

Phase 2 (Week 2): Add Finnhub Earnings Calendar

  • Create endpoint: GET /api/screen/:ticker/earnings
  • Returns next earnings date + EPS estimates + surprise data
  • Display in tearsheet "Upcoming Events" section
  • Cost: 0 (Finnhub FREE tier, rate limit: 60 req/min, plenty for your volume)

Phase 3 (Week 3): Add Alpha Vantage Market Context

  • Create endpoint: GET /api/market-context
  • Called once daily (cached), returns top market news + sentiment
  • Display in screener header "Market Context" strip
  • LLM optional: analyze headlines → sector impact
  • Cost: 0 (Alpha Vantage FREE tier)

Phase 4 (Week 4): Add API Ninjas as Backup

  • Create endpoint: GET /api/earnings-calendar
  • Returns earnings calendar for portfolio
  • Used as fallback if Finnhub hits rate limits
  • Cost: 0 (API Ninjas FREE tier, 100 req/month)

Phase 5 (Week 5): Wire Everything into Tearsheet

  • Compile all data sources into single modal view
  • Optimize API calls (parallel, cached where appropriate)
  • Test rate limits under load

Phase 6 (Week 6): Add LLM Enrichment

  • Optional: Send yfinance news + fundamentals → LLM
  • Generate sentiment analysis
  • Generate decision framework ("why this verdict?")
  • Add to tearsheet

Why This Approach:

Zero Cost: $0/month (all sources FREE) Zero Redundancy: Each source has ONE job, no overlap Professional Grade: Layered sources like institutional traders use Reliability: Redundancy where it matters (earnings calendar via Finnhub + backup via API Ninjas) Intelligent: Your LLM adds 10x value without additional data cost Teachable: Users see full decision framework, learn what matters

Cost Comparison:

Stack Monthly Cost Quality Breadth Redundancy
Zacks API $200+ High Limited No
Finnhub PRO $99 High Excellent No
Your FREE stack $0 HIGH EXCELLENT Strategic (earnings only)

Rate Limits & Sustainability:

  • Yahoo Finance (via YahooFinanceClient): No official limits (already proven in production)
  • yfinance: No limits (wraps Yahoo, same as above)
  • Finnhub FREE: 60 API calls/minute (sufficient for screening 250 stocks, polling earnings calendar)
  • Alpha Vantage FREE: 5 calls/minute (one daily call to market news, easily manageable)
  • API Ninjas: 100 calls/month (backup only, minimal usage)

Scaling Plan:

  • Users 1-1000: Current FREE stack, no cost
  • Users 1000-10,000: Upgrade to Finnhub FREE tier (still free, same rate limit works)
  • Users 10,000+: Upgrade to Finnhub PAID tier ($99/mo) OR use API Ninjas as primary with Finnhub as backup
  • Enterprise (100K+): Evaluate FactSet/Bloomberg (only if paying customers justify cost)

Key Insight:

You don't need one expensive, comprehensive API. You need five free, specialized sources composed intelligently. This is exactly how professional traders and quants build systems:

  1. Yahoo (YahooFinanceClient) = broad fundamentals (your screening foundation)
  2. yfinance = stock-specific catalysts (is this stock moving for a reason?)
  3. Finnhub = earnings calendar (what's the catalyst timeline?)
  4. Alpha Vantage = market sentiment (is the tide rising or falling?)
  5. API Ninjas = backup reliability (always have a Plan B)
  6. Your LLM = intelligence layer (what does all this mean for my decision?)

Together, they're better than any single $200/mo API because they're specialized, not bloated. This is professional-grade without the professional price tag.


Phase 11 — Day Trading: Authentication & Authorization

Goal: Add multi-user support with JWT auth, role-based access control, and user portfolio isolation.

Timeline: 2-3 weeks. Do this BEFORE adding any real-time trading features (Phase 12+).

Why Auth is First

Without auth, you cannot test:

  • Multi-user portfolios (can't separate user A's holdings from user B's)
  • Public + private access (can't invite members)
  • Discord notifications with user context (can't tag which user triggered it)
  • Trade journal with user attribution (can't track who made the decision)

11a — Create auth domain

server/domains/auth/
  ├── AuthController.ts         (POST /auth/login, /auth/register, /auth/refresh)
  ├── AuthService.ts            (JWT generation, password hashing)
  ├── JWTStrategy.ts            (JWT validation + extraction)
  ├── RBACGuard.ts              (middleware for role checks)
  ├── persistence/
  │   └── UserStore.ts          (users table CRUD)
  └── types/
      ├── auth.model.ts         (User, Token, Role types)
      └── schemas.ts            (JSON schemas for /auth/* requests)

11b — Database schema changes

Add to SQLite:

-- users table
CREATE TABLE users (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  role TEXT DEFAULT 'viewer' CHECK (role IN ('trader', 'viewer', 'admin')),
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  last_login DATETIME
);

-- Extend holdings table with user_id
ALTER TABLE holdings ADD COLUMN user_id TEXT NOT NULL REFERENCES users(id);

-- Extend portfolio_advice with user context
ALTER TABLE portfolio_advice ADD COLUMN user_id TEXT REFERENCES users(id);

-- Extend market_calls with creator tracking
ALTER TABLE market_calls ADD COLUMN created_by TEXT REFERENCES users(id);

11c — Middleware + route protection

Update server/app.ts:

app.register(require('@fastify/jwt'), {
  secret: process.env.JWT_SECRET || 'dev-secret-change-me',
});

// Apply RBACGuard to protected routes
app.post('/api/portfolio/add', 
  { onRequest: [authGuard, roleGuard('trader')] }, 
  portfolioController.add
);

app.get('/api/trading/safe-buys',
  { onRequest: [authGuard, roleGuard('trader')] },
  tradingController.safeBuys
);

11d — UI auth layer

Add to SvelteKit:

routes/
  └── auth/
      ├── login/
      │   ├── +page.ts
      │   └── +page.svelte
      └── register/
          ├── +page.ts
          └── +page.svelte

lib/stores/
  └── auth.store.svelte.ts       (currentUser, JWT, login/logout)

lib/api/
  └── auth.ts                    (login, register, refresh endpoints)

Commit: feat: add Phase 11 — authentication & RBAC


Phase 12 — Day Trading: News Webhooks

Goal: Ingest real-time market news via Polygon.io webhooks and trigger downstream analysis.

Timeline: 2-3 weeks.

Why Webhooks Come Second

News feeds everything downstream:

  • Safe Buys monitor watches for tickers mentioned in news
  • LLM analysis needs fresh news context
  • Price dips are more valuable when correlated with news

12a — Create news domain

server/domains/news/
  ├── NewsController.ts          (POST /webhooks/news for Polygon)
  ├── WebhookHandler.ts          (parse + validate Polygon events)
  ├── NewsStore.ts               (insert articles + search)
  ├── NewsQueue.ts               (BullMQ worker for async processing)
  ├── persistence/
  │   └── NewsArticleStore.ts    (news_articles table)
  └── types/
      └── news.model.ts          (Article, PolygonEvent types)

12b — Database schema

CREATE TABLE news_articles (
  id TEXT PRIMARY KEY,
  ticker TEXT NOT NULL,
  headline TEXT NOT NULL,
  body TEXT,
  source TEXT,
  url TEXT,
  sentiment TEXT,  -- positive, neutral, negative (optional)
  published_at DATETIME,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY(ticker) REFERENCES holdings(ticker)
);

CREATE INDEX idx_news_ticker_date ON news_articles(ticker, published_at DESC);
CREATE INDEX idx_news_created ON news_articles(created_at DESC);

12c — Set up Polygon.io webhook

  1. Subscribe to Polygon news API (requires paid tier, ~$200/month)
  2. Register webhook endpoint: https://yourapp.com/webhooks/news
  3. Validate webhook signature (Polygon sends HMAC)
  4. Queue article for processing (don't block HTTP response)
// NewsController.ts
async handleWebhook(req: FastifyRequest, reply: FastifyReply) {
  const signature = req.headers['x-polygon-signature'];
  if (!validateSignature(req.body, signature)) {
    return reply.code(401).send({ error: 'Invalid signature' });
  }

  // Queue immediately, respond fast
  await newsQueue.add('ingest', req.body);
  return reply.code(202).send({ status: 'queued' });
}

12d — Async processing with BullMQ

// NewsQueue.ts
newsQueue.process('ingest', async (job) => {
  const article = job.data;
  
  // 1. Store in DB
  await newsStore.insert(article);
  
  // 2. Trigger LLM analysis if article mentions key tickers
  const mentionedTickers = extractTickers(article.body);
  for (const ticker of mentionedTickers) {
    await llmQueue.add('analyze', { ticker, article });
  }
  
  // 3. Notify subscribers (Discord, etc)
  await notifySubscribers(article);
  
  return { status: 'processed' };
});

Commit: feat: add Phase 12 — news webhooks & async processing


Phase 13 — Day Trading: Prompt Caching & LLM Optimization

Goal: Reduce LLM costs by 90% using Anthropic prompt caching. Store analysis results in DB for fast retrieval.

Timeline: 2-3 weeks.

13a — Create llm domain (refactored)

server/domains/llm/
  ├── LLMRouter.ts               (NEW: route by cost/model)
  ├── PromptCache.ts             (NEW: Anthropic cache mgmt)
  ├── LLMAnalyst.ts              (refactored from shared)
  ├── persistence/
  │   ├── AnalysisStore.ts       (llm_analysis table)
  │   └── CacheStore.ts          (prompt_cache table)
  └── types/
      └── llm.model.ts           (Analysis, CacheEntry types)

13b — Database schema

CREATE TABLE llm_analysis (
  id TEXT PRIMARY KEY,
  ticker TEXT NOT NULL,
  analysis_result TEXT NOT NULL,  -- JSON: signal, sentiment, risks
  model_used TEXT DEFAULT 'claude-opus',
  tokens_used INTEGER,
  cache_hit BOOLEAN DEFAULT false,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  expires_at DATETIME  -- for caching strategy
);

CREATE TABLE prompt_cache (
  cache_key TEXT PRIMARY KEY,
  prompt_hash TEXT NOT NULL,
  result TEXT NOT NULL,
  model TEXT,
  expires_at DATETIME,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_analysis_ticker ON llm_analysis(ticker);
CREATE INDEX idx_analysis_expires ON llm_analysis(expires_at);

13c — Implement Anthropic prompt caching

// PromptCache.ts
async analyze(ticker: string, newsContext: string): Promise<Analysis> {
  const systemPrompt = buildSystemPrompt();  // ~5000 tokens, static
  const userPrompt = buildUserPrompt(ticker, newsContext);
  
  const response = await anthropic.messages.create({
    model: 'claude-opus-4-1',
    max_tokens: 1000,
    system: [
      {
        type: 'text',
        text: systemPrompt,
        cache_control: { type: 'ephemeral' },  // Cache this forever
      },
    ],
    messages: [
      {
        role: 'user',
        content: userPrompt,
      },
    ],
  });

  // Log cache metrics
  const { usage } = response;
  await analysisStore.insert({
    ticker,
    result: parseJSON(response.content[0].text),
    model: 'claude-opus-4-1',
    cache_hit: usage.cache_read_input_tokens > 0,
    tokens_used: usage.input_tokens + usage.output_tokens,
  });

  return result;
}

13d — LLM Router for cost optimization

// LLMRouter.ts
async analyze(ticker: string): Promise<Analysis> {
  const isCostSensitive = true;  // set based on usage/quota
  
  const model = isCostSensitive 
    ? 'claude-sonnet'      // cheaper, 90% quality
    : 'claude-opus';       // best quality
  
  try {
    return await llmAnalyst.analyze(ticker, model);
  } catch (error) {
    if (error.status === 429) {  // rate limited
      // Fallback to OpenAI GPT-4 Turbo
      return await openaiAnalyst.analyze(ticker);
    }
    throw error;
  }
}

Commit: feat: add Phase 13 — prompt caching & LLM optimization


Phase 14 — Day Trading: Safe Buys Monitor with Discord Alerts

Goal: Monitor safe-buy stocks in real-time, detect 5%+ dips, and notify via Discord.

Timeline: 3-4 weeks.

14a — Create trading domain

server/domains/trading/
  ├── TradingController.ts        (GET /api/trading/safe-buys)
  ├── DipDetector.ts              (5% threshold logic)
  ├── PriceMonitor.ts             (Alpaca/IB price polling)
  ├── DiscordNotifier.ts          (webhook to Discord)
  ├── persistence/
  │   ├── PriceSnapshotStore.ts   (price snapshots)
  │   └── TradeSignalStore.ts     (buy/sell signals)
  └── types/
      └── trading.model.ts        (Signal, Dip, Alert types)

14b — Database schema

CREATE TABLE price_snapshots (
  id TEXT PRIMARY KEY,
  ticker TEXT NOT NULL,
  price REAL NOT NULL,
  timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
  source TEXT,  -- 'alpaca', 'interactive_brokers', 'polygon'
  dip_detected BOOLEAN DEFAULT false
);

CREATE TABLE trading_signals (
  id TEXT PRIMARY KEY,
  ticker TEXT NOT NULL,
  signal_type TEXT CHECK (signal_type IN ('strong_buy', 'dip', 'warning')),
  entry_price REAL,
  detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  notified BOOLEAN DEFAULT false,
  outcome TEXT  -- 'win', 'loss', 'pending' (after 5 days)
);

CREATE INDEX idx_price_ticker_time ON price_snapshots(ticker, timestamp DESC);
CREATE INDEX idx_signal_notified ON trading_signals(notified, ticker);

14c — Real-time price polling

// PriceMonitor.ts (runs every 5 seconds)
async checkPrices() {
  const watchedTickers = await getWatchedTickers();  // from holdings
  
  for (const ticker of watchedTickers) {
    const currentPrice = await alpacaAdapter.getPrice(ticker);
    const previousPrice = await priceSnapshotStore.getLatest(ticker);
    
    const priceChange = ((currentPrice - previousPrice) / previousPrice) * 100;
    
    // Store snapshot
    await priceSnapshotStore.insert({ ticker, price: currentPrice });
    
    // Check for 5% dip
    if (priceChange <= -5) {
      await dipDetector.processDip({
        ticker,
        entry_price: previousPrice,
        current_price: currentPrice,
        pct_change: priceChange,
      });
    }
  }
}

14d — Discord notifications

// DiscordNotifier.ts
async notifyDip(alert: DipAlert) {
  const llmAnalysis = await llmAnalyst.analyze(alert.ticker);
  
  const embed = {
    title: `🔴 5% Dip Detected: ${alert.ticker}`,
    description: `Price fell from $${alert.entry_price} to $${alert.current_price} (${alert.pct_change.toFixed(2)}%)`,
    fields: [
      { name: 'LLM Analysis', value: llmAnalysis.sentiment },
      { name: 'Recommendation', value: llmAnalysis.signal },
      { name: 'Risks', value: llmAnalysis.risks.join(', ') },
    ],
    color: 0xff0000,  // red
  };
  
  await fetch(process.env.DISCORD_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ embeds: [embed] }),
  });
}

14e — UI: Safe Buys Monitor

Add to SvelteKit:

routes/
  └── trading/
      └── safe-buys/
          ├── +page.ts
          └── +page.svelte    (TickerWatchList, DipAlerts components)

Commit: feat: add Phase 14 — real-time safe buys monitor


Phase 15 — Day Trading: Trade Journal & Performance Tracking

Goal: Log every decision, track outcomes, and measure strategy performance over time.

Timeline: 1-2 weeks.

15a — Database schema

CREATE TABLE trade_journal (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  ticker TEXT NOT NULL,
  signal TEXT,  -- 'strong_buy', 'momentum', 'dip', etc
  entry_price REAL NOT NULL,
  entry_date DATETIME DEFAULT CURRENT_TIMESTAMP,
  exit_price REAL,
  exit_date DATETIME,
  outcome TEXT CHECK (outcome IN ('win', 'loss', 'pending')),
  pnl REAL,  -- profit/loss in dollars
  reason TEXT,  -- why you took the trade
  notes TEXT
);

CREATE INDEX idx_journal_user ON trade_journal(user_id, entry_date DESC);
CREATE INDEX idx_journal_outcome ON trade_journal(outcome);

15b — Trade stats dashboard

Compute daily aggregates:

// TradeJournal service
async getDailyStats(userId: string) {
  const trades = await db.query(`
    SELECT * FROM trade_journal 
    WHERE user_id = ? AND DATE(entry_date) = DATE('now')
  `, [userId]);
  
  return {
    total_trades: trades.length,
    winning_trades: trades.filter(t => t.outcome === 'win').length,
    losing_trades: trades.filter(t => t.outcome === 'loss').length,
    win_rate: ...,
    total_pnl: trades.reduce((sum, t) => sum + (t.pnl || 0), 0),
    avg_win: ...,
    avg_loss: ...,
  };
}

15c — UI: Trade Stats Dashboard

routes/
  └── trading/
      └── journal/
          ├── +page.ts
          └── +page.svelte    (TradeStats, TradeHistory components)

Commit: feat: add Phase 15 — trade journal & performance tracking


Phase 16 — Multi-LLM Support (Optional, Weeks 8-9)

Goal: Support Claude, OpenAI, and optionally Llama for cost optimization and experimentation.

Timeline: 2-3 weeks (do after Phase 14 core monitor works).

Minimal implementation:

// LLMRouter.ts
const MODELS = {
  'claude-opus': { cost: 0.015, speed: 'slow', quality: 'best' },
  'claude-sonnet': { cost: 0.003, speed: 'fast', quality: 'good' },
  'gpt-4': { cost: 0.03, speed: 'medium', quality: 'excellent' },
  'gpt-3.5-turbo': { cost: 0.002, speed: 'fast', quality: 'ok' },
};

async analyze(ticker: string, preferredModel?: string) {
  const model = preferredModel || 'claude-sonnet';  // default: cheap + good
  return await {
    'claude-opus': anthropicAnalyst,
    'claude-sonnet': anthropicAnalyst,
    'gpt-4': openaiAnalyst,
  }[model].analyze(ticker);
}

Commit: feat: add Phase 16 — multi-LLM routing


Final Architecture Summary

After Phases 11-16, your app:

Layer Tech Status
Auth JWT + RBAC Weeks 1-2
Data SQLite (→ Postgres if 1000+ users) Phase 11
News Polygon.io webhooks Phase 12
LLM Anthropic + OpenAI w/ prompt caching Phase 13-14
Trading Real-time price monitoring + Discord alerts Phase 14
Tracking Trade journal + performance stats Phase 15
UI Svelte 5 + Phase 10 structure Phase 10

Cost per month: ~$330-450 (Polygon + APIs + infrastructure)
Codebase size: ~3500 LOC server + 1500 LOC UI (clean, navigable)
UI latency: <100ms (async queue + caching)
Time to ship: 12-16 weeks solo, 8 weeks with 1-2 junior devs


Adding a New Asset Type

  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.tsgetInflatedOverrides().
  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 barrelserver/services/index.ts re-exports all services so controllers import from one path

Implementation Pattern

Controllers (Classes with DI)

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)

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)

export class StockScorer {
  static score(metrics: StockMetrics, rules: ScoringRules): ScoreResult { ... }
  private static _sanitize(m: StockMetrics): SanitizedMetrics { ... }
}

Import Guidelines

// ✅ 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/domains/shared/types/<domain>.model.ts
  2. Define schema → server/domains/shared/schemas.ts
  3. Create service → server/domains/<domain>/<Service>.ts
  4. Wire controller → server/domains/<domain>/<Domain>Controller.ts
  5. Register → server/app.ts

Day Trading: Cost Estimation & Production Readiness

Monthly Operating Costs (Steady State)

Service Cost Notes
Polygon.io (real-time news + quotes) $200 Entry tier, required for webhooks
Anthropic Claude API (w/ prompt caching) $50100 Most analyses cached; reduces cost 90%
OpenAI API (fallback, optional) $50 Only if you add GPT-4 as fallback
Alpaca/Interactive Brokers (real-time data) $30100 Depends on which feed you choose
BullMQ (Redis queue, if scaled) $030 Free if self-hosted; $30/mo if managed
Total ~$330450/month Scales well (no per-user seat cost)

Cost Optimization Strategies

  1. Prompt Caching (Phase 13) — Saves $50100/month by reusing cached system prompts
  2. Batch LLM Calls — Process 10 articles in 1 call instead of 10 calls
  3. Smart Polling — Check watched tickers every 5s, others every 60s
  4. Cache Price Data — Redis or in-memory cache for frequently accessed tickers

Production Checklist (Before Going Live)

  • Environment variables locked down (.env.production, no secrets in code)
  • Database: Migrate from SQLite to Postgres if expect >10 concurrent users
  • Job Queue: Set up BullMQ with Redis (or Bull's memory adapter for small scale)
  • Logging: Add structured logging (Winston, Pino) to track LLM calls + costs
  • Rate Limiting: Enabled on all public endpoints (@fastify/rate-limit)
  • Discord Webhook: Test alerts with real market data
  • Auth: JWT secret rotated, session timeout set to 1h
  • SSL/TLS: HTTPS enforced, domain SSL cert in place
  • Monitoring: Set up alerts for:
    • Job queue backlog (if >100 pending, page on-call)
    • API latency (target <100ms for UI, <5s for LLM)
    • Prompt cache hit rate (should be >80%)
    • Webhook failure rate (should be <0.1%)
    • Alpaca price feed staleness (should be <5s)

Postgres Migration Path (When Needed)

If you grow to 10+ active traders:

  1. Create Postgres RDS instance (AWS: ~$15/mo, db.t3.micro)
  2. Update connection string in .env to point to Postgres
  3. Run schema dump from SQLite → Postgres (pg_restore)
  4. Test thoroughly on staging first
  5. Blue-green deploy: run both DBs in parallel for 1 day, switch, keep SQLite as backup
  6. Update backup strategy: use pg_dump + S3, not JSON files

Time: 24 hours. No code changes needed (uses same better-sqlite3 interface wrapper).

Once you're live, track:

  • Daily active traders
  • Average win rate (target: >55% for trending tickers)
  • Prompt cache efficiency (% cache hits)
  • Webhook latency (p50, p95, p99)
  • LLM cost per analysis
  • System uptime (target: 99.5%)

Use Grafana + Prometheus, or simple JSON endpoint that logs to CloudWatch.


Frequently Asked Questions

Q: How many traders can this system handle?

A:

  • 1050 traders: Single instance (current setup). Costs ~$450/mo.
  • 50500 traders: Add Postgres + Redis queue. Costs ~$1000/mo.
  • 500+ traders: Add Kubernetes cluster + load balancing. Costs ~$5000+/mo.

For now, optimize for the first tier. Scaling is a good problem to have.

Q: What if Polygon.io goes down?

A: Have a fallback plan:

  1. Switch to Finnhub webhooks (similar API, different provider)
  2. Or fall back to polling (5s instead of real-time, less expensive)
  3. Add circuit breaker: if Polygon fails for >5 min, automatically switch to polling

Q: Can I trade with real money using this?

A: Yes, but:

  1. Start with paper trading (Alpaca's paper account, no real money)
  2. Test for 2+ weeks on real market conditions
  3. Once you hit 55%+ win rate on paper, go live with small position sizes
  4. Scale up gradually (1% of portfolio → 5% → 10%)
  5. Always have a manual kill-switch (can disable alerts + halt new trades)

Q: Should I use local LLM training?

A: Not yet. Only consider if:

  • You have 6+ months of clean trade data
  • Your LLM bill is >$1000/mo (suggests high volume)
  • You have $20K+ to spend on GPU infrastructure + ops

For now, optimize prompts instead. A good prompt beats a fine-tuned model.


Roadmap Summary

Phase Feature Effort When
9 Server refactor (domains) 3 weeks June 2026
10 UI refactor (components) 1 week June 2026
11 Auth & RBAC 2-3 weeks Early July 2026
12 News webhooks 2-3 weeks Mid July 2026
13 Prompt caching 2-3 weeks Late July (parallel)
14 Safe buys monitor 3-4 weeks August 2026
15 Trade journal 1-2 weeks Late August 2026
16 Multi-LLM router 2-3 weeks September 2026 (optional)
17 Local LLM training 6-8 weeks 2027+ (maybe)

Total time to "trading ready": 12-16 weeks solo, 8 weeks with 1 junior dev.
Go-live target: Q3 2026 (JulySeptember).
Current status: Phase 9-10 prep work (this month), then Phase 11 (auth) next month. You're at Q2 2026, so you have the full summer to ship.