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 @@ + + +
+ + + + {#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 screenedAt} + Last screened {screenedAt} + {/if} +
+ + {#if searchOpen} +
+ e.key === 'Enter' && screen()} + /> + +
+ {/if} +
+ + {#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} +
+
+ + +
+
+

Signal Summary

+ {allAssets.length} assets +
+
+ + + + + + + + + + + + {#each allAssets as r} + + + + + + + + {/each} + +
TickerTypeSignalMkt-AdjustedFundamental
{r.asset.ticker}{r.asset.type} + + {verdictShort(r.inflated.label)} + + + + {verdictShort(r.fundamental.label)} + +
+
+
+ + + {#each ['STOCK', 'ETF', 'BOND'] as type} + {#if results[type]?.length} + {@const count = results[type].length} +
+
+

{type}S

+ {count} +
+ + +
+ +
+ +
+ + + + + + + + {#if type === 'STOCK'} + + + + + {:else if type === 'ETF'} + + {:else} + + {/if} + + + + {#each sorted(results[type]) as r} + {@const mode = getTab(type)} + {@const m = r.asset.displayMetrics ?? {}} + {@const v = r[mode]} + + + + + + {#if type === 'STOCK'} + + + + + + + + + {:else if type === 'ETF'} + + + + + {:else} + + + + {/if} + + {/each} + +
TickerPriceVerdictScoreSectorP/EPEGROE%OpMgn%FCF%D/EFlagsExpenseYieldAUM5Y RetYTMDurationRating
{r.asset.ticker}{m.Price ?? '—'} + + {verdictShort(v.label)} + + {v.scoreSummary}{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} + {m['Exp Ratio%'] ?? '—'}{m['Yield%'] ?? '—'}{m['AUM'] ?? '—'}{m['5Y Return%'] ?? '—'}{m['YTM%'] ?? '—'}{m['Duration'] ?? '—'}{m['Rating'] ?? '—'}
+
+
+ {/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} +
+

New Market Call

+
{ e.preventDefault(); submit(); }}> +
+ + + +
+ + + {#if formError} +
⚠ {formError}
+ {/if} + +
+
+ {/if} + + + {#if (data.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} + + + {#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.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} +
+
+ {/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}

+
+ + +
+
+

Performance since call date

+ {tickers.length} tickers +
+ +
+ + + + + + + + + + + + + + + {#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}> + + + + + + + + + + {/each} + +
TickerCall PriceNowReturnCall SignalNow SignalCall VerdictNow Verdict
{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} +
+
+
+ {/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} + +
+ + {#if refreshing} + Updating prices… + {/if} +
+ + + {#if formOpen} +
+
Add Holding
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ {#if formError} +
⚠ {formError}
+ {/if} +
+ {/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

+ + + + + + + + + + + + + + + + {#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} + + {/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} +
+ ✅ Strong Buy + Pass both Market-Adjusted and Fundamental gates +
+ + {#if strongEtfs.length} +
+
+

ETFs

+ {strongEtfs.length} +
+
+ + + + + + + + + + + + + + + + {#each sorted(strongEtfs) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + + {/each} + +
TickerPriceMkt-AdjGrahamExpenseYieldAUM5Y RetScore
{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}
+
+
+ {/if} + + {#if strongBonds.length} +
+
+

Bond ETFs

+ {strongBonds.length} +
+
+ + + + + + + + + + + + + + + {#each sorted(strongBonds) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + {/each} + +
TickerPriceMkt-AdjGrahamYTMDurationRatingScore
{r.asset.ticker}{m.Price ?? '—'}{verdictShort(r.inflated.label)}{verdictShort(r.fundamental.label)}{m['YTM%'] ?? '—'}{m['Duration'] ?? '—'}{m['Rating'] ?? '—'}{r.inflated.scoreSummary}
+
+
+ {/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} +
+ 👀 Watch List + Pass one gate — monitor for entry +
+ + {#if watchEtfs.length} +
+
+

ETFs

+ {watchEtfs.length} +
+
+ + + + + + + + + + + + + + + + {#each sorted(watchEtfs) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + + {/each} + +
TickerPriceSignalMkt-AdjGrahamExpenseYieldAUM5Y Ret
{r.asset.ticker}{m.Price ?? '—'}{verdictShort(r.inflated.label)}{verdictShort(r.fundamental.label)}{m['Exp Ratio%'] ?? '—'}{m['Yield%'] ?? '—'}{m['AUM'] ?? '—'}{m['5Y Return%'] ?? '—'}
+
+
+ {/if} + + {#if watchBonds.length} +
+
+

Bond ETFs

+ {watchBonds.length} +
+
+ + + + + + + + + + + + + + + {#each sorted(watchBonds) as r} + {@const m = r.asset.displayMetrics ?? {}} + + + + + + + + + + + {/each} + +
TickerPriceSignalMkt-AdjGrahamYTMDurationRating
{r.asset.ticker}{m.Price ?? '—'}{verdictShort(r.inflated.label)}{verdictShort(r.fundamental.label)}{m['YTM%'] ?? '—'}{m['Duration'] ?? '—'}{m['Rating'] ?? '—'}
+
+
+ {/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', + }, + }, +});