diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..ed36181
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "market-screener-ui",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev", "--prefix", "ui"],
+ "port": 5173
+ }
+ ]
+}
diff --git a/.gitignore b/.gitignore
index b512c09..d3c60b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,16 @@
-node_modules
\ No newline at end of file
+node_modules
+ui/node_modules
+
+# Sensitive data — never commit
+portfolio.json
+market-calls.json
+.env
+.env.*
+
+# Build outputs
+ui/.svelte-kit
+ui/build
+
+# Reports
+screener-report.html
+finance-report.html
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 254e150..1927fe2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,37 +4,54 @@ Guidance for working in this repository.
## Overview
-`market-screener` is a Node.js CLI tool that screens stocks, ETFs, and bonds by fetching live data from Yahoo Finance and scoring each asset under two lenses:
+`market-screener` is a Node.js project with two modes:
-- **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 inflated market.
-- **Fundamental** — strict Graham/value-investing style gates from `ScoringConfig`. Reflects genuine value regardless of market conditions.
+1. **CLI** — screens stocks, ETFs, and bonds via `npm start`, generates HTML reports
+2. **Fastify API server** — powers the SvelteKit dashboard in the `ui/` subdirectory
+
+Every asset is scored under two lenses:
+
+- **Market-Adjusted** — gates derived from live Yahoo benchmarks (SPY P/E, XLK P/E, XLRE yield, LQD spread). Reflects what is acceptable in today's market.
+- **Fundamental** — strict Graham/value-investing gates from `ScoringConfig`. Reflects genuine value regardless of market conditions.
The comparison produces a **Signal** (Strong Buy / Momentum / Speculation / Neutral / Avoid).
ES module project (`"type": "module"`); use `import`/`export`, not `require`.
+---
+
## Commands
```bash
-npm install # install dependencies (yahoo-finance2, dotenv, prettier)
-npm start # Yahoo news → catalyst tickers → screener-report.html
-npm start -- watch # default watchlist
-npm start -- AAPL MSFT VOO # specific tickers
-npm run finance # portfolio advice + SimpleFIN → finance-report.html
-npm run import-portfolio -- holdings.csv # import Robinhood/Vanguard CSV into portfolio.json
-npm test # run all unit tests (node:test, zero deps)
-npm run test:watch # watch mode during development
+npm install # install dependencies
+npm run dev # start API server (port 3000) + SvelteKit UI (port 5173) together
+npm run server # API server only (port 3000)
+npm start # CLI: Yahoo news → catalyst tickers → screener-report.html
+npm start -- watch # CLI: default watchlist
+npm start -- AAPL MSFT VOO # CLI: specific tickers
+npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html
+npm test # run all unit tests (node:test, zero external deps)
+npm run test:watch # watch mode — uses verbose spec reporter
npm run format # format all src/bin/tests with Prettier
-npm run format:check # check formatting without writing (CI)
+npm run format:check # check formatting without writing (used in CI/pre-commit)
+npm run ui:install # install UI dependencies (ui/ subdirectory)
```
+`npm run dev` runs both the API server and the SvelteKit UI (in `ui/`) concurrently. Run `npm run ui:install` once before first use.
+
+---
+
## Project Structure
```
bin/
- screen.js ← market screener entry point
- finance.js ← personal finance entry point
+ screen.js ← CLI screener entry point
+ finance.js ← CLI personal finance entry point
import-portfolio.js ← broker CSV importer
+ server.js ← Fastify API server entry point
+
+scripts/
+ summary-reporter.js ← custom node:test reporter (silent on pass, summary line at end)
prompts/
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
@@ -42,44 +59,75 @@ prompts/
src/
config/
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth)
+ constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER
market/ ← Yahoo Finance data layer
YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff
BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext
- MarketRegime.js ← derives INFLATED gate overrides from live benchmarks
+ MarketRegime.js ← derives INFLATED gate overrides from live benchmarks + rate regime
screener/ ← core screening domain
- ScreenerEngine.js ← orchestrates: fetch → score × 2 → HTML report
+ ScreenerEngine.js ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data),
+ screenWithProgress() (CLI with stdout). Accepts { logger } option.
DataMapper.js ← normalises Yahoo payload → flat asset data object
+ NOTE: uses trailingPE (not forwardPE). Preserves negative FCF.
+ Infers bond duration from category string. Maps ETF volume.
RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
Chunker.js ← splits ticker list into batches
assets/
Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers
- Stock.js ← metrics: peRatio, pegRatio, priceToBook, ROE, opMargin, etc.
- Etf.js ← metrics: expenseRatio, yield, totalAssets
+ 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)
+ EtfScorer.js ← expense gate + registry (cost, yield, volume, fiveYearReturn)
BondScorer.js ← credit gate + spread/duration scoring
analyst/
- CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers
+ CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }.
+ LLMAnalyst.js ← uses Claude Haiku (ANTHROPIC_API_KEY) to analyze headlines → summary,
+ sentiment (BULLISH/NEUTRAL/BEARISH), affectedIndustries, relatedTickers.
+ Returns null gracefully if API key is not set. Accepts { logger }.
+
+ calls/
+ MarketCallStore.js ← persists quarterly market thesis entries to market-calls.json.
+ Each call stores: title, quarter, date, thesis, tickers[], snapshot{}
+ (price + signal per ticker at creation time). CRUD: list/get/create/delete.
finance/
clients/
- SimpleFINClient.js ← claims setup token → access URL, fetches /accounts
+ 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
- PortfolioImporter.js ← parses Robinhood/Vanguard/Fidelity CSV → merges into portfolio.json
reporters/
- HtmlReporter.js ← generates screener-report.html (tabbed inflated/fundamental views)
- FinanceReporter.js ← generates finance-report.html (net worth, portfolio, spending)
+ HtmlReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
+ FinanceReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
+ server/
+ app.js ← Fastify app factory (buildApp). Registers CORS + routes.
+ routes/
+ screener.js ← POST /api/screen, GET /api/screen/catalysts
+ Serializes asset.getDisplayMetrics() before JSON response.
+ finance.js ← GET /api/finance/portfolio, GET /api/finance/market-context
+ calls.js ← CRUD for market calls + GET /api/calls/calendar (earnings/dividend events)
+
+ui/ ← SvelteKit dashboard (lives inside this repo, not a separate repo)
+ src/routes/
+ +page.svelte ← main screener UI
+ calls/ ← market calls list + detail views
+ portfolio/ ← portfolio advice view
+ safe-buys/ ← filtered strong-buy view
+
+market-calls.json ← persisted market thesis calls (written by MarketCallStore)
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
+.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY
```
+---
+
## Data Flow
```
@@ -90,9 +138,9 @@ BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD
rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } }
↓
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND)
- computes: pFFO proxy, FCF yield, computed PEG, 52-week position
+ uses trailingPE as primary; preserves negative FCF yield; infers bond duration
↓
-Asset subclass — Stock / Etf / Bond holds metrics, formats display
+Asset subclass — Stock / Etf / Bond holds metrics + getDisplayMetrics()
↓
RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style)
INFLATED mode: sector override + MarketRegime live gate overrides
@@ -101,15 +149,43 @@ Scorer × 2 — StockScorer / EtfScorer / BondScorer, fully stateless
↓
ScreenerEngine — derives Signal from comparing both verdicts
↓
-HtmlReporter — screener-report.html: signal summary + two tabbed tables per asset class
+ ├── CLI path: screenWithProgress() → HtmlReporter.generate() → screener-report.html
+ └── API path: screenTickers() → JSON (with serialized displayMetrics) → SvelteKit UI
```
+---
+
+## API Routes (Fastify)
+
+| Method | Path | Description |
+|---|---|---|
+| GET | `/health` | Health check |
+| POST | `/api/screen` | Screen tickers. Body: `{ tickers: string[] }`. Returns `{ STOCK, ETF, BOND, ERROR, marketContext }` with `asset.displayMetrics` pre-serialized |
+| GET | `/api/screen/catalysts` | Yahoo news → `{ tickers, stories }` |
+| GET | `/api/finance/portfolio` | Portfolio advice + optional SimpleFIN data |
+| GET | `/api/finance/market-context` | Live benchmark data only |
+| GET | `/api/calls` | List all market calls (newest first) |
+| GET | `/api/calls/:id` | Get one call + re-screened current prices for comparison |
+| POST | `/api/analyze` | Fetch Yahoo news for specific tickers + run LLM analysis. Body: `{ tickers: string[] }`. Returns `{ analysis }` |
+| POST | `/api/calls` | Create a market call; snapshots current prices. Body: `{ title, quarter, thesis, tickers[], date? }` |
+| DELETE | `/api/calls/:id` | Delete a market call |
+| GET | `/api/calls/calendar` | Earnings + dividend calendar. Query: `?tickers=AAPL,MSFT` (omit for all call tickers) |
+
+CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`).
+
+---
+
## Scoring Modes
-| Mode | P/E Gate (general) | Source |
-|---|---|---|
-| FUNDAMENTAL | 20x | ScoringConfig (Graham) |
-| INFLATED | S&P 500 P/E × 1.5 | Live SPY data |
+| 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 |
|---|---|
@@ -119,46 +195,129 @@ HtmlReporter — screener-report.html: signal summary + two tabbed tab
| 🔄 Neutral | Hold territory in one or both lenses |
| ❌ Avoid | Fails both |
-## ScoringConfig Structure
+---
-`src/config/ScoringConfig.js` exports two things:
+## ScoringConfig Key Values
-- `CREDIT_RATING_SCALE` — `{ AAA: 10, AA: 9, ..., D: 1 }`. Used by Bond.js and BondScorer.
-- `ScoringRules` — per-type `{ gates, weights, thresholds }` + `SECTOR_OVERRIDE` map for STOCK.
+`src/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds.
-Sector overrides are structural (apply in both modes). MarketRegime overrides valuation gates in INFLATED mode only.
+**STOCK base gates (Fundamental mode):**
+- `maxPERatio: 15` — Graham's actual rule (trailing P/E)
+- `maxPegGate: 1.0` — Lynch standard: PEG > 1.0 means paying full price
+- `maxDebtToEquity: 1.5` — most distress starts above 2x
+- `minQuickRatio: 0.8` — below this signals real liquidity stress
-## Sector Notes
+**Sector overrides** (structural — apply in both modes):
-- **TECHNOLOGY** — `maxDebtToEquity: 2.0` (mega-cap tech borrows for buybacks), `minQuickRatio: 0.8` (AAPL runs ~0.9). P/E and PEG gates inflated from XLK live data.
-- **REIT** — P/E and PEG disabled (9999). Scored on dividend yield and P/FFO proxy. All base weights (margin, peg, revenue) explicitly zeroed out.
-- **FINANCIAL** — P/E, PEG, D/E disabled. Scored on ROE + Price-to-Book. Quick ratio gate lowered (0.1).
+| Sector | Key difference |
+|---|---|
+| TECHNOLOGY | D/E up to 2.0, P/E up to 35x, FCF weight raised |
+| REIT | P/E and PEG disabled (9999), scored on yield + P/FFO |
+| FINANCIAL | D/E disabled, scored on ROE + P/B, maxPriceToBook 1.5x |
+| ENERGY | FCF weight 4, yield weight 3, opMargin primary |
+| HEALTHCARE | Revenue growth primary, P/E up to 25x |
+| COMMUNICATION | FCF weight 4, P/E up to 25x (META, GOOGL, NFLX) |
+| CONSUMER_STAPLES | Margin/ROE focus, low revenue growth expectations |
+| CONSUMER_DISCRETIONARY | Revenue growth primary, P/E up to 25x |
+
+**ETF gates:**
+- `maxExpenseRatio: 0.2%` — hard gate
+- `minFiveYearReturn: 8.0%` — S&P long-run floor
+- `minVolume: 1,000,000` ADV
+
+**BOND gates:**
+- `minCreditRating: 7` (BBB = investment-grade floor)
+- `minSpread: 1.5%` above risk-free
+- `maxDuration: 7` years
+
+---
## MarketRegime (INFLATED overrides)
-`src/market/MarketRegime.js` derives gate overrides from live benchmarks:
+`src/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime:
-| Gate | Formula |
-|---|---|
-| Stock maxPERatio | SPY trailing P/E × 1.5 |
-| Tech maxPERatio | XLK P/E × 1.3 |
-| Tech maxPegGate | XLK P/E ÷ 15 |
-| REIT minYield | XLRE dividend yield × 0.85 |
-| Bond minSpread | LQD−TNX spread × 0.80 |
-| ETF maxExpenseRatio | 0.75% (structural loosening) |
+| Gate | Formula (NORMAL rates) | Formula (HIGH rates) |
+|---|---|---|
+| Stock maxPERatio | SPY trailing P/E × 1.5 | SPY trailing P/E × 1.2 |
+| Tech maxPERatio | XLK P/E × 1.3 | XLK P/E × 1.3 |
+| Tech maxPegGate | XLK P/E ÷ 15 | XLK P/E ÷ 15 |
+| REIT minYield | XLRE yield × 0.85 | XLRE yield × 0.95 |
+| Bond minSpread | LQD−TNX × 0.80 | LQD−TNX × 0.90 |
+| ETF maxExpenseRatio | 0.75% | 0.75% |
+
+---
+
+## Sector Detection
+
+`Stock._mapToStandardSector()` maps Yahoo Finance `sector`/`industry` strings to internal constants.
+Order matters — more specific matches first:
+
+```
+TECHNOLOGY → "technology", "electronic", "semiconductor", "software"
+REIT → "real estate", "reit"
+FINANCIAL → "financial", "bank", "insurance", "asset management"
+ENERGY → "energy", "oil", "gas", "petroleum"
+HEALTHCARE → "health", "biotech", "pharmaceutical", "medical"
+COMMUNICATION→ "communication", "media", "entertainment", "telecom"
+CONSUMER_STAPLES → "consumer defensive", "consumer staples", "household", "beverage", "food"
+CONSUMER_DISCRETIONARY → "consumer cyclical", "consumer discretionary", "retail", "apparel", "auto"
+GENERAL → fallback
+```
+
+---
+
+## DataMapper Notes
+
+- **peRatio**: prefers `trailingPE` (audited) over `forwardPE` (analyst estimate, ~10-15% optimistic)
+- **FCF yield**: `freeCashflow !== 0` (not `> 0`) — negative FCF preserved so cash-burning companies fail the gate, not silently skip it
+- **Bond duration**: inferred from category string ("Short-Term" → 2y, "Intermediate" → 5y, "Long" → 18y, default 6y). Yahoo does not expose effective duration in the modules we fetch.
+- **ETF volume**: `summaryDetail.averageVolume` — was missing before, causing the `-2` liquidity penalty on every ETF
+
+---
## Missing Data Convention
-- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` values rather than auto-failing.
+- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` rather than auto-failing.
- `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it.
- `quickRatio` falls back to `currentRatio` when missing.
+---
+
+## Logger Injection Pattern
+
+Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context:
+
+```js
+// CLI (default) — writes to stdout
+new ScreenerEngine()
+
+// Server — fully silent
+new ScreenerEngine({ logger: { write: () => {}, log: () => {}, warn: () => {} } })
+```
+
+Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.
+
+---
+
+## Reporter Pattern
+
+Both reporters have two methods:
+
+```js
+reporter.render(...) // → HTML string (use in server route responses)
+reporter.generate(...) // → writes file to disk, returns path (use in CLI)
+```
+
+---
+
## SimpleFIN Auth Flow
1. User gets a Setup Token from https://beta-bridge.simplefin.org
-2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim URL → receives Access URL
-3. Access URL is auto-appended to `.env` as `SIMPLEFIN_ACCESS_URL`
-4. All subsequent requests use Access URL directly (setup token is one-time use)
+2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim Access URL
+3. `onAccessUrlClaimed` callback is called with the URL — CLI uses `saveAccessUrlToEnv()`, server stores elsewhere
+4. All subsequent requests use Access URL with `Authorization: Basic` header (not embedded in URL)
+
+---
## portfolio.json Format
@@ -174,52 +333,125 @@ Sector overrides are structural (apply in both modes). MarketRegime overrides va
`type` values: `stock`, `etf`, `crypto`. Crypto is priced via Yahoo (BTC-USD style) but not fundamentally scored.
+---
+
## Tests
-Uses Node's built-in test runner (`node:test` + `node:assert/strict`) — no test framework to install.
+Uses Node's built-in test runner (`node:test` + `node:assert/strict`) — no test framework needed.
```
tests/
- ScoringConfig.test.js ← CREDIT_RATING_SCALE, gate values, sector overrides
+ 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 override formulas per asset type and sector
+ 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, score tiers
+ 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, null convention
- PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping
+ DataMapper.test.js ← type detection, PEG computation, trailing PE preference,
+ negative FCF, ETF volume, bond duration inference
+ PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping, BRK.B dot-notation normalisation
+ LLMAnalyst.test.js ← markdown fence stripping, JSON parse correctness
```
-**Key unit to remember:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation. `minSpread` in `ScoringConfig` is also in percentage form (e.g. `1.0` = 1%).
+Pre-commit hook runs `lint-staged` (Prettier) then `npm test`. Pre-push hook runs `npm test`.
+Test output: silent on pass, shows only failures + one summary line (`scripts/summary-reporter.js`).
+
+**Key unit:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation.
+
+---
## Conventions
-- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map in ScreenerEngine, and ScoringRules.
+- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules.
- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers.
-- BenchmarkProvider caches for 1 hour — restart the process to force a fresh fetch during development.
+- BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch.
- All entry points live in `bin/`. Do not add logic to entry points — they call into `src/`.
+- `bin/server.js` starts Fastify; `src/server/` contains all route logic.
+- **Never** call `process.exit()` inside `src/` — only `bin/` may do that.
+- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `src/server/routes/screener.js` `serializeAssets()`).
-## Planned Enhancements
+---
-### 1. Rate Sensitivity Flag (no new fetches needed)
-Flag assets exposed to rate changes using `riskFreeRate` and `rateRegime`:
-- Long-duration bonds (duration > 7) in HIGH rate regime → warn
-- REITs with high D/E in HIGH rate regime → warn
-- Growth stocks with negative FCF in HIGH rate regime → warn
+## Architecture Roadmap
-### 2. 52-Week Positioning (data already mapped)
-`week52High` and `week52Low` are in asset metrics. Add interpretation:
-- `> 85%` position + high P/E = crowded/momentum trade
-- `< 15%` position + passing fundamental gates = potential opportunity
+Planned improvements in priority order. Do not start a later phase before completing earlier ones.
-### 3. Sector Rotation Cues (3–4 new fetches)
-Add XLF, XLE, XLV, XLI to BenchmarkProvider. Compare each sector ETF's 1-year return to S&P 500 to identify leading/lagging sectors.
+### Phase 1 — Cleanup ✅ COMPLETE
+All items completed. Additional features delivered alongside cleanup:
-### 4. Client/Server Architecture
-Planned split into:
-- **Server** (Fastify + BullMQ) — API endpoints, webhook-driven news monitoring, WebSocket for live updates
-- **Client** (SvelteKit) — interactive dashboard replacing the HTML reports
-- LLM integration (Claude Haiku) wired into the server for deeper catalyst analysis
+**Cleanup done:**
+- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md`
+- Deleted `src/server/routes/analyze.js` (orphaned route file)
+- Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte`
+- Fixed `.gitignore` — `portfolio.json`, `market-calls.json`, `.env` are now excluded from git
+
+**Features added during Phase 1:**
+- `POST /api/analyze` — per-tab LLM analysis with sidebar (✦ Analyze button on each asset section)
+- `POST /api/finance/holdings` + `DELETE /api/finance/holdings/:ticker` — add/edit/delete holdings via UI
+- Portfolio page: inline row editing, optimistic UI updates, sortable columns, collapsible market context with tooltips, P&L summary card tooltips
+- Holdings can be added/edited/deleted via the portfolio UI (manual entry replaces CSV importer)
+- `BRK.B` dot-notation tickers now normalised to Yahoo Finance format (`BRK.B → BRK-B`)
+- Market graph drawing-line animation replaces generic spinner (lg/md); dot-pulse for sm (buttons)
+- Portfolio page loads client-side (`$effect`) to avoid blocking navigation
+- Catalyst page auto-loads on mount; LLM analysis only runs on explicit ✦ Analyze click
+
+**Pending (deferred to later):**
+- LLM Analysis button on portfolio page (analyse holdings against current news)
+
+### Phase 2 — Extract Shared Utilities
+- Create `ui/src/lib/utils.ts` with all pure functions currently duplicated across pages: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass`
+- Create `src/server/utils/logger.js` with shared `noopLogger` constant (currently copy-pasted in `screener.js` and `app.js`)
+
+### Phase 3 — Rename `src/` → `server/`
+- Rename the directory and update all import paths in `bin/`, internal routes, and `CLAUDE.md`
+- Makes the API layer unambiguous — `src/` conventionally implies "all project source"
+
+### Phase 4 — SCSS Migration
+Replace per-component `
diff --git a/ui/src/lib/SignalBadge.svelte b/ui/src/lib/SignalBadge.svelte
new file mode 100644
index 0000000..52ad5a3
--- /dev/null
+++ b/ui/src/lib/SignalBadge.svelte
@@ -0,0 +1,29 @@
+
+
+{signal ?? '—'}
+
+
diff --git a/ui/src/lib/Spinner.svelte b/ui/src/lib/Spinner.svelte
new file mode 100644
index 0000000..e2c2cb5
--- /dev/null
+++ b/ui/src/lib/Spinner.svelte
@@ -0,0 +1,139 @@
+
+
+{#if size === 'sm'}
+
+
+
+
+{:else}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if label}
+
{label}
+ {/if}
+
+{/if}
+
+
diff --git a/ui/src/lib/api.js b/ui/src/lib/api.js
new file mode 100644
index 0000000..60ace6b
--- /dev/null
+++ b/ui/src/lib/api.js
@@ -0,0 +1,96 @@
+const BASE = '/api';
+
+export async function screenTickers(tickers) {
+ const res = await fetch(`${BASE}/screen`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tickers }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function fetchCatalysts() {
+ const res = await fetch(`${BASE}/screen/catalysts`);
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function analyzeTickers(tickers) {
+ const res = await fetch(`${BASE}/analyze`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tickers }),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function fetchPortfolio() {
+ const res = await fetch(`${BASE}/finance/portfolio`);
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function addHolding(holding) {
+ const res = await fetch(`${BASE}/finance/holdings`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(holding),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function removeHolding(ticker) {
+ const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
+ method: 'DELETE',
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function fetchMarketContext() {
+ const res = await fetch(`${BASE}/finance/market-context`);
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+// ── Market Calls ──────────────────────────────────────────────────────────────
+
+export async function fetchCalls() {
+ const res = await fetch(`${BASE}/calls`);
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function fetchCall(id) {
+ const res = await fetch(`${BASE}/calls/${id}`);
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function createCall(payload) {
+ const res = await fetch(`${BASE}/calls`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function deleteCall(id) {
+ const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
+
+export async function fetchCallsCalendar(tickers = null) {
+ const url = tickers?.length
+ ? `${BASE}/calls/calendar?tickers=${tickers.join(',')}`
+ : `${BASE}/calls/calendar`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(await res.text());
+ return res.json();
+}
diff --git a/ui/src/routes/+layout.js b/ui/src/routes/+layout.js
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/ui/src/routes/+layout.js
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
new file mode 100644
index 0000000..4927685
--- /dev/null
+++ b/ui/src/routes/+layout.svelte
@@ -0,0 +1,132 @@
+
+
+
+
+ 📊 Market Screener
+
+
+
+
+ {#if $navigating}
+
+ {/if}
+
+
+ {#if $navigating}
+
+
+ {:else}
+ {@render children()}
+ {/if}
+
+
+
+
diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte
new file mode 100644
index 0000000..94b909e
--- /dev/null
+++ b/ui/src/routes/+page.svelte
@@ -0,0 +1,858 @@
+
+
+
+
+
+
+
+ {#if error}
+
⚠ {error}
+ {/if}
+
+ {#if loading || loadingCats}
+
+
+
+ {/if}
+
+ {#if ctx}
+
+
+
+ 10Y
+ {ctx.riskFreeRate?.toFixed(2)}%
+
+
+ VIX
+ {ctx.vixLevel?.toFixed(1)}
+
+
+ S&P
+ {ctx.sp500Price?.toLocaleString()}
+
+
+ S&P P/E
+ {fmtPE(ctx.benchmarks?.marketPE?.toFixed(1))}
+
+
+ Tech P/E
+ {fmtPE(ctx.benchmarks?.techPE?.toFixed(1))}
+
+
+ REIT Yld
+ {ctx.benchmarks?.reitYield?.toFixed(2)}%
+
+
+ IG Sprd
+ {ctx.benchmarks?.igSpread?.toFixed(2)}%
+
+
+ Rates
+ {ctx.rateRegime}
+
+
+ Vol
+ {ctx.volatilityRegime}
+
+
+
+
+
+
+
+
+
+
+ Ticker
+ Type
+ Signal
+ Mkt-Adjusted
+ Fundamental
+
+
+
+ {#each allAssets as r}
+
+ {r.asset.ticker}
+ {r.asset.type}
+
+
+
+ {verdictShort(r.inflated.label)}
+
+
+
+
+ {verdictShort(r.fundamental.label)}
+
+
+
+ {/each}
+
+
+
+
+
+
+ {#each ['STOCK', 'ETF', 'BOND'] as type}
+ {#if results[type]?.length}
+ {@const count = results[type].length}
+
+
+
+
+
+
+
+ Ticker
+ Price
+ Verdict
+ Score
+ {#if type === 'STOCK'}
+ Sector
+ P/E PEG ROE%
+ OpMgn% FCF% D/E
+ Flags
+ {:else if type === 'ETF'}
+ Expense Yield AUM 5Y Ret
+ {:else}
+ YTM Duration Rating
+ {/if}
+
+
+
+ {#each sorted(results[type]) as r}
+ {@const mode = getTab(type)}
+ {@const m = r.asset.displayMetrics ?? {}}
+ {@const v = r[mode]}
+
+ {r.asset.ticker}
+ {m.Price ?? '—'}
+
+
+ {verdictShort(v.label)}
+
+
+ {v.scoreSummary}
+ {#if type === 'STOCK'}
+ {m.Sector ?? '—'}
+ {m['P/E'] ?? '—'}
+ {m['PEG'] ?? '—'}
+ {m['ROE%'] ?? '—'}
+ {m['OpMgn%'] ?? '—'}
+ {m['FCF Yld%'] ?? '—'}
+ {m['D/E'] ?? '—'}
+
+ {#each v.audit?.riskFlags ?? [] as flag}
+ ⚠ {flag}
+ {/each}
+
+ {:else if type === 'ETF'}
+ {m['Exp Ratio%'] ?? '—'}
+ {m['Yield%'] ?? '—'}
+ {m['AUM'] ?? '—'}
+ {m['5Y Return%'] ?? '—'}
+ {:else}
+ {m['YTM%'] ?? '—'}
+ {m['Duration'] ?? '—'}
+ {m['Rating'] ?? '—'}
+ {/if}
+
+ {/each}
+
+
+
+
+ {/if}
+ {/each}
+
+ {#if results.ERROR?.length}
+
+ Failed {results.ERROR.length}
+
+ {#each results.ERROR as e}
+
{e.ticker} {e.message}
+ {/each}
+
+
+ {/if}
+ {/if}
+
+
+
+{#if sidebar.open}
+
+
+
+{/if}
+
+
diff --git a/ui/src/routes/calls/+page.js b/ui/src/routes/calls/+page.js
new file mode 100644
index 0000000..9ba6f7d
--- /dev/null
+++ b/ui/src/routes/calls/+page.js
@@ -0,0 +1,8 @@
+export async function load({ fetch }) {
+ const [callsRes, calRes] = await Promise.all([fetch('/api/calls'), fetch('/api/calls/calendar')]);
+
+ const { calls } = callsRes.ok ? await callsRes.json() : { calls: [] };
+ const { events } = calRes.ok ? await calRes.json() : { events: [] };
+
+ return { calls, events };
+}
diff --git a/ui/src/routes/calls/+page.svelte b/ui/src/routes/calls/+page.svelte
new file mode 100644
index 0000000..a2b5584
--- /dev/null
+++ b/ui/src/routes/calls/+page.svelte
@@ -0,0 +1,420 @@
+
+
+
+
+
+
+ {#if showForm}
+
+ {/if}
+
+
+ {#if (data.events ?? []).length > 0}
+
+
+
+ {#each upcoming as ev}
+
+
{ev.date}
+
+ {ev.ticker}
+
+ {eventIcon(ev.type)} {ev.label}
+ {#if ev.detail}· {ev.detail} {/if}
+
+ {#if ev.epsEstimate != null}
+ EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}
+ {/if}
+
+
+ {/each}
+ {#if past.length > 0}
+
— Past —
+ {#each past as ev}
+
+
{ev.date}
+
+ {ev.ticker}
+
+ {eventIcon(ev.type)} {ev.label}
+
+
+
+ {/each}
+ {/if}
+
+
+ {/if}
+
+
+ {#if data.error}
+
⚠ {data.error}
+ {:else if data.calls.length === 0}
+
No market calls yet. Create your first one to start tracking.
+ {:else}
+ {#each data.calls as call}
+
+
+
+
+
{call.thesis}
+
+ {#if Object.keys(call.snapshot ?? {}).length}
+
+
View performance →
+ {/if}
+
+
+ {/each}
+ {/if}
+
+
+
diff --git a/ui/src/routes/calls/[id]/+page.js b/ui/src/routes/calls/[id]/+page.js
new file mode 100644
index 0000000..a468725
--- /dev/null
+++ b/ui/src/routes/calls/[id]/+page.js
@@ -0,0 +1,5 @@
+export async function load({ fetch, params }) {
+ const res = await fetch(`/api/calls/${params.id}`);
+ if (!res.ok) return { error: await res.text() };
+ return res.json();
+}
diff --git a/ui/src/routes/calls/[id]/+page.svelte b/ui/src/routes/calls/[id]/+page.svelte
new file mode 100644
index 0000000..b23aaa5
--- /dev/null
+++ b/ui/src/routes/calls/[id]/+page.svelte
@@ -0,0 +1,202 @@
+
+
+
+ {#if data?.error}
+
⚠ {data.error}
+
+ {:else if data}
+
+
+
+
+ {data.quarter}
+ {data.date}
+ ({daysSince(data.date)} days ago)
+
+
{data.title}
+
{data.thesis}
+
+
+
+
+
+
+
+
+
+
+ Ticker
+ Call Price
+ Now
+ Return
+ Call Signal
+ Now Signal
+ Call Verdict
+ Now Verdict
+
+
+
+ {#each tickers as ticker}
+ {@const snap = snapshot[ticker]}
+ {@const cur = current[ticker]}
+ {@const ret = pctChange(snap?.price, cur?.price)}
+ = 10} class:worst={ret != null && ret <= -10}>
+ {ticker}
+ {fmt(snap?.price)}
+ {fmt(cur?.price)}
+ {fmtPct(ret)}
+
+ {#if snap?.signal}
+ {snap.signal}
+ {:else}
+ —
+ {/if}
+
+
+ {#if cur?.signal}
+ {cur.signal}
+ {:else}
+ —
+ {/if}
+
+
+ {#if snap?.inflatedVerdict}
+
+ {snap.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
+
+ {:else}
+ —
+ {/if}
+
+
+ {#if cur?.inflatedVerdict}
+
+ {cur.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
+
+ {:else}
+ —
+ {/if}
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+
diff --git a/ui/src/routes/portfolio/+page.js b/ui/src/routes/portfolio/+page.js
new file mode 100644
index 0000000..f9610a3
--- /dev/null
+++ b/ui/src/routes/portfolio/+page.js
@@ -0,0 +1,8 @@
+// Disable SSR — data is fetched client-side in the component so navigation
+// is instant instead of blocking until all Yahoo Finance calls resolve.
+export const ssr = false;
+export const prerender = false;
+
+export function load() {
+ return {};
+}
diff --git a/ui/src/routes/portfolio/+page.svelte b/ui/src/routes/portfolio/+page.svelte
new file mode 100644
index 0000000..e74ca9e
--- /dev/null
+++ b/ui/src/routes/portfolio/+page.svelte
@@ -0,0 +1,795 @@
+
+
+
+ {#if loading}
+
+
+
+
+ {:else if loadError}
+
{loadError}
+
+ {:else if data?.advice}
+
+
+
+ {formOpen ? '✕ Cancel' : '+ Add Holding'}
+
+ {#if refreshing}
+ Updating prices…
+ {/if}
+
+
+
+ {#if formOpen}
+
+ {/if}
+
+ {#if data.marketContext}
+
+ {/if}
+
+
+
+
+
+ Total Value
+
+ ?
+ Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position.
+
+
+
{fmtShort(totalValue)}
+
+
+
+ Total Cost
+
+ ?
+ Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered.
+
+
+
{fmtShort(totalCost)}
+
+
+
+ Total G/L
+
+ ?
+ Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down.
+
+
+
{fmtShort(totalGL)}
+
+
+
+
+
+ Holdings — Hold / Sell / Add Advice
+
+
+
+
+ {#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
+
+ Account Type Institution Balance
+
+ {#each pf.accounts as a}
+
+ {a.name}
+ {a.type}
+ {a.org}
+ {fmt(a.balance)}
+
+ {/each}
+
+
+
+
+
+ Spending — Last 30 Days
+
+ Category Amount % Share
+
+ {#each pf.categoryBreakdown.slice(0, 10) as c}
+
+ {c.category}
+ {fmt(c.amount)}
+ {c.pct}%
+
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {/if}
+
+
+
diff --git a/ui/src/routes/safe-buys/+page.js b/ui/src/routes/safe-buys/+page.js
new file mode 100644
index 0000000..b7a5624
--- /dev/null
+++ b/ui/src/routes/safe-buys/+page.js
@@ -0,0 +1,60 @@
+// Curated watchlist of well-established, low-cost ETFs and investment-grade bond funds.
+// Screened for Strong Buy signal under both Market-Adjusted and Fundamental lenses.
+const SAFE_WATCHLIST = [
+ // ── Broad Market ETFs
+ 'VOO', // S&P 500 — Vanguard (0.03%)
+ 'IVV', // S&P 500 — iShares (0.03%)
+ 'VTI', // Total US Market — Vanguard (0.03%)
+ 'SPY', // S&P 500 — SPDR (0.0945%)
+ 'QQQ', // Nasdaq-100 — Invesco (0.20%)
+ 'VEA', // Developed Markets ex-US — Vanguard
+ 'VWO', // Emerging Markets — Vanguard
+
+ // ── Dividend / Quality ETFs
+ 'VIG', // Dividend Appreciation — Vanguard
+ 'SCHD', // Dividend — Schwab (0.06%)
+ 'DGRO', // Dividend Growth — iShares
+ 'VYM', // High Dividend Yield — Vanguard
+
+ // ── Sector ETFs (established)
+ 'XLK', // Technology
+ 'XLV', // Healthcare
+ 'XLF', // Financials
+ 'XLE', // Energy
+
+ // ── Investment-Grade Bond ETFs
+ 'BND', // Total Bond Market — Vanguard
+ 'AGG', // US Aggregate Bond — iShares
+ 'LQD', // IG Corporate Bond — iShares
+ 'VCIT', // Intermediate Corp Bond — Vanguard
+
+ // ── Treasury ETFs
+ 'TLT', // 20+ Year Treasury — iShares
+ 'IEF', // 7-10 Year Treasury — iShares
+ 'SHY', // 1-3 Year Treasury — iShares
+ 'GOVT', // US Treasury — iShares
+ 'SGOV', // 0-3 Month T-Bill — iShares
+
+ // ── Municipal / TIPS
+ 'MUB', // Muni Bond — iShares
+ 'TIP', // TIPS — iShares
+];
+
+export async function load({ fetch }) {
+ const res = await fetch('/api/screen', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tickers: SAFE_WATCHLIST }),
+ });
+
+ if (!res.ok)
+ return { ETF: [], BOND: [], ERROR: [], marketContext: null, error: await res.text() };
+
+ const data = await res.json();
+ return {
+ ETF: data.ETF ?? [],
+ BOND: data.BOND ?? [],
+ ERROR: data.ERROR ?? [],
+ marketContext: data.marketContext ?? null,
+ };
+}
diff --git a/ui/src/routes/safe-buys/+page.svelte b/ui/src/routes/safe-buys/+page.svelte
new file mode 100644
index 0000000..1788b19
--- /dev/null
+++ b/ui/src/routes/safe-buys/+page.svelte
@@ -0,0 +1,368 @@
+
+
+
+
+
+ {#if data.error}
+
⚠ {data.error}
+ {/if}
+
+ {#if data.marketContext}
+
+ {/if}
+
+
+ {#if strongEtfs.length || strongBonds.length}
+
+
+ {#if strongEtfs.length}
+
+
+
+
+
+
+ Ticker
+ Price
+ Mkt-Adj
+ Graham
+ Expense
+ Yield
+ AUM
+ 5Y Ret
+ Score
+
+
+
+ {#each sorted(strongEtfs) as r}
+ {@const m = r.asset.displayMetrics ?? {}}
+
+ {r.asset.ticker}
+ {m.Price ?? '—'}
+ {verdictShort(r.inflated.label)}
+ {verdictShort(r.fundamental.label)}
+ {m['Exp Ratio%'] ?? '—'}
+ {m['Yield%'] ?? '—'}
+ {m['AUM'] ?? '—'}
+ {m['5Y Return%'] ?? '—'}
+ {r.inflated.scoreSummary}
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if strongBonds.length}
+
+
+
+
+
+
+ Ticker
+ Price
+ Mkt-Adj
+ Graham
+ YTM
+ Duration
+ Rating
+ Score
+
+
+
+ {#each sorted(strongBonds) as r}
+ {@const m = r.asset.displayMetrics ?? {}}
+
+ {r.asset.ticker}
+ {m.Price ?? '—'}
+ {verdictShort(r.inflated.label)}
+ {verdictShort(r.fundamental.label)}
+ {m['YTM%'] ?? '—'}
+ {m['Duration'] ?? '—'}
+ {m['Rating'] ?? '—'}
+ {r.inflated.scoreSummary}
+
+ {/each}
+
+
+
+
+ {/if}
+ {:else}
+
+ No assets currently pass both gates — market conditions may be elevated.
+ Check the Watch List below for assets passing at least one mode.
+
+ {/if}
+
+
+ {#if watchEtfs.length || watchBonds.length}
+
+
+ {#if watchEtfs.length}
+
+
+
+
+
+
+ Ticker
+ Price
+ Signal
+ Mkt-Adj
+ Graham
+ Expense
+ Yield
+ AUM
+ 5Y Ret
+
+
+
+ {#each sorted(watchEtfs) as r}
+ {@const m = r.asset.displayMetrics ?? {}}
+
+ {r.asset.ticker}
+ {m.Price ?? '—'}
+
+ {verdictShort(r.inflated.label)}
+ {verdictShort(r.fundamental.label)}
+ {m['Exp Ratio%'] ?? '—'}
+ {m['Yield%'] ?? '—'}
+ {m['AUM'] ?? '—'}
+ {m['5Y Return%'] ?? '—'}
+
+ {/each}
+
+
+
+
+ {/if}
+
+ {#if watchBonds.length}
+
+
+
+
+
+
+ Ticker
+ Price
+ Signal
+ Mkt-Adj
+ Graham
+ YTM
+ Duration
+ Rating
+
+
+
+ {#each sorted(watchBonds) as r}
+ {@const m = r.asset.displayMetrics ?? {}}
+
+ {r.asset.ticker}
+ {m.Price ?? '—'}
+
+ {verdictShort(r.inflated.label)}
+ {verdictShort(r.fundamental.label)}
+ {m['YTM%'] ?? '—'}
+ {m['Duration'] ?? '—'}
+ {m['Rating'] ?? '—'}
+
+ {/each}
+
+
+
+
+ {/if}
+ {/if}
+
+
+
diff --git a/ui/svelte.config.js b/ui/svelte.config.js
new file mode 100644
index 0000000..df45697
--- /dev/null
+++ b/ui/svelte.config.js
@@ -0,0 +1,5 @@
+import adapter from '@sveltejs/adapter-auto';
+
+export default {
+ kit: { adapter: adapter() },
+};
diff --git a/ui/vite.config.js b/ui/vite.config.js
new file mode 100644
index 0000000..1bfb950
--- /dev/null
+++ b/ui/vite.config.js
@@ -0,0 +1,11 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ server: {
+ proxy: {
+ '/api': 'http://127.0.0.1:3000',
+ },
+ },
+});