diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9e1da92 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,68 @@ +{ + "env": { + "node": true, + "es2020": true + }, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript" + ], + "ignorePatterns": [ + "node_modules", + "dist", + "ui" + ], + "rules": { + "no-var": "error", + "prefer-const": "error", + "prefer-arrow-callback": "warn", + "no-console": [ + "warn", + { + "allow": [ + "warn", + "error" + ] + } + ], + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-explicit-any": "off", + "no-undef": "off", + "import/order": "off", + "import/no-unresolved": "off" + }, + "overrides": [ + { + "files": [ + "bin/**/*.ts", + "tests/**/*.ts" + ], + "rules": { + "no-console": "off" + } + }, + { + "files": [ + "server/types/**/*.ts", + "server/schemas/**/*.ts" + ], + "rules": { + "no-unused-vars": "off" + } + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index fd2d8df..630196b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,9 @@ npm run ui:install # install UI dependencies (ui/ sub ``` bin/ - screen.js ← CLI screener entry point - finance.js ← CLI personal finance entry point - import-portfolio.js ← broker CSV importer - server.js ← Fastify API server entry point + screen.ts ← CLI screener entry point + finance.ts ← CLI personal finance entry point + server.ts ← Fastify API server entry point (imports buildApp from server/app.ts) scripts/ summary-reporter.js ← custom node:test reporter (silent on pass, summary line at end) @@ -57,66 +56,95 @@ prompts/ catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow) server/ - config/ - ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth) - constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER + app.ts ← Fastify app factory (buildApp). Registers CORS + all controllers. + NOTE: lives at server/app.ts, NOT inside server/controllers/. - market/ ← Yahoo Finance data layer - YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff - BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext - MarketRegime.js ← derives INFLATED gate overrides from live benchmarks + rate regime + controllers/ ← HTTP only: parse request, call service, return response + screener.controller.ts ← POST /api/screen, GET /api/screen/catalysts + finance.controller.ts ← GET /api/finance/portfolio, POST|DELETE /api/finance/holdings + calls.controller.ts ← CRUD for market calls + GET /api/calls/calendar + analyze.controller.ts ← POST /api/analyze (LLM analysis for a ticker set) - screener/ ← core screening domain - ScreenerEngine.js ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data), + services/ ← business logic, no HTTP or I/O concerns + ScreenerEngine.ts ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data), screenWithProgress() (CLI with stdout). Accepts { logger } option. - DataMapper.js ← normalises Yahoo payload → flat asset data object - NOTE: uses trailingPE (not forwardPE). Preserves negative FCF. - Infers bond duration from category string. Maps ETF volume. - RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode) - Chunker.js ← splits ticker list into batches - assets/ - Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers - Stock.js ← metrics + _mapToStandardSector (8 sectors detected) - Etf.js ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets - Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric - scorers/ - StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf) - EtfScorer.js ← expense gate + registry (cost, yield, volume, fiveYearReturn) - BondScorer.js ← credit gate + spread/duration scoring + DataMapper.ts ← normalises Yahoo payload → flat asset data object. + Computes: DCF intrinsic value, analyst upside, 52W movement fields, + grossMargin, marketCap. Uses trailingPE. Preserves negative FCF. + RuleMerger.ts ← merges base rules + sector overrides + MarketRegime (INFLATED mode) + BenchmarkProvider.ts ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext. + In-memory cache: 1 hr TTL. Resets on server restart. + MarketRegime.ts ← derives INFLATED gate overrides from live benchmarks + rate regime + CatalystAnalyst.ts ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }. + LLMAnalyst.ts ← uses AnthropicClient to analyze headlines → summary, sentiment, + affectedIndustries, relatedTickers. Returns null if API key not set. + PersonalFinanceAnalyzer.ts ← net worth, cash vs investments, spending by category + PortfolioAdvisor.ts ← cross-references holdings with screener signals → hold/sell/add advice + index.ts ← barrel re-export (import services from here, not individual files) - analyst/ - CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }. - LLMAnalyst.js ← uses Claude Haiku (ANTHROPIC_API_KEY) to analyze headlines → summary, - sentiment (BULLISH/NEUTRAL/BEARISH), affectedIndustries, relatedTickers. - Returns null gracefully if API key is not set. Accepts { logger }. + repositories/ ← data persistence only (JSON file read/write) + MarketCallRepository.ts ← persists market thesis entries to market-calls.json. + CRUD: list/get/create/delete. + PortfolioRepository.ts ← read/write portfolio.json. Methods: read, upsert, remove. - calls/ - MarketCallStore.js ← persists quarterly market thesis entries to market-calls.json. - Each call stores: title, quarter, date, thesis, tickers[], snapshot{} - (price + signal per ticker at creation time). CRUD: list/get/create/delete. + clients/ ← external API connectors, one class per third-party system + YahooFinanceClient.ts ← wraps yahoo-finance2 v3, retry + backoff. Methods: fetchSummary, + fetchCalendarEvents, search. Typed via YahooFinanceLib interface. + SimpleFINClient.ts ← claims setup token → access URL, fetches /accounts via Basic Auth. + AnthropicClient.ts ← wraps Anthropic SDK. complete(system, user) → raw text response. - finance/ - clients/ - SimpleFINClient.js ← claims setup token → access URL, fetches /accounts via Basic Auth header - (NOT embedded credentials in URL). Accepts { logger, onAccessUrlClaimed }. - PersonalFinanceAnalyzer.js ← net worth, cash vs investments, spending by category - PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice + models/ ← domain entity classes with metrics + display logic + Asset.ts ← abstract base: ticker, currentPrice, type, formatting helpers + Stock.ts ← metrics + _mapToStandardSector (8 sectors) + _classifyMarketCap + (Mega/Large/Mid/Small/Micro) + _classifyGrowth (style classification). + Holds: valuation, quality, risk, 52W movement, analyst consensus, DCF. + Etf.ts ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets + Bond.ts ← metrics: ytm, duration, creditRating, creditRatingNumeric - reporters/ - HtmlReporter.js ← render() → HTML string (server), generate() → writes file (CLI) - FinanceReporter.js ← render() → HTML string (server), generate() → writes file (CLI) + scorers/ ← stateless pure scoring functions, no I/O + StockScorer.ts ← gate checks + weighted registry: + core: ROE, opMargin, margin, peg, revenue, fcf + expert: analyst consensus (inverted Yahoo 1–5 scale), DCF margin of safety + riskFlags: beta, 52W position, 52W momentum, analyst divergence, DCF divergence + EtfScorer.ts ← expense gate + registry (cost, yield, volume, fiveYearReturn) + BondScorer.ts ← credit gate + spread/duration scoring - server/ - app.js ← Fastify app factory (buildApp). Registers CORS + routes. - routes/ - screener.js ← POST /api/screen, GET /api/screen/catalysts - Serializes asset.getDisplayMetrics() before JSON response. - finance.js ← GET /api/finance/portfolio, GET /api/finance/market-context - calls.js ← CRUD for market calls + GET /api/calls/calendar (earnings/dividend events) + reporters/ ← HTML rendering, no business logic + HtmlReporter.ts ← render() → HTML string (server), generate() → writes file (CLI) + FinanceReporter.ts ← render() → HTML string (server), generate() → writes file (CLI) + + config/ + ScoringConfig.ts ← CREDIT_RATING_SCALE + ScoringRules (single source of truth for all + gates, weights, thresholds including analyst and dcf weights) + constants.ts ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER, + CAP_CATEGORY (Mega/Large/Mid/Small/Micro), + GROWTH_CATEGORY (High Growth/Growth/Stable/Value/Turnaround/Declining) + + types/ ← all domain types, one file per domain + asset.model.ts ← Signal, AssetType, ScoreMode, ScoringRules, ScoreResult, AssetResult, + ScreenerResult, GateSet, WeightSet, ThresholdSet, RuleBlock, StockRules + market.model.ts ← RateRegime, VolatilityRegime, Benchmarks, MarketContext + portfolio.model.ts ← HoldingType, PortfolioHolding, PortfolioAdvice, AdviceRow + calls.model.ts ← TickerSnapshot, MarketCall, SnapshotEntry, CreateCallInput + finance.model.ts ← LLMAnalysis, CatalystStory, CalendarEvent, SimpleFINAccount, + SimpleFINTransaction, SimpleFINData, YahooNewsItem, YahooFinanceLib + logger.model.ts ← Logger + models.model.ts ← AssetData, StockData, StockMetrics, EtfData, EtfMetrics, BondData, BondMetrics + repositories.model.ts ← StoreData, PortfolioData + scorers.model.ts ← NumVal, SanitizedMetrics, SanitizedBondMetrics + services.model.ts ← BenchmarkProviderOptions, InflatedOverrides, ErrorResult, RuleSet, + ScreenerEngineOptions, CatalystResult, MappedData, and other service shapes + schemas.ts ← Fastify JSON Schema objects for request body validation + index.ts ← re-exports all of the above (use this barrel for imports) + types.ts ← thin barrel: export type * from './types/index.js' + + utils/ + Chunker.ts ← splits ticker list into batches + logger.ts ← noopLogger constant for silencing output in server context ui/ ← SvelteKit dashboard (lives inside this repo, not a separate repo) src/ - styles/ ← global SCSS design-token system (Phase 4) + styles/ ← global SCSS design-token system app.scss ← root file — @use all partials _tokens.scss ← CSS custom properties generated from SCSS maps ($bg, $text, $blues, $signals…) _reset.scss ← box-sizing reset + body base @@ -126,24 +154,30 @@ ui/ ← SvelteKit dashboard (lives inside this repo, not a _buttons.scss ← button base, .btn-primary, .btn-catalyst, .btn-ghost, .btn-screen, .btn-analyze _badges.scss ← .verdict-pill, .sentiment-pill, .text-* helpers (SCSS @each maps) lib/ - api.js ← typed fetch wrappers for all API routes + api.ts ← backward-compat shim; re-exports from api/index.ts + api/ + screener.ts ← screenTickers, fetchCatalysts, analyzeTickers + finance.ts ← fetchPortfolio, addHolding, removeHolding, fetchMarketContext + calls.ts ← fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar + index.ts ← barrel re-export + types.ts ← re-exports shared types from $types (server/types/); UI-only types defined here utils.ts ← shared pure functions: sigOrd, sorted, verdictShort, vClass, fmtPE, fmt… MarketContext.svelte ← collapsible card-grid context (used in portfolio + safe-buys) - MarketContextStrip.svelte ← horizontal chip strip (used in screener — Phase 5) - AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table (Phase 5) - AnalysisSidebar.svelte ← LLM analysis slide-over panel (Phase 5) - VerdictPill.svelte ← verdict-pill span; props: label (Phase 5) + MarketContextStrip.svelte ← horizontal chip strip (used in screener) + AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table + AnalysisSidebar.svelte ← LLM analysis slide-over panel + VerdictPill.svelte ← verdict-pill span; props: label SignalBadge.svelte ← signal emoji + label badge Spinner.svelte ← sm: dot-pulse | md/lg: chart-line animation routes/ - +page.js ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount (Phase 5) - +page.svelte ← main screener UI (~230 lines after Phase 5 decomposition) + +page.ts ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount + +page.svelte ← main screener UI +layout.svelte ← shell, nav, nav-progress bar, nav-overlay with Spinner calls/ ← market calls list + detail views portfolio/ ← portfolio advice view safe-buys/ ← filtered strong-buy view -market-calls.json ← persisted market thesis calls (written by MarketCallStore) +market-calls.json ← persisted market thesis calls (written by MarketCallRepository) portfolio.json ← user's holdings: ticker, shares, costBasis, source, type .env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY ``` @@ -161,8 +195,11 @@ BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD ↓ DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND) uses trailingPE as primary; preserves negative FCF yield; infers bond duration + computes: DCF intrinsic value, analyst upside, 52W movement, grossMargin, marketCap ↓ Asset subclass — Stock / Etf / Bond holds metrics + getDisplayMetrics() + Stock also derives capCategory (Mega/Large/Mid/Small/Micro) + and growthCategory (High Growth/Growth/Stable/Value/Turnaround/Declining) ↓ RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style) INFLATED mode: sector override + MarketRegime live gate overrides @@ -182,7 +219,7 @@ ScreenerEngine — derives Signal from comparing both verdicts | Method | Path | Description | |---|---|---| | GET | `/health` | Health check | -| POST | `/api/screen` | Screen tickers. Body: `{ tickers: string[] }`. Returns `{ STOCK, ETF, BOND, ERROR, marketContext }` with `asset.displayMetrics` pre-serialized | +| POST | `/api/screen` | Screen tickers. Body: `{ tickers: string[] }` (max 50). Returns `{ STOCK, ETF, BOND, ERROR, marketContext }` with `asset.displayMetrics` pre-serialized | | GET | `/api/screen/catalysts` | Yahoo news → `{ tickers, stories }` | | GET | `/api/finance/portfolio` | Portfolio advice + optional SimpleFIN data | | GET | `/api/finance/market-context` | Live benchmark data only | @@ -195,6 +232,8 @@ ScreenerEngine — derives Signal from comparing both verdicts CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`). +Request body validation uses Fastify JSON Schema, defined in `server/types/schemas.ts`. + --- ## Scoring Modes @@ -221,7 +260,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`) ## ScoringConfig Key Values -`server/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds. +`server/config/ScoringConfig.ts` — single source of truth for all gates, weights, thresholds. **STOCK base gates (Fundamental mode):** - `maxPERatio: 15` — Graham's actual rule (trailing P/E) @@ -229,6 +268,12 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`) - `maxDebtToEquity: 1.5` — most distress starts above 2x - `minQuickRatio: 0.8` — below this signals real liquidity stress +**STOCK base weights:** +- `roe: 3`, `fcf: 3` — primary quality signals +- `opMargin: 2`, `margin: 2`, `peg: 2`, `revenue: 2` — secondary factors +- `analyst: 2` — Wall Street consensus (Yahoo 1–5 scale inverted; requires ≥3 analysts) +- `dcf: 2` — DCF margin of safety (positive = undervalued; only fires when FCF > 0) + **Sector overrides** (structural — apply in both modes): | Sector | Key difference | @@ -256,7 +301,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`) ## MarketRegime (INFLATED overrides) -`server/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime: +`server/services/MarketRegime.ts` derives gate overrides from live benchmarks and current rate regime: | Gate | Formula (NORMAL rates) | Formula (HIGH rates) | |---|---|---| @@ -267,6 +312,76 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`) | Bond minSpread | LQD−TNX × 0.80 | LQD−TNX × 0.90 | | ETF maxExpenseRatio | 0.75% | 0.75% | +Rate regime thresholds: `< 2%` = LOW, `2–5%` = 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; 0–20% → +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 5–15% | +| Value | revenueGrowth < 5% AND dividendYield ≥ 3% | +| Stable | Low growth, modest or no dividend | +| Turnaround | earningsGrowth < 0% AND revenueGrowth ≥ 0% | +| Declining | revenueGrowth < -5% | + +Both `Cap Tier` and `Style` appear in `getDisplayMetrics()` and the screener table. + --- ## Sector Detection @@ -292,8 +407,14 @@ GENERAL → fallback - **peRatio**: prefers `trailingPE` (audited) over `forwardPE` (analyst estimate, ~10-15% optimistic) - **FCF yield**: `freeCashflow !== 0` (not `> 0`) — negative FCF preserved so cash-burning companies fail the gate, not silently skip it -- **Bond duration**: inferred from category string ("Short-Term" → 2y, "Intermediate" → 5y, "Long" → 18y, default 6y). Yahoo does not expose effective duration in the modules we fetch. -- **ETF volume**: `summaryDetail.averageVolume` — was missing before, causing the `-2` liquidity penalty on every ETF +- **grossMargin**: `financialData.grossMargins * 100` — exposed in display; not yet a scoring factor +- **marketCap**: `price.marketCap` — used for cap tier classification +- **analystRating**: `financialData.recommendationMean` (1=Strong Buy, 5=Strong Sell). `targetMeanPrice` and `numberOfAnalystOpinions` also extracted +- **52W movement**: `defaultKeyStatistics['52WeekChange']` for annual return; `From High`/`From Low` computed from `fiftyTwoWeekHigh`/`fiftyTwoWeekLow` +- **DCF growth rate**: uses `earningsGrowth` (TTM decimal) first, falls back to `revenueGrowth * 0.7`. Capped at 30%, floored at -5% +- **Bond duration**: inferred from category string ("Short-Term" → 2y, "Intermediate" → 5y, "Long" → 18y, default 6y). Yahoo does not expose effective duration in the modules we fetch +- **D/E ratio**: Yahoo returns `financialData.debtToEquity` as a percentage (e.g. 175.56 for 1.7556×); dividing by 100 normalises to the standard ratio +- **Quick ratio**: falls back to `currentRatio` when missing — known methodological issue; current ratio includes inventory and can overstate liquidity for retailers/manufacturers --- @@ -301,31 +422,31 @@ GENERAL → fallback - Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` rather than auto-failing. - `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it. -- `quickRatio` falls back to `currentRatio` when missing. +- DCF, analyst, and 52W scoring factors are all optional — they activate only when the underlying data is non-null. --- ## Logger Injection Pattern -Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context: +Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context. Pass `noopLogger` from `server/utils/logger.ts` to silence all output. -```js +Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`. + +```ts // CLI (default) — writes to stdout new ScreenerEngine() // Server — fully silent -new ScreenerEngine({ logger: { write: () => {}, log: () => {}, warn: () => {} } }) +new ScreenerEngine({ logger: noopLogger }) ``` -Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`. - --- ## Reporter Pattern Both reporters have two methods: -```js +```ts reporter.render(...) // → HTML string (use in server route responses) reporter.generate(...) // → writes file to disk, returns path (use in CLI) ``` @@ -371,7 +492,7 @@ tests/ BondScorer.test.js ← credit gate, spread/duration scoring, unit handling DataMapper.test.js ← type detection, PEG computation, trailing PE preference, negative FCF, ETF volume, bond duration inference - PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping, BRK.B dot-notation normalisation + PortfolioAdvisor.test.js ← position gain/loss calc, advice signal mapping, BRK.B normalisation LLMAnalyst.test.js ← markdown fence stripping, JSON parse correctness ``` @@ -381,121 +502,272 @@ Test output: silent on pass, shows only failures + one summary line (`scripts/su **Key unit:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation. **Coverage gaps (known):** -- `MarketCallStore.js` — no tests; CRUD against `market-calls.json` is untested +- `MarketCallRepository.ts` — no tests; CRUD against `market-calls.json` is untested - `LLMAnalyst.test.js` — tests a local copy of the fence-stripping regex rather than importing from source; will silently drift if the regex changes -- API routes (`server/server/routes/`) — no integration tests; covered implicitly by manual testing only -- UI components — not tested at the unit level (Phases 4–5 are pure UI; no server logic changed) +- API controllers (`server/controllers/`) — no integration tests; covered implicitly by manual testing only +- Expert scoring features (analyst, DCF, 52W) — not yet covered in `StockScorer.test.js` +- UI components — not tested at the unit level --- -## Conventions +## Architecture Guide -- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules. +This section is the single reference for where code lives and how to add features. Read this before touching any file. + +### Server layer map + +| Folder | Role | Rule | +|---|---|---| +| `server/app.ts` | Fastify bootstrap — wires DI, registers controllers | No business logic | +| `server/controllers/` | HTTP only — parse request, call service, return response | No business logic, no file I/O, no external API calls | +| `server/services/` | Business logic and orchestration | No HTTP concerns (`req`/`reply`), no direct file I/O | +| `server/repositories/` | Data persistence — JSON file read/write | No business logic; one class per data file | +| `server/clients/` | External API connectors — one class per third-party system | No business logic; only I/O and protocol handling | +| `server/models/` | Domain entity classes — hold metrics and `getDisplayMetrics()` | No I/O; pure data + formatting | +| `server/scorers/` | Stateless pure scoring functions | No I/O, no state; `score(metrics, rules, marketContext)` only | +| `server/reporters/` | HTML rendering | No business logic; `render()` → string, `generate()` → file | +| `server/config/` | Constants and scoring gates/weights | No logic; change numbers here, not in scorers | +| `server/types/` | TypeScript interfaces and types | No logic; one `*.model.ts` per domain | +| `server/utils/` | Shared pure utilities | No domain knowledge | +| `bin/` | CLI entry points | Call into services only; the only place `process.exit()` is allowed | + +### UI layer map + +| Path | Role | +|---|---| +| `ui/src/routes/*/+page.ts` | SvelteKit load functions — fetch data for the page | +| `ui/src/routes/*/+page.svelte` | Route component — composition only; minimal logic | +| `ui/src/lib/api/screener.ts` | Fetch wrappers for `/api/screen*` | +| `ui/src/lib/api/finance.ts` | Fetch wrappers for `/api/finance/*` | +| `ui/src/lib/api/calls.ts` | Fetch wrappers for `/api/calls/*` | +| `ui/src/lib/stores/` | Reactive state shared across components (Svelte 5 runes) | +| `ui/src/lib/components/` | Generic shared components (VerdictPill, SignalBadge, Spinner) | +| `ui/src/lib/portfolio/` | Portfolio-specific components | +| `ui/src/lib/calls/` | Calls-specific components | +| `ui/src/lib/types.ts` | Re-exports server types via `$types` alias; UI-only types defined here | +| `ui/src/lib/utils.ts` | Pure formatting and sorting helpers | +| `ui/src/styles/` | SCSS partials — global design tokens, layout, tables, forms | + +### Where to put a new type + +- Shared domain type (used by server + UI): `server/types/.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/.controller.ts` + register in `server/app.ts` | +| Business logic for that endpoint | New method in `server/services/.ts` | +| Call to a new external API | New class in `server/clients/Client.ts` | +| New data stored in a JSON file | New class in `server/repositories/Repository.ts` | +| New scoring rule or gate value | `server/config/ScoringConfig.ts` | +| New market regime override | `server/services/MarketRegime.ts` → `getInflatedOverrides()` | +| New stock metric (mapped from Yahoo) | `server/services/DataMapper.ts` → `mapStockData()` + `StockData`/`StockMetrics` interfaces in `server/types/models.model.ts` + `server/models/Stock.ts` constructor + `getDisplayMetrics()` | +| New scoring factor | `server/config/ScoringConfig.ts` (add weight + threshold) + `server/scorers/StockScorer.ts` (add factor to array) | +| New UI page | `ui/src/routes//+page.ts` + `+page.svelte` | +| New UI fetch call | `ui/src/lib/api/.ts` + re-export from `api/index.ts` | +| Reactive state shared by >1 component | `ui/src/lib/stores/.store.ts` | +| New shared UI component | `ui/src/lib/components/` (generic) or domain subfolder | +| New global style | `ui/src/styles/_.scss` + `@use` in `app.scss` | + +### Conventions + +- Asset `type` (uppercased: `STOCK` / `ETF` / `BOND`) is the routing key across `DataMapper`, model classes, `SCORERS` map, and `ScoringRules`. Keep it consistent everywhere. - Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers. -- BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch. -- All entry points live in `bin/`. Do not add logic to entry points — they call into `server/`. -- `bin/server.js` starts Fastify; `server/server/` contains all route logic. +- `BenchmarkProvider` caches for 1 hour in memory — cache is lost on server restart. A persistent cache is planned (see Phase 8). +- All entry points live in `bin/`. Do not add logic there — they call into `services/` and controllers. - **Never** call `process.exit()` inside `server/` — only `bin/` may do that. -- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `server/server/routes/screener.js` `serializeAssets()`). +- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `serializeAssets()` in `screener.controller.ts`). +- Controllers use constructor injection — dependencies are wired in `server/app.ts`, not created inside handlers. +- The `$types` alias in the UI resolves to `server/types/` — use it instead of duplicating type definitions. +- Ticker normalisation (`BRK.B → BRK-B`) currently only happens in `FinanceController.normalizeYahoo()`. Submitting `BRK.B` directly to `/api/screen` will fail. Fix target: move normalisation into `YahooFinanceClient.fetchSummary()`. + +### Adding a new scoring metric — step-by-step + +When adding a new data point that flows from Yahoo → scoring: + +1. Extract field in `DataMapper.mapStockData()` from the Yahoo payload +2. Add field to `StockData` and `StockMetrics` interfaces in `server/types/models.model.ts` +3. Add field to `Stock` constructor assignment and `getDisplayMetrics()` in `server/models/Stock.ts` +4. Add weight + threshold to `ScoringConfig.ts` base weights/thresholds (and any relevant sector overrides) +5. Add the field to `SanitizedMetrics` and `_sanitize()` in `StockScorer.ts` +6. Add a factor entry to the `factors` array in `StockScorer.score()` +7. Add a test case to `StockScorer.test.js` + +**Warning**: The `scorer.score(asset.metrics as never, ...)` cast in `ScreenerEngine._process()` bypasses TypeScript for the scorer dispatch. If you add a field to `StockMetrics` but forget to add it to `SanitizedMetrics`, the compiler will not catch it — the scorer will silently receive `undefined`. Always update `_sanitize()` when adding metrics. --- ## Architecture Roadmap -Planned improvements in priority order. Do not start a later phase before completing earlier ones. +### Phases 1–7 ✅ COMPLETE -### Phase 1 — Cleanup ✅ COMPLETE -All items completed. Additional features delivered alongside cleanup: +All phases of the original roadmap are done. Summary of what was delivered: +- Phase 1–4: 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 7a–7b: Type domain split, API module split +- Phase 7f: Server layer restructured to layered architecture (controllers / services / repositories / clients / models / scorers) +- Phase 7g: Controllers converted to classes with DI, types moved to domain files, `YahooFinanceClient` properly typed -**Cleanup done:** -- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md` -- Deleted `server/server/routes/analyze.js` (orphaned route file) -- Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte` -- Fixed `.gitignore` — `portfolio.json`, `market-calls.json`, `.env` are now excluded from git +**Pending UI work (Phase 7c–7e):** +- 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 ` diff --git a/ui/src/lib/AssetTable.svelte b/ui/src/lib/AssetTable.svelte index 27b4518..fc05cb3 100644 --- a/ui/src/lib/AssetTable.svelte +++ b/ui/src/lib/AssetTable.svelte @@ -11,14 +11,22 @@ analyzeLoading = false, onAnalyze, }: { - type: AssetType; - rows: AssetResult[]; + type: AssetType; + rows: AssetResult[]; analyzeLoading?: boolean; - onAnalyze: () => void; + onAnalyze: () => void; } = $props(); // Mode state is self-contained — each table independently tracks inflated vs fundamental let mode = $state('inflated'); + + // Colour class for signed % values (52W Chg, From High, Upside, DCF Safety) + function signClass(val: string | number | null | undefined): string { + if (val == null) return ''; + const n = typeof val === 'number' ? val : parseFloat(String(val)); + if (isNaN(n)) return ''; + return n > 0 ? 'pos' : n < 0 ? 'neg' : ''; + }
@@ -54,9 +62,27 @@ Verdict Score {#if type === 'STOCK'} - Sector - P/EPEGROE% - OpMgn%FCF%D/E + + Cap + Style + + P/E + PEG + + GrossM% + ROE% + OpMgn% + FCF% + + D/E + + 52W Chg + From High + + Analyst + Upside + DCF Safety + Flags {:else if type === 'ETF'} ExpenseYieldAUM5Y Ret @@ -68,20 +94,34 @@ {#each sorted(rows) as r} {@const m = r.asset.displayMetrics ?? {}} - {@const v = r[mode]} + {@const v = r[mode as 'inflated' | 'fundamental']} {r.asset.ticker} {m.Price ?? '—'} {v.scoreSummary} {#if type === 'STOCK'} - {m.Sector ?? '—'} + + {m['Cap Tier'] ?? '—'} + {m['Style'] ?? '—'} + {m['P/E'] ?? '—'} {m['PEG'] ?? '—'} + + {m['GrossM%'] ?? '—'} {m['ROE%'] ?? '—'} {m['OpMgn%'] ?? '—'} {m['FCF Yld%'] ?? '—'} + {m['D/E'] ?? '—'} + + {m['52W Chg'] ?? '—'} + {m['From High'] ?? '—'} + + {m['Analyst'] ?? '—'} + {m['Upside'] ?? '—'} + {m['DCF Safety'] ?? '—'} + {#each v.audit?.riskFlags ?? [] as flag} ⚠ {flag} @@ -105,15 +145,32 @@
diff --git a/ui/src/lib/MarketContextStrip.svelte b/ui/src/lib/MarketContextStrip.svelte index ad238fd..224f110 100644 --- a/ui/src/lib/MarketContextStrip.svelte +++ b/ui/src/lib/MarketContextStrip.svelte @@ -8,8 +8,8 @@ { label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' }, { label: 'VIX', value: ctx.vixLevel?.toFixed(1) }, { label: 'S&P', value: ctx.sp500Price?.toLocaleString() }, - { label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE?.toFixed(1)) }, - { label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE?.toFixed(1)) }, + { label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE) }, + { label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE) }, { label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' }, { label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' }, { label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime }, @@ -36,7 +36,6 @@ border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; - margin-bottom: 20px; } .ctx-chip { diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index d79da52..e5b77d8 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -1,127 +1,6 @@ -import type { - ScreenerResult, - MarketContext, - MarketCall, - CalendarEvent, - CatalystStory, - LLMAnalysis, - PortfolioHolding, - PortfolioAdvice, -} from '$lib/types.js'; - -const BASE = '/api'; - -// ── Screener ────────────────────────────────────────────────────────────────── - -export async function screenTickers(tickers: string[]): Promise { - const res = await fetch(`${BASE}/screen`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tickers }), - }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: CatalystStory[] }> { - const res = await fetch(`${BASE}/screen/catalysts`); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function analyzeTickers(tickers: string[]): Promise<{ analysis: LLMAnalysis | null }> { - const res = await fetch(`${BASE}/analyze`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tickers }), - }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -// ── Finance / Portfolio ─────────────────────────────────────────────────────── - -export async function fetchPortfolio(): Promise<{ - advice: PortfolioAdvice[]; - holdings: PortfolioHolding[]; - marketContext: MarketContext | null; - netWorth: number | null; - error?: string; -}> { - const res = await fetch(`${BASE}/finance/portfolio`); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function addHolding( - holding: Omit, -): Promise<{ holdings: PortfolioHolding[] }> { - const res = await fetch(`${BASE}/finance/holdings`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(holding), - }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> { - const res = await fetch(`${BASE}/finance/holdings/${ticker}`, { - method: 'DELETE', - }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function fetchMarketContext(): Promise { - const res = await fetch(`${BASE}/finance/market-context`); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -// ── Market Calls ────────────────────────────────────────────────────────────── - -export async function fetchCalls(): Promise<{ calls: MarketCall[] }> { - const res = await fetch(`${BASE}/calls`); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function fetchCall(id: string): Promise { - const res = await fetch(`${BASE}/calls/${id}`); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function createCall(payload: { - title: string; - quarter: string; - thesis: string; - tickers: string[]; - date?: string; -}): Promise { - const res = await fetch(`${BASE}/calls`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function deleteCall(id: string): Promise<{ ok: boolean }> { - const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} - -export async function fetchCallsCalendar( - tickers: string[] | null = null, -): Promise<{ events: CalendarEvent[] }> { - const url = tickers?.length - ? `${BASE}/calls/calendar?tickers=${tickers.join(',')}` - : `${BASE}/calls/calendar`; - const res = await fetch(url); - if (!res.ok) throw new Error(await res.text()); - return res.json(); -} +// ── Backward-compat shim ────────────────────────────────────────────────── +// All API functions now live in $lib/api/*.ts domain modules. +// This file re-exports everything so existing import sites are unaffected. +// New code should import directly from the domain module: +// import { screenTickers } from '$lib/api/screener.js' +export * from './api/index.js'; diff --git a/ui/src/lib/api/calls.ts b/ui/src/lib/api/calls.ts new file mode 100644 index 0000000..7774dec --- /dev/null +++ b/ui/src/lib/api/calls.ts @@ -0,0 +1,48 @@ +import type { MarketCall, CalendarEvent, ScreenerResult } from '$lib/types.js'; + +const BASE = '/api'; + +export async function fetchCalls(): Promise<{ calls: MarketCall[] }> { + const res = await fetch(`${BASE}/calls`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchCall(id: string): Promise { + const res = await fetch(`${BASE}/calls/${id}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function createCall(payload: { + title: string; + quarter: string; + thesis: string; + tickers: string[]; + date?: string; +}): Promise { + const res = await fetch(`${BASE}/calls`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deleteCall(id: string): Promise<{ ok: boolean }> { + const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchCallsCalendar( + tickers: string[] | null = null, +): Promise<{ events: CalendarEvent[] }> { + const url = tickers?.length + ? `${BASE}/calls/calendar?tickers=${tickers.join(',')}` + : `${BASE}/calls/calendar`; + const res = await fetch(url); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} diff --git a/ui/src/lib/api/finance.ts b/ui/src/lib/api/finance.ts new file mode 100644 index 0000000..aacb89c --- /dev/null +++ b/ui/src/lib/api/finance.ts @@ -0,0 +1,41 @@ +import type { MarketContext, PortfolioHolding, PortfolioAdvice } from '$lib/types.js'; + +const BASE = '/api'; + +export async function fetchPortfolio(): Promise<{ + advice: PortfolioAdvice[]; + holdings: PortfolioHolding[]; + marketContext: MarketContext | null; + netWorth: number | null; + error?: string; +}> { + const res = await fetch(`${BASE}/finance/portfolio`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function addHolding( + holding: PortfolioHolding, +): Promise<{ holdings: PortfolioHolding[] }> { + const res = await fetch(`${BASE}/finance/holdings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(holding), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function removeHolding(ticker: string): Promise<{ holdings: PortfolioHolding[] }> { + const res = await fetch(`${BASE}/finance/holdings/${ticker}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchMarketContext(): Promise { + const res = await fetch(`${BASE}/finance/market-context`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} diff --git a/ui/src/lib/api/index.ts b/ui/src/lib/api/index.ts new file mode 100644 index 0000000..2300a71 --- /dev/null +++ b/ui/src/lib/api/index.ts @@ -0,0 +1,7 @@ +// ── API module barrel ───────────────────────────────────────────────────── +// Drop-in replacement for the old $lib/api.ts flat file. +// Existing imports from '$lib/api.js' continue to work via api.ts re-export. + +export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js'; +export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js'; +export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js'; diff --git a/ui/src/lib/api/screener.ts b/ui/src/lib/api/screener.ts new file mode 100644 index 0000000..224d503 --- /dev/null +++ b/ui/src/lib/api/screener.ts @@ -0,0 +1,32 @@ +import type { ScreenerResult } from '$lib/types.js'; +import type { LLMAnalysis, CatalystStory } from '$lib/types.js'; + +const BASE = '/api'; + +export async function screenTickers(tickers: string[]): Promise { + const res = await fetch(`${BASE}/screen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tickers }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: CatalystStory[] }> { + const res = await fetch(`${BASE}/screen/catalysts`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function analyzeTickers( + tickers: string[], +): Promise<{ analysis: LLMAnalysis | null; reason?: string | null }> { + const res = await fetch(`${BASE}/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tickers }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} diff --git a/ui/src/lib/calls/CalendarSection.svelte b/ui/src/lib/calls/CalendarSection.svelte new file mode 100644 index 0000000..770ebb1 --- /dev/null +++ b/ui/src/lib/calls/CalendarSection.svelte @@ -0,0 +1,60 @@ + + +{#if events.length > 0} +
+
+

📅 Upcoming Events

+ {upcoming.length} upcoming + {#if past.length > 0} + {past.length} recent + {/if} +
+
+ {#each upcoming as ev} +
+
{ev.date}
+
+ {ev.ticker} + + {eventIcon(ev.type)} {ev.label} + {#if ev.detail}· {ev.detail}{/if} + + {#if ev.epsEstimate != null} + EPS est. ${ev.epsEstimate.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)} + {/if} +
+
+ {/each} + + {#if past.length > 0} +
— Past —
+ {#each past as ev} +
+
{ev.date}
+
+ {ev.ticker} + {eventIcon(ev.type)} {ev.label} +
+
+ {/each} + {/if} +
+
+{/if} + diff --git a/ui/src/lib/calls/CallCard.svelte b/ui/src/lib/calls/CallCard.svelte new file mode 100644 index 0000000..db37332 --- /dev/null +++ b/ui/src/lib/calls/CallCard.svelte @@ -0,0 +1,69 @@ + + +
+
+
+ {call.title} +
+ {call.quarter} + {call.date} + {call.tickers.length} tickers +
+
+ +
+ +
+

{call.thesis}

+ + {#if Object.keys(call.snapshot ?? {}).length} +
+ {#each call.tickers as ticker} + {@const snap = call.snapshot[ticker]} + {#if snap} + +
{ticker}
+
${snap.price?.toFixed(2) ?? '—'}
+
+ {snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'} +
+
+ {/if} + {/each} +
+ View performance → + {/if} +
+
+ diff --git a/ui/src/lib/calls/CallForm.svelte b/ui/src/lib/calls/CallForm.svelte new file mode 100644 index 0000000..cd4bf32 --- /dev/null +++ b/ui/src/lib/calls/CallForm.svelte @@ -0,0 +1,86 @@ + + +
+

New Market Call

+
+
+ + + +
+ + + {#if error} +
⚠ {error}
+ {/if} +
+ + +
+
+
+ diff --git a/ui/src/lib/portfolio/AccountsTable.svelte b/ui/src/lib/portfolio/AccountsTable.svelte new file mode 100644 index 0000000..f3dce0b --- /dev/null +++ b/ui/src/lib/portfolio/AccountsTable.svelte @@ -0,0 +1,66 @@ + + +
+
Net Worth
+
{fmtShort(pf.netWorth)}
+
Total Assets
+
{fmtShort(pf.totalAssets)}
+
Liabilities
+
{fmtShort(pf.totalLiabilities)}
+
Cash ({pf.cashPct}%)
+
{fmtShort(pf.totalCash)}
+
Investments ({pf.investPct}%)
+
{fmtShort(pf.totalInvestments)}
+ {#if pf.savingsRate != null} +
Savings Rate
+
{pf.savingsRate}%
+ {/if} +
Monthly Income
+
{fmtShort(pf.totalIncome)}
+
Monthly Spend
+
{fmtShort(pf.totalSpend)}
+
+ +
+
+

Accounts

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

Spending — Last 30 Days

+ + + + {#each pf.categoryBreakdown.slice(0, 10) as c} + + + + + + + {/each} + +
CategoryAmount%Share
{c.category}{fmt(c.amount)}{c.pct}% +
+
+
+
+ diff --git a/ui/src/lib/portfolio/AddHoldingForm.svelte b/ui/src/lib/portfolio/AddHoldingForm.svelte new file mode 100644 index 0000000..1c7d1e2 --- /dev/null +++ b/ui/src/lib/portfolio/AddHoldingForm.svelte @@ -0,0 +1,71 @@ + + +
+
Add Holding
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ {#if error} +
⚠ {error}
+ {/if} +
+ diff --git a/ui/src/lib/portfolio/AdviceTable.svelte b/ui/src/lib/portfolio/AdviceTable.svelte new file mode 100644 index 0000000..7fb9a99 --- /dev/null +++ b/ui/src/lib/portfolio/AdviceTable.svelte @@ -0,0 +1,186 @@ + + + +
+
+
+ Total Value + + ? + Current market value of all holdings. Shares × live price from Yahoo Finance. + +
+
{fmtShort(totalValue)}
+
+
+
+ Total Cost + + ? + Total amount invested — sum of cost basis × shares across all positions. + +
+
{fmtShort(totalCost)}
+
+
+
+ Total G/L + + ? + Total unrealised gain or loss — Total Value minus Total Cost. + +
+
{fmtShort(totalGL)}
+
+
+ + +
+

Holdings — Hold / Sell / Add Advice

+ + + + + + + + + + + + + + + + {#each sorted as a} + {@const isEditing = editing?.ticker === a.ticker} + + + + + + + + + + + + + + {/each} + +
toggleSort('ticker')}>Ticker {sortIcon('ticker')} toggleSort('type')}>Type {sortIcon('type')} toggleSort('shares')}>Shares {sortIcon('shares')} toggleSort('cost')}>Cost {sortIcon('cost')} toggleSort('current')}>Current {sortIcon('current')} toggleSort('value')}>Value {sortIcon('value')} toggleSort('gl')}>G/L {sortIcon('gl')} toggleSort('signal')}>Signal {sortIcon('signal')}AdviceReason
{a.ticker} + {#if isEditing && editing} + + {:else} + {a.type} + {/if} + + {#if isEditing && editing} + + {:else} + {a.shares} + {/if} + + {#if isEditing && editing} + + {:else} + {fmt(a.costBasis)} + {/if} + {fmt(a.currentPrice != null ? parseFloat(a.currentPrice) : null)}{fmt(a.marketValue != null ? parseFloat(a.marketValue) : null)}{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}{#if a.signal}{:else}{/if}{a.advice}{a.reason} + {#if isEditing} + + + {:else} + + + {/if} +
+
+ diff --git a/ui/src/lib/stores/portfolio.store.svelte.ts b/ui/src/lib/stores/portfolio.store.svelte.ts new file mode 100644 index 0000000..3d491e6 --- /dev/null +++ b/ui/src/lib/stores/portfolio.store.svelte.ts @@ -0,0 +1,144 @@ +import { addHolding, removeHolding } from '$lib/api.js'; +import type { MarketContext, AdviceRow, PersonalFinance, HoldingFormData } from '$lib/types.js'; + +interface PortfolioData { + advice: AdviceRow[]; + marketContext: MarketContext | null; + personalFinance: PersonalFinance | null; +} + +class PortfolioStore { + // ── State ────────────────────────────────────────────────────────── + data = $state(null); + loading = $state(true); + refreshing = $state(false); + loadError = $state(null); + formOpen = $state(false); + saving = $state(false); + formError = $state(null); + + // ── Fetch ────────────────────────────────────────────────────────── + fetch(showFullSpinner = false): void { + if (showFullSpinner) this.loading = true; + else this.refreshing = true; + this.loadError = null; + + window + .fetch('/api/finance/portfolio') + .then((res) => + res.ok + ? res.json() + : res.text().then((t) => { + throw new Error(t); + }), + ) + .then((json: PortfolioData) => { + this.data = json; + }) + .catch((e: Error) => { + this.loadError = e.message; + }) + .finally(() => { + this.loading = false; + this.refreshing = false; + }); + } + + // ── Add holding ──────────────────────────────────────────────────── + async add(formData: HoldingFormData): Promise { + this.formError = null; + this.saving = true; + try { + await addHolding(formData); + // Optimistic: insert placeholder row immediately + const exists = this.data?.advice?.find((a) => a.ticker === formData.ticker); + if (this.data?.advice && !exists) { + this.data = { + ...this.data, + advice: [ + ...this.data.advice, + { + ticker: formData.ticker, + shares: formData.shares, + costBasis: formData.costBasis, + type: formData.type, + source: formData.source, + currentPrice: null, + marketValue: null, + gainLossPct: null, + signal: null, + advice: '⏳ Fetching…', + reason: 'Screener data loading in background.', + }, + ], + }; + } + this.formOpen = false; + this.fetch(false); + } catch (e) { + this.formError = (e as Error).message; + } finally { + this.saving = false; + } + } + + // ── Update holding ───────────────────────────────────────────────── + async update( + ticker: string, + updated: { shares: number; costBasis: number; type: string; source: string }, + ): Promise { + try { + await addHolding({ ticker, ...updated, type: updated.type as HoldingFormData['type'] }); + if (this.data?.advice) { + this.data = { + ...this.data, + advice: this.data.advice.map((a) => + a.ticker === ticker + ? { + ...a, + ...updated, + marketValue: String(updated.shares * (parseFloat(a.currentPrice ?? '0') || 0)), + gainLossPct: a.currentPrice + ? ( + ((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * + 100 + ).toFixed(1) + : null, + } + : a, + ), + }; + } + this.fetch(false); + } catch (e) { + this.loadError = (e as Error).message; + } + } + + // ── Delete holding ───────────────────────────────────────────────── + async remove(ticker: string): Promise { + if (!confirm(`Remove ${ticker} from your portfolio?`)) return; + if (this.data?.advice) { + this.data = { ...this.data, advice: this.data.advice.filter((a) => a.ticker !== ticker) }; + } + try { + await removeHolding(ticker); + this.fetch(false); + } catch (e) { + this.loadError = (e as Error).message; + } + } + + // ── Form helpers ─────────────────────────────────────────────────── + openForm(): void { + this.formOpen = true; + this.formError = null; + } + closeForm(): void { + this.formOpen = false; + this.formError = null; + } +} + +export const portfolioStore = new PortfolioStore(); +export type { PortfolioData }; diff --git a/ui/src/lib/stores/screener.store.svelte.ts b/ui/src/lib/stores/screener.store.svelte.ts new file mode 100644 index 0000000..d35d465 --- /dev/null +++ b/ui/src/lib/stores/screener.store.svelte.ts @@ -0,0 +1,91 @@ +import { fetchCatalysts, screenTickers, analyzeTickers } from '$lib/api.js'; +import { sorted } from '$lib/utils.js'; +import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js'; + +class ScreenerStore { + // ── State ────────────────────────────────────────────────────────── + input = $state(''); + results = $state(null); + screenedAt = $state(''); + loading = $state(false); + loadingCats = $state(false); + error = $state(null); + sidebar = $state({ + open: false, + loading: false, + analysis: null, + type: null, + error: null, + }); + + // ── Derived ──────────────────────────────────────────────────────── + ctx = $derived(this.results?.marketContext ?? null); + + allAssets = $derived( + this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [], + ); + + // ── Actions ──────────────────────────────────────────────────────── + async screen(): Promise { + this.error = null; + this.loading = true; + try { + const tickers = this.input + .split(/[\s,]+/) + .map((t) => t.trim().toUpperCase()) + .filter(Boolean); + this.results = await screenTickers(tickers); + this.screenedAt = new Date().toLocaleTimeString(); + } catch (e) { + this.error = (e as Error).message; + } finally { + this.loading = false; + } + } + + async reloadCatalysts(): Promise { + this.loadingCats = true; + this.error = null; + try { + const cat = await fetchCatalysts(); + this.input = cat.tickers.join(', '); + this.results = await screenTickers(cat.tickers); + this.screenedAt = new Date().toLocaleTimeString(); + } catch (e) { + this.error = (e as Error).message; + } finally { + this.loadingCats = false; + } + } + + async runTabAnalysis(type: AssetType): Promise { + const tickers = (this.results?.[type] ?? []).map((r) => r.asset.ticker); + if (!tickers.length) return; + this.sidebar = { open: true, loading: true, analysis: null, type, error: null }; + try { + const res = await analyzeTickers(tickers); + const reason = res.reason === 'no_stories' ? 'No recent news found for these tickers.' : null; + this.sidebar = { + open: true, + loading: false, + analysis: res.analysis, + type, + error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.'), + }; + } catch (e) { + this.sidebar = { + open: true, + loading: false, + analysis: null, + type, + error: (e as Error).message, + }; + } + } + + closeSidebar(): void { + this.sidebar = { ...this.sidebar, open: false }; + } +} + +export const screenerStore = new ScreenerStore(); diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 9b481aa..63e2992 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -1,88 +1,100 @@ -// ── Shared UI types ─────────────────────────────────────────────────────── -// Mirror of the server's domain types, used across Svelte components. +// ── UI type layer ───────────────────────────────────────────────────────── +// Shared domain types are imported from the server's canonical model files +// via the $types alias (→ server/types/). Only UI-specific types live here. +// +// All consumers should import from '$lib/types.js' as before — nothing changes +// at the call site. -export type Signal = - | '✅ Strong Buy' - | '⚡ Momentum' - | '⚠️ Speculation' - | '🔄 Neutral' - | '❌ Avoid'; +// ── Re-export shared domain types ──────────────────────────────────────── +export type { + Signal, + AssetType, + ScoreMode, + ScoreResult, + AssetResult, + ScreenerResult, +} from '$types/asset.model.js'; -export type AssetType = 'STOCK' | 'ETF' | 'BOND'; -export type ScoreMode = 'inflated' | 'fundamental'; +export type { + RateRegime, + VolatilityRegime, + Benchmarks, + MarketContext, +} from '$types/market.model.js'; -export interface Benchmarks { - marketPE: number | null; - techPE: number | null; - reitYield: number | null; - igSpread: number | null; -} +export type { HoldingType, PortfolioHolding, PortfolioAdvice } from '$types/portfolio.model.js'; -export interface MarketContext { - sp500Price: number | null; - riskFreeRate: number | null; - vixLevel: number | null; - rateRegime: 'HIGH' | 'NORMAL' | 'LOW'; - volatilityRegime: 'HIGH' | 'NORMAL' | 'LOW'; - benchmarks: Benchmarks; -} +export type { TickerSnapshot, MarketCall } from '$types/calls.model.js'; -export interface ScoreResult { - label: string; - score: number; - scoreSummary: string; - audit: { - riskFlags?: string[]; - [key: string]: unknown; - }; -} +export type { LLMAnalysis, CatalystStory, CalendarEvent } from '$types/finance.model.js'; +// ── UI-only types (not on the server) ──────────────────────────────────── + +import type { AssetType } from '$types/asset.model.js'; +import type { LLMAnalysis } from '$types/finance.model.js'; + +/** Detailed display metrics rendered per asset row in the screener table. */ export interface AssetDisplayMetrics { + // ── Common ────────────────────────────────────────────────────────── Price?: string; + + // ── Stock: classification ──────────────────────────────────────────── Sector?: string; + 'Cap Tier'?: string; // Mega Cap / Large Cap / Mid Cap / Small Cap / Micro Cap + Style?: string; // High Growth / Growth / Stable / Value / Turnaround / Declining + + // ── Stock: valuation ───────────────────────────────────────────────── 'P/E'?: string; PEG?: string; + 'P/B'?: string; + + // ── Stock: quality ─────────────────────────────────────────────────── + 'GrossM%'?: string; // gross margin — key for tech/software moat 'ROE%'?: string; 'OpMgn%'?: string; + 'NetMgn%'?: string; 'FCF Yld%'?: string; + 'Div%'?: string; + + // ── Stock: risk ─────────────────────────────────────────────────────── 'D/E'?: string; + Quick?: string; + Beta?: string; + + // ── Stock: 52-week movement ─────────────────────────────────────────── + '52W Pos'?: string; // % position within the 52-week range + '52W Chg'?: string; // total price return over last 52 weeks (signed %) + 'From High'?: string; // % below 52-week high (negative = drawdown) + 'From Low'?: string; // % above 52-week low (positive = recovery) + + // ── Stock: analyst consensus ────────────────────────────────────────── + Analyst?: string; // Strong Buy / Buy / Hold / Sell / Strong Sell + '# Analysts'?: string; + Target?: string; // analyst consensus price target + Upside?: string; // % upside to analyst target (signed %) + + // ── Stock: DCF intrinsic value ──────────────────────────────────────── + 'DCF Value'?: string; // intrinsic value per share + 'DCF Safety'?: string; // margin of safety % (positive = undervalued) + + // ── Stock: REIT-specific ────────────────────────────────────────────── + 'P/FFO'?: string; + + // ── ETF ─────────────────────────────────────────────────────────────── 'Exp Ratio%'?: string; 'Yield%'?: string; AUM?: string; '5Y Return%'?: string; + + // ── Bond ────────────────────────────────────────────────────────────── 'YTM%'?: string; Duration?: string; Rating?: string; + [key: string]: string | null | undefined; } -export interface AssetResult { - asset: { - ticker: string; - currentPrice: number; - type: AssetType; - displayMetrics: AssetDisplayMetrics; - }; - signal: Signal; - inflated: ScoreResult; - fundamental: ScoreResult; -} - -export interface ScreenerResult { - STOCK: AssetResult[]; - ETF: AssetResult[]; - BOND: AssetResult[]; - ERROR: Array<{ ticker: string; message: string }>; - marketContext: MarketContext; -} - -export interface LLMAnalysis { - summary: string; - sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL'; - affectedIndustries: Array<{ name: string; reason: string }>; - relatedTickers: Array<{ ticker: string; reason: string }>; -} - +/** State object for the LLM analysis slide-over sidebar. */ export interface SidebarState { open: boolean; loading: boolean; @@ -91,49 +103,68 @@ export interface SidebarState { error: string | null; } -export interface PortfolioHolding { +/** Transient state for inline row editing in the portfolio table. */ +export interface InlineEdit { + ticker: string; + shares: string; + costBasis: string; + type: string; + source: string; +} + +// ── Portfolio component types ───────────────────────────────────────────── + +import type { Signal } from '$types/asset.model.js'; + +/** A single row in the portfolio advice table. */ +export interface AdviceRow { + ticker: string; + type: string; + source: string; + shares: number; + costBasis: number; + currentPrice: string | null; + marketValue: string | null; + gainLossPct: string | null; + signal: Signal | null; + advice: string; + reason: string; +} + +/** Form data for adding or updating a holding. */ +export interface HoldingFormData { ticker: string; shares: number; costBasis: number; - source: string; type: 'stock' | 'etf' | 'bond' | 'crypto'; + source: string; } -export interface TickerSnapshot { - price: number | null; - signal: Signal | null; +interface SimpleFINAccount { + name: string; + type: string; + org: string; + balance: number; } -export interface MarketCall { - id: string; - title: string; - quarter: string; - date: string; - thesis: string; - tickers: string[]; - snapshot: Record; +interface CategoryBreakdown { + category: string; + amount: number; + pct: number; } -export interface CalendarEvent { - ticker: string; - type: 'earnings' | 'dividend'; - date: string; - [key: string]: unknown; -} - -export interface CatalystStory { - title: string; - link: string; - publisher: string; - publishedAt: string; - relatedTickers: string[]; -} - -export interface PortfolioAdvice { - ticker: string; - action: 'hold' | 'sell' | 'add' | 'watch'; - reason: string; - signal: Signal | null; - currentPrice: number | null; - gainLossPct: number | null; +/** Personal finance summary from SimpleFIN. */ +export interface PersonalFinance { + netWorth: number; + totalAssets: number; + totalLiabilities: number; + totalCash: number; + totalInvestments: number; + totalIncome: number; + totalSpend: number; + cashPct: number; + investPct: number; + savingsRate: string | null; + accounts: SimpleFINAccount[]; + categoryBreakdown: CategoryBreakdown[]; } diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index c203467..eb67ed2 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -1,80 +1,26 @@
@@ -82,8 +28,8 @@
- - {#if screenedAt} - Last screened {screenedAt} + {#if s.screenedAt} + Last screened {s.screenedAt} {/if}
{#if searchOpen} -
- e.key === 'Enter' && screen()} - /> - -
+
+ e.key === 'Enter' && s.screen()} + /> + +
+ {/if} + + {#if s.ctx} + {/if}
- {#if error} -
⚠ {error}
+ {#if s.error} +
⚠ {s.error}
{/if} - {#if loading || loadingCats} + {#if s.loading || s.loadingCats}
- +
{/if} - {#if ctx} - - + {#if s.results && !s.loading && !s.loadingCats}

Signal Summary

- {allAssets.length} assets + {s.allAssets.length} assets
@@ -139,16 +87,21 @@ + + - {#each allAssets as r} + {#each s.allAssets as r} + {@const dm = r.asset.displayMetrics ?? {}} + + {/each} @@ -158,22 +111,22 @@ {#each (['STOCK', 'ETF', 'BOND'] as const) as type} - {#if results[type]?.length} + {#if s.results[type]?.length} runTabAnalysis(type)} + rows={s.results[type]} + analyzeLoading={s.sidebar.loading && s.sidebar.type === type} + onAnalyze={() => s.runTabAnalysis(type)} /> {/if} {/each} - {#if results.ERROR?.length} + {#if s.results.ERROR?.length}
-

Failed {results.ERROR.length}

+

Failed {s.results.ERROR.length}

- {#each results.ERROR as e} + {#each s.results.ERROR as e}
{e.ticker} {e.message}
{/each}
@@ -182,12 +135,11 @@ {/if} - sidebar = { ...sidebar, open: false }} /> + s.closeSidebar()} /> diff --git a/ui/src/routes/portfolio/+page.svelte b/ui/src/routes/portfolio/+page.svelte index a40abb2..ec9a243 100644 --- a/ui/src/routes/portfolio/+page.svelte +++ b/ui/src/routes/portfolio/+page.svelte @@ -1,751 +1,55 @@ -
- {#if loading} +
+ {#if p.loading}
- {:else if loadError} -
{loadError}
+ {:else if p.loadError} +
{p.loadError}
- {:else if data?.advice} - -
- - {#if refreshing} + {#if p.refreshing} Updating prices… {/if}
- - {#if formOpen} -
-
Add Holding
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
- {#if formError} -
⚠ {formError}
- {/if} -
+ {#if p.formOpen} + p.add(d)} onClose={() => p.closeForm()} /> {/if} - {#if data.marketContext} - + {#if p.data.marketContext} + {/if} - -
-
-
- Total Value - - ? - Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position. - -
-
{fmtShort(totalValue)}
-
-
-
- Total Cost - - ? - Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered. - -
-
{fmtShort(totalCost)}
-
-
-
- Total G/L - - ? - Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down. - -
-
{fmtShort(totalGL)}
-
-
+ p.update(t, d)} onDelete={t => p.remove(t)} /> - -
-

Holdings — Hold / Sell / Add Advice

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

Accounts

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

Spending — Last 30 Days

- - - - {#each pf.categoryBreakdown.slice(0, 10) as c} - - - - - - - {/each} - -
CategoryAmount%Share
{c.category}{fmt(c.amount)}{c.pct}% -
-
-
-
-
-
+ {#if p.data.personalFinance} + {/if} - {/if}
- diff --git a/ui/src/styles/_calls.scss b/ui/src/styles/_calls.scss new file mode 100644 index 0000000..8530301 --- /dev/null +++ b/ui/src/styles/_calls.scss @@ -0,0 +1,179 @@ +// ── Calls route — page, CallForm, CallCard, CalendarSection ────────────── + +// ── calls/+page.svelte ──────────────────────────────────────────────────── + +.calls-page { + max-width: 1100px; + padding-bottom: 60px; +} + +.calls-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + + h1 { + font-size: var(--fs-2xl); + font-weight: 700; + color: var(--text-primary); + margin-bottom: 4px; + } + + .subtitle { font-size: 12px; color: var(--text-dimmer); } +} + +.calls-empty { + color: var(--text-dimmer); + font-size: var(--fs-md); + padding: 40px 0; + text-align: center; +} + +// ── CallForm ────────────────────────────────────────────────────────────── + +.call-form { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.call-form-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 12px; + align-items: start; + + .narrow { min-width: 120px; } +} + +.call-hint { font-size: var(--fs-sm); color: var(--text-dimmer); } +.call-form-actions { display: flex; gap: 10px; align-items: center; } + +// ── CallCard ────────────────────────────────────────────────────────────── + +.call-card-meta { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 0; +} + +.call-card-title { + font-size: 14px; + font-weight: 700; + color: var(--text-primary); + text-decoration: none; + + &:hover { color: var(--blue-muted); } +} + +.call-card-badges { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.call-date-badge { font-size: var(--fs-sm); color: var(--text-dimmer); } + +.call-card-body { + padding: var(--space-xl); + display: flex; + flex-direction: column; + gap: 16px; +} + +.call-thesis { + font-size: var(--fs-md); + color: var(--text-muted); + line-height: 1.6; + border-left: 3px solid var(--blue-surface); + padding-left: 14px; + margin: 0; +} + +.snapshot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; +} + +.snap-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 3px; + text-decoration: none; + transition: border-color var(--transition); + + &:hover { border-color: var(--text-faint); } +} + +.snap-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); } +.snap-price { font-size: var(--fs-sm); color: var(--text-dim); font-variant-numeric: tabular-nums; } +.snap-signal { font-size: var(--fs-xs); font-weight: 600; } + +.call-view-link { + font-size: 12px; + color: var(--blue-muted); + text-decoration: none; + + &:hover { text-decoration: underline; } +} + +.btn-call-delete { + background: transparent; + border: none; + color: var(--text-dimmer); + padding: 4px 8px; + font-size: 14px; + cursor: pointer; + border-radius: var(--radius-xs); + + &:hover { color: var(--red); } +} + +// ── CalendarSection ─────────────────────────────────────────────────────── + +.cal-grid { + padding: 8px var(--space-xl) 14px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cal-event { + display: grid; + grid-template-columns: 96px 1fr; + gap: 14px; + align-items: start; + padding: 8px 6px; + border-radius: var(--radius-sm); + transition: background 0.1s; + + &:hover { background: var(--bg-elevated); } + &.past { opacity: 0.45; } +} + +.cal-date { font-size: var(--fs-sm); font-variant-numeric: tabular-nums; color: var(--text-dimmer); padding-top: 1px; white-space: nowrap; } +.cal-content { display: flex; flex-direction: column; gap: 2px; } +.cal-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); } +.cal-type { font-size: var(--fs-sm); font-weight: 600; } +.cal-detail { font-weight: 400; color: var(--text-dim); } +.past-type { color: var(--text-dimmer) !important; } +.cal-est { font-size: var(--fs-xs); color: var(--text-dimmer); } + +.cal-divider { + font-size: var(--fs-xs); + color: var(--text-faint); + text-align: center; + padding: 8px 0 4px; + letter-spacing: 0.06em; +} diff --git a/ui/src/styles/_forms.scss b/ui/src/styles/_forms.scss new file mode 100644 index 0000000..0b4f3e6 --- /dev/null +++ b/ui/src/styles/_forms.scss @@ -0,0 +1,133 @@ +// ── Shared form field styles ────────────────────────────────────────────── +// Used by both portfolio (AddHoldingForm) and calls (CallForm). + +// ── Field + label ───────────────────────────────────────────────────────── + +.field { + display: flex; + flex-direction: column; + gap: 5px; + + label, + > span { + font-size: var(--fs-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-dimmer); + } + + input::placeholder { color: var(--text-faint); } +} + +// ── Shared input / select / textarea ───────────────────────────────────── + +%form-control { + background: var(--bg-card); + border: 1px solid var(--border-input); + border-radius: var(--radius-sm); + color: var(--text-secondary); + padding: 8px 12px; + font-size: var(--fs-md); + font-family: inherit; + outline: none; + min-width: 100px; + height: 38px; + box-sizing: border-box; + transition: border-color var(--transition); + + &:focus { border-color: var(--blue); } +} + +.field input { @extend %form-control; } +.field select { + @extend %form-control; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2364748b' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + padding-right: 32px; + appearance: none; + -webkit-appearance: none; + cursor: pointer; +} + +// ── Call-form inputs (slightly different padding) ───────────────────────── + +.call-form { + input, + textarea { + background: var(--bg-card); + border: 1px solid var(--border-input); + border-radius: var(--radius-md); + color: var(--text-secondary); + padding: 9px 12px; + font-size: var(--fs-md); + font-family: inherit; + outline: none; + transition: border-color var(--transition); + width: 100%; + box-sizing: border-box; + + &:focus { border-color: var(--blue); } + } + + textarea { resize: vertical; height: auto; } + + label { + display: flex; + flex-direction: column; + gap: 5px; + + > span { + font-size: var(--fs-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); + } + } +} + +// ── Shared form-error ───────────────────────────────────────────────────── + +.form-error { + color: var(--red); + font-size: 12px; + margin-top: 10px; +} + +.form-error-block { + color: var(--red); + font-size: 12px; + background: var(--red-bg); + padding: 8px 12px; + border-radius: var(--radius-sm); +} + +// ── Inline save/cancel button pair (portfolio table rows) ───────────────── + +.btn-save-inline { + background: #14532d55; + border: none; + color: var(--green); + font-size: var(--fs-md); + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-xs); + font-weight: 700; + + &:hover:not(:disabled) { background: #14532d99; } + &:disabled { opacity: 0.5; cursor: default; } +} + +.btn-cancel-inline { + background: none; + border: none; + color: var(--text-dimmer); + font-size: var(--fs-md); + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-xs); + + &:hover { color: var(--text-muted); } +} diff --git a/ui/src/styles/_portfolio.scss b/ui/src/styles/_portfolio.scss new file mode 100644 index 0000000..f978a63 --- /dev/null +++ b/ui/src/styles/_portfolio.scss @@ -0,0 +1,299 @@ +// ── Portfolio route — page, AddHoldingForm, AdviceTable, AccountsTable ──── + +// ── portfolio/+page.svelte ──────────────────────────────────────────────── + +.portfolio-page { max-width: 1400px; } + +.portfolio-toolbar { + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 10px; +} + +.btn-add { + background: var(--blue-dark); + color: #fff; + border: none; + border-radius: var(--radius-md); + padding: 9px 18px; + font-size: var(--fs-md); + font-weight: 600; + cursor: pointer; + + &:hover { background: var(--blue-darker); } +} + +.refreshing-hint { + font-size: var(--fs-sm); + color: var(--text-dimmer); + animation: portfolio-pulse 1.5s ease-in-out infinite; +} + +@keyframes portfolio-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } + +// ── AddHoldingForm ──────────────────────────────────────────────────────── + +.add-form { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--space-xl); + margin-bottom: 16px; +} + +.add-form-title { + font-size: var(--fs-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dimmer); + margin-bottom: 14px; +} + +.add-form-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; } + +.btn-form-save { + background: var(--blue-dark); + color: #fff; + border: none; + border-radius: var(--radius-sm); + padding: 8px 20px; + font-size: var(--fs-md); + font-weight: 600; + cursor: pointer; + align-self: flex-end; + height: 38px; + + &:hover:not(:disabled) { background: var(--blue-darker); } + &:disabled { opacity: 0.5; cursor: default; } +} + +.btn-form-cancel { + background: none; + border: none; + color: var(--text-dimmer); + font-size: var(--fs-md); + cursor: pointer; + padding: 4px 10px; + align-self: flex-end; + height: 38px; + + &:hover { color: var(--text-muted); } +} + +// ── AdviceTable — P&L summary ───────────────────────────────────────────── + +.pnl-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} + +.pnl-card { + background: var(--bg-card); + border-radius: var(--radius-md); + padding: 12px var(--space-lg); +} + +.pnl-label-row { display: flex; align-items: center; justify-content: space-between; gap: 4px; } +.pnl-label { font-size: var(--fs-xs); color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } +.pnl-value { font-size: var(--fs-xl); font-weight: 700; color: var(--text-primary); margin-top: 4px; } + +// ── Summary card tooltip ────────────────────────────────────────────────── + +.stip-wrap { position: relative; display: inline-flex; flex-shrink: 0; } + +.stip-anchor { + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--bg-base); + border: 1px solid var(--text-faint); + color: var(--text-dimmer); + font-size: var(--fs-2xs); + font-weight: 700; + cursor: help; +} + +.stip-box { + display: none; + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + width: 220px; + background: var(--bg-card); + border: 1px solid var(--text-faint); + border-radius: var(--radius-sm); + padding: 8px 10px; + font-size: var(--fs-sm); + color: var(--text-muted); + line-height: 1.5; + z-index: 200; + pointer-events: none; + white-space: normal; + + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--text-faint); + } +} + +.stip-wrap:hover .stip-box { display: block; } + +// ── AdviceTable — holdings table ────────────────────────────────────────── + +.advice-section { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + margin-bottom: 20px; + + h2 { + font-size: var(--fs-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); + margin-bottom: 14px; + } +} + +.advice-table { + width: 100%; + border-collapse: collapse; + + thead th { + text-align: left; + padding: 7px 10px; + font-size: var(--fs-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dimmer); + border-bottom: 1px solid var(--border); + white-space: nowrap; + cursor: pointer; + user-select: none; + + &:hover { color: var(--text-muted); } + } + + tbody tr { + border-bottom: 1px solid var(--bg-row-alt); + &:hover { background: #1e293b55; } + &.editing { background: var(--blue-badge); } + } + + tbody td { + padding: 9px 10px; + vertical-align: middle; + white-space: nowrap; + } +} + +.advice-row-actions { display: flex; gap: 4px; align-items: center; } + +.btn-row-edit { + background: none; + border: none; + color: var(--text-faint); + font-size: var(--fs-md); + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-xs); + + &:hover { color: var(--blue-muted); background: var(--blue-deep); } +} + +.btn-row-delete { + background: none; + border: none; + color: var(--text-faint); + font-size: 12px; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius-xs); + + &:hover { color: var(--red); background: var(--red-bg); } +} + +.inline-input { + background: var(--bg-card); + border: 1px solid var(--border-input); + border-radius: var(--radius-xs); + color: var(--text-secondary); + padding: 3px 6px; + font-size: 12px; + width: 80px; + outline: none; + + &:focus { border-color: var(--blue); } +} + +.inline-select { + background: var(--bg-card); + border: 1px solid var(--border-input); + border-radius: var(--radius-xs); + color: var(--text-secondary); + padding: 3px 6px; + font-size: var(--fs-sm); + outline: none; +} + +// ── AccountsTable — personal finance ───────────────────────────────────── + +.accounts-two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.accounts-section { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; + + h2 { + font-size: var(--fs-sm); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-dim); + margin-bottom: 14px; + } +} + +.accounts-table { + width: 100%; + border-collapse: collapse; + + thead th { + text-align: left; + padding: 7px 10px; + font-size: var(--fs-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dimmer); + border-bottom: 1px solid var(--border); + } + + tbody tr { border-bottom: 1px solid var(--bg-row-alt); } + tbody td { padding: 9px 10px; vertical-align: middle; white-space: nowrap; } +} + +.spend-bar-bg { background: var(--bg-card); border-radius: var(--radius-xs); height: 6px; } +.spend-bar-fill { background: var(--blue); border-radius: var(--radius-xs); height: 6px; } diff --git a/ui/src/styles/_sidebar.scss b/ui/src/styles/_sidebar.scss new file mode 100644 index 0000000..70e16e2 --- /dev/null +++ b/ui/src/styles/_sidebar.scss @@ -0,0 +1,155 @@ +// ── AnalysisSidebar — slide-over panel ──────────────────────────────────── + +.sidebar-backdrop { + position: fixed; + inset: 0; + background: #00000055; + z-index: 100; +} + +.sidebar { + position: fixed; + top: 0; right: 0; bottom: 0; + width: 380px; + background: var(--bg-surface); + border-left: 1px solid var(--blue-surface); + z-index: 101; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px var(--space-xl); + border-bottom: 1px solid var(--border); + background: var(--blue-badge); + flex-shrink: 0; +} + +.sidebar-title { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--fs-md); + font-weight: 700; + color: var(--text-secondary); +} + +.sidebar-type { + font-size: var(--fs-xs); + font-weight: 700; + letter-spacing: 0.06em; + background: var(--blue-surface); + color: var(--blue-muted); + padding: 2px 8px; + border-radius: var(--radius-pill); +} + +.sidebar-close { + background: none; + border: none; + color: var(--text-dimmer); + font-size: 14px; + padding: 4px 8px; + cursor: pointer; + border-radius: var(--radius-xs); + + &:hover { color: var(--text-muted); background: var(--bg-card); } +} + +.sidebar-body { + flex: 1; + overflow-y: auto; + padding: var(--space-xl); + display: flex; + flex-direction: column; + gap: 16px; +} + +.sidebar-loading { + display: flex; + justify-content: center; + align-items: center; + flex: 1; + padding: 60px 0; +} + +.sidebar-error { + color: var(--red); + background: var(--red-bg); + border-radius: var(--radius-md); + padding: 12px var(--space-lg); + font-size: var(--fs-md); +} + +// ── Sidebar content blocks ──────────────────────────────────────────────── + +.sb-sentiment-row { display: flex; align-items: center; gap: 8px; } + +.sb-summary { + font-size: var(--fs-md); + color: var(--text-muted); + line-height: 1.6; + border-left: 3px solid var(--blue-surface); + padding-left: 12px; + margin: 0; +} + +.sb-sub { + font-size: var(--fs-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dimmer); + margin: 0; +} + +.sb-list { display: flex; flex-direction: column; gap: 8px; } + +.sb-item { + display: flex; + flex-direction: column; + gap: 3px; + padding: 10px 12px; + background: var(--bg-elevated); + border-radius: var(--radius-sm); + border: 1px solid var(--border); +} + +.sb-ticker-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.sb-chips { display: flex; gap: 4px; flex-shrink: 0; } + +.sb-chip { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + padding: 2px 6px; + border-radius: var(--radius-pill); +} + +.sb-bias[data-bias="BULL"] { background: var(--green-bg, #0d2a1a); color: var(--green, #4ade80); } +.sb-bias[data-bias="BEAR"] { background: var(--red-bg, #2a0d0d); color: var(--red, #f87171); } + +.sb-horizon { background: var(--blue-badge); color: var(--blue-muted); } +.sb-sensitivity { background: var(--bg-card); color: var(--text-dimmer); border: 1px solid var(--border); } + +.sb-name { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +.sb-reason { + font-size: var(--fs-sm); + color: var(--text-dim); + line-height: 1.4; +} diff --git a/ui/src/styles/app.scss b/ui/src/styles/app.scss index ab4a800..b3fcce9 100644 --- a/ui/src/styles/app.scss +++ b/ui/src/styles/app.scss @@ -9,3 +9,7 @@ @use 'table'; @use 'buttons'; @use 'badges'; +@use 'forms'; +@use 'sidebar'; +@use 'calls'; +@use 'portfolio'; diff --git a/ui/svelte.config.js b/ui/svelte.config.js index df45697..246c605 100644 --- a/ui/svelte.config.js +++ b/ui/svelte.config.js @@ -1,5 +1,12 @@ import adapter from '@sveltejs/adapter-auto'; export default { - kit: { adapter: adapter() }, + kit: { + adapter: adapter(), + // $types → server/types/ — lets UI import shared domain types without duplication. + // SvelteKit auto-wires this into both Vite's resolve.alias and the generated tsconfig. + alias: { + $types: '../server/types', + }, + }, }; diff --git a/ui/tsconfig.json b/ui/tsconfig.json index c497a95..445b4d5 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -4,6 +4,13 @@ "strict": true, "allowJs": true, "checkJs": false, - "types": ["node"] + "types": ["node"], + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$app/types": ["./.svelte-kit/types/index.d.ts"], + "$types": ["../server/types"], + "$types/*": ["../server/types/*"] + } } }