phase-1: optimize code

This commit is contained in:
Kazuma
2026-06-04 01:32:05 -04:00
parent cd74497de6
commit 3513024fc6
58 changed files with 7380 additions and 850 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "market-screener-ui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "ui"],
"port": 5173
}
]
}
+15
View File
@@ -1 +1,16 @@
node_modules 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
+312 -79
View File
@@ -4,37 +4,54 @@ Guidance for working in this repository.
## Overview ## 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. 1. **CLI**screens stocks, ETFs, and bonds via `npm start`, generates HTML reports
- **Fundamental** — strict Graham/value-investing style gates from `ScoringConfig`. Reflects genuine value regardless of market conditions. 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). The comparison produces a **Signal** (Strong Buy / Momentum / Speculation / Neutral / Avoid).
ES module project (`"type": "module"`); use `import`/`export`, not `require`. ES module project (`"type": "module"`); use `import`/`export`, not `require`.
---
## Commands ## Commands
```bash ```bash
npm install # install dependencies (yahoo-finance2, dotenv, prettier) npm install # install dependencies
npm start # Yahoo news → catalyst tickers → screener-report.html npm run dev # start API server (port 3000) + SvelteKit UI (port 5173) together
npm start -- watch # default watchlist npm run server # API server only (port 3000)
npm start -- AAPL MSFT VOO # specific tickers npm start # CLI: Yahoo news → catalyst tickers → screener-report.html
npm run finance # portfolio advice + SimpleFIN → finance-report.html npm start -- watch # CLI: default watchlist
npm run import-portfolio -- holdings.csv # import Robinhood/Vanguard CSV into portfolio.json npm start -- AAPL MSFT VOO # CLI: specific tickers
npm test # run all unit tests (node:test, zero deps) npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html
npm run test:watch # watch mode during development 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 # 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 ## Project Structure
``` ```
bin/ bin/
screen.js ← market screener entry point screen.js ← CLI screener entry point
finance.js ← personal finance entry point finance.js ← CLI personal finance entry point
import-portfolio.js ← broker CSV importer 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/ prompts/
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow) catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
@@ -42,44 +59,75 @@ prompts/
src/ src/
config/ config/
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth) 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 market/ ← Yahoo Finance data layer
YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff
BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext 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 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 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) RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
Chunker.js ← splits ticker list into batches Chunker.js ← splits ticker list into batches
assets/ assets/
Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers
Stock.js ← metrics: peRatio, pegRatio, priceToBook, ROE, opMargin, etc. Stock.js ← metrics + _mapToStandardSector (8 sectors detected)
Etf.js ← metrics: expenseRatio, yield, totalAssets Etf.js ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets
Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric
scorers/ scorers/
StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf) 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 BondScorer.js ← credit gate + spread/duration scoring
analyst/ 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/ finance/
clients/ 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 PersonalFinanceAnalyzer.js ← net worth, cash vs investments, spending by category
PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice
PortfolioImporter.js ← parses Robinhood/Vanguard/Fidelity CSV → merges into portfolio.json
reporters/ reporters/
HtmlReporter.js ← generates screener-report.html (tabbed inflated/fundamental views) HtmlReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
FinanceReporter.js ← generates finance-report.html (net worth, portfolio, spending) 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 portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY
``` ```
---
## Data Flow ## Data Flow
``` ```
@@ -90,9 +138,9 @@ BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD
rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } } rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } }
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND) 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) RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style)
INFLATED mode: sector override + MarketRegime live gate overrides 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 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 ## Scoring Modes
| Mode | P/E Gate (general) | Source | | Mode | P/E Gate (general) | P/E Gate (tech) | Source |
|---|---|---| |---|---|---|---|
| FUNDAMENTAL | 20x | ScoringConfig (Graham) | | FUNDAMENTAL | 15x | 35x | ScoringConfig (true Graham) |
| INFLATED | S&P 500 P/E × 1.5 | Live SPY data | | 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 | | Signal | Meaning |
|---|---| |---|---|
@@ -119,46 +195,129 @@ HtmlReporter — screener-report.html: signal summary + two tabbed tab
| 🔄 Neutral | Hold territory in one or both lenses | | 🔄 Neutral | Hold territory in one or both lenses |
| ❌ Avoid | Fails both | | ❌ 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. `src/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds.
- `ScoringRules` — per-type `{ gates, weights, thresholds }` + `SECTOR_OVERRIDE` map for STOCK.
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. | Sector | Key difference |
- **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). | 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) ## 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 | | Gate | Formula (NORMAL rates) | Formula (HIGH rates) |
|---|---| |---|---|---|
| Stock maxPERatio | SPY trailing P/E × 1.5 | | Stock maxPERatio | SPY trailing P/E × 1.5 | SPY trailing P/E × 1.2 |
| Tech maxPERatio | XLK P/E × 1.3 | | Tech maxPERatio | XLK P/E × 1.3 | XLK P/E × 1.3 |
| Tech maxPegGate | XLK P/E ÷ 15 | | Tech maxPegGate | XLK P/E ÷ 15 | XLK P/E ÷ 15 |
| REIT minYield | XLRE dividend yield × 0.85 | | REIT minYield | XLRE yield × 0.85 | XLRE yield × 0.95 |
| Bond minSpread | LQDTNX spread × 0.80 | | Bond minSpread | LQDTNX × 0.80 | LQDTNX × 0.90 |
| ETF maxExpenseRatio | 0.75% (structural loosening) | | 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 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. - `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it.
- `quickRatio` falls back to `currentRatio` when missing. - `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 ## SimpleFIN Auth Flow
1. User gets a Setup Token from https://beta-bridge.simplefin.org 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 2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim Access URL
3. Access URL is auto-appended to `.env` as `SIMPLEFIN_ACCESS_URL` 3. `onAccessUrlClaimed` callback is called with the URL — CLI uses `saveAccessUrlToEnv()`, server stores elsewhere
4. All subsequent requests use Access URL directly (setup token is one-time use) 4. All subsequent requests use Access URL with `Authorization: Basic` header (not embedded in URL)
---
## portfolio.json Format ## 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. `type` values: `stock`, `etf`, `crypto`. Crypto is priced via Yahoo (BTC-USD style) but not fundamentally scored.
---
## Tests ## 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/ 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 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 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 BondScorer.test.js ← credit gate, spread/duration scoring, unit handling
DataMapper.test.js ← type detection, PEG computation, null convention DataMapper.test.js ← type detection, PEG computation, trailing PE preference,
PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping 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 ## 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. - 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/`. - 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) ## Architecture Roadmap
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
### 2. 52-Week Positioning (data already mapped) Planned improvements in priority order. Do not start a later phase before completing earlier ones.
`week52High` and `week52Low` are in asset metrics. Add interpretation:
- `> 85%` position + high P/E = crowded/momentum trade
- `< 15%` position + passing fundamental gates = potential opportunity
### 3. Sector Rotation Cues (34 new fetches) ### Phase 1 — Cleanup ✅ COMPLETE
Add XLF, XLE, XLV, XLI to BenchmarkProvider. Compare each sector ETF's 1-year return to S&P 500 to identify leading/lagging sectors. All items completed. Additional features delivered alongside cleanup:
### 4. Client/Server Architecture **Cleanup done:**
Planned split into: - Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md`
- **Server** (Fastify + BullMQ) — API endpoints, webhook-driven news monitoring, WebSocket for live updates - Deleted `src/server/routes/analyze.js` (orphaned route file)
- **Client** (SvelteKit) — interactive dashboard replacing the HTML reports - Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte`
- LLM integration (Claude Haiku) wired into the server for deeper catalyst analysis - 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 `<style>` blocks with a shared token system in `ui/src/styles/`:
```
_tokens.scss ← all color variables, spacing, font-size scale
_reset.scss ← current 3-line app.css
_layout.scss ← shell, nav, main (from +layout.svelte)
_table.scss ← shared table/thead/.ticker/.num styles (used across 4 pages)
_buttons.scss ← btn-primary, btn-ghost, btn-analyze, btn-catalyst
_badges.scss ← verdict-pill, tag, sentiment-pill (resolves verdict-pill vs vpill inconsistency)
_section.scss ← section card + section-header pattern
app.scss ← root file, @uses all partials
```
Component `<style>` blocks should only contain styles genuinely unique to that component.
### Phase 5 — Decompose `+page.svelte`
At 962 lines it is the biggest maintenance liability. Extract into:
- `AssetTable.svelte` — accepts `type`, `rows`, `mode`; renders STOCK/ETF/BOND table with mode tabs and Analyze button
- `AnalysisSidebar.svelte` — owns open/close/loading state, takes `type` + `onAnalyze` as props
- `VerdictPill.svelte` — single component replacing both `.verdict-pill` and `.vpill` usages
- `MarketContextStrip.svelte` — consolidates the inline version in `+page.svelte` with the existing `MarketContext.svelte` in `$lib/`
Also: split `loadCatalysts()` into two functions (fetch tickers / screen tickers). Replace the `_booted` / `$effect` hack with a proper `+page.js` load function.
### Phase 6 — TypeScript
Convert server first (no framework coupling), then `$lib/utils`, then Svelte components.
Define shared types first:
```ts
type Signal = '✅ Strong Buy' | '⚡ Momentum' | '🔄 Neutral' | '⚠️ Speculation' | '❌ Avoid'
type AssetType = 'STOCK' | 'ETF' | 'BOND'
type ScoreMode = 'inflated' | 'fundamental'
interface ScreenerResult { STOCK, ETF, BOND, ERROR, marketContext }
interface MarketContext { sp500Price, riskFreeRate, vixLevel, rateRegime, benchmarks }
interface LLMAnalysis { summary, sentiment, affectedIndustries, relatedTickers }
interface MarketCall { id, title, quarter, date, thesis, tickers, snapshot }
interface PortfolioHolding { ticker, shares, costBasis, source, type }
```
SvelteKit supports TypeScript natively — components just need `<script lang="ts">`.
### Not Planned
- **npm workspaces / monorepo** — current `ui/` subdirectory structure works; high friction for low gain at this scale
- **Database** — JSON files are sufficient at current portfolio size; Yahoo Finance rate limiting is the real bottleneck, not storage. Revisit with SQLite only if portfolio grows to 500+ holdings with frequent concurrent reads.
---
## Adding a New Asset Type ## Adding a New Asset Type
@@ -229,3 +461,4 @@ Planned split into:
4. Create a Scorer in `src/screener/scorers/` exposing `score(metrics, rules, marketContext)`. 4. Create a Scorer in `src/screener/scorers/` exposing `score(metrics, rules, marketContext)`.
5. Add a mapper in `DataMapper.js`. 5. Add a mapper in `DataMapper.js`.
6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map. 6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map.
7. Add the new type to `serializeAssets()` handling in `src/server/routes/screener.js`.
+152 -90
View File
@@ -1,17 +1,22 @@
# Market Screener & Personal Finance Assistant # Market Screener
A Node.js CLI tool that screens stocks, ETFs, bonds, and crypto using live Yahoo Finance data. It scores each asset under two lenses — **Market-Adjusted** (what's acceptable in today's inflated market) and **Fundamental** (Graham-style strict value investing) — and gives you an honest signal by comparing both. A Node.js stock screener and personal finance assistant. Screens stocks, ETFs, and bonds using live Yahoo Finance data and scores each asset under two lenses — **Market-Adjusted** (what's acceptable in today's market) and **Fundamental** (strict Graham value-investing) — then compares both to give you an honest signal.
It also connects to your brokerage accounts via **SimpleFIN** to track net worth, spending, and give hold/sell/add advice on your actual portfolio. Comes with a **Fastify API server** and a companion **SvelteKit dashboard** (`../market-screener-ui`) for an interactive UI, or use it as a CLI to generate HTML reports.
--- ---
## Quick Start ## Quick Start
```bash ```bash
# API + Dashboard (recommended)
npm install npm install
cp .env.example .env # add SIMPLEFIN_SETUP_TOKEN if you have SimpleFIN cd ../market-screener-ui && npm install && cd ../market_screener
npm start # screen today's catalyst tickers from Yahoo news npm run dev # starts API on :3000 + UI on :5173
# open http://localhost:5173
# CLI only
npm start # screen today's news catalyst tickers → screener-report.html
``` ```
--- ---
@@ -20,39 +25,50 @@ npm start # screen today's catalyst tickers from Yahoo news
| Command | What it does | | Command | What it does |
|---|---| |---|---|
| `npm start` | Fetches today's market news, extracts catalyst tickers, screens them | | `npm run dev` | Start API server (port 3000) + SvelteKit UI (port 5173) together |
| `npm start -- watch` | Screens the default watchlist instead | | `npm run server` | Start API server only |
| `npm start -- AAPL MSFT VOO` | Screens specific tickers | | `npm start` | CLI: fetch today's market news, extract tickers, screen them |
| `npm run finance` | Portfolio advice + SimpleFIN account data → `finance-report.html` | | `npm start -- watch` | CLI: screen the default watchlist |
| `npm run import-portfolio -- file.csv` | Imports Robinhood/Vanguard/Fidelity CSV into `portfolio.json` | | `npm start -- AAPL MSFT VOO` | CLI: screen specific tickers |
| `npm test` | Runs all unit tests (51 tests, zero external dependencies) | | `npm run finance` | CLI: portfolio advice + SimpleFIN → `finance-report.html` |
| `npm run test:watch` | Re-runs tests on file changes during development | | `npm run import-portfolio -- file.csv` | Import Robinhood/Vanguard/Fidelity CSV into `portfolio.json` |
| `npm run format` | Formats all source files with Prettier | | `npm test` | Run all 61 unit tests |
| `npm run format:check` | Checks formatting without writing (useful in CI) | | `npm run test:watch` | Re-run tests on file changes |
| `npm run format` | Format all source files with Prettier |
Both commands generate self-contained HTML reports that open in any browser.
--- ---
## How the Screener Works ## How the Screener Works
Every asset is scored twice: Every asset is scored **twice** under different rule sets:
**Market-Adjusted** — gates derived from live Yahoo Finance benchmarks: ### Market-Adjusted mode
- Stock P/E gate = S&P 500 P/E (via SPY) × 1.5 Gates derived from live Yahoo Finance benchmarks — reflects what is acceptable in today's market:
- Tech P/E gate = XLK sector P/E × 1.3
- REIT min yield = XLRE dividend yield × 0.85
- Bond min spread = LQD TNX live spread × 0.80
**Fundamental** — strict Graham/value-investing gates from `src/config/ScoringConfig.js`: | Gate | Formula |
- Stock P/E < 20x, PEG < 1.5 |---|---|
- Bond spread > 1.0% above risk-free rate | Stock P/E | S&P 500 P/E (via SPY) × 1.5× (or × 1.2× in HIGH rate regime) |
| Tech P/E | XLK sector P/E × 1.3× |
| REIT min yield | XLRE dividend yield × 0.85× |
| Bond min spread | LQD TNX spread × 0.80× |
The comparison produces a **Signal**: ### Fundamental mode
Strict Graham/value-investing gates — reflects genuine value regardless of market conditions:
| Gate | Value | Rationale |
|---|---|---|
| Stock P/E | < 15× | Graham's actual rule (trailing earnings) |
| Stock PEG | < 1.0 | Lynch standard: PEG > 1.0 = paying full price |
| D/E ratio | < 1.5× | Distress typically starts above 2× |
| Quick ratio | > 0.8 | Below 0.8 = real liquidity stress |
| Bond spread | > 1.5% | Must clear risk-free rate meaningfully |
| Bond duration | < 7 years | Rate sensitivity management |
### Signals
| Signal | Meaning | | Signal | Meaning |
|---|---| |---|---|
| ✅ Strong Buy | Passes both — genuinely good value | | ✅ Strong Buy | Passes both lenses — genuinely good value |
| ⚡ Momentum | Passes market-adjusted, holds fundamentally | | ⚡ Momentum | Passes market-adjusted, holds fundamentally |
| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection | | ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection |
| 🔄 Neutral | Hold territory in one or both lenses | | 🔄 Neutral | Hold territory in one or both lenses |
@@ -60,6 +76,38 @@ The comparison produces a **Signal**:
--- ---
## Sector Overrides
Sector-specific rules apply in both modes (not just inflated):
| Sector | Key adjustments |
|---|---|
| **Technology** | P/E up to 35×, D/E up to 2.0 (buybacks), FCF weight raised |
| **REIT** | P/E/PEG disabled, scored on dividend yield + P/FFO proxy |
| **Financial** | D/E disabled, scored on ROE (≥12%) + P/B (< 1.5×) |
| **Energy** | FCF primary signal (weight 4), dividend yield scored |
| **Healthcare** | Revenue growth primary, P/E up to 25× (R&D burn) |
| **Communication** | FCF primary (META/GOOGL platforms), P/E up to 25× |
| **Consumer Staples** | Margin/ROE focus, low revenue growth expectations (25%) |
| **Consumer Discretionary** | Revenue growth primary, P/E up to 25× |
---
## API Server
```
GET /health → { status: "ok" }
POST /api/screen → screen tickers
body: { tickers: string[] }
GET /api/screen/catalysts → Yahoo news → { tickers, stories }
GET /api/finance/portfolio → portfolio advice + net worth
GET /api/finance/market-context → live benchmark data
```
Set `CLIENT_ORIGIN` env var to allow a different CORS origin (default: `http://localhost:5173`).
---
## Personal Finance ## Personal Finance
Edit `portfolio.json` with your holdings (or import from a broker CSV): Edit `portfolio.json` with your holdings (or import from a broker CSV):
@@ -74,15 +122,13 @@ Edit `portfolio.json` with your holdings (or import from a broker CSV):
} }
``` ```
`npm run finance` screens your holdings, fetches crypto prices, and generates hold/sell/add advice based on the screener signal crossed with your gain/loss position. `npm run finance` (CLI) or `GET /api/finance/portfolio` (API) screens your holdings and cross-references the screener signal with your gain/loss position to give hold/sell/add advice.
### SimpleFIN (optional) ### SimpleFIN (optional — live bank/brokerage balances)
Connects to your real bank and brokerage accounts for net worth, balances, and 30-day spending breakdown.
1. Get your setup token from [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org) 1. Get your setup token from [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org)
2. Add to `.env`: `SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...` 2. Add to `.env`: `SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...`
3. Run `npm run finance` the Access URL is claimed and saved automatically 3. On first run the Access URL is claimed and saved to `.env` automatically
### Importing broker holdings ### Importing broker holdings
@@ -91,73 +137,89 @@ npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv
npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv
``` ```
Broker is auto-detected from CSV headers. Running multiple imports merges them into `portfolio.json`. Broker is auto-detected from CSV headers. Multiple imports merge into `portfolio.json`.
--- ---
## Project Structure ## Project Structure
``` ```
├── bin/ bin/
│ ├── screen.js # Market screener entry point screen.js CLI screener entry point
│ ├── finance.js # Personal finance entry point finance.js CLI personal finance entry point
│ └── import-portfolio.js # Broker CSV importer import-portfolio.js Broker CSV importer
server.js Fastify API server entry point
├── prompts/
│ └── catalyst-analysis.md # Daily catalyst analysis playbook scripts/
summary-reporter.js Custom node:test reporter (silent pass, shows failures + summary)
├── src/
│ ├── config/ src/
│ │ └── ScoringConfig.js # All scoring gates, weights, thresholds config/
│ │ ScoringConfig.js All gates, weights, thresholds (single source of truth)
│ ├── market/ # Yahoo Finance data layer constants.js Shared enums: SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME
│ │ ├── YahooClient.js
│ │ ├── BenchmarkProvider.js market/
│ │ └── MarketRegime.js # Derives inflated gate overrides from live data YahooClient.js Wraps yahoo-finance2 v3 with retry + backoff
│ │ BenchmarkProvider.js Fetches live benchmarks → marketContext (1-hour cache)
│ ├── screener/ # Core screening domain MarketRegime.js Derives inflated gate overrides from live data + rate regime
│ │ ├── ScreenerEngine.js
│ │ ├── DataMapper.js screener/
│ │ ├── RuleMerger.js ScreenerEngine.js Orchestrates fetch → score × 2.
│ │ ├── Chunker.js screenTickers() → pure data (server/CLI)
│ │ ├── assets/ # Stock, Etf, Bond data containers screenWithProgress() → with stdout progress (CLI only)
│ │ └── scorers/ # StockScorer, EtfScorer, BondScorer DataMapper.js Normalises Yahoo payload → flat asset objects
│ │ Uses trailingPE (not forwardPE). Preserves negative FCF.
│ ├── analyst/ RuleMerger.js Merges base rules + sector overrides + MarketRegime
│ └── CatalystAnalyst.js # Extracts tickers from Yahoo Finance news assets/ Stock, Etf, Bond data containers
│ │ scorers/ StockScorer, EtfScorer, BondScorer (stateless)
│ ├── finance/
│ ├── clients/ analyst/
│ │ └── SimpleFINClient.js CatalystAnalyst.js Extracts tickers from Yahoo Finance news headlines
│ │ ├── PersonalFinanceAnalyzer.js
│ │ ├── PortfolioAdvisor.js finance/
│ │ └── PortfolioImporter.js clients/
│ │ SimpleFINClient.js Auth + account fetching via Basic Auth header
│ └── reporters/ PersonalFinanceAnalyzer.js Net worth, cash vs investments, spending
│ ├── HtmlReporter.js # screener-report.html PortfolioAdvisor.js Hold/sell/add advice per holding
│ └── FinanceReporter.js # finance-report.html PortfolioImporter.js Parses Robinhood/Vanguard/Fidelity CSV
├── portfolio.json # Your holdings (edit this) reporters/
└── .env # SIMPLEFIN_SETUP_TOKEN / SIMPLEFIN_ACCESS_URL HtmlReporter.js render() → string | generate() → file (CLI)
FinanceReporter.js render() → string | generate() → file (CLI)
server/
app.js Fastify app factory (buildApp)
routes/
screener.js POST /api/screen, GET /api/screen/catalysts
finance.js GET /api/finance/portfolio, GET /api/finance/market-context
``` ```
--- ---
## Metrics Scored per Stock ## Environment Variables
| Metric | Source | Why it matters | ```bash
|---|---|---| # .env
| P/E ratio | Yahoo forwardPE / trailingPE | Valuation | SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
| PEG ratio | Yahoo or computed (trailingPE ÷ earningsGrowth) | Valuation vs growth | # or on first run:
| Price-to-Book | Yahoo | Graham's primary value metric | SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
| ROE | Yahoo returnOnEquity | Buffett's primary quality metric |
| Operating margin | Yahoo operatingMargins | Pricing power |
| Net profit margin | Yahoo profitMargins | Bottom-line profitability |
| Revenue growth | Yahoo revenueGrowth | Top-line momentum |
| FCF yield | Computed (freeCashflow ÷ market cap) | Cash generation quality |
| Debt/Equity | Yahoo debtToEquity | Balance sheet risk |
| Quick ratio | Yahoo quickRatio (falls back to currentRatio) | Liquidity |
| Beta | Yahoo beta | Market sensitivity |
| 52-week position | Yahoo fiftyTwoWeekHigh/Low | Momentum / opportunity flag |
Sector overrides apply: REIT scores on yield + P/FFO, FINANCIAL on ROE + P/B, TECHNOLOGY with realistic D/E tolerance. # Optional server config
PORT=3000
HOST=0.0.0.0
CLIENT_ORIGIN=http://localhost:5173 # CORS allowed origin for SvelteKit UI
```
---
## Testing
61 unit tests, no external test framework:
```bash
npm test # summary output: "✅ 61 tests: 61 passed (0.02s)"
npm run test:watch # verbose spec output for development
```
Pre-commit: Prettier (auto-format staged files) + full test suite.
Pre-push: full test suite.
-36
View File
@@ -1,36 +0,0 @@
/**
* bin/import-portfolio.js — Portfolio CSV Importer
*
* Reads a holdings export from Robinhood, Vanguard, or Fidelity
* and merges the positions into portfolio.json.
*
* Broker is auto-detected from CSV headers.
* Existing entries are updated in-place; new tickers are added.
*
* How to export:
* Robinhood → Account → Statements & History → Export → Holdings
* Vanguard → My Accounts → Holdings → Download (top-right icon)
* Fidelity → Accounts & Trade → Portfolio → Positions → Download CSV
*
* Usage:
* npm run import-portfolio -- <file.csv>
*/
import { PortfolioImporter } from '../src/finance/PortfolioImporter.js';
const csvPath = process.argv[2];
if (!csvPath) {
console.error('Usage: npm run import-portfolio -- <path-to-csv>\n');
console.error('Examples:');
console.error(' npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv');
console.error(' npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv');
process.exit(1);
}
try {
new PortfolioImporter().import(csvPath);
} catch (err) {
console.error(`\nImport failed: ${err.message}`);
process.exit(1);
}
+14
View File
@@ -0,0 +1,14 @@
import 'dotenv/config';
import { buildApp } from '../src/server/app.js';
const PORT = process.env.PORT ?? 3000;
const HOST = process.env.HOST ?? '0.0.0.0';
const app = await buildApp();
try {
await app.listen({ port: Number(PORT), host: HOST });
} catch (err) {
app.log.error(err);
process.exit(1);
}
-82
View File
@@ -1,82 +0,0 @@
// finance.js — Personal Finance Entry Point
//
// Runs independently of the news screener.
// Fetches your accounts from SimpleFIN, screens your portfolio holdings,
// and generates a finance-report.html with:
//
// 1. Net worth + account overview (SimpleFIN)
// 2. Portfolio positions with hold/sell/add advice (screener + crypto prices)
// 3. Spending breakdown (SimpleFIN)
//
// Usage:
// npm run finance
import 'dotenv/config';
import { readFileSync, existsSync } from 'fs';
import { SimpleFINClient } from './src/finance/SimpleFINClient.js';
import { PersonalFinanceAnalyzer } from './src/finance/PersonalFinanceAnalyzer.js';
import { PortfolioAdvisor } from './src/finance/PortfolioAdvisor.js';
import { ScreenerEngine } from './src/core/engine/ScreenerEngine.js';
import { FinanceReporter } from './src/reporters/FinanceReporter.js';
async function main() {
// ── 1. Load portfolio ──────────────────────────────────────────────────────
if (!existsSync('./portfolio.json')) {
console.error('portfolio.json not found. Edit it with your holdings and re-run.');
process.exit(1);
}
const { holdings } = JSON.parse(readFileSync('./portfolio.json', 'utf8'));
const byType = holdings.reduce((acc, h) => {
const t = h.type ?? 'stock';
acc[t] = (acc[t] ?? 0) + 1;
return acc;
}, {});
console.log(
`📋 Portfolio: ${holdings.length} positions — ${Object.entries(byType)
.map(([t, n]) => `${n} ${t}`)
.join(', ')}\n`,
);
// ── 2. Fetch SimpleFIN data ────────────────────────────────────────────────
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) {
try {
process.stdout.write('💰 Fetching accounts from SimpleFIN...');
const client = new SimpleFINClient();
await client.init();
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
process.stdout.write(` ${accounts.length} accounts loaded\n`);
} catch (err) {
process.stdout.write(` skipped — ${err.message}\n`);
}
} else {
console.log(
' SimpleFIN not configured — add SIMPLEFIN_SETUP_TOKEN to .env for account data\n',
);
}
// ── 3. Screen stocks + ETFs (crypto handled separately by PortfolioAdvisor) ─
const screenableTickers = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => h.ticker.toUpperCase());
let results = { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} };
if (screenableTickers.length > 0) {
process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`);
results = await new ScreenerEngine().screenTickers(screenableTickers);
process.stdout.write(' done\n');
}
// ── 4. Fetch crypto prices + generate advice ───────────────────────────────
process.stdout.write('💡 Generating advice...');
const advice = await new PortfolioAdvisor().advise(holdings, results);
process.stdout.write(' done\n');
// ── 5. Generate report ─────────────────────────────────────────────────────
const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext);
console.log(`\n✅ Finance report saved to: ${reportPath}\n`);
}
main().catch((err) => console.error('Failed:', err.message));
-34
View File
@@ -1,34 +0,0 @@
// import-portfolio.js
//
// Imports holdings from a broker CSV export into portfolio.json.
//
// Usage:
// npm run import-portfolio -- holdings.csv
//
// Supported brokers (auto-detected from headers):
// Robinhood → Account → Statements & History → Export → Holdings
// Vanguard → My Accounts → Holdings → Download (top-right icon)
// Fidelity → Accounts & Trade → Portfolio → Positions → Download CSV
//
// If you have multiple brokers, run the command once per file —
// each import merges into portfolio.json without overwriting previous entries.
import { PortfolioImporter } from './src/finance/PortfolioImporter.js';
const csvPath = process.argv[2];
if (!csvPath) {
console.error('Usage: npm run import-portfolio -- <path-to-csv>');
console.error('');
console.error('Examples:');
console.error(' npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv');
console.error(' npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv');
process.exit(1);
}
try {
new PortfolioImporter().import(csvPath);
} catch (err) {
console.error(`\n======>>>> Import failed <<<<====== ${err.message}`);
process.exit(1);
}
-47
View File
@@ -1,47 +0,0 @@
### Request: Optimize Investment Strategy Configuration
I am updating my investment strategy configuration. You are acting as a Senior Quantitative Financial Strategist. Please analyze my current market thesis and update the configuration parameters to align with this view.
**Market Thesis:** [INSERT YOUR THESIS HERE]
### Reasoning Phase (Before the JSON)
1. Briefly summarize your logic for the changes (e.g., "Raising the `maxDebtToEquity` gate because high-interest environments make capital-intensive businesses riskier").
2. Ensure all values are mathematically sound and consistent with the requested thesis.
### JSON Output Requirements
- Return a valid JSON object matching the schema below.
- Ensure all numbers are appropriate for the asset class (e.g., Debt/Equity usually 0-5, P/E usually 0-100).
- **Crucial:** Provide _only_ the JSON inside a single code block. No conversational text after the code block.
```json
{
"STOCK": {
"gates": {
"maxDebtToEquity": 0.0,
"minQuickRatio": 0.0,
"maxPERatio": 0.0
},
"weights": { "margin": 0, "peg": 0, "revenue": 0, "fcf": 0 },
"thresholds": {
"marginHigh": 0,
"marginMed": 0,
"pegHigh": 0,
"pegMed": 0,
"revHigh": 0,
"revMed": 0
}
},
"ETF": {
"gates": { "maxExpenseRatio": 0.0 },
"weights": { "yield": 0, "lowCost": 0 },
"thresholds": { "minYield": 0.0, "maxExpense": 0.0 }
},
"BOND": {
"gates": { "minCreditRating": 0 },
"weights": { "yieldSpread": 0, "duration": 0 },
"thresholds": { "minSpread": 0.0, "maxDuration": 0 }
}
}
```
+741 -2
View File
@@ -8,15 +8,47 @@
"name": "market-screener", "name": "market-screener",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.100.1",
"@fastify/cors": "^11.2.0",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"fastify": "^5.8.5",
"yahoo-finance2": "^3.15.2" "yahoo-finance2": "^3.15.2"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.3",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0" "prettier": "^3.0.0"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.100.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.100.1.tgz",
"integrity": "sha512-RANcEe7LpiLczkKGOwoXOTuFdPhuubS0i4xaAKOMpcqc55YO0mukgxppV7eygx3DXNjxWT6RYOLPyOy0aIAmwg==",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@deno/shim-deno": { "node_modules/@deno/shim-deno": {
"version": "0.18.2", "version": "0.18.2",
"resolved": "https://registry.npmjs.org/@deno/shim-deno/-/shim-deno-0.18.2.tgz", "resolved": "https://registry.npmjs.org/@deno/shim-deno/-/shim-deno-0.18.2.tgz",
@@ -33,6 +65,138 @@
"integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==", "integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
"integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
"integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"fast-json-stringify": "^6.0.0"
}
},
"node_modules/@fastify/forwarded": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
"integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@fastify/proxy-addr": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
"integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"@fastify/forwarded": "^3.0.0",
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/proxy-addr/node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"engines": {
"node": ">= 10"
}
},
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.14", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -85,6 +249,21 @@
} }
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -170,6 +349,33 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz",
"integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"@fastify/error": "^4.0.0",
"fastq": "^1.17.1"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -287,6 +493,20 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/colorette": { "node_modules/colorette": {
"version": "2.0.20", "version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -302,6 +522,30 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/concurrently": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz",
"integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==",
"dev": true,
"dependencies": {
"chalk": "5.6.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.4",
"supports-color": "10.2.2",
"tree-kill": "1.2.2",
"yargs": "18.0.0"
},
"bin": {
"conc": "dist/bin/index.js",
"concurrently": "dist/bin/index.js"
},
"engines": {
"node": ">=22"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -420,6 +664,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"engines": {
"node": ">=6"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -509,6 +761,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -644,12 +905,53 @@
"express": ">= 4.11" "express": ">= 4.11"
} }
}, },
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-json-stringify": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz",
"integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"@fastify/merge-json-schemas": "^0.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0",
"json-schema-ref-resolver": "^3.0.0",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
@@ -666,6 +968,61 @@
], ],
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/fastify": {
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
"integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"@fastify/ajv-compiler": "^4.0.5",
"@fastify/error": "^4.0.0",
"@fastify/fast-json-stringify-compiler": "^5.0.0",
"@fastify/proxy-addr": "^5.0.0",
"abstract-logging": "^2.0.1",
"avvio": "^9.0.0",
"fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0",
"light-my-request": "^6.0.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
"semver": "^7.6.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/fetch-mock-cache": { "node_modules/fetch-mock-cache": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/fetch-mock-cache/-/fetch-mock-cache-2.3.1.tgz", "resolved": "https://registry.npmjs.org/fetch-mock-cache/-/fetch-mock-cache-2.3.1.tgz",
@@ -751,6 +1108,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -778,6 +1148,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": { "node_modules/get-east-asian-width": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
@@ -1043,6 +1422,36 @@
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)" "license": "(AFL-2.1 OR BSD-3-Clause)"
}, },
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -1055,6 +1464,53 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"cookie": "^1.0.1",
"process-warning": "^4.0.0",
"set-cookie-parser": "^2.6.0"
}
},
"node_modules/light-my-request/node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/light-my-request/node_modules/process-warning": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -1331,6 +1787,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1419,6 +1883,40 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
},
"node_modules/pkce-challenge": { "node_modules/pkce-challenge": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -1443,6 +1941,21 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1498,6 +2011,11 @@
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1522,6 +2040,14 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1568,11 +2094,27 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": { "node_modules/rfdc": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
"dev": true
}, },
"node_modules/router": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
@@ -1590,12 +2132,76 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -1641,6 +2247,11 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1668,6 +2279,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1768,6 +2391,31 @@
"url": "https://github.com/chalk/slice-ansi?sponsor=1" "url": "https://github.com/chalk/slice-ansi?sponsor=1"
} }
}, },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1842,6 +2490,34 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="
},
"node_modules/tldts": { "node_modules/tldts": {
"version": "6.1.86", "version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
@@ -1872,6 +2548,14 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/toad-cache": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz",
"integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==",
"engines": {
"node": ">=20"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1908,6 +2592,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/trim-repeated": { "node_modules/trim-repeated": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
@@ -1920,6 +2613,17 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
@@ -2026,6 +2730,15 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/yahoo-finance2": { "node_modules/yahoo-finance2": {
"version": "3.15.2", "version": "3.15.2",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.15.2.tgz", "resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.15.2.tgz",
@@ -2076,6 +2789,32 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yargs": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
"dev": true,
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^7.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+7 -1
View File
@@ -4,8 +4,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node bin/screen.js", "start": "node bin/screen.js",
"server": "node bin/server.js",
"dev": "concurrently -n api,ui -c cyan,magenta \"node bin/server.js\" \"npm run dev --prefix ui\"",
"ui:install": "npm install --prefix ui --legacy-peer-deps",
"finance": "node bin/finance.js", "finance": "node bin/finance.js",
"import-portfolio": "node bin/import-portfolio.js",
"test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js", "test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js",
"test:watch": "node --test --watch --test-reporter=spec tests/*.test.js", "test:watch": "node --test --watch --test-reporter=spec tests/*.test.js",
"format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"", "format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"",
@@ -18,10 +20,14 @@
] ]
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.100.1",
"@fastify/cors": "^11.2.0",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"fastify": "^5.8.5",
"yahoo-finance2": "^3.15.2" "yahoo-finance2": "^3.15.2"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.3",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0" "prettier": "^3.0.0"
+4 -3
View File
@@ -5,15 +5,16 @@ const MAX_STORIES = 15;
const TICKER_REGEX = /^[A-Z]{1,6}$/; const TICKER_REGEX = /^[A-Z]{1,6}$/;
export class CatalystAnalyst { export class CatalystAnalyst {
constructor() { constructor({ logger } = {}) {
this.client = new YahooClient(); this.client = new YahooClient();
this.logger = logger ?? { write: (msg) => process.stdout.write(msg) };
} }
async run() { async run() {
process.stdout.write('🔍 Fetching market news...'); this.logger.write('🔍 Fetching market news...');
const stories = await this._fetchNews(); const stories = await this._fetchNews();
const tickers = this._extractTickers(stories); const tickers = this._extractTickers(stories);
process.stdout.write(` ${stories.length} stories, ${tickers.length} tickers\n`); this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
return { tickers, stories }; return { tickers, stories };
} }
+78
View File
@@ -0,0 +1,78 @@
import Anthropic from '@anthropic-ai/sdk';
// LLMAnalyst — uses Claude Haiku to analyze news catalyst stories.
//
// Given a list of news headlines and the tickers already identified,
// it produces:
// - A concise market summary (2-3 sentences)
// - Industries likely to be affected (beyond the directly mentioned tickers)
// - Up to 5 related tickers worth watching
// - A risk sentiment assessment (BULLISH / NEUTRAL / BEARISH)
//
// Requires ANTHROPIC_API_KEY in environment.
const SYSTEM_PROMPT = `You are a professional equity analyst. You will be given a list of today's market news headlines and the tickers already identified as catalysts.
Your job is to:
1. Write a 2-3 sentence market summary capturing the dominant theme
2. Identify up to 4 industries that are likely to be secondarily affected (not directly mentioned but impacted by contagion, supply chain, regulation, or macro effects)
3. Suggest up to 5 related ticker symbols worth screening that are NOT already in the provided list
4. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH based on the news
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
{
"summary": "string",
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
"affectedIndustries": [
{ "name": "string", "reason": "string (one sentence)" }
],
"relatedTickers": [
{ "ticker": "string", "reason": "string (one sentence)" }
]
}`;
export class LLMAnalyst {
constructor({ logger } = {}) {
this.logger = logger ?? { log: console.log, warn: console.warn };
this.client = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null;
}
// Analyzes news stories and returns structured market intelligence.
// Returns null if ANTHROPIC_API_KEY is not set (graceful degradation).
async analyze(stories, existingTickers = []) {
if (!this.client) {
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
return null;
}
if (!stories?.length) return null;
const headlines = stories
.slice(0, 15)
.map((s, i) => `${i + 1}. ${s.title} (${s.publisher ?? 'unknown'})`)
.join('\n');
const userMessage = `Today's market news headlines:\n\n${headlines}\n\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
try {
const response = await this.client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMessage }],
});
const raw = response.content[0]?.text ?? '';
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned);
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', err.message);
return null;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
const STORE_PATH = './market-calls.json';
// MarketCallStore — persists quarterly market thesis entries to market-calls.json.
//
// A market call captures:
// - A written thesis (the reasoning behind the call)
// - Tickers to watch
// - A snapshot of each ticker's price + signal at the time of the call
// - Performance tracking (current vs snapshot price) computed on read
//
// Format:
// {
// "calls": [
// {
// "id": "uuid",
// "title": "Q3 2025 — Rate pivot & tech rotation",
// "quarter": "Q3 2025",
// "date": "2025-07-01",
// "thesis": "The Fed is expected to begin cutting...",
// "tickers": ["AAPL", "MSFT", "TLT"],
// "snapshot": {
// "AAPL": { "price": 195.00, "signal": "✅ Strong Buy", "verdict": "BUY (High Conviction)" }
// },
// "createdAt": "2025-07-01T14:22:00.000Z"
// }
// ]
// }
export class MarketCallStore {
_load() {
if (!existsSync(STORE_PATH)) return { calls: [] };
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8'));
} catch {
return { calls: [] };
}
}
_save(data) {
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
}
list() {
return this._load().calls.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
get(id) {
return this._load().calls.find((c) => c.id === id) ?? null;
}
// Create a new call. snapshot is an object keyed by ticker with { price, signal, verdict }.
create({ title, quarter, date, thesis, tickers, snapshot }) {
const data = this._load();
const call = {
id: randomUUID(),
title,
quarter,
date: date ?? new Date().toISOString().slice(0, 10),
thesis,
tickers,
snapshot: snapshot ?? {},
createdAt: new Date().toISOString(),
};
data.calls.push(call);
this._save(data);
return call;
}
delete(id) {
const data = this._load();
const before = data.calls.length;
data.calls = data.calls.filter((c) => c.id !== id);
if (data.calls.length === before) return false;
this._save(data);
return true;
}
}
+74 -3
View File
@@ -119,6 +119,76 @@ export const ScoringRules = {
fcfMed: 3, fcfMed: 3,
}, },
}, },
// Communication Services: META, GOOGL, NFLX, DIS, T, VZ.
// Mix of high-margin platform businesses and capital-heavy telcos/media.
// P/E gate at 25: META and GOOGL sustainably trade 20-25x; below 15 is wrong for platforms.
// High FCF weight: platform businesses are judged on FCF (ad revenue converts 35-40% to FCF).
// Revenue growth matters more than for mature industrials — network effects are the moat.
COMMUNICATION: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 2, opMargin: 3, roe: 2, peg: 2, revenue: 3, fcf: 4 },
thresholds: {
marginHigh: 25,
marginMed: 12,
opMarginHigh: 30,
opMarginMed: 15,
roeHigh: 20,
roeMed: 12,
pegHigh: 1.0,
pegMed: 1.5,
revHigh: 15,
revMed: 5,
fcfHigh: 8,
fcfMed: 3,
},
},
// Consumer Staples: KO, PG, WMT, COST, KR. Slow-growth, recession-resistant.
// Lower revenue growth expectations (2-5% is good for staples).
// Higher margin thresholds — pricing power is the primary moat (not growth).
// D/E tolerance is low — staples should be conservatively financed.
CONSUMER_STAPLES: {
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.5, maxPERatio: 22, maxPegGate: 2.0 },
weights: { margin: 3, opMargin: 3, roe: 3, peg: 1, revenue: 1, fcf: 3 },
thresholds: {
marginHigh: 12,
marginMed: 7,
opMarginHigh: 18,
opMarginMed: 10,
roeHigh: 20,
roeMed: 12,
pegHigh: 1.5,
pegMed: 2.0,
revHigh: 5,
revMed: 2,
fcfHigh: 5,
fcfMed: 2,
},
},
// Consumer Discretionary: AMZN, HD, MCD, NKE, TSLA. Cyclical, growth-oriented.
// Revenue growth is the primary signal — discretionary spending expands with the economy.
// Margins are thinner than staples (competitive markets); FCF matters for capital return.
// P/E gate relaxed slightly — quality retailers trade at 20-30x on durable FCF.
CONSUMER_DISCRETIONARY: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.5, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 2, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
thresholds: {
marginHigh: 10,
marginMed: 5,
opMarginHigh: 15,
opMarginMed: 8,
roeHigh: 20,
roeMed: 12,
pegHigh: 1.0,
pegMed: 1.5,
revHigh: 12,
revMed: 5,
fcfHigh: 5,
fcfMed: 2,
},
},
}, },
}, },
@@ -126,11 +196,12 @@ export const ScoringRules = {
// Raised expense gate from 0.5→0.2: with so many sub-0.1% index ETFs available, // Raised expense gate from 0.5→0.2: with so many sub-0.1% index ETFs available,
// a 0.5% expense ratio is genuinely hard to justify except for niche/active strategies. // a 0.5% expense ratio is genuinely hard to justify except for niche/active strategies.
gates: { maxExpenseRatio: 0.2 }, gates: { maxExpenseRatio: 0.2 },
weights: { yield: 2, lowCost: 4 }, // raised lowCost weight: cost is the #1 predictive factor for ETF returns weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 }, // cost is #1 predictive factor; 5Y return rewards consistency
thresholds: { thresholds: {
minYield: 1.5, minYield: 1.5,
maxExpense: 0.05, // lowered from 0.1: 0.05% is achievable for broad market ETFs maxExpense: 0.05, // 0.05% is achievable for broad market ETFs
minVolume: 1000000, // raised from 500k: 1M ADV is the real liquidity floor to avoid slippage minVolume: 1000000, // 1M ADV is the real liquidity floor to avoid slippage
minFiveYearReturn: 8.0, // S&P 500 long-run real return ~7-10%; 8% filters underperformers
}, },
}, },
+5
View File
@@ -17,6 +17,11 @@ export const SECTOR = {
TECHNOLOGY: 'TECHNOLOGY', TECHNOLOGY: 'TECHNOLOGY',
REIT: 'REIT', REIT: 'REIT',
FINANCIAL: 'FINANCIAL', FINANCIAL: 'FINANCIAL',
ENERGY: 'ENERGY',
HEALTHCARE: 'HEALTHCARE',
COMMUNICATION: 'COMMUNICATION',
CONSUMER_STAPLES: 'CONSUMER_STAPLES',
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
GENERAL: 'GENERAL', GENERAL: 'GENERAL',
}; };
+11 -5
View File
@@ -7,13 +7,19 @@ export class PortfolioAdvisor {
} }
async advise(holdings, screenedResults) { async advise(holdings, screenedResults) {
const resultMap = Object.fromEntries( // Build result map keyed by both the Yahoo ticker (BRK-B) and the
[ // dot-notation variant (BRK.B) so lookups work regardless of format.
const resultMap = {};
for (const r of [
...(screenedResults.STOCK ?? []), ...(screenedResults.STOCK ?? []),
...(screenedResults.ETF ?? []), ...(screenedResults.ETF ?? []),
...(screenedResults.BOND ?? []), ...(screenedResults.BOND ?? []),
].map((r) => [r.asset.ticker, r]), ]) {
); const t = r.asset.ticker;
resultMap[t] = r;
resultMap[t.replace(/-/g, '.')] = r; // BRK-B → BRK.B
resultMap[t.replace(/\./g, '-')] = r; // BRK.B → BRK-B
}
const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto')); const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
@@ -35,7 +41,7 @@ export class PortfolioAdvisor {
if (!result) { if (!result) {
return this._row(holding, price, source, '—', '—', '—', { return this._row(holding, price, source, '—', '—', '—', {
action: '⚪ Not screened', action: '⚪ Not screened',
reason: 'Run npm run finance to include this ticker.', reason: 'No screener data available — Yahoo Finance may not support this ticker.',
}); });
} }
return this._row( return this._row(
-249
View File
@@ -1,249 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
// PortfolioImporter
//
// Reads a holdings CSV exported from Robinhood, Vanguard, or Fidelity
// and merges the positions into portfolio.json.
//
// How to export:
// Robinhood → Account → Statements & History → Export CSV (choose Holdings)
// Vanguard → My Accounts → Holdings → Download (top-right icon)
// Fidelity → Accounts & Trade → Portfolio → Positions → Download CSV
//
// Broker is auto-detected from the CSV headers.
// Existing portfolio.json entries are updated in-place; new tickers are added.
// Positions with zero shares are removed.
export class PortfolioImporter {
// ── Broker column maps ──────────────────────────────────────────────────────
// Each broker uses different header names for the same data.
// Listed in priority order — first match wins.
static BROKERS = [
{
name: 'Robinhood',
detect: (headers) =>
headers.some((h) => /average.?cost/i.test(h) && headers.some((h2) => /quantity/i.test(h2))),
ticker: ['Symbol'],
shares: ['Quantity'],
costBasis: ['Average Cost', 'Average Buy Price', 'Avg Cost'],
},
{
name: 'Vanguard',
// Vanguard exports use "Ticker Symbol" and "Shares" — cost basis not always present
detect: (headers) =>
headers.some((h) => /ticker.? symbol|ticker symbol/i.test(h)) &&
headers.some((h) => /^shares$/i.test(h)),
ticker: ['Ticker Symbol', 'Ticker Symbol', 'Symbol', 'Ticker'],
shares: ['Shares', 'Quantity'],
costBasis: [
'Average Cost Basis',
'Cost Basis Per Share',
'Avg Cost Basis/Share',
'Average Cost',
],
},
{
name: 'Fidelity',
detect: (headers) =>
headers.some((h) => /account.?name/i.test(h)) &&
headers.some((h) => /symbol/i.test(h)) &&
headers.some((h) => /cost.?basis/i.test(h)),
ticker: ['Symbol'],
shares: ['Quantity', 'Shares'],
costBasis: ['Cost Basis Per Share', 'Average Cost Basis', 'Cost Basis'],
},
{
name: 'Generic',
detect: () => true, // fallback
ticker: ['Symbol', 'Ticker', 'ticker', 'symbol', 'SYMBOL'],
shares: ['Quantity', 'Shares', 'shares', 'quantity', 'QTY', 'Qty'],
costBasis: [
'Average Cost',
'Cost Basis',
'Avg Cost',
'Average Buy Price',
'Cost Per Share',
'cost_basis',
],
},
];
// ── Public API ──────────────────────────────────────────────────────────────
import(csvPath, portfolioPath = './portfolio.json', source = null) {
if (!existsSync(csvPath)) {
throw new Error(`File not found: ${csvPath}`);
}
const raw = readFileSync(csvPath, 'utf8');
const parsed = this._parseCSV(raw);
if (parsed.length === 0) {
throw new Error('CSV is empty or could not be parsed.');
}
const broker = this._detectBroker(parsed[0]);
const brokerName = source ?? broker.name;
console.log(`\n🔍 Detected broker: ${brokerName}`);
const holdings = this._extractHoldings(parsed, broker, brokerName);
if (holdings.length === 0) {
throw new Error(
`No valid holdings found.\n` +
`Headers detected: ${Object.keys(parsed[0]).join(', ')}\n` +
`Tip: use --broker to specify manually if auto-detection failed.`,
);
}
const merged = this._mergeIntoPortfolio(holdings, portfolioPath);
console.log(`✅ Imported ${holdings.length} positions from ${broker.name}`);
console.log(` portfolio.json now has ${merged.holdings.length} holdings\n`);
holdings.forEach((h) => {
const cb = h.costBasis != null ? ` @ $${h.costBasis.toFixed(2)}` : ' (no cost basis)';
console.log(` ${h.ticker.padEnd(6)} ${h.shares} shares${cb}`);
});
return merged;
}
// ── CSV parser (no external deps) ──────────────────────────────────────────
_parseCSV(raw) {
const lines = raw.split(/\r?\n/).filter((l) => l.trim());
if (lines.length < 2) return [];
// Find the header row — skip metadata rows at the top (Vanguard has these)
// A valid header row has at least one of these keywords
const headerKeywords = /symbol|ticker|shares|quantity|cost|price/i;
let headerIdx = 0;
for (let i = 0; i < Math.min(lines.length, 10); i++) {
if (headerKeywords.test(lines[i])) {
headerIdx = i;
break;
}
}
const headers = this._splitRow(lines[headerIdx]).map((h) => h.trim().replace(/^"|"$/g, ''));
const rows = [];
for (let i = headerIdx + 1; i < lines.length; i++) {
const values = this._splitRow(lines[i]).map((v) => v.trim().replace(/^"|"$/g, ''));
if (values.length < 2 || !values[0]) continue;
const row = {};
headers.forEach((h, idx) => {
row[h] = values[idx] ?? '';
});
rows.push(row);
}
return rows;
}
_splitRow(line) {
// Handle quoted CSV fields that may contain commas
const result = [];
let current = '';
let inQuotes = false;
for (const ch of line) {
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += ch;
}
}
result.push(current);
return result;
}
// ── Broker detection ────────────────────────────────────────────────────────
_detectBroker(sampleRow) {
const headers = Object.keys(sampleRow);
return PortfolioImporter.BROKERS.find((b) => b.detect(headers));
}
// ── Holdings extraction ─────────────────────────────────────────────────────
_extractHoldings(rows, broker, source = null) {
const holdings = [];
for (const row of rows) {
const ticker = this._getField(row, broker.ticker);
const sharesRaw = this._getField(row, broker.shares);
const costRaw = this._getField(row, broker.costBasis);
// Skip non-ticker rows (totals, cash, blanks, fund names)
if (!ticker || !/^[A-Z]{1,6}$/.test(ticker.toUpperCase().trim())) continue;
const shares = parseFloat(sharesRaw?.replace(/[,$]/g, '') ?? '0');
const costBasis = costRaw ? parseFloat(costRaw.replace(/[,$]/g, '')) : null;
if (isNaN(shares) || shares <= 0) continue; // skip zero/empty positions
holdings.push({
ticker: ticker.toUpperCase().trim(),
shares: +shares.toFixed(6),
costBasis: costBasis != null && !isNaN(costBasis) ? +costBasis.toFixed(4) : null,
source: source ?? broker.name,
type: 'stock', // default; user can change to 'etf' or 'crypto' in portfolio.json
});
}
return holdings;
}
_getField(row, candidates) {
const rowKeys = Object.keys(row);
for (const key of candidates) {
// 1. Exact match
if (row[key] !== undefined && row[key] !== '') return row[key];
// 2. Case-insensitive exact match
const exact = rowKeys.find((k) => k.toLowerCase() === key.toLowerCase());
if (exact && row[exact] !== '') return row[exact];
// 3. Normalised match — collapse whitespace and compare
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
const fuzzy = rowKeys.find((k) => norm(k) === norm(key));
if (fuzzy && row[fuzzy] !== '') return row[fuzzy];
}
return null;
}
// ── Merge into portfolio.json ───────────────────────────────────────────────
_mergeIntoPortfolio(newHoldings, portfolioPath) {
const existing = existsSync(portfolioPath)
? JSON.parse(readFileSync(portfolioPath, 'utf8'))
: { holdings: [] };
const holdingMap = Object.fromEntries(
(existing.holdings ?? []).map((h) => [h.ticker.toUpperCase(), h]),
);
for (const h of newHoldings) {
if (holdingMap[h.ticker]) {
// Update existing entry — preserve manually set costBasis if CSV has none
holdingMap[h.ticker].shares = h.shares;
if (h.costBasis != null) holdingMap[h.ticker].costBasis = h.costBasis;
} else {
holdingMap[h.ticker] = {
ticker: h.ticker,
shares: h.shares,
costBasis: h.costBasis ?? 0,
};
}
}
const merged = { holdings: Object.values(holdingMap) };
writeFileSync(portfolioPath, JSON.stringify(merged, null, 2), 'utf8');
return merged;
}
}
-187
View File
@@ -1,187 +0,0 @@
import fs from 'fs';
import https from 'https';
import http from 'http';
// SimpleFINClient
//
// SimpleFIN auth flow:
// 1. You get a Setup Token from https://beta-bridge.simplefin.org
// 2. This client decodes it, POSTs once to claim an Access URL
// 3. The Access URL is saved to .env automatically (setup token is one-time use)
// 4. All subsequent requests use the Access URL directly
//
// .env configuration:
// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8... (your one-time setup token)
// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
// (written automatically on first run — keep it, discard the setup token)
export class SimpleFINClient {
constructor() {
this.accessUrl = null;
}
async init() {
// Case 1: Access URL already claimed and stored
if (process.env.SIMPLEFIN_ACCESS_URL) {
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
return;
}
// Case 2: Setup token present — claim it to get the Access URL
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
this._saveAccessUrl(this.accessUrl);
return;
}
throw new Error(
'SimpleFIN not configured.\n' +
'Add to .env:\n' +
' SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\n' +
'The Access URL will be saved automatically on first run.',
);
}
// Fetches all accounts with balances and recent transactions
async getAccounts(options = {}) {
if (!this.accessUrl) await this.init();
const startDate = options.startDate ?? this._daysAgo(30);
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
const url = `${this.accessUrl}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
}
const data = await response.json();
// Always surface SimpleFIN errors to the user
if (data.errors?.length) {
data.errors.forEach((e) => console.warn(` ⚠ SimpleFIN: ${e}`));
}
return this._normalise(data);
}
// ── Auth ─────────────────────────────────────────────────────────────────────
async _claimAccessUrl(setupToken) {
// Setup token is a base64-encoded claim URL
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
process.stdout.write(`\n🔑 Claiming SimpleFIN access URL...\n${claimUrl}\n`);
const accessUrl = await this._post(claimUrl);
if (!accessUrl || !accessUrl.startsWith('http')) {
throw new Error(
`Unexpected response from SimpleFIN: "${accessUrl}"\n` +
'Setup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org',
);
}
process.stdout.write('✅ Access URL received\n');
return accessUrl.trim();
}
// Raw HTTP POST using Node's built-in module (avoids fetch redirect/header quirks)
_post(url) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const options = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(body.trim());
} else {
reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
}
});
});
req.on('error', reject);
req.end();
});
}
// Appends SIMPLEFIN_ACCESS_URL to .env so the setup token isn't re-used
_saveAccessUrl(accessUrl) {
try {
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n');
}
} catch {
// Non-fatal — just print it so the user can save it manually
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
}
}
// ── Normalise ────────────────────────────────────────────────────────────────
_normalise(data) {
const accounts = (data.accounts ?? []).map((acc) => ({
id: acc.id,
name: acc.name,
currency: acc.currency ?? 'USD',
balance: parseFloat(acc.balance) ?? 0,
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
org: acc.org?.name ?? 'Unknown',
type: this._classifyAccount(acc.name),
transactions: (acc.transactions ?? []).map((tx) => ({
id: tx.id,
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
amount: parseFloat(tx.amount) ?? 0,
description: tx.description ?? '',
category: this._categorise(tx.description ?? ''),
})),
}));
return { accounts, errors: data.errors ?? [] };
}
_classifyAccount(name) {
const n = name.toLowerCase();
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
if (n.includes('saving')) return 'SAVINGS';
if (n.includes('credit') || n.includes('card')) return 'CREDIT';
if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira'))
return 'INVESTMENT';
if (n.includes('loan') || n.includes('mortgage')) return 'LOAN';
return 'OTHER';
}
_categorise(description) {
const d = description.toLowerCase();
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions';
if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining';
if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas';
if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport';
if (d.match(/rent|mortgage|hoa|property/)) return 'Housing';
if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities';
if (d.match(/payroll|salary|direct deposit/)) return 'Income';
if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer';
return 'Other';
}
_daysAgo(n) {
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
}
}
+14 -2
View File
@@ -54,8 +54,20 @@ export class SimpleFINClient {
const startDate = options.startDate ?? this._daysAgo(30); const startDate = options.startDate ?? this._daysAgo(30);
const endDate = options.endDate ?? Math.floor(Date.now() / 1000); const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
const url = `${this.accessUrl}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`; // fetch() rejects URLs with embedded credentials (user:pass@host).
const response = await fetch(url); // Extract them and send as a Basic Auth header instead.
const parsed = new URL(this.accessUrl);
const auth = parsed.username
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
: null;
parsed.username = '';
parsed.password = '';
const cleanBase = parsed.toString().replace(/\/$/, '');
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
const response = await fetch(url, {
headers: auth ? { Authorization: auth } : {},
});
if (!response.ok) { if (!response.ok) {
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`); throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
+17 -4
View File
@@ -1,4 +1,4 @@
import { SECTOR, ASSET_TYPE } from '../config/constants.js'; import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js';
export class MarketRegime { export class MarketRegime {
constructor(marketContext) { constructor(marketContext) {
@@ -7,6 +7,8 @@ export class MarketRegime {
this.techPE = b.techPE ?? 30; this.techPE = b.techPE ?? 30;
this.reitYield = b.reitYield ?? 3.5; this.reitYield = b.reitYield ?? 3.5;
this.igSpread = b.igSpread ?? 1.0; this.igSpread = b.igSpread ?? 1.0;
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
} }
getInflatedOverrides(type, sector) { getInflatedOverrides(type, sector) {
@@ -20,7 +22,11 @@ export class MarketRegime {
if (sector === SECTOR.REIT) { if (sector === SECTOR.REIT) {
return { return {
gates: {}, gates: {},
thresholds: { minYield: +(this.reitYield * 0.85).toFixed(2), maxPFFO: 20 }, // In HIGH rate environment tighten REIT yield floor — REITs must compete harder with bonds.
thresholds: {
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
maxPFFO: 20,
},
}; };
} }
if (sector === SECTOR.TECHNOLOGY) { if (sector === SECTOR.TECHNOLOGY) {
@@ -32,9 +38,12 @@ export class MarketRegime {
thresholds: {}, thresholds: {},
}; };
} }
// In HIGH rate environment, compress the P/E tolerance — higher rates mean
// future earnings are discounted more aggressively (lower DCF valuations).
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
return { return {
gates: { gates: {
maxPERatio: Math.round(this.marketPE * 1.5), maxPERatio: Math.round(this.marketPE * peMultiplier),
maxPegGate: +(this.marketPE / 12).toFixed(1), maxPegGate: +(this.marketPE / 12).toFixed(1),
}, },
thresholds: {}, thresholds: {},
@@ -44,7 +53,11 @@ export class MarketRegime {
_etf() { _etf() {
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } }; return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
} }
_bond() { _bond() {
return { gates: {}, thresholds: { minSpread: +(this.igSpread * 0.8).toFixed(2) } }; // In HIGH rate environment demand a wider spread — the opportunity cost of holding
// corporate bonds over Treasuries is higher when risk-free rate is elevated.
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
return { gates: {}, thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) } };
} }
} }
+11 -1
View File
@@ -11,7 +11,6 @@ export class YahooClient {
async fetchSummary(ticker, retries = 3, backoff = 1000) { async fetchSummary(ticker, retries = 3, backoff = 1000) {
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
// Use the instance (this.yf) instead of the static import
return await this.yf.quoteSummary(ticker, { return await this.yf.quoteSummary(ticker, {
modules: [ modules: [
'assetProfile', 'assetProfile',
@@ -27,4 +26,15 @@ export class YahooClient {
} }
} }
} }
// Fetches upcoming earnings dates, ex-dividend date, and dividend date for a ticker.
// Returns null on failure so callers can skip gracefully.
async fetchCalendarEvents(ticker) {
try {
const r = await this.yf.quoteSummary(ticker, { modules: ['calendarEvents'] });
return r.calendarEvents ?? null;
} catch {
return null;
}
}
} }
+27 -7
View File
@@ -46,9 +46,11 @@ const mapStockData = (summary) => {
? currentPrice / (operatingCashflow / sharesOutstanding) ? currentPrice / (operatingCashflow / sharesOutstanding)
: null; : null;
// FCF yield = free cash flow per share / price (more meaningful than binary positive/negative) // FCF yield = free cash flow per share / price.
// Negative FCF is preserved (not nulled) — a company burning cash should fail the gate,
// not be silently skipped as "no data".
const fcfYield = const fcfYield =
freeCashflow > 0 && sharesOutstanding > 0 && currentPrice > 0 freeCashflow !== 0 && sharesOutstanding > 0 && currentPrice > 0
? (freeCashflow / sharesOutstanding / currentPrice) * 100 ? (freeCashflow / sharesOutstanding / currentPrice) * 100
: null; : null;
@@ -65,8 +67,9 @@ const mapStockData = (summary) => {
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null; const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
return { return {
// Valuation // Valuation — trailing PE is the audited number; forward PE is an analyst estimate
peRatio: ks.forwardPE ?? trailingPE, // (historically 10-15% optimistic). Use trailing as primary for fundamental mode.
peRatio: trailingPE ?? ks.forwardPE,
trailingPE, trailingPE,
pegRatio, pegRatio,
priceToBook: ks.priceToBook ?? null, priceToBook: ks.priceToBook ?? null,
@@ -107,7 +110,10 @@ const mapEtfData = (summary) => ({
expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100, expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100,
totalAssets: summary.summaryDetail?.totalAssets ?? 0, totalAssets: summary.summaryDetail?.totalAssets ?? 0,
yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100, yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100,
fiveYearReturn: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0, // fiveYearAverageReturn is annualised total return — valid proxy for performance vs peers.
fiveYearReturn: (summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0) * 100,
// averageVolume from summaryDetail is average daily trading volume — used for liquidity gate.
volume: summary.summaryDetail?.averageVolume ?? summary.price?.averageVolume ?? 0,
currentPrice: summary.price?.regularMarketPrice ?? 0, currentPrice: summary.price?.regularMarketPrice ?? 0,
}); });
@@ -124,10 +130,24 @@ const inferCreditRating = (category) => {
return 'BBB'; // conservative default return 'BBB'; // conservative default
}; };
// Infers approximate effective duration (years) from bond ETF category name.
// Buckets match standard industry classifications (short < 3y, intermediate 3-7y, long > 10y).
const inferDuration = (category) => {
const cat = (category || '').toLowerCase();
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
return 6; // conservative default — typical aggregate bond fund duration
};
const mapBondData = (summary) => ({ const mapBondData = (summary) => ({
yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100, yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100,
// fiveYearAverageReturn is the closest Yahoo proxy for effective duration on bond ETFs // KNOWN LIMITATION: Yahoo Finance does not expose effective duration via the modules
duration: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0, // we fetch (assetProfile, financialData, defaultKeyStatistics, price, summaryDetail).
// The `fundProfile` module has duration for some funds but requires a separate fetch.
// We use the ETF category name to infer a rough duration bucket as a proxy.
duration: inferDuration(summary.assetProfile?.category),
creditRating: inferCreditRating(summary.assetProfile?.category), creditRating: inferCreditRating(summary.assetProfile?.category),
currentPrice: summary.price?.regularMarketPrice ?? 0, currentPrice: summary.price?.regularMarketPrice ?? 0,
}); });
+3 -2
View File
@@ -7,8 +7,9 @@ export class Etf extends Asset {
expenseRatio: parseFloat(data.expenseRatio) || 0, expenseRatio: parseFloat(data.expenseRatio) || 0,
totalAssets: parseFloat(data.totalAssets) || 0, totalAssets: parseFloat(data.totalAssets) || 0,
yield: parseFloat(data.yield) || 0, yield: parseFloat(data.yield) || 0,
volume: parseFloat(data.volume) || 0,
fiveYearReturn: parseFloat(data.fiveYearReturn) || 0,
}; };
this.fiveYearReturn = parseFloat(data.fiveYearReturn) || 0;
} }
getDisplayMetrics() { getDisplayMetrics() {
@@ -19,7 +20,7 @@ export class Etf extends Asset {
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`, 'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
'Yield%': `${this.metrics.yield.toFixed(2)}%`, 'Yield%': `${this.metrics.yield.toFixed(2)}%`,
AUM: this.formatLargeNumber(this.metrics.totalAssets), AUM: this.formatLargeNumber(this.metrics.totalAssets),
'5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`, '5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
}; };
} }
} }
+54 -6
View File
@@ -36,18 +36,66 @@ export class Stock extends Asset {
} }
_mapToStandardSector(data) { _mapToStandardSector(data) {
// 1. Safely grab the profile from the data object
const profile = data.assetProfile || {}; const profile = data.assetProfile || {};
// 2. Extract values safely
const industry = (profile.industry || '').toLowerCase(); const industry = (profile.industry || '').toLowerCase();
const sector = (profile.sector || '').toLowerCase(); const sector = (profile.sector || '').toLowerCase();
const combined = `${industry} ${sector}`; const combined = `${industry} ${sector}`;
// 3. Match logic // Yahoo Finance sector/industry strings mapped to our internal sector constants.
if (combined.includes('technology') || combined.includes('electronic')) return 'TECHNOLOGY'; // Order matters — more specific matches first.
if (
combined.includes('technology') ||
combined.includes('electronic') ||
combined.includes('semiconductor') ||
combined.includes('software')
)
return 'TECHNOLOGY';
if (combined.includes('real estate') || combined.includes('reit')) return 'REIT'; if (combined.includes('real estate') || combined.includes('reit')) return 'REIT';
if (combined.includes('financial') || combined.includes('bank')) return 'FINANCIAL'; if (
combined.includes('financial') ||
combined.includes('bank') ||
combined.includes('insurance') ||
combined.includes('asset management')
)
return 'FINANCIAL';
if (
combined.includes('energy') ||
combined.includes('oil') ||
combined.includes('gas') ||
combined.includes('petroleum')
)
return 'ENERGY';
if (
combined.includes('health') ||
combined.includes('biotech') ||
combined.includes('pharmaceutical') ||
combined.includes('medical')
)
return 'HEALTHCARE';
// Yahoo calls this "Communication Services" — covers META, GOOGL, NFLX, DIS, T
if (
combined.includes('communication') ||
combined.includes('media') ||
combined.includes('entertainment') ||
combined.includes('telecom')
)
return 'COMMUNICATION';
if (
combined.includes('consumer defensive') ||
combined.includes('consumer staples') ||
combined.includes('household') ||
combined.includes('beverage') ||
combined.includes('food')
)
return 'CONSUMER_STAPLES';
if (
combined.includes('consumer cyclical') ||
combined.includes('consumer discretionary') ||
combined.includes('retail') ||
combined.includes('apparel') ||
combined.includes('auto')
)
return 'CONSUMER_DISCRETIONARY';
return 'GENERAL'; return 'GENERAL';
} }
+9 -1
View File
@@ -5,6 +5,7 @@ export const EtfScorer = {
expenseRatio: parseFloat(m.expenseRatio) || 0, expenseRatio: parseFloat(m.expenseRatio) || 0,
yield: parseFloat(m.yield) || 0, yield: parseFloat(m.yield) || 0,
volume: parseFloat(m.volume) || 0, volume: parseFloat(m.volume) || 0,
fiveYearReturn: parseFloat(m.fiveYearReturn) || 0,
}; };
if (metrics.expenseRatio > gates.maxExpenseRatio) { if (metrics.expenseRatio > gates.maxExpenseRatio) {
@@ -14,7 +15,14 @@ export const EtfScorer = {
const breakdown = { const breakdown = {
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3, cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1, yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
vol: metrics.volume >= (thresholds.minVolume ?? 500000) ? 0 : -2, vol: metrics.volume >= (thresholds.minVolume ?? 1000000) ? 0 : -2,
// 5Y return: strong long-term performance vs the ~10% S&P average is rewarded
fiveYearReturn:
thresholds.minFiveYearReturn != null
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
? (weights.fiveYearReturn ?? 1)
: -1
: 0,
}; };
const score = Object.values(breakdown).reduce((a, b) => a + b, 0); const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
+77
View File
@@ -0,0 +1,77 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import screenerRoutes from './routes/screener.js';
import financeRoutes from './routes/finance.js';
import callsRoutes from './routes/calls.js';
import { YahooClient } from '../market/YahooClient.js';
import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
export async function buildApp({ logger = true } = {}) {
const app = Fastify({ logger });
await app.register(cors, {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
});
await app.register(screenerRoutes);
await app.register(financeRoutes);
await app.register(callsRoutes);
// POST /api/analyze — fetch Yahoo news for tickers and run LLM analysis
app.post('/api/analyze', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
handler: async (req, reply) => {
if (!process.env.ANTHROPIC_API_KEY) {
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
}
const tickers = req.body.tickers.map((t) => t.toUpperCase());
const client = new YahooClient();
const llm = new LLMAnalyst({ logger: noopLogger });
const seen = new Map();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
try {
const { news = [] } = await client.yf.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const s of news) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip */
}
}),
);
const stories = [...seen.values()].slice(0, 15);
if (!stories.length) {
return reply.code(200).send({ analysis: null, reason: 'no_stories' });
}
const analysis = await llm.analyze(stories, tickers);
return { analysis };
},
});
app.get('/health', async () => ({ status: 'ok' }));
return app;
}
+187
View File
@@ -0,0 +1,187 @@
import { MarketCallStore } from '../../calls/MarketCallStore.js';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { YahooClient } from '../../market/YahooClient.js';
import { chunkArray } from '../../screener/Chunker.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
const store = new MarketCallStore();
// Takes a screener result entry and flattens it to a snapshot record
const toSnapshot = (r) => {
if (!r) return null;
const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {};
return {
price: r.asset?.currentPrice ?? null,
signal: r.signal ?? null,
inflatedVerdict: r.inflated?.label ?? null,
fundamentalVerdict: r.fundamental?.label ?? null,
pe: m['P/E'] ?? null,
roe: m['ROE%'] ?? null,
fcf: m['FCF Yld%'] ?? null,
};
};
export default async function callsRoutes(app) {
// GET /api/calls — list all market calls (newest first)
app.get('/api/calls', async () => {
return { calls: store.list() };
});
// GET /api/calls/:id — get one call + enrich with current prices for comparison
app.get('/api/calls/:id', async (req, reply) => {
const call = store.get(req.params.id);
if (!call) return reply.code(404).send({ error: 'Call not found' });
// Re-screen the tickers to get current prices for comparison
let current = {};
if (call.tickers.length > 0) {
try {
const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(call.tickers);
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
for (const r of all) {
current[r.asset.ticker] = toSnapshot(r);
}
} catch {
// Non-fatal — return call without current prices
}
}
return { ...call, current };
});
// POST /api/calls — create a new market call and snapshot current prices
app.post('/api/calls', {
schema: {
body: {
type: 'object',
required: ['title', 'quarter', 'thesis', 'tickers'],
properties: {
title: { type: 'string', minLength: 3 },
quarter: { type: 'string', minLength: 2 },
date: { type: 'string' },
thesis: { type: 'string', minLength: 10 },
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 },
},
},
},
handler: async (req, reply) => {
const { title, quarter, date, thesis, tickers } = req.body;
const upperTickers = tickers.map((t) => t.toUpperCase());
// Snapshot current screener data for each ticker
let snapshot = {};
try {
const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(upperTickers);
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
for (const r of all) {
snapshot[r.asset.ticker] = toSnapshot(r);
}
} catch (err) {
app.log.warn('Could not snapshot prices for market call:', err.message);
}
const call = store.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot });
return reply.code(201).send(call);
},
});
// DELETE /api/calls/:id
app.delete('/api/calls/:id', async (req, reply) => {
const deleted = store.delete(req.params.id);
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
return { ok: true };
});
// GET /api/calls/calendar?tickers=AAPL,MSFT (or omit to use all call tickers)
// Returns upcoming earnings dates, ex-dividend dates and dividend dates per ticker.
// Fetched in parallel batches of 5 with rate-limit delay.
app.get('/api/calls/calendar', async (req) => {
const client = new YahooClient();
// Resolve tickers: from query param, or aggregate all unique tickers across all calls
let tickers;
if (req.query.tickers) {
tickers = req.query.tickers
.split(',')
.map((t) => t.trim().toUpperCase())
.filter(Boolean);
} else {
const allCalls = store.list();
const set = new Set(allCalls.flatMap((c) => c.tickers));
tickers = [...set];
}
if (tickers.length === 0) return { events: [] };
// Fetch calendarEvents in parallel batches
const results = {};
for (const batch of chunkArray(tickers, 5)) {
await Promise.all(
batch.map(async (ticker) => {
const cal = await client.fetchCalendarEvents(ticker);
if (cal) results[ticker] = cal;
}),
);
await new Promise((r) => setTimeout(r, 500));
}
// Flatten into a sorted event list
const events = [];
const now = Date.now();
for (const [ticker, cal] of Object.entries(results)) {
// Upcoming earnings dates
for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal);
events.push({
ticker,
type: 'earnings',
date: d.toISOString().slice(0, 10),
label: 'Earnings',
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
epsEstimate: cal.earnings.earningsAverage ?? null,
revEstimate: cal.earnings.revenueAverage ?? null,
isPast: d.getTime() < now,
});
}
// Ex-dividend date
if (cal.exDividendDate) {
const d = new Date(cal.exDividendDate);
events.push({
ticker,
type: 'exdividend',
date: d.toISOString().slice(0, 10),
label: 'Ex-Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
// Dividend payment date
if (cal.dividendDate) {
const d = new Date(cal.dividendDate);
events.push({
ticker,
type: 'dividend',
date: d.toISOString().slice(0, 10),
label: 'Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
}
// Sort: upcoming first, then past
events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast
? new Date(b.date) - new Date(a.date) // most recent past first
: new Date(a.date) - new Date(b.date); // soonest upcoming first
});
return { events, tickers };
});
}
+111
View File
@@ -0,0 +1,111 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js';
import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js';
import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
const PORTFOLIO_PATH = './portfolio.json';
export default async function financeRoutes(app) {
// GET /api/finance/portfolio
// Returns: { advice, personalFinance, marketContext }
app.get('/api/finance/portfolio', async (req, reply) => {
if (!existsSync(PORTFOLIO_PATH)) {
return reply.code(404).send({ error: 'portfolio.json not found' });
}
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
// SimpleFIN is optional — omit if not configured
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
}
// Normalize dot-notation tickers to Yahoo Finance format (BRK.B → BRK-B)
const normalizeYahoo = (t) => t.toUpperCase().replace(/\./g, '-');
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => normalizeYahoo(h.ticker));
const engine = new ScreenerEngine({ logger: noopLogger });
const results =
screenable.length > 0
? await engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} };
const advice = await new PortfolioAdvisor().advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
});
// POST /api/finance/holdings
// Add or update a single holding in portfolio.json.
// Body: { ticker, shares, costBasis, type, source }
app.post('/api/finance/holdings', {
schema: {
body: {
type: 'object',
required: ['ticker', 'shares'],
properties: {
ticker: { type: 'string', minLength: 1, maxLength: 10 },
shares: { type: 'number', exclusiveMinimum: 0 },
costBasis: { type: 'number', minimum: 0 },
type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] },
source: { type: 'string' },
},
},
},
handler: async (req, reply) => {
const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body;
const normalized = ticker.toUpperCase().trim();
const portfolio = existsSync(PORTFOLIO_PATH)
? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'))
: { holdings: [] };
const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
const entry = { ticker: normalized, shares, costBasis, type, source };
if (idx >= 0) {
portfolio.holdings[idx] = entry; // update existing
} else {
portfolio.holdings.push(entry); // add new
}
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return reply.code(201).send(entry);
},
});
// DELETE /api/finance/holdings/:ticker
// Remove a holding from portfolio.json.
app.delete('/api/finance/holdings/:ticker', async (req, reply) => {
const ticker = req.params.ticker.toUpperCase();
if (!existsSync(PORTFOLIO_PATH))
return reply.code(404).send({ error: 'portfolio.json not found' });
const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
const before = portfolio.holdings.length;
portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker);
if (portfolio.holdings.length === before)
return reply.code(404).send({ error: 'Holding not found' });
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return { ok: true };
});
// GET /api/finance/market-context
// Returns live benchmark data without running a full screen
app.get('/api/finance/market-context', async () => {
const engine = new ScreenerEngine({ logger: noopLogger });
return engine.benchmarkProvider.getMarketContext();
});
}
+59
View File
@@ -0,0 +1,59 @@
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the
// server so the browser receives plain serializable objects.
const serializeAssets = (arr) =>
arr.map((r) => ({
...r,
asset: {
ticker: r.asset.ticker,
type: r.asset.type,
currentPrice: r.asset.currentPrice,
metrics: r.asset.metrics,
displayMetrics: r.asset.getDisplayMetrics(),
},
}));
export default async function screenerRoutes(app) {
// Shared engine — BenchmarkProvider caches for 1 hour across requests.
const engine = new ScreenerEngine({ logger: noopLogger });
// POST /api/screen
// Body: { tickers: string[] }
// Returns: { STOCK, ETF, BOND, ERROR, marketContext }
app.post('/api/screen', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
handler: async (req) => {
const tickers = req.body.tickers.map((t) => t.toUpperCase());
const results = await engine.screenTickers(tickers);
return {
...results,
STOCK: serializeAssets(results.STOCK),
ETF: serializeAssets(results.ETF),
BOND: serializeAssets(results.BOND),
};
},
});
// GET /api/screen/catalysts
// Returns: { tickers, stories, analysis? }
// analysis is present only when ANTHROPIC_API_KEY is set.
app.get('/api/screen/catalysts', async () => {
const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js');
const catalyst = new CatalystAnalyst({ logger: noopLogger });
const { tickers, stories } = await catalyst.run();
return { tickers, stories };
});
}
+57
View File
@@ -78,6 +78,63 @@ test('FCF yield is computed when data available', () => {
assert(result.fcfYield > 0); assert(result.fcfYield > 0);
}); });
test('peRatio prefers trailingPE over forwardPE', () => {
// trailingPE=30 in summaryDetail, forwardPE=28 in defaultKeyStatistics
const result = mapToStandardFormat('AAPL', base);
assert.equal(result.peRatio, 30); // trailing should win
});
test('negative FCF yield is preserved, not nulled', () => {
const negativeFcf = {
...base,
financialData: { ...base.financialData, freeCashflow: -2e9 },
};
const result = mapToStandardFormat('AAPL', negativeFcf);
assert.notEqual(result.fcfYield, null);
assert(result.fcfYield < 0, 'negative FCF should produce negative yield, not null');
});
test('ETF maps volume from summaryDetail', () => {
const etfSummary = {
...base,
price: { ...base.price, quoteType: 'ETF' },
assetProfile: { category: 'Large Blend' },
summaryDetail: {
...base.summaryDetail,
averageVolume: 5000000,
expenseRatio: 0.0003,
trailingAnnualDividendYield: 0.013,
},
defaultKeyStatistics: { fiveYearAverageReturn: 0.12 },
};
const result = mapToStandardFormat('VOO', etfSummary);
assert.equal(result.volume, 5000000);
});
test('bond duration inferred from category — intermediate maps to 5y', () => {
const bondSummary = {
...base,
price: { ...base.price, quoteType: 'ETF' },
assetProfile: { category: 'Intermediate-Term Bond' },
summaryDetail: { yield: 0.045 },
defaultKeyStatistics: {},
};
const result = mapToStandardFormat('BND', bondSummary);
assert.equal(result.duration, 5);
});
test('bond duration inferred from category — short-term maps to 2y', () => {
const bondSummary = {
...base,
price: { ...base.price, quoteType: 'ETF' },
assetProfile: { category: 'Short-Term Bond' },
summaryDetail: { yield: 0.05 },
defaultKeyStatistics: {},
};
const result = mapToStandardFormat('SHY', bondSummary);
assert.equal(result.duration, 2);
});
test('metrics are null (not 0) when data missing', () => { test('metrics are null (not 0) when data missing', () => {
const sparse = { const sparse = {
price: { quoteType: 'EQUITY', regularMarketPrice: 100 }, price: { quoteType: 'EQUITY', regularMarketPrice: 100 },
+23
View File
@@ -29,3 +29,26 @@ test('audit breakdown includes cost, yield, vol keys', () => {
assert(result.audit.breakdown.yield != null); assert(result.audit.breakdown.yield != null);
assert(result.audit.breakdown.vol != null); assert(result.audit.breakdown.vol != null);
}); });
test('penalises ETF with volume below liquidity floor', () => {
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 100000 }, rules);
assert(result.audit.breakdown.vol < 0, 'low-volume ETF should receive negative vol score');
});
test('scores 5Y return when threshold configured', () => {
const rulesWithReturn = {
...rules,
weights: { ...rules.weights, fiveYearReturn: 2 },
thresholds: { ...rules.thresholds, minFiveYearReturn: 8.0 },
};
const good = EtfScorer.score(
{ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 10 },
rulesWithReturn,
);
const poor = EtfScorer.score(
{ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 5 },
rulesWithReturn,
);
assert(good.audit.breakdown.fiveYearReturn > 0, 'strong 5Y return should score positively');
assert(poor.audit.breakdown.fiveYearReturn < 0, 'weak 5Y return should score negatively');
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Test the markdown fence stripping logic in isolation —
// we don't instantiate LLMAnalyst (requires Anthropic SDK + API key).
// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim()
function stripFences(raw) {
return raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
}
const VALID_JSON =
'{"summary":"test","sentiment":"BULLISH","affectedIndustries":[],"relatedTickers":[]}';
test('stripFences: passes clean JSON through unchanged', () => {
assert.equal(stripFences(VALID_JSON), VALID_JSON);
});
test('stripFences: strips ```json ... ``` fences', () => {
const wrapped = '```json\n' + VALID_JSON + '\n```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
test('stripFences: strips ``` ... ``` fences (no language tag)', () => {
const wrapped = '```\n' + VALID_JSON + '\n```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
test('stripFences: result is valid parseable JSON', () => {
const wrapped = '```json\n' + VALID_JSON + '\n```';
const parsed = JSON.parse(stripFences(wrapped));
assert.equal(parsed.sentiment, 'BULLISH');
assert.equal(parsed.summary, 'test');
});
test('stripFences: handles no trailing newline before closing fence', () => {
const wrapped = '```json\n' + VALID_JSON + '```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
test('stripFences: case-insensitive fence tag', () => {
const wrapped = '```JSON\n' + VALID_JSON + '\n```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
+29 -5
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { MarketRegime } from '../src/market/MarketRegime.js'; import { MarketRegime } from '../src/market/MarketRegime.js';
import { SECTOR, ASSET_TYPE } from '../src/config/constants.js'; import { SECTOR, ASSET_TYPE } from '../src/config/constants.js';
const regime = (benchmarks) => new MarketRegime({ benchmarks }); const regime = (benchmarks, extra = {}) => new MarketRegime({ benchmarks, ...extra });
test('stock inflated P/E = marketPE × 1.5', () => { test('stock inflated P/E = marketPE × 1.5', () => {
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL); const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
@@ -18,22 +18,46 @@ test('tech inflated P/E = techPE × 1.3', () => {
assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52 assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52
}); });
test('REIT inflated minYield = reitYield × 0.85', () => { test('REIT inflated minYield = reitYield × 0.85 in NORMAL rate regime', () => {
const { thresholds } = regime({ reitYield: 4.0 }).getInflatedOverrides( const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'NORMAL' }).getInflatedOverrides(
ASSET_TYPE.STOCK, ASSET_TYPE.STOCK,
SECTOR.REIT, SECTOR.REIT,
); );
assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40 assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40
}); });
test('bond inflated minSpread = igSpread × 0.80', () => { test('REIT inflated minYield = reitYield × 0.95 in HIGH rate regime', () => {
const { thresholds } = regime({ igSpread: 1.5 }).getInflatedOverrides( const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
ASSET_TYPE.STOCK,
SECTOR.REIT,
);
assert.equal(thresholds.minYield, +(4.0 * 0.95).toFixed(2)); // 3.80
});
test('bond inflated minSpread = igSpread × 0.80 in NORMAL rate regime', () => {
const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'NORMAL' }).getInflatedOverrides(
ASSET_TYPE.BOND, ASSET_TYPE.BOND,
SECTOR.GENERAL, SECTOR.GENERAL,
); );
assert.equal(thresholds.minSpread, +(1.5 * 0.8).toFixed(2)); // 1.20 assert.equal(thresholds.minSpread, +(1.5 * 0.8).toFixed(2)); // 1.20
}); });
test('bond inflated minSpread = igSpread × 0.90 in HIGH rate regime', () => {
const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
ASSET_TYPE.BOND,
SECTOR.GENERAL,
);
assert.equal(thresholds.minSpread, +(1.5 * 0.9).toFixed(2)); // 1.35
});
test('GENERAL stock P/E multiplier compresses to 1.2× in HIGH rate regime', () => {
const { gates } = regime({ marketPE: 25 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
ASSET_TYPE.STOCK,
SECTOR.GENERAL,
);
assert.equal(gates.maxPERatio, Math.round(25 * 1.2)); // 30
});
test('ETF inflated loosens expense gate to 0.75', () => { test('ETF inflated loosens expense gate to 0.75', () => {
const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF); const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF);
assert.equal(gates.maxExpenseRatio, 0.75); assert.equal(gates.maxExpenseRatio, 0.75);
+43
View File
@@ -47,3 +47,46 @@ test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000); const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000);
assert.equal(action, '🟠 Consider taking profits'); assert.equal(action, '🟠 Consider taking profits');
}); });
// ── Result map dot-notation normalisation (BRK.B / BRK-B) ───────────────────
test('advise: BRK-B screener result matches BRK.B holding', async () => {
const mockResult = {
asset: { ticker: 'BRK-B', currentPrice: 500 },
signal: SIGNAL.STRONG_BUY,
inflated: { label: '🟢 BUY (High Conviction)' },
fundamental: { label: '🟢 BUY (High Conviction)' },
};
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
const holding = {
ticker: 'BRK.B',
shares: 1,
costBasis: 400,
type: 'stock',
source: 'Robinhood',
};
const advice = await advisor.advise([holding], screenedResults);
// Should match and return a real signal, not "Not screened"
assert.equal(advice[0].signal, SIGNAL.STRONG_BUY);
});
test('advise: BRK.B screener result matches BRK-B holding', async () => {
const mockResult = {
asset: { ticker: 'BRK.B', currentPrice: 500 },
signal: SIGNAL.STRONG_BUY,
inflated: { label: '🟢 BUY (High Conviction)' },
fundamental: { label: '🟢 BUY (High Conviction)' },
};
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
const holding = {
ticker: 'BRK-B',
shares: 1,
costBasis: 400,
type: 'stock',
source: 'Robinhood',
};
const advice = await advisor.advise([holding], screenedResults);
assert.equal(advice[0].signal, SIGNAL.STRONG_BUY);
});
+170
View File
@@ -0,0 +1,170 @@
# CLAUDE.md
Guidance for working in this repository.
## Overview
`market-screener-ui` is a SvelteKit 5 single-page application (CSR, no SSR) that serves as the interactive dashboard for the `market_screener` Fastify API.
- All data comes from the API at `http://localhost:3000` (proxied through Vite in dev)
- No SSR — `+layout.js` exports `ssr = false`
- Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`)
---
## Commands
```bash
npm install # install dependencies (SvelteKit, Vite, Svelte 5)
npm run dev # dev server on port 5173
npm run build # production build → .svelte-kit/output
npm run preview # preview production build
```
To run the full stack, use `npm run dev` from the **API repo** (`market_screener/`) instead — it starts both servers together using `concurrently`.
---
## Architecture
### No SSR
`src/routes/+layout.js` exports `ssr = false`. All data fetching happens in the browser. This avoids Svelte 5 SSR compatibility issues and makes sense for a live-data dashboard.
### API Proxy
`vite.config.js` proxies `/api/*``http://localhost:3000` in dev. In production, configure your reverse proxy (nginx/Caddy) to do the same.
### Data Loading
- **Screener page** (`/`): data loaded client-side on button click via `$lib/api.js`
- **Portfolio page** (`/portfolio`): data loaded via SvelteKit `+page.js` `load()` function — this fires on navigation and is the correct SvelteKit pattern for CSR page data
**Do not use `onMount` for initial data fetching** — use `load()` in `+page.js` instead. `onMount` does not reliably fire in SvelteKit CSR for page-level data.
---
## Project Structure
```
src/
app.html ← HTML shell
app.css ← Global reset + body styles (no :global() in .svelte files)
routes/
+layout.js ← exports ssr = false
+layout.svelte ← nav bar (Screener / Portfolio links)
+page.svelte ← Screener page
portfolio/
+page.js ← load() function — fetches /api/finance/portfolio
+page.svelte ← Portfolio + SimpleFIN page
lib/
api.js ← All fetch calls to the Fastify API
SignalBadge.svelte ← Signal pill component (Strong Buy / Avoid / etc.)
MarketContext.svelte ← Benchmark strip component
.claude/
launch.json ← Preview server config for Claude Code
vite.config.js ← Vite config with /api proxy
svelte.config.js ← SvelteKit config (adapter-auto)
```
---
## Key Files
### `src/lib/api.js`
All API calls in one place. If the API base URL changes, change it here only.
```js
screenTickers(tickers) // POST /api/screen
fetchCatalysts() // GET /api/screen/catalysts
fetchPortfolio() // GET /api/finance/portfolio
fetchMarketContext() // GET /api/finance/market-context
```
### `src/routes/+page.svelte` (Screener)
- Ticker input pre-filled with a default watchlist
- `screen()` calls API and stores results in `$state`
- `loadCatalysts()` fetches news tickers then **immediately calls `screen()`** — one click, full results
- `results` is `null` until first screen — nothing renders below the toolbar
- `verdictShort()` abbreviates long verdict strings (`"🟢 BUY (High Conviction)"``"Strong"`)
### `src/routes/portfolio/+page.svelte`
- Receives `data` from `+page.js` load function via `let { data } = $props()`
- Shows `data.error` if load failed, `data.advice` for holdings, `data.personalFinance` for SimpleFIN section
---
## Svelte 5 Patterns Used
```svelte
<!-- Reactive state -->
let loading = $state(false);
<!-- Derived values -->
const totalGL = $derived(totalValue - totalCost);
<!-- Derived with block -->
const cards = $derived.by(() => { ... return [...] });
<!-- Props -->
let { ctx } = $props();
let { data } = $props();
<!-- Event handlers (no on:click, use onclick) -->
<button onclick={screen}>Screen</button>
<!-- Conditionals in template -->
{@const mode = getTab(type)}
```
---
## API Response Shape
The Fastify API serializes asset class instances before sending — `asset.getDisplayMetrics()` is called server-side and included as `asset.displayMetrics`. In the browser, use `r.asset.displayMetrics` directly (not `r.asset.getDisplayMetrics()` which doesn't exist on plain JSON objects).
```js
// Screener response shape
{
STOCK: [{ asset: { ticker, type, currentPrice, metrics, displayMetrics }, fundamental, inflated, signal }],
ETF: [...],
BOND: [...],
ERROR: [...],
marketContext: { sp500Price, riskFreeRate, vixLevel, rateRegime, volatilityRegime, benchmarks }
}
```
---
## Styling Conventions
- Dark theme throughout: page background `#0f1117`, card sections `#0d1117`/`#111827`
- All colors are CSS custom values inline (no CSS variables yet — keep consistent with existing palette)
- Tables: `width: max-content; min-width: 100%` inside a `.table-wrap { overflow-x: auto }` container
- First column sticky: `position: sticky; left: 0; background: inherit`
- Verdict pills: `.verdict-pill.green/yellow/red` — colored background tint + text
- Monospace font for the ticker input field
- `white-space: nowrap` on `tbody td` — tables scroll horizontally, not wrap
**Color palette:**
```
page bg: #0f1117
card bg: #0d1117 / #111827 (header rows)
border: #1e293b
muted: #64748b / #475569
text: #e2e8f0 / #f1f5f9
green: #4ade80 (bg tint: #14532d33)
yellow: #facc15 (bg tint: #71350033)
red: #f87171 (bg tint: #450a0a33)
blue accent: #2563eb / #3b82f6
```
---
## Conventions
- Do not use `:global()` in `<style>` blocks — put global styles in `src/app.css`
- Use `load()` in `+page.js` for page-level data, not `onMount`
- `$derived` for computed values — do not recalculate in templates
- Keep `api.js` as the single place for fetch calls
- If adding a new page: create `+page.js` with a `load()` that fetches the needed API endpoint, receive via `$props()` in the component
+114
View File
@@ -0,0 +1,114 @@
# Market Screener UI
SvelteKit 5 dashboard for the [Market Screener](../market_screener) API. Provides an interactive interface for screening stocks, ETFs, and bonds — and tracking your portfolio with live hold/sell/add advice.
---
## Quick Start
This UI requires the Market Screener API running on port 3000.
```bash
# Recommended: start both from the API repo
cd ../market_screener
npm run dev # starts API (:3000) + this UI (:5173) together
# Or start the UI independently
npm install
npm run dev # http://localhost:5173
```
---
## Pages
### Screener (`/`)
- Enter any tickers (comma or space separated) and click **Screen**
- Click **📰 Catalysts** to load today's news-driven tickers and screen them automatically (one click)
- Market context strip shows live benchmarks: 10Y yield, VIX, S&P 500, P/E ratios, rate regime
- Signal Summary table ranks all assets by signal strength
- Drill-down tables for Stocks, ETFs, and Bonds with **Mkt-Adjusted** / **Graham** tab toggle
- Ticker column stays pinned while scrolling wide tables
### Portfolio (`/portfolio`)
- Reads `portfolio.json` from the API server
- Shows total value, cost basis, and unrealised G/L
- Per-holding advice: ✅ Hold & Add, 🟡 Reduce, 🔴 Sell
- If SimpleFIN is configured: net worth, account balances, 30-day spending breakdown
---
## Signals Explained
| Signal | Meaning |
|---|---|
| ✅ Strong Buy | Passes both Market-Adjusted AND Fundamental gates |
| ⚡ Momentum | Passes market-adjusted, holds fundamentally |
| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection |
| 🔄 Neutral | Hold territory in one or both lenses |
| ❌ Avoid | Fails both gates |
The **Mkt-Adjusted** tab uses gates derived from live market data (e.g. S&P P/E × 1.5 for the P/E gate). The **Graham** tab uses strict historical value-investing gates (P/E < 15×, PEG < 1.0).
---
## Tech Stack
| Layer | Choice |
|---|---|
| Framework | SvelteKit 2 + Svelte 5 |
| Build tool | Vite 6 |
| Adapter | `@sveltejs/adapter-auto` |
| Rendering | Client-side only (SSR disabled) |
| API | Proxied via Vite dev server → Fastify on :3000 |
---
## Project Structure
```
src/
app.html HTML shell
app.css Global reset + dark theme base
routes/
+layout.js ssr = false
+layout.svelte Nav bar
+page.svelte Screener page
portfolio/
+page.js load() → fetches /api/finance/portfolio
+page.svelte Portfolio + SimpleFIN page
lib/
api.js All API fetch functions
SignalBadge.svelte Signal pill component
MarketContext.svelte Benchmark strip component
vite.config.js /api proxy → localhost:3000
svelte.config.js SvelteKit config
```
---
## Configuration
### API URL
In `vite.config.js`, the Vite dev server proxies `/api/*` to `http://localhost:3000`. To point at a different API host, update the `proxy` target there.
For production, configure your reverse proxy (nginx, Caddy, etc.) to route `/api/*` to the Fastify server.
### CORS
The Fastify API allows `http://localhost:5173` by default. If you deploy the UI to a different origin, set `CLIENT_ORIGIN` in the API's `.env`:
```
CLIENT_ORIGIN=https://your-deployed-ui.example.com
```
---
## Development Notes
- Uses Svelte 5 runes: `$state`, `$derived`, `$derived.by`, `$props`
- `onclick={handler}` not `on:click` — Svelte 5 syntax
- Page data loaded via `+page.js` `load()` function, not `onMount`
- Global CSS lives in `src/app.css` — no `:global()` in component `<style>` blocks
- `asset.displayMetrics` (plain object from API) — never call `getDisplayMetrics()` in the browser
+1456
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "market-screener-ui",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1117;
color: #e2e8f0;
font-size: 13px;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
<script>
let { ctx, collapsible = false } = $props();
let expanded = $state(!collapsible); // collapsed by default when collapsible=true
const cards = $derived.by(() => {
const b = ctx?.benchmarks ?? {};
return [
{
label: '10Y Yield',
value: ctx?.riskFreeRate != null ? ctx.riskFreeRate.toFixed(2) + '%' : '—',
tip: 'US 10-year Treasury yield — the risk-free rate benchmark. Higher = tighter conditions for stocks and bonds.',
},
{
label: 'VIX',
value: ctx?.vixLevel?.toFixed(1) ?? '—',
tip: 'CBOE Volatility Index — measures expected market volatility. Above 20 = elevated fear; above 30 = high stress.',
},
{
label: 'S&P 500',
value: ctx?.sp500Price?.toLocaleString() ?? '—',
tip: 'Live S&P 500 index price — broad US large-cap benchmark.',
},
{
label: 'S&P P/E',
value: b.marketPE != null ? b.marketPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E ratio of SPY. Used to set the INFLATED mode P/E gate (S&P P/E × 1.5 in normal rates).',
},
{
label: 'Tech P/E',
value: b.techPE != null ? b.techPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E of XLK (tech sector ETF). Sets the tech-sector gate in INFLATED mode (XLK P/E × 1.3).',
},
{
label: 'REIT Yield',
value: b.reitYield != null ? b.reitYield.toFixed(2) + '%' : '—',
tip: 'Dividend yield of XLRE (real estate ETF). Used as the REIT minimum yield gate in INFLATED mode.',
},
{
label: 'IG Spread',
value: b.igSpread != null ? b.igSpread.toFixed(2) + '%' : '—',
tip: 'Investment-grade bond spread (LQD yield 10Y yield). Sets the bond minimum spread gate in INFLATED mode.',
},
{
label: 'Rate Regime',
value: ctx?.rateRegime ?? '—',
tip: 'HIGH (>4.5%) compresses P/E gates and tightens bond/REIT requirements. NORMAL uses looser INFLATED gates.',
},
{
label: 'Volatility',
value: ctx?.volatilityRegime ?? '—',
tip: 'Derived from VIX level — LOW (<15), NORMAL (1525), HIGH (>25). Informational; not currently gating scores.',
},
];
});
</script>
<div class="ctx-wrap">
{#if collapsible}
<button class="ctx-toggle" onclick={() => expanded = !expanded}>
<span class="ctx-toggle-label">Market Context</span>
<span class="ctx-toggle-chevron">{expanded ? '▲' : '▼'}</span>
</button>
{/if}
{#if expanded}
<div class="grid">
{#each cards as c}
<div class="card">
<div class="label-row">
<span class="label">{c.label}</span>
<span class="tip-wrap">
<span class="tip-anchor">?</span>
<span class="tip-box">{c.tip}</span>
</span>
</div>
<div class="value">{c.value}</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.ctx-wrap { margin-bottom: 20px; }
/* ── Collapsible toggle ─────────────────────────────────────────── */
.ctx-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
margin-bottom: 10px;
}
.ctx-toggle-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #475569;
}
.ctx-toggle-chevron {
font-size: 9px;
color: #334155;
}
/* ── Cards grid ────────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.card { background: #1e293b; border-radius: 8px; padding: 12px 14px; }
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.label {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Tooltip ──────────────────────────────────────────────────── */
.tip-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.tip-anchor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
background: #1e293b;
border: 1px solid #334155;
color: #475569;
font-size: 9px;
font-weight: 700;
cursor: help;
}
.tip-box {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 220px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
padding: 8px 10px;
font-size: 11px;
color: #94a3b8;
line-height: 1.5;
z-index: 50;
pointer-events: none;
white-space: normal;
}
.tip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #334155;
}
.tip-wrap:hover .tip-box { display: block; }
.value { font-size: 17px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
</style>
+29
View File
@@ -0,0 +1,29 @@
<script>
let { signal } = $props();
const cls = () => {
if (signal?.includes('Strong')) return 'strong';
if (signal?.includes('Momentum')) return 'momentum';
if (signal?.includes('Speculation')) return 'spec';
if (signal?.includes('Neutral')) return 'neutral';
return 'avoid';
};
</script>
<span class="badge {cls()}">{signal ?? '—'}</span>
<style>
.badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
white-space: nowrap;
}
.strong { background: #14532d33; color: #4ade80; }
.momentum { background: #1e3a5f33; color: #60a5fa; }
.spec { background: #7c2d1233; color: #fb923c; }
.neutral { background: #1e293b; color: #94a3b8; }
.avoid { background: #450a0a33; color: #f87171; }
</style>
+139
View File
@@ -0,0 +1,139 @@
<script>
// size: 'sm' | 'md' | 'lg'
// label: optional text shown below (lg only)
let { size = 'md', label = null } = $props();
</script>
{#if size === 'sm'}
<!-- Compact dot-pulse for buttons -->
<span class="dot-pulse">
<span></span><span></span><span></span>
</span>
{:else}
<!-- Market chart line animation for md / lg -->
<div class="chart-wrap" data-size={size}>
<svg
viewBox="0 0 160 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="chart-svg"
aria-hidden="true"
>
<!-- Faint grid lines -->
<line x1="0" y1="15" x2="160" y2="15" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="30" x2="160" y2="30" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="45" x2="160" y2="45" stroke="#1e293b" stroke-width="1" />
<!-- The market line — rises, dips, spikes, recovers -->
<polyline
class="chart-line"
points="
0,45
12,38
22,42
32,28
42,32
52,18
62,24
72,14
82,20
92,10
104,22
114,16
124,28
134,20
148,8
160,12
"
/>
<!-- Glowing dot at the leading edge -->
<circle class="chart-dot" cx="160" cy="12" r="3" />
</svg>
{#if label}
<span class="chart-label">{label}</span>
{/if}
</div>
{/if}
<style>
/* ── Dot pulse (sm) ─────────────────────────────────────────────── */
.dot-pulse {
display: inline-flex;
align-items: center;
gap: 3px;
}
.dot-pulse span {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: #60a5fa;
animation: dot-bounce 0.9s ease-in-out infinite;
}
.dot-pulse span:nth-child(2) { animation-delay: 0.15s; }
.dot-pulse span:nth-child(3) { animation-delay: 0.30s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ── Chart wrap (md / lg) ───────────────────────────────────────── */
.chart-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.chart-wrap[data-size="md"] .chart-svg { width: 120px; height: 45px; }
.chart-wrap[data-size="lg"] .chart-svg { width: 200px; height: 75px; }
.chart-svg { overflow: visible; }
/* The animated line */
.chart-line {
stroke: #3b82f6;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
/* total path length ≈ 220 — animate draw-in then loop */
stroke-dasharray: 220;
stroke-dashoffset: 220;
animation: draw-line 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes draw-line {
0% { stroke-dashoffset: 220; opacity: 1; }
70% { stroke-dashoffset: 0; opacity: 1; }
85% { stroke-dashoffset: 0; opacity: 0; }
100% { stroke-dashoffset: 220; opacity: 0; }
}
/* Glowing dot that appears when the line finishes drawing */
.chart-dot {
fill: #3b82f6;
filter: drop-shadow(0 0 4px #3b82f6);
opacity: 0;
animation: dot-appear 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes dot-appear {
0% { opacity: 0; }
60% { opacity: 0; }
70% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
.chart-label {
font-size: 12px;
color: #475569;
letter-spacing: 0.02em;
}
</style>
+96
View File
@@ -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();
}
+1
View File
@@ -0,0 +1 @@
export const ssr = false;
+132
View File
@@ -0,0 +1,132 @@
<script>
import { page, navigating } from '$app/stores';
import '../app.css';
let { children } = $props();
// Label shown under the nav progress bar while loading a page
const navLabel = $derived(
$navigating?.to?.url?.pathname === '/portfolio' ? 'Loading portfolio…' :
$navigating?.to?.url?.pathname?.startsWith('/calls') ? 'Loading market calls…' :
$navigating?.to?.url?.pathname === '/safe-buys' ? 'Screening safe buys…' :
'Loading…'
);
</script>
<div class="shell">
<nav>
<span class="brand">📊 Market Screener</span>
<div class="links">
<a href="/" class:active={$page.url.pathname === '/'}>Screener</a>
<a href="/portfolio" class:active={$page.url.pathname === '/portfolio'}>Portfolio</a>
<a href="/calls" class:active={$page.url.pathname.startsWith('/calls')}>Market Calls</a>
<a href="/safe-buys" class:active={$page.url.pathname === '/safe-buys'}>🛡 Safe Buys</a>
</div>
</nav>
<!-- Thin progress bar at top of screen — always visible even on first nav -->
{#if $navigating}
<div class="nav-progress">
<div class="nav-bar"></div>
</div>
{/if}
<main>
{#if $navigating}
<!-- Replace old page content immediately — old page disappears, spinner takes over -->
<div class="nav-overlay">
<div class="nav-spinner"></div>
<span class="nav-label">{navLabel}</span>
</div>
{:else}
{@render children()}
{/if}
</main>
</div>
<style>
.shell { min-height: 100vh; display: flex; flex-direction: column; }
nav {
display: flex;
align-items: center;
gap: 24px;
padding: 14px 32px;
border-bottom: 1px solid #1e293b;
background: #0f1117;
position: sticky;
top: 0;
z-index: 10;
}
.brand { font-size: 15px; font-weight: 700; color: #f1f5f9; }
.links { display: flex; gap: 4px; margin-left: auto; }
.links a {
color: #64748b;
text-decoration: none;
padding: 6px 14px;
border-radius: 6px;
font-weight: 500;
transition: color 0.15s, background 0.15s;
}
.links a:hover { color: #e2e8f0; background: #1e293b; }
.links a.active { color: #e2e8f0; background: #1e293b; }
main { flex: 1; padding: 28px 32px; }
/* ── Navigation progress ─────────────────────────────────────────── */
.nav-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
z-index: 100;
background: #1e293b;
overflow: hidden;
}
.nav-bar {
height: 100%;
background: #3b82f6;
animation: progress 1.5s ease-in-out infinite;
transform-origin: left;
}
@keyframes progress {
0% { transform: translateX(-100%) scaleX(0.3); }
50% { transform: translateX(0%) scaleX(0.7); }
100% { transform: translateX(100%) scaleX(0.3); }
}
/* Centered spinner + label in the page body */
.nav-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 100px 0;
flex: 1;
}
.nav-spinner {
width: 40px;
height: 40px;
border: 3px solid #1e293b;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.nav-label {
font-size: 12px;
color: #475569;
letter-spacing: 0.02em;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+858
View File
@@ -0,0 +1,858 @@
<script>
import { screenTickers, fetchCatalysts, analyzeTickers } from '$lib/api.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
let input = $state('');
let searchOpen = $state(false); // collapsed by default
let loading = $state(false);
let loadingCats = $state(false);
let error = $state(null);
let results = $state(null);
let activeTab = $state({});
let screenedAt = $state(null);
// Auto-load catalysts once on mount
let _booted = false;
$effect(() => {
if (!_booted) { _booted = true; loadCatalysts(); }
});
// ── Per-tab LLM Analysis sidebar ────────────────────────────────────────────
let sidebar = $state({ open: false, loading: false, analysis: null, type: null, error: null });
async function runTabAnalysis(type) {
const tickers = (results?.[type] ?? []).map(r => r.asset.ticker);
if (!tickers.length) return;
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;
sidebar = { open: true, loading: false, analysis: res.analysis, type, error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.') };
} catch (e) {
sidebar = { open: true, loading: false, analysis: null, type, error: e.message };
}
}
function closeSidebar() {
sidebar = { ...sidebar, open: false };
}
async function screen() {
error = null;
loading = true;
try {
const tickers = input.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean);
results = await screenTickers(tickers);
screenedAt = new Date().toLocaleTimeString();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
// Load catalysts then immediately screen — no extra click needed.
// LLM analysis (if available) is shown alongside the results.
async function loadCatalysts() {
loadingCats = true;
error = null;
try {
const cat = await fetchCatalysts();
const catInput = cat.tickers.join(', ');
loading = true;
results = await screenTickers(cat.tickers);
screenedAt = new Date().toLocaleTimeString();
if (!input) input = catInput;
} catch (e) {
error = e.message;
} finally {
loading = false;
loadingCats = false;
}
}
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const verdictShort = label => {
if (!label) return '—';
if (label.includes('High Conviction')) return 'Strong';
if (label.includes('Speculative')) return 'Speculative';
if (label.includes('BUY')) return 'Buy';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const getTab = type => activeTab[type] ?? 'inflated';
const setTab = (type, tab) => activeTab = { ...activeTab, [type]: tab };
const ctx = $derived(results?.marketContext ?? null);
const allAssets = $derived(results
? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
: []);
const fmtPE = v => v != null ? v + 'x' : '—';
</script>
<div class="page">
<!-- ── Toolbar ──────────────────────────────────────────────────── -->
<div class="toolbar">
<div class="toolbar-top">
<button onclick={loadCatalysts} disabled={loading || loadingCats} class="btn-catalyst">
{#if loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
</button>
<button
onclick={() => searchOpen = !searchOpen}
class="btn-search-toggle"
title="Screen custom tickers"
>
🔍 {searchOpen ? 'Hide search' : 'Search tickers'}
</button>
{#if screenedAt}
<span class="screened-at">Last screened {screenedAt}</span>
{/if}
</div>
{#if searchOpen}
<div class="search-row">
<input
bind:value={input}
placeholder="AAPL, MSFT, VOO …"
onkeydown={e => e.key === 'Enter' && screen()}
/>
<button onclick={screen} disabled={loading || loadingCats} class="btn-screen">
{#if loading}<Spinner size="sm" />{:else}Screen{/if}
</button>
</div>
{/if}
</div>
{#if error}
<div class="error-banner">{error}</div>
{/if}
{#if loading || loadingCats}
<div class="loading-area">
<Spinner size="lg" label={loadingCats ? 'Fetching news catalysts…' : loading ? `Screening tickers…` : ''} />
</div>
{/if}
{#if ctx}
<!-- ── Market Context Strip ────────────────────────────────────── -->
<div class="ctx-strip">
<div class="ctx-chip">
<span class="ctx-label">10Y</span>
<span class="ctx-val">{ctx.riskFreeRate?.toFixed(2)}%</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">VIX</span>
<span class="ctx-val">{ctx.vixLevel?.toFixed(1)}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">S&P</span>
<span class="ctx-val">{ctx.sp500Price?.toLocaleString()}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">S&P P/E</span>
<span class="ctx-val">{fmtPE(ctx.benchmarks?.marketPE?.toFixed(1))}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">Tech P/E</span>
<span class="ctx-val">{fmtPE(ctx.benchmarks?.techPE?.toFixed(1))}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">REIT Yld</span>
<span class="ctx-val">{ctx.benchmarks?.reitYield?.toFixed(2)}%</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">IG Sprd</span>
<span class="ctx-val">{ctx.benchmarks?.igSpread?.toFixed(2)}%</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">Rates</span>
<span class="ctx-val ctx-regime" data-regime={ctx.rateRegime}>{ctx.rateRegime}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">Vol</span>
<span class="ctx-val ctx-regime" data-regime={ctx.volatilityRegime}>{ctx.volatilityRegime}</span>
</div>
</div>
<!-- ── Signal Summary ─────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2>Signal Summary</h2>
<span class="count">{allAssets.length} assets</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Type</th>
<th>Signal</th>
<th>Mkt-Adjusted</th>
<th>Fundamental</th>
</tr>
</thead>
<tbody>
{#each allAssets as r}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td><span class="tag">{r.asset.type}</span></td>
<td><SignalBadge signal={r.signal} /></td>
<td>
<span class="verdict-pill {vClass(r.inflated.label)}">
{verdictShort(r.inflated.label)}
</span>
</td>
<td>
<span class="verdict-pill {vClass(r.fundamental.label)}">
{verdictShort(r.fundamental.label)}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<!-- ── Detail Sections ────────────────────────────────────────── -->
{#each ['STOCK', 'ETF', 'BOND'] as type}
{#if results[type]?.length}
{@const count = results[type].length}
<section class="section">
<div class="section-header">
<h2>{type}S</h2>
<span class="count">{count}</span>
<div class="mode-tabs">
<button
class:active={getTab(type) === 'inflated'}
onclick={() => setTab(type, 'inflated')}
>Mkt-Adjusted</button>
<button
class:active={getTab(type) === 'fundamental'}
onclick={() => setTab(type, 'fundamental')}
>Graham</button>
</div>
<button
class="btn-analyze"
onclick={() => runTabAnalysis(type)}
disabled={sidebar.loading && sidebar.type === type}
title="AI analysis of news for these tickers"
>
{#if sidebar.loading && sidebar.type === type}
<Spinner size="sm" />
{:else}
✦ Analyze
{/if}
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Verdict</th>
<th>Score</th>
{#if type === 'STOCK'}
<th>Sector</th>
<th>P/E</th><th>PEG</th><th>ROE%</th>
<th>OpMgn%</th><th>FCF%</th><th>D/E</th>
<th>Flags</th>
{:else if type === 'ETF'}
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
{:else}
<th>YTM</th><th>Duration</th><th>Rating</th>
{/if}
</tr>
</thead>
<tbody>
{#each sorted(results[type]) as r}
{@const mode = getTab(type)}
{@const m = r.asset.displayMetrics ?? {}}
{@const v = r[mode]}
<tr class="data-row" data-signal={sigOrd(r.signal)}>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td>
<span class="verdict-pill {vClass(v.label)}">
{verdictShort(v.label)}
</span>
</td>
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
{#if type === 'STOCK'}
<td><span class="tag sm">{m.Sector ?? '—'}</span></td>
<td class="num">{m['P/E'] ?? '—'}</td>
<td class="num">{m['PEG'] ?? '—'}</td>
<td class="num">{m['ROE%'] ?? '—'}</td>
<td class="num">{m['OpMgn%'] ?? '—'}</td>
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
<td class="num">{m['D/E'] ?? '—'}</td>
<td class="flags">
{#each v.audit?.riskFlags ?? [] as flag}
<span class="flag">{flag}</span>
{/each}
</td>
{:else if type === 'ETF'}
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
{:else}
<td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{/each}
{#if results.ERROR?.length}
<section class="section">
<h2>Failed <span class="count">{results.ERROR.length}</span></h2>
<div class="error-list">
{#each results.ERROR as e}
<div class="error-item"><span class="ticker">{e.ticker}</span> {e.message}</div>
{/each}
</div>
</section>
{/if}
{/if}
</div>
<!-- ── LLM Analysis Sidebar ─────────────────────────────────────────────── -->
{#if sidebar.open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="sidebar-backdrop" onclick={closeSidebar}></div>
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<span>🤖 LLM Analysis</span>
{#if sidebar.type}<span class="sidebar-type">{sidebar.type}S</span>{/if}
</div>
<button class="sidebar-close" onclick={closeSidebar}>✕</button>
</div>
<div class="sidebar-body">
{#if sidebar.loading}
<div class="sidebar-loading">
<Spinner size="lg" label="Analyzing tickers…" />
</div>
{:else if sidebar.error}
<div class="sidebar-error">{sidebar.error}</div>
{:else if sidebar.analysis}
{@const a = sidebar.analysis}
<div class="sb-sentiment-row">
<span class="sentiment-pill" data-sentiment={a.sentiment}>{a.sentiment}</span>
</div>
<p class="sb-summary">{a.summary}</p>
<h3 class="sb-sub">Affected Industries</h3>
<div class="sb-list">
{#each a.affectedIndustries ?? [] as ind}
<div class="sb-item">
<span class="sb-name">{ind.name}</span>
<span class="sb-reason">{ind.reason}</span>
</div>
{/each}
</div>
<h3 class="sb-sub">Related Tickers to Watch</h3>
<div class="sb-list">
{#each a.relatedTickers ?? [] as rt}
<div class="sb-item">
<span class="sb-name ticker">{rt.ticker}</span>
<span class="sb-reason">{rt.reason}</span>
</div>
{/each}
</div>
{/if}
</div>
</aside>
{/if}
<style>
/* ── Page ──────────────────────────────────────────────────────── */
.page { max-width: 1400px; padding-bottom: 60px; }
/* ── Toolbar ────────────────────────────────────────────────────── */
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
.toolbar-top {
display: flex;
align-items: center;
gap: 8px;
}
.search-row {
display: flex;
gap: 8px;
align-items: center;
}
input {
flex: 1;
min-width: 0;
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 8px;
color: #e2e8f0;
padding: 10px 14px;
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace;
letter-spacing: 0.02em;
outline: none;
transition: border-color 0.15s;
}
input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f620; }
button {
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
white-space: nowrap;
transition: background 0.15s, opacity 0.15s;
}
button:disabled { opacity: 0.45; cursor: default; }
/* Primary catalyst button */
.btn-catalyst {
background: #2563eb;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
}
.btn-catalyst:hover:not(:disabled) { background: #1d4ed8; }
/* Secondary search toggle */
.btn-search-toggle {
background: #1e293b;
color: #64748b;
border: 1px solid #2d3f55;
font-size: 12px;
padding: 8px 14px;
}
.btn-search-toggle:hover { background: #263347; color: #94a3b8; }
/* Screen button inside the expanded search row */
.btn-screen {
background: #1e3a5f;
color: #60a5fa;
border: 1px solid #1e3a5f;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 80px;
}
.btn-screen:hover:not(:disabled) { background: #163356; }
.screened-at {
margin-left: auto;
font-size: 11px;
color: #475569;
}
.loading-area {
display: flex;
justify-content: center;
align-items: center;
padding: 80px 0;
}
.error-banner {
background: #450a0a55;
border: 1px solid #7f1d1d;
border-radius: 8px;
color: #f87171;
padding: 10px 14px;
margin-bottom: 16px;
font-size: 13px;
}
/* ── Market Context Strip ───────────────────────────────────────── */
.ctx-strip {
display: flex;
gap: 1px;
background: #1e293b;
border: 1px solid #1e293b;
border-radius: 10px;
overflow: hidden;
margin-bottom: 20px;
}
.ctx-chip {
flex: 1;
min-width: 70px;
background: #0f1117;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 3px;
}
.ctx-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #475569;
}
.ctx-val {
font-size: 15px;
font-weight: 700;
color: #f1f5f9;
font-variant-numeric: tabular-nums;
}
.ctx-regime[data-regime="HIGH"] { color: #f87171; }
.ctx-regime[data-regime="NORMAL"] { color: #94a3b8; }
.ctx-regime[data-regime="LOW"] { color: #4ade80; }
/* ── Section ────────────────────────────────────────────────────── */
.section {
background: #0d1117;
border: 1px solid #1e293b;
border-radius: 10px;
margin-bottom: 16px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px 12px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
margin: 0;
}
.count {
font-size: 10px;
font-weight: 600;
color: #334155;
background: #1e293b;
padding: 2px 7px;
border-radius: 20px;
}
/* ── Mode Tabs ──────────────────────────────────────────────────── */
.mode-tabs {
display: flex;
gap: 4px;
margin-left: auto;
}
.mode-tabs button {
background: transparent;
color: #475569;
border: 1px solid #1e293b;
font-size: 11px;
padding: 4px 12px;
border-radius: 6px;
}
.mode-tabs button.active {
background: #1e3a5f;
color: #60a5fa;
border-color: #1e3a5f;
}
/* ── Table ──────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { width: max-content; min-width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 8px 14px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #334155;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
background: #111827;
}
tbody tr { border-bottom: 1px solid #161f2e; }
tbody tr:hover { background: #131c2b; }
tbody td {
padding: 10px 14px;
vertical-align: middle;
white-space: nowrap;
font-size: 13px;
}
/* Sticky ticker column */
.col-ticker,
tbody td:first-child {
position: sticky;
left: 0;
background: inherit;
z-index: 1;
}
thead .col-ticker { background: #111827; }
tbody td:first-child { background: #0d1117; }
tbody tr:hover td:first-child { background: #131c2b; }
.ticker {
font-weight: 700;
font-size: 13px;
color: #f1f5f9;
letter-spacing: 0.02em;
}
.num {
color: #64748b;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
/* Score cell: truncates gate failure text, shown in full via title tooltip */
.score-cell {
color: #64748b;
font-size: 11px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Verdict Pill ───────────────────────────────────────────────── */
.verdict-pill {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.verdict-pill.green { background: #14532d33; color: #4ade80; }
.verdict-pill.yellow { background: #71350033; color: #facc15; }
.verdict-pill.red { background: #450a0a33; color: #f87171; }
/* ── Tags ───────────────────────────────────────────────────────── */
.tag {
display: inline-block;
background: #1e293b;
color: #64748b;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tag.sm { font-size: 10px; padding: 1px 6px; }
/* ── Risk Flags ─────────────────────────────────────────────────── */
.flags { display: flex; flex-direction: column; gap: 2px; }
.flag { color: #fb923c; font-size: 11px; }
/* ── Errors ─────────────────────────────────────────────────────── */
.error-list { padding: 12px 18px; display: flex; flex-direction: column; gap: 6px; }
.error-item { color: #64748b; font-size: 12px; }
.error-item .ticker { color: #f87171; font-weight: 700; margin-right: 8px; }
/* ── Analyze button ─────────────────────────────────────────────── */
.btn-analyze {
background: transparent;
color: #7c93b0;
border: 1px solid #1e293b;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 4px 12px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.btn-analyze:hover:not(:disabled) {
background: #0f2240;
color: #93c5fd;
border-color: #1e3a5f;
}
.btn-analyze:disabled { opacity: 0.4; cursor: default; }
/* ── LLM Sidebar ────────────────────────────────────────────────── */
.sidebar-backdrop {
position: fixed;
inset: 0;
background: #00000055;
z-index: 100;
}
.sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 380px;
background: #0d1117;
border-left: 1px solid #1e3a5f;
z-index: 101;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid #1e293b;
background: #0d1e30;
flex-shrink: 0;
}
.sidebar-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: #e2e8f0;
}
.sidebar-type {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
background: #1e3a5f;
color: #60a5fa;
padding: 2px 8px;
border-radius: 20px;
}
.sidebar-close {
background: none;
border: none;
color: #475569;
font-size: 14px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.sidebar-close:hover { color: #94a3b8; background: #1e293b; }
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: 18px;
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: #f87171;
background: #450a0a33;
border-radius: 8px;
padding: 12px 14px;
font-size: 13px;
}
.sb-sentiment-row { display: flex; align-items: center; gap: 8px; }
.sb-summary {
font-size: 13px;
color: #94a3b8;
line-height: 1.6;
border-left: 3px solid #1e3a5f;
padding-left: 12px;
margin: 0;
}
.sb-sub {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
margin: 0;
}
.sb-list { display: flex; flex-direction: column; gap: 8px; }
.sb-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
background: #111827;
border-radius: 6px;
border: 1px solid #1e293b;
}
.sb-name {
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
}
.sb-reason {
font-size: 11px;
color: #64748b;
line-height: 1.4;
}
/* ── Sidebar sentiment pill ─────────────────────────────────────── */
.sentiment-pill {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 3px 10px;
border-radius: 20px;
}
.sentiment-pill[data-sentiment="BULLISH"] { background: #14532d33; color: #4ade80; }
.sentiment-pill[data-sentiment="BEARISH"] { background: #450a0a33; color: #f87171; }
.sentiment-pill[data-sentiment="NEUTRAL"] { background: #1e293b; color: #94a3b8; }
</style>
+8
View File
@@ -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 };
}
+420
View File
@@ -0,0 +1,420 @@
<script>
import { createCall, deleteCall } from '$lib/api.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
// New call form state
let showForm = $state(false);
let saving = $state(false);
let formError = $state(null);
let form = $state({
title: '',
quarter: currentQuarter(),
date: today(),
thesis: '',
tickers: '',
});
function currentQuarter() {
const d = new Date();
const q = Math.ceil((d.getMonth() + 1) / 3);
return `Q${q} ${d.getFullYear()}`;
}
function today() {
return new Date().toISOString().slice(0, 10);
}
async function submit() {
formError = null;
saving = true;
try {
await createCall({
title: form.title.trim(),
quarter: form.quarter.trim(),
date: form.date,
thesis: form.thesis.trim(),
tickers: form.tickers.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean),
});
showForm = false;
form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' };
await invalidateAll(); // re-run load() to refresh the list
} catch (e) {
formError = e.message;
} finally {
saving = false;
}
}
async function remove(id) {
if (!confirm('Delete this market call?')) return;
await deleteCall(id);
await invalidateAll();
}
const signalColor = s => {
if (s?.includes('Strong')) return '#4ade80';
if (s?.includes('Momentum')) return '#60a5fa';
if (s?.includes('Neutral')) return '#94a3b8';
if (s?.includes('Speculation')) return '#fb923c';
return '#f87171';
};
const eventIcon = type => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅';
const eventColor = type => ({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[type] ?? '#94a3b8';
const upcoming = $derived((data.events ?? []).filter(e => !e.isPast).slice(0, 20));
const past = $derived((data.events ?? []).filter(e => e.isPast).slice(0, 10));
const fmtMoney = n => n == null ? null :
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
</script>
<div class="page">
<div class="page-header">
<div>
<h1>Market Calls</h1>
<p class="subtitle">Quarterly investment theses tracked from the day you made the call</p>
</div>
<button class="btn-primary" onclick={() => showForm = !showForm}>
{showForm ? 'Cancel' : ' New Call'}
</button>
</div>
<!-- ── New Call Form ──────────────────────────────────────────────── -->
{#if showForm}
<section class="section form-section">
<div class="section-header"><h2>New Market Call</h2></div>
<form class="call-form" onsubmit={e => { e.preventDefault(); submit(); }}>
<div class="form-row">
<label>
<span>Title</span>
<input bind:value={form.title} placeholder="Q3 2025 Rate pivot & tech rotation" required />
</label>
<label class="narrow">
<span>Quarter</span>
<input bind:value={form.quarter} placeholder="Q3 2025" required />
</label>
<label class="narrow">
<span>Date</span>
<input type="date" bind:value={form.date} required />
</label>
</div>
<label>
<span>Thesis</span>
<textarea
bind:value={form.thesis}
rows="4"
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
required
></textarea>
</label>
<label>
<span>Tickers to track</span>
<input
bind:value={form.tickers}
placeholder="AAPL, MSFT, TLT, GLD …"
required
/>
<span class="hint">Comma or space separated. Current prices will be snapshot automatically.</span>
</label>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<button type="submit" class="btn-primary" disabled={saving}>
{#if saving}
<Spinner size="sm" />
<span>Snapshotting prices…</span>
{:else}
Save Call
{/if}
</button>
</form>
</section>
{/if}
<!-- ── Calendar ──────────────────────────────────────────────────── -->
{#if (data.events ?? []).length > 0}
<section class="section">
<div class="section-header">
<h2>📅 Upcoming Events</h2>
<span class="count">{upcoming.length} upcoming</span>
{#if past.length > 0}
<span class="count" style="margin-left:4px">{past.length} recent</span>
{/if}
</div>
<div class="cal-grid">
{#each upcoming as ev}
<div class="cal-event">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type" style="color:{eventColor(ev.type)}">
{eventIcon(ev.type)} {ev.label}
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
</span>
{#if ev.epsEstimate != null}
<span class="cal-est">EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
{/if}
</div>
</div>
{/each}
{#if past.length > 0}
<div class="cal-divider">— Past —</div>
{#each past as ev}
<div class="cal-event past">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type past-type">
{eventIcon(ev.type)} {ev.label}
</span>
</div>
</div>
{/each}
{/if}
</div>
</section>
{/if}
<!-- ── Calls List ────────────────────────────────────────────────── -->
{#if data.error}
<div class="error-banner">{data.error}</div>
{:else if data.calls.length === 0}
<div class="empty">No market calls yet. Create your first one to start tracking.</div>
{:else}
{#each data.calls as call}
<section class="section call-card">
<div class="section-header">
<div class="call-meta">
<a href="/calls/{call.id}" class="call-title">{call.title}</a>
<div class="call-badges">
<span class="tag">{call.quarter}</span>
<span class="date-badge">{call.date}</span>
<span class="count">{call.tickers.length} tickers</span>
</div>
</div>
<button class="btn-delete" onclick={() => remove(call.id)}>✕</button>
</div>
<div class="call-body">
<p class="thesis">{call.thesis}</p>
{#if Object.keys(call.snapshot ?? {}).length}
<div class="snapshot-grid">
{#each call.tickers as ticker}
{@const snap = call.snapshot[ticker]}
{#if snap}
<a href="/calls/{call.id}" class="snap-card">
<div class="snap-ticker">{ticker}</div>
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
</div>
</a>
{/if}
{/each}
</div>
<a href="/calls/{call.id}" class="view-link">View performance → </a>
{/if}
</div>
</section>
{/each}
{/if}
</div>
<style>
.page { max-width: 1100px; padding-bottom: 60px; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: #475569; }
/* ── Buttons ─────────────────────────────────────────────────────── */
button {
padding: 9px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: background 0.15s;
}
.btn-primary { background: #2563eb; color: #fff; display: inline-flex; align-items: center; gap: 8px; }
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-delete { background: transparent; color: #475569; padding: 4px 8px; font-size: 14px; }
.btn-delete:hover { color: #f87171; }
/* ── Section ─────────────────────────────────────────────────────── */
.section {
background: #0d1117;
border: 1px solid #1e293b;
border-radius: 10px;
margin-bottom: 16px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
/* ── Form ────────────────────────────────────────────────────────── */
.call-form { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.form-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: start; }
.form-row .narrow { min-width: 120px; }
label { display: flex; flex-direction: column; gap: 5px; }
label > span { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
input, textarea {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 8px;
color: #e2e8f0;
padding: 9px 12px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
input:focus, textarea:focus { border-color: #3b82f6; }
textarea { resize: vertical; }
.hint { font-size: 11px; color: #475569; }
.form-error { color: #f87171; font-size: 12px; background: #450a0a33; padding: 8px 12px; border-radius: 6px; }
/* ── Call card ───────────────────────────────────────────────────── */
.call-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.call-title {
font-size: 14px;
font-weight: 700;
color: #f1f5f9;
text-decoration: none;
}
.call-title:hover { color: #60a5fa; }
.call-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tag {
display: inline-block;
background: #1e293b;
color: #64748b;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.date-badge { font-size: 11px; color: #475569; }
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
.call-body { padding: 18px; display: flex; flex-direction: column; gap: 16px; }
.thesis {
font-size: 13px;
color: #94a3b8;
line-height: 1.6;
border-left: 3px solid #1e3a5f;
padding-left: 14px;
margin: 0;
}
/* ── Snapshot grid ───────────────────────────────────────────────── */
.snapshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
.snap-card {
background: #111827;
border: 1px solid #1e293b;
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
text-decoration: none;
transition: border-color 0.15s;
}
.snap-card:hover { border-color: #334155; }
.snap-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
.snap-price { font-size: 11px; color: #64748b; font-variant-numeric: tabular-nums; }
.snap-signal { font-size: 10px; font-weight: 600; }
.view-link { font-size: 12px; color: #60a5fa; text-decoration: none; }
.view-link:hover { text-decoration: underline; }
.empty { color: #475569; font-size: 13px; padding: 40px 0; text-align: center; }
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
/* ── Calendar ───────────────────────────────────────────────────── */
.cal-grid {
padding: 8px 18px 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: 6px;
transition: background 0.1s;
}
.cal-event:hover { background: #111827; }
.cal-event.past { opacity: 0.45; }
.cal-date {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: #475569;
padding-top: 1px;
white-space: nowrap;
}
.cal-content { display: flex; flex-direction: column; gap: 2px; }
.cal-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
.cal-type { font-size: 11px; font-weight: 600; }
.cal-detail { font-weight: 400; color: #64748b; }
.past-type { color: #475569 !important; }
.cal-est { font-size: 10px; color: #475569; }
.cal-divider {
font-size: 10px;
color: #334155;
text-align: center;
padding: 8px 0 4px;
letter-spacing: 0.06em;
}
</style>
+5
View File
@@ -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();
}
+202
View File
@@ -0,0 +1,202 @@
<script>
let { data } = $props();
const fmt = v => v != null ? '$' + v.toFixed(2) : '—';
const pctChange = (then, now) => {
if (then == null || now == null || then === 0) return null;
return ((now - then) / then) * 100;
};
const pctClass = v => v == null ? '' : v >= 0 ? 'pos' : 'neg';
const fmtPct = v => v == null ? '—' : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
const verdictColor = label => {
if (!label) return '#64748b';
if (label.startsWith('🟢')) return '#4ade80';
if (label.startsWith('🟡')) return '#facc15';
return '#f87171';
};
const daysSince = dateStr => {
const diff = Date.now() - new Date(dateStr).getTime();
return Math.floor(diff / 86400000);
};
const tickers = $derived(data?.tickers ?? []);
const snapshot = $derived(data?.snapshot ?? {});
const current = $derived(data?.current ?? {});
</script>
<div class="page">
{#if data?.error}
<div class="error-banner">{data.error}</div>
{:else if data}
<div class="breadcrumb"><a href="/calls">← Market Calls</a></div>
<div class="call-hero">
<div class="hero-meta">
<span class="tag">{data.quarter}</span>
<span class="date">{data.date}</span>
<span class="days">({daysSince(data.date)} days ago)</span>
</div>
<h1>{data.title}</h1>
<p class="thesis">{data.thesis}</p>
</div>
<!-- ── Performance Table ─────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2>Performance since call date</h2>
<span class="count">{tickers.length} tickers</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ticker</th>
<th>Call Price</th>
<th>Now</th>
<th>Return</th>
<th>Call Signal</th>
<th>Now Signal</th>
<th>Call Verdict</th>
<th>Now Verdict</th>
</tr>
</thead>
<tbody>
{#each tickers as ticker}
{@const snap = snapshot[ticker]}
{@const cur = current[ticker]}
{@const ret = pctChange(snap?.price, cur?.price)}
<tr class:best={ret != null && ret >= 10} class:worst={ret != null && ret <= -10}>
<td class="ticker">{ticker}</td>
<td class="num">{fmt(snap?.price)}</td>
<td class="num">{fmt(cur?.price)}</td>
<td class="num {pctClass(ret)}">{fmtPct(ret)}</td>
<td>
{#if snap?.signal}
<span class="signal-text">{snap.signal}</span>
{:else}
<span class="muted"></span>
{/if}
</td>
<td>
{#if cur?.signal}
<span class="signal-text">{cur.signal}</span>
{:else}
<span class="muted"></span>
{/if}
</td>
<td>
{#if snap?.inflatedVerdict}
<span class="verdict-pill" style="color:{verdictColor(snap.inflatedVerdict)}">
{snap.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
</span>
{:else}
<span class="muted"></span>
{/if}
</td>
<td>
{#if cur?.inflatedVerdict}
<span class="verdict-pill" style="color:{verdictColor(cur.inflatedVerdict)}">
{cur.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
</span>
{:else}
<span class="muted"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
.page { max-width: 1100px; padding-bottom: 60px; }
.breadcrumb { margin-bottom: 20px; }
.breadcrumb a { font-size: 12px; color: #475569; text-decoration: none; }
.breadcrumb a:hover { color: #94a3b8; }
.call-hero { margin-bottom: 24px; }
.hero-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.tag { background: #1e293b; color: #64748b; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.date { font-size: 12px; color: #475569; }
.days { font-size: 12px; color: #334155; }
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 10px; }
.thesis {
font-size: 13px;
color: #94a3b8;
line-height: 1.7;
border-left: 3px solid #1e3a5f;
padding-left: 14px;
max-width: 800px;
}
/* ── Section ─────────────────────────────────────────────────────── */
.section { background: #0d1117; border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; }
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
/* ── Table ───────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: max-content; min-width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 8px 14px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #334155;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
background: #111827;
}
tbody tr { border-bottom: 1px solid #161f2e; }
tbody tr:hover { background: #131c2b; }
tbody tr.best td { background: #14532d11; }
tbody tr.worst td { background: #450a0a11; }
tbody td { padding: 10px 14px; vertical-align: middle; white-space: nowrap; font-size: 13px; }
.ticker { font-weight: 700; color: #f1f5f9; }
.num { font-variant-numeric: tabular-nums; color: #64748b; }
.pos { color: #4ade80; font-weight: 600; }
.neg { color: #f87171; font-weight: 600; }
.muted { color: #334155; }
.signal-text { font-size: 12px; color: #94a3b8; }
.verdict-pill {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
background: #1e293b;
}
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; font-size: 13px; }
</style>
+8
View File
@@ -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 {};
}
+795
View File
@@ -0,0 +1,795 @@
<script>
import SignalBadge from '$lib/SignalBadge.svelte';
import MarketContext from '$lib/MarketContext.svelte';
import Spinner from '$lib/Spinner.svelte';
import { addHolding, removeHolding } from '$lib/api.js';
let { data: _data } = $props(); // unused — we load client-side
let data = $state(null);
let loading = $state(true);
let refreshing = $state(false); // background refresh — keeps page visible
let loadError = $state(null);
// ── Add holding form (new holdings only) ────────────────────────────────────
let formOpen = $state(false);
let saving = $state(false);
let formError = $state(null);
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
// ── Inline row editing ───────────────────────────────────────────────────────
let inlineEdit = $state(null); // { ticker, shares, costBasis, type, source } or null
let inlineSaving = $state(false);
function startInlineEdit(a) {
inlineEdit = {
ticker: a.ticker,
shares: String(a.shares),
costBasis: String(a.costBasis ?? 0),
type: a.type ?? 'stock',
source: a.source ?? 'Robinhood',
};
}
async function saveInlineEdit() {
if (!inlineEdit) return;
inlineSaving = true;
try {
const updated = {
ticker: inlineEdit.ticker,
shares: parseFloat(inlineEdit.shares),
costBasis: parseFloat(inlineEdit.costBasis) || 0,
type: inlineEdit.type,
source: inlineEdit.source,
};
await addHolding(updated);
// Optimistic update — patch the row immediately, don't wait for Yahoo
if (data?.advice) {
data = {
...data,
advice: data.advice.map(a =>
a.ticker === updated.ticker
? { ...a, shares: updated.shares, costBasis: updated.costBasis, type: updated.type, source: updated.source,
marketValue: updated.shares * (parseFloat(a.currentPrice) || 0),
gainLossPct: a.currentPrice ? (((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * 100).toFixed(1) : null }
: a
),
};
}
inlineEdit = null;
fetchPortfolioData(false); // background: update prices + signals
} catch (e) {
loadError = e.message;
} finally {
inlineSaving = false;
}
}
function openAdd() {
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
formOpen = !formOpen;
formError = null;
inlineEdit = null;
}
async function submitHolding() {
formError = null;
const ticker = form.ticker.trim().toUpperCase();
const shares = parseFloat(form.shares);
const costBasis = parseFloat(form.costBasis) || 0;
if (!ticker) { formError = 'Ticker is required.'; return; }
if (!shares || shares <= 0) { formError = 'Shares must be greater than 0.'; return; }
saving = true;
try {
await addHolding({ ticker, shares, costBasis, type: form.type, source: form.source });
// Optimistic update — add placeholder row immediately
const existing = data?.advice?.find(a => a.ticker === ticker);
if (data?.advice && !existing) {
data = {
...data,
advice: [...data.advice, {
ticker, shares, costBasis, type: form.type, source: form.source,
currentPrice: null, marketValue: null, gainLossPct: null,
signal: null, advice: '⏳ Fetching…', reason: 'Screener data loading in background.',
}],
};
}
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
formOpen = false;
fetchPortfolioData(false); // background: get real price + signal
} catch (e) {
formError = e.message;
} finally {
saving = false;
}
}
async function deleteHolding(ticker) {
if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
// Optimistic remove — drop the row immediately
if (data?.advice) {
data = { ...data, advice: data.advice.filter(a => a.ticker !== ticker) };
}
try {
await removeHolding(ticker);
fetchPortfolioData(false); // background: recalculate totals
} catch (e) {
loadError = e.message;
}
}
function fetchPortfolioData(showFullSpinner = false) {
if (showFullSpinner) loading = true;
else refreshing = true;
loadError = null;
fetch('/api/finance/portfolio')
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
.then(json => { data = json; })
.catch(e => { loadError = e.message; })
.finally(() => { loading = false; refreshing = false; });
}
let _booted = false;
$effect(() => {
if (_booted) return;
_booted = true;
fetchPortfolioData(true); // initial load — show full spinner
});
// ── Table sorting ────────────────────────────────────────────────────────────
let sortCol = $state('ticker');
let sortDir = $state(1); // 1 = asc, -1 = desc
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
function toggleSort(col) {
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
else { sortCol = col; sortDir = 1; }
}
const sortedAdvice = $derived.by(() => {
if (!data?.advice) return [];
return [...data.advice].sort((a, b) => {
let av, bv;
switch (sortCol) {
case 'ticker': av = a.ticker; bv = b.ticker; break;
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
case 'current': av = parseFloat(a.currentPrice) || 0; bv = parseFloat(b.currentPrice) || 0; break;
case 'value': av = parseFloat(a.marketValue) || 0; bv = parseFloat(b.marketValue) || 0; break;
case 'gl': av = parseFloat(a.gainLossPct) || 0; bv = parseFloat(b.gainLossPct) || 0; break;
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
default: return 0;
}
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
});
const sortIcon = (col) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
const fmt = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
: '—';
const fmtShort = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
: '—';
const glClass = (pct) => parseFloat(pct) >= 0 ? 'green' : 'red';
const advClass = (a) => {
if (a?.includes('🟢')) return 'green';
if (a?.includes('🟡')) return 'yellow';
if (a?.includes('🟠')) return 'orange';
if (a?.includes('🔴')) return 'red';
return 'gray';
};
const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0);
const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0);
const totalGL = $derived(totalValue - totalCost);
</script>
<div class="page">
{#if loading}
<div class="loading-area">
<Spinner size="lg" label="Loading portfolio…" />
</div>
{:else if loadError}
<div class="error">{loadError}</div>
{:else if data?.advice}
<!-- ── Toolbar ──────────────────────────────────────────────── -->
<div class="toolbar">
<button class="btn-add" onclick={openAdd}>
{formOpen ? '✕ Cancel' : '+ Add Holding'}
</button>
{#if refreshing}
<span class="refreshing-hint">Updating prices…</span>
{/if}
</div>
<!-- ── Add Holding Form ─────────────────────────────────────── -->
{#if formOpen}
<div class="add-form">
<div class="form-title">Add Holding</div>
<div class="form-row">
<div class="field">
<label>Ticker</label>
<input bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
</div>
<div class="field">
<label>Shares</label>
<input bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
</div>
<div class="field">
<label>Cost Basis / share</label>
<input bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
</div>
<div class="field">
<label>Type</label>
<select bind:value={form.type}>
<option value="stock">Stock</option>
<option value="etf">ETF</option>
<option value="bond">Bond</option>
<option value="crypto">Crypto</option>
</select>
</div>
<div class="field">
<label>Source</label>
<input bind:value={form.source} placeholder="Robinhood" />
</div>
<button class="btn-save" onclick={submitHolding} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
</div>
{/if}
{#if data.marketContext}
<MarketContext ctx={data.marketContext} collapsible={true} />
{/if}
<!-- P&L Summary -->
<div class="summary-grid">
<div class="scard">
<div class="slabel-row">
<span class="slabel">Total Value</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position.</span>
</span>
</div>
<div class="svalue">{fmtShort(totalValue)}</div>
</div>
<div class="scard">
<div class="slabel-row">
<span class="slabel">Total Cost</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered.</span>
</span>
</div>
<div class="svalue">{fmtShort(totalCost)}</div>
</div>
<div class="scard">
<div class="slabel-row">
<span class="slabel">Total G/L</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down.</span>
</span>
</div>
<div class="svalue {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
</div>
</div>
<!-- Holdings -->
<section class="card-section">
<h2>Holdings — Hold / Sell / Add Advice</h2>
<table>
<thead>
<tr>
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
<th>Advice</th><th>Reason</th><th></th>
</tr>
</thead>
<tbody>
{#each sortedAdvice as a}
{@const isEditing = inlineEdit?.ticker === a.ticker}
<tr class:editing={isEditing}>
<td class="ticker">{a.ticker}</td>
<td>
{#if isEditing}
<select class="inline-select" bind:value={inlineEdit.type}>
<option value="stock">stock</option>
<option value="etf">etf</option>
<option value="bond">bond</option>
<option value="crypto">crypto</option>
</select>
{:else}
<span class="tag">{a.type}</span>
{/if}
</td>
<td class="num">
{#if isEditing}
<input class="inline-input" bind:value={inlineEdit.shares} type="number" min="0" step="any" />
{:else}
{a.shares}
{/if}
</td>
<td class="num">
{#if isEditing}
<input class="inline-input" bind:value={inlineEdit.costBasis} type="number" min="0" step="any" />
{:else}
{fmt(a.costBasis)}
{/if}
</td>
<td class="num">{fmt(parseFloat(a.currentPrice))}</td>
<td class="num">{fmt(parseFloat(a.marketValue))}</td>
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray"></span>{/if}</td>
<td class={advClass(a.advice)}>{a.advice}</td>
<td class="reason">{a.reason}</td>
<td class="row-actions">
{#if isEditing}
<button class="btn-save-inline" onclick={saveInlineEdit} disabled={inlineSaving}>
{inlineSaving ? '…' : '✓'}
</button>
<button class="btn-cancel-inline" onclick={() => inlineEdit = null}>✕</button>
{:else}
<button class="btn-edit" onclick={() => startInlineEdit(a)} title="Edit"></button>
<button class="btn-delete" onclick={() => deleteHolding(a.ticker)} title="Remove"></button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
<!-- Personal Finance (SimpleFIN) -->
{#if data.personalFinance}
{@const pf = data.personalFinance}
<div class="summary-grid">
<div class="scard">
<div class="slabel">Net Worth</div>
<div class="svalue {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div>
</div>
<div class="scard">
<div class="slabel">Total Assets</div>
<div class="svalue">{fmtShort(pf.totalAssets)}</div>
</div>
<div class="scard">
<div class="slabel">Liabilities</div>
<div class="svalue red">{fmtShort(pf.totalLiabilities)}</div>
</div>
<div class="scard">
<div class="slabel">Cash ({pf.cashPct}%)</div>
<div class="svalue">{fmtShort(pf.totalCash)}</div>
</div>
<div class="scard">
<div class="slabel">Investments ({pf.investPct}%)</div>
<div class="svalue">{fmtShort(pf.totalInvestments)}</div>
</div>
{#if pf.savingsRate != null}
<div class="scard">
<div class="slabel">Savings Rate</div>
<div class="svalue {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div>
</div>
{/if}
<div class="scard">
<div class="slabel">Monthly Income</div>
<div class="svalue">{fmtShort(pf.totalIncome)}</div>
</div>
<div class="scard">
<div class="slabel">Monthly Spend</div>
<div class="svalue">{fmtShort(pf.totalSpend)}</div>
</div>
</div>
<div class="two-col">
<section class="card-section">
<h2>Accounts</h2>
<table>
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
<tbody>
{#each pf.accounts as a}
<tr>
<td class="ticker">{a.name}</td>
<td><span class="tag">{a.type}</span></td>
<td class="gray">{a.org}</td>
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section class="card-section">
<h2>Spending — Last 30 Days</h2>
<table>
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
<tbody>
{#each pf.categoryBreakdown.slice(0, 10) as c}
<tr>
<td>{c.category}</td>
<td class="num right">{fmt(c.amount)}</td>
<td class="num right gray">{c.pct}%</td>
<td style="width:100px">
<div class="bar-bg">
<div class="bar-fill" style="width:{Math.min(c.pct,100)}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
{/if}
{/if}
</div>
<style>
.page { max-width: 1400px; }
/* ── Toolbar ─────────────────────────────────────────────────────── */
.toolbar { margin-bottom: 12px; }
.btn-add {
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
padding: 9px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-add:hover { background: #1d4ed8; }
.refreshing-hint {
font-size: 11px;
color: #475569;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ── Add holding form ────────────────────────────────────────────── */
.add-form {
background: #111827;
border: 1px solid #1e293b;
border-radius: 10px;
padding: 18px;
margin-bottom: 16px;
}
.form-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
}
.field label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #475569;
}
.field input::placeholder { color: #334155; }
.field input {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 6px;
color: #e2e8f0;
padding: 8px 12px;
font-size: 13px;
outline: none;
min-width: 100px;
height: 38px;
box-sizing: border-box;
}
.field input:focus { border-color: #3b82f6; }
.field select {
background: #1e293b 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") no-repeat right 10px center;
border: 1px solid #2d3f55;
border-radius: 6px;
color: #e2e8f0;
padding: 8px 32px 8px 12px;
font-size: 13px;
outline: none;
min-width: 100px;
height: 38px;
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
.field select:focus { border-color: #3b82f6; }
.btn-save {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
align-self: flex-end;
}
.btn-save:hover:not(:disabled) { background: #1d4ed8; }
.btn-save:disabled { opacity: 0.5; cursor: default; }
.form-error {
color: #f87171;
font-size: 12px;
margin-top: 10px;
}
/* ── Delete button ───────────────────────────────────────────────── */
.form-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #475569;
margin-bottom: 14px;
}
.field input.readonly {
opacity: 0.5;
cursor: not-allowed;
}
.btn-cancel-edit {
background: transparent;
border: 1px solid #2d3f55;
color: #64748b;
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
align-self: flex-end;
}
.btn-cancel-edit:hover { color: #94a3b8; }
tr.editing { background: #0d1e30; }
.inline-input {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 4px;
color: #e2e8f0;
padding: 3px 6px;
font-size: 12px;
width: 80px;
outline: none;
}
.inline-input:focus { border-color: #3b82f6; }
.inline-select {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 4px;
color: #e2e8f0;
padding: 3px 6px;
font-size: 11px;
outline: none;
}
.btn-save-inline {
background: #14532d55;
border: none;
color: #4ade80;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-weight: 700;
}
.btn-save-inline:hover:not(:disabled) { background: #14532d99; }
.btn-save-inline:disabled { opacity: 0.5; cursor: default; }
.btn-cancel-inline {
background: none;
border: none;
color: #475569;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.btn-cancel-inline:hover { color: #94a3b8; }
.row-actions { display: flex; gap: 4px; align-items: center; }
.btn-edit {
background: none;
border: none;
color: #334155;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.btn-edit:hover { color: #60a5fa; background: #0f2240; }
.btn-delete {
background: none;
border: none;
color: #334155;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.btn-delete:hover { color: #f87171; background: #450a0a33; }
.loading-area {
display: flex;
justify-content: center;
align-items: center;
padding: 100px 0;
}
.error { color: #f87171; background: #450a0a33; border-radius: 8px; padding: 10px 14px; }
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.scard { background: #1e293b; border-radius: 8px; padding: 12px 14px; }
.slabel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.slabel { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.svalue { font-size: 18px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
/* ── Summary card tooltips ───────────────────────────────────────── */
.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: #0f1117;
border: 1px solid #334155;
color: #475569;
font-size: 9px;
font-weight: 700;
cursor: help;
}
.stip-box {
display: none;
position: fixed;
width: 220px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
padding: 8px 10px;
font-size: 11px;
color: #94a3b8;
line-height: 1.5;
z-index: 200;
pointer-events: none;
white-space: normal;
/* anchor via JS-free trick: use absolute + translate to float above icon */
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
}
.stip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #334155;
}
.stip-wrap:hover .stip-box { display: block; }
.card-section {
background: #111827;
border: 1px solid #1e293b;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
h2 {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
margin-bottom: 14px;
}
table { width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 7px 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #475569;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid #1a2233; }
tbody tr:hover { background: #1e293b55; }
tbody td { padding: 9px 10px; vertical-align: middle; white-space: nowrap; }
th.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th.sortable:hover { color: #94a3b8; }
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
.num { font-variant-numeric: tabular-nums; color: #94a3b8; }
.tag { background: #1e293b; color: #94a3b8; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
.reason { color: #94a3b8; font-size: 11px; white-space: normal; max-width: 260px; }
.right { text-align: right; }
.green { color: #4ade80; font-weight: 600; }
.yellow { color: #facc15; font-weight: 600; }
.orange { color: #fb923c; font-weight: 600; }
.red { color: #f87171; font-weight: 600; }
.gray { color: #64748b; }
.bar-bg { background: #1e293b; border-radius: 4px; height: 6px; }
.bar-fill { background: #3b82f6; border-radius: 4px; height: 6px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
</style>
+60
View File
@@ -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,
};
}
+368
View File
@@ -0,0 +1,368 @@
<script>
import MarketContext from '$lib/MarketContext.svelte';
import SignalBadge from '$lib/SignalBadge.svelte';
let { data } = $props();
const SIGNAL_STRONG = '✅ Strong Buy';
// Filter to only Strong Buy in both modes — the safest picks
const strongEtfs = $derived((data.ETF ?? []).filter(r => r.signal === SIGNAL_STRONG));
const strongBonds = $derived((data.BOND ?? []).filter(r => r.signal === SIGNAL_STRONG));
// All other non-error results — "watch" tier (pass one mode but not both)
const watchEtfs = $derived((data.ETF ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const watchBonds = $derived((data.BOND ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...arr].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const verdictShort = label => {
if (!label) return '—';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const totalScreened = $derived((data.ETF?.length ?? 0) + (data.BOND?.length ?? 0));
const totalStrong = $derived(strongEtfs.length + strongBonds.length);
</script>
<div class="page">
<div class="page-header">
<div>
<h1>🛡 Safe Buys</h1>
<p class="subtitle">
Low-cost ETFs and investment-grade bonds passing <strong>both</strong> Market-Adjusted and Fundamental gates.
{totalStrong} of {totalScreened} screened assets qualify.
</p>
</div>
</div>
{#if data.error}
<div class="error-banner">{data.error}</div>
{/if}
{#if data.marketContext}
<MarketContext ctx={data.marketContext} />
{/if}
<!-- ── Strong Buy ─────────────────────────────────────────────────── -->
{#if strongEtfs.length || strongBonds.length}
<div class="strong-header">
<span class="strong-badge">✅ Strong Buy</span>
<span class="strong-sub">Pass both Market-Adjusted and Fundamental gates</span>
</div>
{#if strongEtfs.length}
<section class="section">
<div class="section-header">
<h2>ETFs</h2>
<span class="count">{strongEtfs.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>Expense</th>
<th>Yield</th>
<th>AUM</th>
<th>5Y Ret</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{#each sorted(strongEtfs) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
<td class="score">{r.inflated.scoreSummary}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if strongBonds.length}
<section class="section">
<div class="section-header">
<h2>Bond ETFs</h2>
<span class="count">{strongBonds.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>YTM</th>
<th>Duration</th>
<th>Rating</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{#each sorted(strongBonds) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
<td class="score">{r.inflated.scoreSummary}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{:else}
<div class="empty-strong">
No assets currently pass both gates — market conditions may be elevated.
Check the Watch List below for assets passing at least one mode.
</div>
{/if}
<!-- ── Watch List ─────────────────────────────────────────────────── -->
{#if watchEtfs.length || watchBonds.length}
<div class="watch-header">
<span class="watch-label">👀 Watch List</span>
<span class="watch-sub">Pass one gate — monitor for entry</span>
</div>
{#if watchEtfs.length}
<section class="section watch-section">
<div class="section-header">
<h2>ETFs</h2>
<span class="count">{watchEtfs.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Signal</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>Expense</th>
<th>Yield</th>
<th>AUM</th>
<th>5Y Ret</th>
</tr>
</thead>
<tbody>
{#each sorted(watchEtfs) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><SignalBadge signal={r.signal} /></td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if watchBonds.length}
<section class="section watch-section">
<div class="section-header">
<h2>Bond ETFs</h2>
<span class="count">{watchBonds.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Signal</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>YTM</th>
<th>Duration</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
{#each sorted(watchBonds) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><SignalBadge signal={r.signal} /></td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{/if}
</div>
<style>
.page { max-width: 1100px; padding-bottom: 60px; }
.page-header { margin-bottom: 20px; }
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 6px; }
.subtitle { font-size: 12px; color: #475569; line-height: 1.5; }
.subtitle strong { color: #94a3b8; }
/* ── Strong Buy banner ───────────────────────────────────────────── */
.strong-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.strong-badge {
font-size: 12px;
font-weight: 700;
color: #4ade80;
background: #14532d33;
padding: 4px 14px;
border-radius: 20px;
}
.strong-sub { font-size: 11px; color: #475569; }
.empty-strong {
padding: 32px 20px;
background: #111827;
border: 1px solid #1e293b;
border-radius: 10px;
font-size: 13px;
color: #64748b;
text-align: center;
margin-bottom: 24px;
line-height: 1.6;
}
/* ── Watch List ──────────────────────────────────────────────────── */
.watch-header {
display: flex;
align-items: center;
gap: 12px;
margin-top: 28px;
margin-bottom: 12px;
}
.watch-label {
font-size: 12px;
font-weight: 700;
color: #94a3b8;
background: #1e293b;
padding: 4px 14px;
border-radius: 20px;
}
.watch-sub { font-size: 11px; color: #475569; }
/* ── Section ─────────────────────────────────────────────────────── */
.section {
background: #0d1117;
border: 1px solid #1e293b;
border-radius: 10px;
margin-bottom: 14px;
overflow: hidden;
}
.watch-section { opacity: 0.75; }
.watch-section:hover { opacity: 1; transition: opacity 0.2s; }
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
/* ── Table ───────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: max-content; min-width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 7px 14px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #334155;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
background: #111827;
}
tbody tr { border-bottom: 1px solid #161f2e; }
tbody tr:hover { background: #131c2b; }
tbody td { padding: 10px 14px; vertical-align: middle; white-space: nowrap; font-size: 13px; }
.col-ticker,
tbody td:first-child { position: sticky; left: 0; background: #0d1117; z-index: 1; }
thead .col-ticker { background: #111827; }
tbody tr:hover td:first-child { background: #131c2b; }
.ticker { font-weight: 700; color: #f1f5f9; letter-spacing: 0.02em; }
.num { color: #64748b; font-variant-numeric: tabular-nums; font-size: 12px; }
.score { color: #475569; font-size: 11px; }
/* ── Verdict pills ───────────────────────────────────────────────── */
.vpill {
display: inline-block;
padding: 2px 9px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
}
.vpill.green { background: #14532d33; color: #4ade80; }
.vpill.yellow { background: #71350033; color: #facc15; }
.vpill.red { background: #450a0a33; color: #f87171; }
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
</style>
+5
View File
@@ -0,0 +1,5 @@
import adapter from '@sveltejs/adapter-auto';
export default {
kit: { adapter: adapter() },
};
+11
View File
@@ -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',
},
},
});