phase-1: optimize code
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "market-screener-ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--prefix", "ui"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
+16
-1
@@ -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
|
||||
@@ -4,37 +4,54 @@ Guidance for working in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
`market-screener` is a Node.js CLI tool that screens stocks, ETFs, and bonds by fetching live data from Yahoo Finance and scoring each asset under two lenses:
|
||||
`market-screener` is a Node.js project with two modes:
|
||||
|
||||
- **Market-Adjusted** — gates derived from live Yahoo benchmarks (SPY P/E, XLK P/E, XLRE yield, LQD spread). Reflects what is acceptable in today's inflated market.
|
||||
- **Fundamental** — strict Graham/value-investing style gates from `ScoringConfig`. Reflects genuine value regardless of market conditions.
|
||||
1. **CLI** — screens stocks, ETFs, and bonds via `npm start`, generates HTML reports
|
||||
2. **Fastify API server** — powers the SvelteKit dashboard in the `ui/` subdirectory
|
||||
|
||||
Every asset is scored under two lenses:
|
||||
|
||||
- **Market-Adjusted** — gates derived from live Yahoo benchmarks (SPY P/E, XLK P/E, XLRE yield, LQD spread). Reflects what is acceptable in today's market.
|
||||
- **Fundamental** — strict Graham/value-investing gates from `ScoringConfig`. Reflects genuine value regardless of market conditions.
|
||||
|
||||
The comparison produces a **Signal** (Strong Buy / Momentum / Speculation / Neutral / Avoid).
|
||||
|
||||
ES module project (`"type": "module"`); use `import`/`export`, not `require`.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install # install dependencies (yahoo-finance2, dotenv, prettier)
|
||||
npm start # Yahoo news → catalyst tickers → screener-report.html
|
||||
npm start -- watch # default watchlist
|
||||
npm start -- AAPL MSFT VOO # specific tickers
|
||||
npm run finance # portfolio advice + SimpleFIN → finance-report.html
|
||||
npm run import-portfolio -- holdings.csv # import Robinhood/Vanguard CSV into portfolio.json
|
||||
npm test # run all unit tests (node:test, zero deps)
|
||||
npm run test:watch # watch mode during development
|
||||
npm install # install dependencies
|
||||
npm run dev # start API server (port 3000) + SvelteKit UI (port 5173) together
|
||||
npm run server # API server only (port 3000)
|
||||
npm start # CLI: Yahoo news → catalyst tickers → screener-report.html
|
||||
npm start -- watch # CLI: default watchlist
|
||||
npm start -- AAPL MSFT VOO # CLI: specific tickers
|
||||
npm run finance # CLI: portfolio advice + SimpleFIN → finance-report.html
|
||||
npm test # run all unit tests (node:test, zero external deps)
|
||||
npm run test:watch # watch mode — uses verbose spec reporter
|
||||
npm run format # format all src/bin/tests with Prettier
|
||||
npm run format:check # check formatting without writing (CI)
|
||||
npm run format:check # check formatting without writing (used in CI/pre-commit)
|
||||
npm run ui:install # install UI dependencies (ui/ subdirectory)
|
||||
```
|
||||
|
||||
`npm run dev` runs both the API server and the SvelteKit UI (in `ui/`) concurrently. Run `npm run ui:install` once before first use.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
bin/
|
||||
screen.js ← market screener entry point
|
||||
finance.js ← personal finance entry point
|
||||
screen.js ← CLI screener entry point
|
||||
finance.js ← CLI personal finance entry point
|
||||
import-portfolio.js ← broker CSV importer
|
||||
server.js ← Fastify API server entry point
|
||||
|
||||
scripts/
|
||||
summary-reporter.js ← custom node:test reporter (silent on pass, summary line at end)
|
||||
|
||||
prompts/
|
||||
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
|
||||
@@ -42,44 +59,75 @@ prompts/
|
||||
src/
|
||||
config/
|
||||
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth)
|
||||
constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER
|
||||
|
||||
market/ ← Yahoo Finance data layer
|
||||
YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff
|
||||
BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext
|
||||
MarketRegime.js ← derives INFLATED gate overrides from live benchmarks
|
||||
MarketRegime.js ← derives INFLATED gate overrides from live benchmarks + rate regime
|
||||
|
||||
screener/ ← core screening domain
|
||||
ScreenerEngine.js ← orchestrates: fetch → score × 2 → HTML report
|
||||
ScreenerEngine.js ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data),
|
||||
screenWithProgress() (CLI with stdout). Accepts { logger } option.
|
||||
DataMapper.js ← normalises Yahoo payload → flat asset data object
|
||||
NOTE: uses trailingPE (not forwardPE). Preserves negative FCF.
|
||||
Infers bond duration from category string. Maps ETF volume.
|
||||
RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
|
||||
Chunker.js ← splits ticker list into batches
|
||||
assets/
|
||||
Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers
|
||||
Stock.js ← metrics: peRatio, pegRatio, priceToBook, ROE, opMargin, etc.
|
||||
Etf.js ← metrics: expenseRatio, yield, totalAssets
|
||||
Stock.js ← metrics + _mapToStandardSector (8 sectors detected)
|
||||
Etf.js ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets
|
||||
Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric
|
||||
scorers/
|
||||
StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf)
|
||||
EtfScorer.js ← expense gate + registry (cost, yield, volume)
|
||||
EtfScorer.js ← expense gate + registry (cost, yield, volume, fiveYearReturn)
|
||||
BondScorer.js ← credit gate + spread/duration scoring
|
||||
|
||||
analyst/
|
||||
CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers
|
||||
CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }.
|
||||
LLMAnalyst.js ← uses Claude Haiku (ANTHROPIC_API_KEY) to analyze headlines → summary,
|
||||
sentiment (BULLISH/NEUTRAL/BEARISH), affectedIndustries, relatedTickers.
|
||||
Returns null gracefully if API key is not set. Accepts { logger }.
|
||||
|
||||
calls/
|
||||
MarketCallStore.js ← persists quarterly market thesis entries to market-calls.json.
|
||||
Each call stores: title, quarter, date, thesis, tickers[], snapshot{}
|
||||
(price + signal per ticker at creation time). CRUD: list/get/create/delete.
|
||||
|
||||
finance/
|
||||
clients/
|
||||
SimpleFINClient.js ← claims setup token → access URL, fetches /accounts
|
||||
SimpleFINClient.js ← claims setup token → access URL, fetches /accounts via Basic Auth header
|
||||
(NOT embedded credentials in URL). Accepts { logger, onAccessUrlClaimed }.
|
||||
PersonalFinanceAnalyzer.js ← net worth, cash vs investments, spending by category
|
||||
PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice
|
||||
PortfolioImporter.js ← parses Robinhood/Vanguard/Fidelity CSV → merges into portfolio.json
|
||||
|
||||
reporters/
|
||||
HtmlReporter.js ← generates screener-report.html (tabbed inflated/fundamental views)
|
||||
FinanceReporter.js ← generates finance-report.html (net worth, portfolio, spending)
|
||||
HtmlReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
|
||||
FinanceReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
|
||||
|
||||
server/
|
||||
app.js ← Fastify app factory (buildApp). Registers CORS + routes.
|
||||
routes/
|
||||
screener.js ← POST /api/screen, GET /api/screen/catalysts
|
||||
Serializes asset.getDisplayMetrics() before JSON response.
|
||||
finance.js ← GET /api/finance/portfolio, GET /api/finance/market-context
|
||||
calls.js ← CRUD for market calls + GET /api/calls/calendar (earnings/dividend events)
|
||||
|
||||
ui/ ← SvelteKit dashboard (lives inside this repo, not a separate repo)
|
||||
src/routes/
|
||||
+page.svelte ← main screener UI
|
||||
calls/ ← market calls list + detail views
|
||||
portfolio/ ← portfolio advice view
|
||||
safe-buys/ ← filtered strong-buy view
|
||||
|
||||
market-calls.json ← persisted market thesis calls (written by MarketCallStore)
|
||||
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
|
||||
.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
@@ -90,9 +138,9 @@ BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD
|
||||
rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } }
|
||||
↓
|
||||
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND)
|
||||
computes: pFFO proxy, FCF yield, computed PEG, 52-week position
|
||||
uses trailingPE as primary; preserves negative FCF yield; infers bond duration
|
||||
↓
|
||||
Asset subclass — Stock / Etf / Bond holds metrics, formats display
|
||||
Asset subclass — Stock / Etf / Bond holds metrics + getDisplayMetrics()
|
||||
↓
|
||||
RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style)
|
||||
INFLATED mode: sector override + MarketRegime live gate overrides
|
||||
@@ -101,15 +149,43 @@ Scorer × 2 — StockScorer / EtfScorer / BondScorer, fully stateless
|
||||
↓
|
||||
ScreenerEngine — derives Signal from comparing both verdicts
|
||||
↓
|
||||
HtmlReporter — screener-report.html: signal summary + two tabbed tables per asset class
|
||||
├── CLI path: screenWithProgress() → HtmlReporter.generate() → screener-report.html
|
||||
└── API path: screenTickers() → JSON (with serialized displayMetrics) → SvelteKit UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Routes (Fastify)
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/health` | Health check |
|
||||
| POST | `/api/screen` | Screen tickers. Body: `{ tickers: string[] }`. Returns `{ STOCK, ETF, BOND, ERROR, marketContext }` with `asset.displayMetrics` pre-serialized |
|
||||
| GET | `/api/screen/catalysts` | Yahoo news → `{ tickers, stories }` |
|
||||
| GET | `/api/finance/portfolio` | Portfolio advice + optional SimpleFIN data |
|
||||
| GET | `/api/finance/market-context` | Live benchmark data only |
|
||||
| GET | `/api/calls` | List all market calls (newest first) |
|
||||
| GET | `/api/calls/:id` | Get one call + re-screened current prices for comparison |
|
||||
| POST | `/api/analyze` | Fetch Yahoo news for specific tickers + run LLM analysis. Body: `{ tickers: string[] }`. Returns `{ analysis }` |
|
||||
| POST | `/api/calls` | Create a market call; snapshots current prices. Body: `{ title, quarter, thesis, tickers[], date? }` |
|
||||
| DELETE | `/api/calls/:id` | Delete a market call |
|
||||
| GET | `/api/calls/calendar` | Earnings + dividend calendar. Query: `?tickers=AAPL,MSFT` (omit for all call tickers) |
|
||||
|
||||
CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`).
|
||||
|
||||
---
|
||||
|
||||
## Scoring Modes
|
||||
|
||||
| Mode | P/E Gate (general) | Source |
|
||||
|---|---|---|
|
||||
| FUNDAMENTAL | 20x | ScoringConfig (Graham) |
|
||||
| INFLATED | S&P 500 P/E × 1.5 | Live SPY data |
|
||||
| Mode | P/E Gate (general) | P/E Gate (tech) | Source |
|
||||
|---|---|---|---|
|
||||
| FUNDAMENTAL | 15x | 35x | ScoringConfig (true Graham) |
|
||||
| INFLATED | S&P 500 P/E × 1.5 | XLK P/E × 1.3 | Live SPY/XLK data |
|
||||
|
||||
**Rate regime effect on INFLATED mode:**
|
||||
- HIGH rate regime: P/E multiplier compresses to 1.2× (vs 1.5× in NORMAL)
|
||||
- HIGH rate regime: REIT yield floor tightens (0.95× vs 0.85×)
|
||||
- HIGH rate regime: bond spread demand increases (0.90× vs 0.80×)
|
||||
|
||||
| Signal | Meaning |
|
||||
|---|---|
|
||||
@@ -119,46 +195,129 @@ HtmlReporter — screener-report.html: signal summary + two tabbed tab
|
||||
| 🔄 Neutral | Hold territory in one or both lenses |
|
||||
| ❌ Avoid | Fails both |
|
||||
|
||||
## ScoringConfig Structure
|
||||
---
|
||||
|
||||
`src/config/ScoringConfig.js` exports two things:
|
||||
## ScoringConfig Key Values
|
||||
|
||||
- `CREDIT_RATING_SCALE` — `{ AAA: 10, AA: 9, ..., D: 1 }`. Used by Bond.js and BondScorer.
|
||||
- `ScoringRules` — per-type `{ gates, weights, thresholds }` + `SECTOR_OVERRIDE` map for STOCK.
|
||||
`src/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds.
|
||||
|
||||
Sector overrides are structural (apply in both modes). MarketRegime overrides valuation gates in INFLATED mode only.
|
||||
**STOCK base gates (Fundamental mode):**
|
||||
- `maxPERatio: 15` — Graham's actual rule (trailing P/E)
|
||||
- `maxPegGate: 1.0` — Lynch standard: PEG > 1.0 means paying full price
|
||||
- `maxDebtToEquity: 1.5` — most distress starts above 2x
|
||||
- `minQuickRatio: 0.8` — below this signals real liquidity stress
|
||||
|
||||
## Sector Notes
|
||||
**Sector overrides** (structural — apply in both modes):
|
||||
|
||||
- **TECHNOLOGY** — `maxDebtToEquity: 2.0` (mega-cap tech borrows for buybacks), `minQuickRatio: 0.8` (AAPL runs ~0.9). P/E and PEG gates inflated from XLK live data.
|
||||
- **REIT** — P/E and PEG disabled (9999). Scored on dividend yield and P/FFO proxy. All base weights (margin, peg, revenue) explicitly zeroed out.
|
||||
- **FINANCIAL** — P/E, PEG, D/E disabled. Scored on ROE + Price-to-Book. Quick ratio gate lowered (0.1).
|
||||
| Sector | Key difference |
|
||||
|---|---|
|
||||
| TECHNOLOGY | D/E up to 2.0, P/E up to 35x, FCF weight raised |
|
||||
| REIT | P/E and PEG disabled (9999), scored on yield + P/FFO |
|
||||
| FINANCIAL | D/E disabled, scored on ROE + P/B, maxPriceToBook 1.5x |
|
||||
| ENERGY | FCF weight 4, yield weight 3, opMargin primary |
|
||||
| HEALTHCARE | Revenue growth primary, P/E up to 25x |
|
||||
| COMMUNICATION | FCF weight 4, P/E up to 25x (META, GOOGL, NFLX) |
|
||||
| CONSUMER_STAPLES | Margin/ROE focus, low revenue growth expectations |
|
||||
| CONSUMER_DISCRETIONARY | Revenue growth primary, P/E up to 25x |
|
||||
|
||||
**ETF gates:**
|
||||
- `maxExpenseRatio: 0.2%` — hard gate
|
||||
- `minFiveYearReturn: 8.0%` — S&P long-run floor
|
||||
- `minVolume: 1,000,000` ADV
|
||||
|
||||
**BOND gates:**
|
||||
- `minCreditRating: 7` (BBB = investment-grade floor)
|
||||
- `minSpread: 1.5%` above risk-free
|
||||
- `maxDuration: 7` years
|
||||
|
||||
---
|
||||
|
||||
## MarketRegime (INFLATED overrides)
|
||||
|
||||
`src/market/MarketRegime.js` derives gate overrides from live benchmarks:
|
||||
`src/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime:
|
||||
|
||||
| Gate | Formula |
|
||||
|---|---|
|
||||
| Stock maxPERatio | SPY trailing P/E × 1.5 |
|
||||
| Tech maxPERatio | XLK P/E × 1.3 |
|
||||
| Tech maxPegGate | XLK P/E ÷ 15 |
|
||||
| REIT minYield | XLRE dividend yield × 0.85 |
|
||||
| Bond minSpread | LQD−TNX spread × 0.80 |
|
||||
| ETF maxExpenseRatio | 0.75% (structural loosening) |
|
||||
| Gate | Formula (NORMAL rates) | Formula (HIGH rates) |
|
||||
|---|---|---|
|
||||
| Stock maxPERatio | SPY trailing P/E × 1.5 | SPY trailing P/E × 1.2 |
|
||||
| Tech maxPERatio | XLK P/E × 1.3 | XLK P/E × 1.3 |
|
||||
| Tech maxPegGate | XLK P/E ÷ 15 | XLK P/E ÷ 15 |
|
||||
| REIT minYield | XLRE yield × 0.85 | XLRE yield × 0.95 |
|
||||
| Bond minSpread | LQD−TNX × 0.80 | LQD−TNX × 0.90 |
|
||||
| ETF maxExpenseRatio | 0.75% | 0.75% |
|
||||
|
||||
---
|
||||
|
||||
## Sector Detection
|
||||
|
||||
`Stock._mapToStandardSector()` maps Yahoo Finance `sector`/`industry` strings to internal constants.
|
||||
Order matters — more specific matches first:
|
||||
|
||||
```
|
||||
TECHNOLOGY → "technology", "electronic", "semiconductor", "software"
|
||||
REIT → "real estate", "reit"
|
||||
FINANCIAL → "financial", "bank", "insurance", "asset management"
|
||||
ENERGY → "energy", "oil", "gas", "petroleum"
|
||||
HEALTHCARE → "health", "biotech", "pharmaceutical", "medical"
|
||||
COMMUNICATION→ "communication", "media", "entertainment", "telecom"
|
||||
CONSUMER_STAPLES → "consumer defensive", "consumer staples", "household", "beverage", "food"
|
||||
CONSUMER_DISCRETIONARY → "consumer cyclical", "consumer discretionary", "retail", "apparel", "auto"
|
||||
GENERAL → fallback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DataMapper Notes
|
||||
|
||||
- **peRatio**: prefers `trailingPE` (audited) over `forwardPE` (analyst estimate, ~10-15% optimistic)
|
||||
- **FCF yield**: `freeCashflow !== 0` (not `> 0`) — negative FCF preserved so cash-burning companies fail the gate, not silently skip it
|
||||
- **Bond duration**: inferred from category string ("Short-Term" → 2y, "Intermediate" → 5y, "Long" → 18y, default 6y). Yahoo does not expose effective duration in the modules we fetch.
|
||||
- **ETF volume**: `summaryDetail.averageVolume` — was missing before, causing the `-2` liquidity penalty on every ETF
|
||||
|
||||
---
|
||||
|
||||
## Missing Data Convention
|
||||
|
||||
- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` values rather than auto-failing.
|
||||
- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` rather than auto-failing.
|
||||
- `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it.
|
||||
- `quickRatio` falls back to `currentRatio` when missing.
|
||||
|
||||
---
|
||||
|
||||
## Logger Injection Pattern
|
||||
|
||||
Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context:
|
||||
|
||||
```js
|
||||
// CLI (default) — writes to stdout
|
||||
new ScreenerEngine()
|
||||
|
||||
// Server — fully silent
|
||||
new ScreenerEngine({ logger: { write: () => {}, log: () => {}, warn: () => {} } })
|
||||
```
|
||||
|
||||
Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.
|
||||
|
||||
---
|
||||
|
||||
## Reporter Pattern
|
||||
|
||||
Both reporters have two methods:
|
||||
|
||||
```js
|
||||
reporter.render(...) // → HTML string (use in server route responses)
|
||||
reporter.generate(...) // → writes file to disk, returns path (use in CLI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SimpleFIN Auth Flow
|
||||
|
||||
1. User gets a Setup Token from https://beta-bridge.simplefin.org
|
||||
2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim URL → receives Access URL
|
||||
3. Access URL is auto-appended to `.env` as `SIMPLEFIN_ACCESS_URL`
|
||||
4. All subsequent requests use Access URL directly (setup token is one-time use)
|
||||
2. `SimpleFINClient.init()` base64-decodes it → POSTs once to claim Access URL
|
||||
3. `onAccessUrlClaimed` callback is called with the URL — CLI uses `saveAccessUrlToEnv()`, server stores elsewhere
|
||||
4. All subsequent requests use Access URL with `Authorization: Basic` header (not embedded in URL)
|
||||
|
||||
---
|
||||
|
||||
## portfolio.json Format
|
||||
|
||||
@@ -174,52 +333,125 @@ Sector overrides are structural (apply in both modes). MarketRegime overrides va
|
||||
|
||||
`type` values: `stock`, `etf`, `crypto`. Crypto is priced via Yahoo (BTC-USD style) but not fundamentally scored.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
Uses Node's built-in test runner (`node:test` + `node:assert/strict`) — no test framework to install.
|
||||
Uses Node's built-in test runner (`node:test` + `node:assert/strict`) — no test framework needed.
|
||||
|
||||
```
|
||||
tests/
|
||||
ScoringConfig.test.js ← CREDIT_RATING_SCALE, gate values, sector overrides
|
||||
ScoringConfig.test.js ← gate values (P/E 15x, PEG 1.0, QuickRatio 0.8), sector overrides
|
||||
RuleMerger.test.js ← FUNDAMENTAL vs INFLATED modes, sector merging
|
||||
MarketRegime.test.js ← inflated override formulas per asset type and sector
|
||||
MarketRegime.test.js ← inflated overrides including HIGH/NORMAL rate regime variants
|
||||
StockScorer.test.js ← gate failures, scoring labels, risk flags
|
||||
EtfScorer.test.js ← expense gate, score tiers
|
||||
EtfScorer.test.js ← expense gate, volume penalty, 5Y return scoring
|
||||
BondScorer.test.js ← credit gate, spread/duration scoring, unit handling
|
||||
DataMapper.test.js ← type detection, PEG computation, null convention
|
||||
PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping
|
||||
DataMapper.test.js ← type detection, PEG computation, trailing PE preference,
|
||||
negative FCF, ETF volume, bond duration inference
|
||||
PortfolioAdvisor.test.js ← _position gain/loss calc, _advice signal mapping, BRK.B dot-notation normalisation
|
||||
LLMAnalyst.test.js ← markdown fence stripping, JSON parse correctness
|
||||
```
|
||||
|
||||
**Key unit to remember:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation. `minSpread` in `ScoringConfig` is also in percentage form (e.g. `1.0` = 1%).
|
||||
Pre-commit hook runs `lint-staged` (Prettier) then `npm test`. Pre-push hook runs `npm test`.
|
||||
Test output: silent on pass, shows only failures + one summary line (`scripts/summary-reporter.js`).
|
||||
|
||||
**Key unit:** `ytm` in `Bond.metrics` is stored as a percentage (e.g. `6.5` = 6.5%). `BondScorer._sanitize` divides by 100 before spread calculation.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map in ScreenerEngine, and ScoringRules.
|
||||
- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules.
|
||||
- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers.
|
||||
- BenchmarkProvider caches for 1 hour — restart the process to force a fresh fetch during development.
|
||||
- BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch.
|
||||
- All entry points live in `bin/`. Do not add logic to entry points — they call into `src/`.
|
||||
- `bin/server.js` starts Fastify; `src/server/` contains all route logic.
|
||||
- **Never** call `process.exit()` inside `src/` — only `bin/` may do that.
|
||||
- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `src/server/routes/screener.js` `serializeAssets()`).
|
||||
|
||||
## Planned Enhancements
|
||||
---
|
||||
|
||||
### 1. Rate Sensitivity Flag (no new fetches needed)
|
||||
Flag assets exposed to rate changes using `riskFreeRate` and `rateRegime`:
|
||||
- Long-duration bonds (duration > 7) in HIGH rate regime → warn
|
||||
- REITs with high D/E in HIGH rate regime → warn
|
||||
- Growth stocks with negative FCF in HIGH rate regime → warn
|
||||
## Architecture Roadmap
|
||||
|
||||
### 2. 52-Week Positioning (data already mapped)
|
||||
`week52High` and `week52Low` are in asset metrics. Add interpretation:
|
||||
- `> 85%` position + high P/E = crowded/momentum trade
|
||||
- `< 15%` position + passing fundamental gates = potential opportunity
|
||||
Planned improvements in priority order. Do not start a later phase before completing earlier ones.
|
||||
|
||||
### 3. Sector Rotation Cues (3–4 new fetches)
|
||||
Add XLF, XLE, XLV, XLI to BenchmarkProvider. Compare each sector ETF's 1-year return to S&P 500 to identify leading/lagging sectors.
|
||||
### Phase 1 — Cleanup ✅ COMPLETE
|
||||
All items completed. Additional features delivered alongside cleanup:
|
||||
|
||||
### 4. Client/Server Architecture
|
||||
Planned split into:
|
||||
- **Server** (Fastify + BullMQ) — API endpoints, webhook-driven news monitoring, WebSocket for live updates
|
||||
- **Client** (SvelteKit) — interactive dashboard replacing the HTML reports
|
||||
- LLM integration (Claude Haiku) wired into the server for deeper catalyst analysis
|
||||
**Cleanup done:**
|
||||
- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md`
|
||||
- Deleted `src/server/routes/analyze.js` (orphaned route file)
|
||||
- Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte`
|
||||
- Fixed `.gitignore` — `portfolio.json`, `market-calls.json`, `.env` are now excluded from git
|
||||
|
||||
**Features added during Phase 1:**
|
||||
- `POST /api/analyze` — per-tab LLM analysis with sidebar (✦ Analyze button on each asset section)
|
||||
- `POST /api/finance/holdings` + `DELETE /api/finance/holdings/:ticker` — add/edit/delete holdings via UI
|
||||
- Portfolio page: inline row editing, optimistic UI updates, sortable columns, collapsible market context with tooltips, P&L summary card tooltips
|
||||
- Holdings can be added/edited/deleted via the portfolio UI (manual entry replaces CSV importer)
|
||||
- `BRK.B` dot-notation tickers now normalised to Yahoo Finance format (`BRK.B → BRK-B`)
|
||||
- Market graph drawing-line animation replaces generic spinner (lg/md); dot-pulse for sm (buttons)
|
||||
- Portfolio page loads client-side (`$effect`) to avoid blocking navigation
|
||||
- Catalyst page auto-loads on mount; LLM analysis only runs on explicit ✦ Analyze click
|
||||
|
||||
**Pending (deferred to later):**
|
||||
- LLM Analysis button on portfolio page (analyse holdings against current news)
|
||||
|
||||
### Phase 2 — Extract Shared Utilities
|
||||
- Create `ui/src/lib/utils.ts` with all pure functions currently duplicated across pages: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass`
|
||||
- Create `src/server/utils/logger.js` with shared `noopLogger` constant (currently copy-pasted in `screener.js` and `app.js`)
|
||||
|
||||
### Phase 3 — Rename `src/` → `server/`
|
||||
- Rename the directory and update all import paths in `bin/`, internal routes, and `CLAUDE.md`
|
||||
- Makes the API layer unambiguous — `src/` conventionally implies "all project source"
|
||||
|
||||
### Phase 4 — SCSS Migration
|
||||
Replace per-component `<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
|
||||
|
||||
@@ -229,3 +461,4 @@ Planned split into:
|
||||
4. Create a Scorer in `src/screener/scorers/` exposing `score(metrics, rules, marketContext)`.
|
||||
5. Add a mapper in `DataMapper.js`.
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
```bash
|
||||
# API + Dashboard (recommended)
|
||||
npm install
|
||||
cp .env.example .env # add SIMPLEFIN_SETUP_TOKEN if you have SimpleFIN
|
||||
npm start # screen today's catalyst tickers from Yahoo news
|
||||
cd ../market-screener-ui && npm install && cd ../market_screener
|
||||
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 |
|
||||
|---|---|
|
||||
| `npm start` | Fetches today's market news, extracts catalyst tickers, screens them |
|
||||
| `npm start -- watch` | Screens the default watchlist instead |
|
||||
| `npm start -- AAPL MSFT VOO` | Screens specific tickers |
|
||||
| `npm run finance` | Portfolio advice + SimpleFIN account data → `finance-report.html` |
|
||||
| `npm run import-portfolio -- file.csv` | Imports Robinhood/Vanguard/Fidelity CSV into `portfolio.json` |
|
||||
| `npm test` | Runs all unit tests (51 tests, zero external dependencies) |
|
||||
| `npm run test:watch` | Re-runs tests on file changes during development |
|
||||
| `npm run format` | Formats all source files with Prettier |
|
||||
| `npm run format:check` | Checks formatting without writing (useful in CI) |
|
||||
|
||||
Both commands generate self-contained HTML reports that open in any browser.
|
||||
| `npm run dev` | Start API server (port 3000) + SvelteKit UI (port 5173) together |
|
||||
| `npm run server` | Start API server only |
|
||||
| `npm start` | CLI: fetch today's market news, extract tickers, screen them |
|
||||
| `npm start -- watch` | CLI: screen the default watchlist |
|
||||
| `npm start -- AAPL MSFT VOO` | CLI: screen specific tickers |
|
||||
| `npm run finance` | CLI: portfolio advice + SimpleFIN → `finance-report.html` |
|
||||
| `npm run import-portfolio -- file.csv` | Import Robinhood/Vanguard/Fidelity CSV into `portfolio.json` |
|
||||
| `npm test` | Run all 61 unit tests |
|
||||
| `npm run test:watch` | Re-run tests on file changes |
|
||||
| `npm run format` | Format all source files with Prettier |
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
- Stock P/E gate = S&P 500 P/E (via SPY) × 1.5
|
||||
- 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
|
||||
### Market-Adjusted mode
|
||||
Gates derived from live Yahoo Finance benchmarks — reflects what is acceptable in today's market:
|
||||
|
||||
**Fundamental** — strict Graham/value-investing gates from `src/config/ScoringConfig.js`:
|
||||
- Stock P/E < 20x, PEG < 1.5
|
||||
- Bond spread > 1.0% above risk-free rate
|
||||
| Gate | Formula |
|
||||
|---|---|
|
||||
| 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 |
|
||||
|---|---|
|
||||
| ✅ Strong Buy | Passes both — genuinely good value |
|
||||
| ✅ Strong Buy | Passes both lenses — genuinely good value |
|
||||
| ⚡ Momentum | Passes market-adjusted, holds fundamentally |
|
||||
| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection |
|
||||
| 🔄 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 (2–5%) |
|
||||
| **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
|
||||
|
||||
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)
|
||||
|
||||
Connects to your real bank and brokerage accounts for net worth, balances, and 30-day spending breakdown.
|
||||
### SimpleFIN (optional — live bank/brokerage balances)
|
||||
|
||||
1. Get your setup token from [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org)
|
||||
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
|
||||
|
||||
@@ -91,73 +137,89 @@ npm run import-portfolio -- ~/Downloads/robinhood_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
|
||||
|
||||
```
|
||||
├── bin/
|
||||
│ ├── screen.js # Market screener entry point
|
||||
│ ├── finance.js # Personal finance entry point
|
||||
│ └── import-portfolio.js # Broker CSV importer
|
||||
│
|
||||
├── prompts/
|
||||
│ └── catalyst-analysis.md # Daily catalyst analysis playbook
|
||||
│
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── ScoringConfig.js # All scoring gates, weights, thresholds
|
||||
│ │
|
||||
│ ├── market/ # Yahoo Finance data layer
|
||||
│ │ ├── YahooClient.js
|
||||
│ │ ├── BenchmarkProvider.js
|
||||
│ │ └── MarketRegime.js # Derives inflated gate overrides from live data
|
||||
│ │
|
||||
│ ├── screener/ # Core screening domain
|
||||
│ │ ├── ScreenerEngine.js
|
||||
│ │ ├── DataMapper.js
|
||||
│ │ ├── RuleMerger.js
|
||||
│ │ ├── Chunker.js
|
||||
│ │ ├── assets/ # Stock, Etf, Bond data containers
|
||||
│ │ └── scorers/ # StockScorer, EtfScorer, BondScorer
|
||||
│ │
|
||||
│ ├── analyst/
|
||||
│ │ └── CatalystAnalyst.js # Extracts tickers from Yahoo Finance news
|
||||
│ │
|
||||
│ ├── finance/
|
||||
│ │ ├── clients/
|
||||
│ │ │ └── SimpleFINClient.js
|
||||
│ │ ├── PersonalFinanceAnalyzer.js
|
||||
│ │ ├── PortfolioAdvisor.js
|
||||
│ │ └── PortfolioImporter.js
|
||||
│ │
|
||||
│ └── reporters/
|
||||
│ ├── HtmlReporter.js # screener-report.html
|
||||
│ └── FinanceReporter.js # finance-report.html
|
||||
│
|
||||
├── portfolio.json # Your holdings (edit this)
|
||||
└── .env # SIMPLEFIN_SETUP_TOKEN / SIMPLEFIN_ACCESS_URL
|
||||
bin/
|
||||
screen.js CLI screener entry point
|
||||
finance.js CLI personal finance entry point
|
||||
import-portfolio.js Broker CSV importer
|
||||
server.js Fastify API server entry point
|
||||
|
||||
scripts/
|
||||
summary-reporter.js Custom node:test reporter (silent pass, shows failures + summary)
|
||||
|
||||
src/
|
||||
config/
|
||||
ScoringConfig.js All gates, weights, thresholds (single source of truth)
|
||||
constants.js Shared enums: SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME
|
||||
|
||||
market/
|
||||
YahooClient.js Wraps yahoo-finance2 v3 with retry + backoff
|
||||
BenchmarkProvider.js Fetches live benchmarks → marketContext (1-hour cache)
|
||||
MarketRegime.js Derives inflated gate overrides from live data + rate regime
|
||||
|
||||
screener/
|
||||
ScreenerEngine.js Orchestrates fetch → score × 2.
|
||||
screenTickers() → pure data (server/CLI)
|
||||
screenWithProgress() → with stdout progress (CLI only)
|
||||
DataMapper.js Normalises Yahoo payload → flat asset objects
|
||||
Uses trailingPE (not forwardPE). Preserves negative FCF.
|
||||
RuleMerger.js Merges base rules + sector overrides + MarketRegime
|
||||
assets/ Stock, Etf, Bond data containers
|
||||
scorers/ StockScorer, EtfScorer, BondScorer (stateless)
|
||||
|
||||
analyst/
|
||||
CatalystAnalyst.js Extracts tickers from Yahoo Finance news headlines
|
||||
|
||||
finance/
|
||||
clients/
|
||||
SimpleFINClient.js Auth + account fetching via Basic Auth header
|
||||
PersonalFinanceAnalyzer.js Net worth, cash vs investments, spending
|
||||
PortfolioAdvisor.js Hold/sell/add advice per holding
|
||||
PortfolioImporter.js Parses Robinhood/Vanguard/Fidelity CSV
|
||||
|
||||
reporters/
|
||||
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 |
|
||||
|---|---|---|
|
||||
| P/E ratio | Yahoo forwardPE / trailingPE | Valuation |
|
||||
| PEG ratio | Yahoo or computed (trailingPE ÷ earningsGrowth) | Valuation vs growth |
|
||||
| Price-to-Book | Yahoo | Graham's primary value metric |
|
||||
| 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 |
|
||||
```bash
|
||||
# .env
|
||||
SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
|
||||
# or on first run:
|
||||
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
|
||||
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
```
|
||||
Generated
+741
-2
@@ -8,15 +8,47 @@
|
||||
"name": "market-screener",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.100.1",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"fastify": "^5.8.5",
|
||||
"yahoo-finance2": "^3.15.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.3",
|
||||
"husky": "^9.0.0",
|
||||
"lint-staged": "^15.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": {
|
||||
"version": "0.18.2",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.19.14",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -170,6 +349,33 @@
|
||||
"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": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -287,6 +493,20 @@
|
||||
"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": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
@@ -302,6 +522,30 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -420,6 +664,14 @@
|
||||
"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": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -509,6 +761,15 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -644,12 +905,53 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"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": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
@@ -666,6 +968,61 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fetch-mock-cache/-/fetch-mock-cache-2.3.1.tgz",
|
||||
@@ -751,6 +1108,19 @@
|
||||
"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": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -778,6 +1148,15 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -1055,6 +1464,53 @@
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -1331,6 +1787,14 @@
|
||||
"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": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -1419,6 +1883,40 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1498,6 +2011,11 @@
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -1522,6 +2040,14 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
@@ -1590,12 +2132,76 @@
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
@@ -1641,6 +2247,11 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -1668,6 +2279,18 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1842,6 +2490,34 @@
|
||||
"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": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
@@ -1872,6 +2548,14 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1908,6 +2592,15 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
|
||||
@@ -1920,6 +2613,17 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
@@ -2026,6 +2730,15 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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": {
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.15.2.tgz",
|
||||
@@ -2076,6 +2789,32 @@
|
||||
"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": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
+7
-1
@@ -4,8 +4,10 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"import-portfolio": "node bin/import-portfolio.js",
|
||||
"test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js",
|
||||
"test:watch": "node --test --watch --test-reporter=spec tests/*.test.js",
|
||||
"format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"",
|
||||
@@ -18,10 +20,14 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.100.1",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"fastify": "^5.8.5",
|
||||
"yahoo-finance2": "^3.15.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.3",
|
||||
"husky": "^9.0.0",
|
||||
"lint-staged": "^15.0.0",
|
||||
"prettier": "^3.0.0"
|
||||
|
||||
@@ -5,15 +5,16 @@ const MAX_STORIES = 15;
|
||||
const TICKER_REGEX = /^[A-Z]{1,6}$/;
|
||||
|
||||
export class CatalystAnalyst {
|
||||
constructor() {
|
||||
constructor({ logger } = {}) {
|
||||
this.client = new YahooClient();
|
||||
this.logger = logger ?? { write: (msg) => process.stdout.write(msg) };
|
||||
}
|
||||
|
||||
async run() {
|
||||
process.stdout.write('🔍 Fetching market news...');
|
||||
this.logger.write('🔍 Fetching market news...');
|
||||
const stories = await this._fetchNews();
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,76 @@ export const ScoringRules = {
|
||||
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,
|
||||
// a 0.5% expense ratio is genuinely hard to justify except for niche/active strategies.
|
||||
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: {
|
||||
minYield: 1.5,
|
||||
maxExpense: 0.05, // lowered from 0.1: 0.05% is achievable for broad market ETFs
|
||||
minVolume: 1000000, // raised from 500k: 1M ADV is the real liquidity floor to avoid slippage
|
||||
maxExpense: 0.05, // 0.05% is achievable for broad market ETFs
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ export const SECTOR = {
|
||||
TECHNOLOGY: 'TECHNOLOGY',
|
||||
REIT: 'REIT',
|
||||
FINANCIAL: 'FINANCIAL',
|
||||
ENERGY: 'ENERGY',
|
||||
HEALTHCARE: 'HEALTHCARE',
|
||||
COMMUNICATION: 'COMMUNICATION',
|
||||
CONSUMER_STAPLES: 'CONSUMER_STAPLES',
|
||||
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
|
||||
GENERAL: 'GENERAL',
|
||||
};
|
||||
|
||||
|
||||
@@ -7,13 +7,19 @@ export class PortfolioAdvisor {
|
||||
}
|
||||
|
||||
async advise(holdings, screenedResults) {
|
||||
const resultMap = Object.fromEntries(
|
||||
[
|
||||
...(screenedResults.STOCK ?? []),
|
||||
...(screenedResults.ETF ?? []),
|
||||
...(screenedResults.BOND ?? []),
|
||||
].map((r) => [r.asset.ticker, r]),
|
||||
);
|
||||
// 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.ETF ?? []),
|
||||
...(screenedResults.BOND ?? []),
|
||||
]) {
|
||||
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'));
|
||||
|
||||
@@ -35,7 +41,7 @@ export class PortfolioAdvisor {
|
||||
if (!result) {
|
||||
return this._row(holding, price, source, '—', '—', '—', {
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,20 @@ export class SimpleFINClient {
|
||||
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);
|
||||
// fetch() rejects URLs with embedded credentials (user:pass@host).
|
||||
// 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) {
|
||||
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SECTOR, ASSET_TYPE } from '../config/constants.js';
|
||||
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js';
|
||||
|
||||
export class MarketRegime {
|
||||
constructor(marketContext) {
|
||||
@@ -7,6 +7,8 @@ export class MarketRegime {
|
||||
this.techPE = b.techPE ?? 30;
|
||||
this.reitYield = b.reitYield ?? 3.5;
|
||||
this.igSpread = b.igSpread ?? 1.0;
|
||||
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
|
||||
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
|
||||
}
|
||||
|
||||
getInflatedOverrides(type, sector) {
|
||||
@@ -20,7 +22,11 @@ export class MarketRegime {
|
||||
if (sector === SECTOR.REIT) {
|
||||
return {
|
||||
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) {
|
||||
@@ -32,9 +38,12 @@ export class MarketRegime {
|
||||
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 {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.marketPE * 1.5),
|
||||
maxPERatio: Math.round(this.marketPE * peMultiplier),
|
||||
maxPegGate: +(this.marketPE / 12).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
@@ -44,7 +53,11 @@ export class MarketRegime {
|
||||
_etf() {
|
||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||
}
|
||||
|
||||
_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,7 +11,6 @@ export class YahooClient {
|
||||
async fetchSummary(ticker, retries = 3, backoff = 1000) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
// Use the instance (this.yf) instead of the static import
|
||||
return await this.yf.quoteSummary(ticker, {
|
||||
modules: [
|
||||
'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,11 @@ const mapStockData = (summary) => {
|
||||
? currentPrice / (operatingCashflow / sharesOutstanding)
|
||||
: 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 =
|
||||
freeCashflow > 0 && sharesOutstanding > 0 && currentPrice > 0
|
||||
freeCashflow !== 0 && sharesOutstanding > 0 && currentPrice > 0
|
||||
? (freeCashflow / sharesOutstanding / currentPrice) * 100
|
||||
: null;
|
||||
|
||||
@@ -65,8 +67,9 @@ const mapStockData = (summary) => {
|
||||
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
|
||||
|
||||
return {
|
||||
// Valuation
|
||||
peRatio: ks.forwardPE ?? trailingPE,
|
||||
// Valuation — trailing PE is the audited number; forward PE is an analyst estimate
|
||||
// (historically 10-15% optimistic). Use trailing as primary for fundamental mode.
|
||||
peRatio: trailingPE ?? ks.forwardPE,
|
||||
trailingPE,
|
||||
pegRatio,
|
||||
priceToBook: ks.priceToBook ?? null,
|
||||
@@ -107,7 +110,10 @@ const mapEtfData = (summary) => ({
|
||||
expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100,
|
||||
totalAssets: summary.summaryDetail?.totalAssets ?? 0,
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -124,10 +130,24 @@ const inferCreditRating = (category) => {
|
||||
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) => ({
|
||||
yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100,
|
||||
// fiveYearAverageReturn is the closest Yahoo proxy for effective duration on bond ETFs
|
||||
duration: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0,
|
||||
// KNOWN LIMITATION: Yahoo Finance does not expose effective duration via the modules
|
||||
// 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),
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
});
|
||||
|
||||
@@ -7,8 +7,9 @@ export class Etf extends Asset {
|
||||
expenseRatio: parseFloat(data.expenseRatio) || 0,
|
||||
totalAssets: parseFloat(data.totalAssets) || 0,
|
||||
yield: parseFloat(data.yield) || 0,
|
||||
volume: parseFloat(data.volume) || 0,
|
||||
fiveYearReturn: parseFloat(data.fiveYearReturn) || 0,
|
||||
};
|
||||
this.fiveYearReturn = parseFloat(data.fiveYearReturn) || 0;
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
@@ -19,7 +20,7 @@ export class Etf extends Asset {
|
||||
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
|
||||
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
|
||||
AUM: this.formatLargeNumber(this.metrics.totalAssets),
|
||||
'5Y Return%': `${this.fiveYearReturn.toFixed(1)}%`,
|
||||
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,66 @@ export class Stock extends Asset {
|
||||
}
|
||||
|
||||
_mapToStandardSector(data) {
|
||||
// 1. Safely grab the profile from the data object
|
||||
const profile = data.assetProfile || {};
|
||||
|
||||
// 2. Extract values safely
|
||||
const industry = (profile.industry || '').toLowerCase();
|
||||
const sector = (profile.sector || '').toLowerCase();
|
||||
const combined = `${industry} ${sector}`;
|
||||
|
||||
// 3. Match logic
|
||||
if (combined.includes('technology') || combined.includes('electronic')) return 'TECHNOLOGY';
|
||||
// Yahoo Finance sector/industry strings mapped to our internal sector constants.
|
||||
// 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('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';
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const EtfScorer = {
|
||||
expenseRatio: parseFloat(m.expenseRatio) || 0,
|
||||
yield: parseFloat(m.yield) || 0,
|
||||
volume: parseFloat(m.volume) || 0,
|
||||
fiveYearReturn: parseFloat(m.fiveYearReturn) || 0,
|
||||
};
|
||||
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
@@ -14,7 +15,14 @@ export const EtfScorer = {
|
||||
const breakdown = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -78,6 +78,63 @@ test('FCF yield is computed when data available', () => {
|
||||
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', () => {
|
||||
const sparse = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 100 },
|
||||
|
||||
@@ -29,3 +29,26 @@ test('audit breakdown includes cost, yield, vol keys', () => {
|
||||
assert(result.audit.breakdown.yield != 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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { MarketRegime } from '../src/market/MarketRegime.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', () => {
|
||||
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
|
||||
});
|
||||
|
||||
test('REIT inflated minYield = reitYield × 0.85', () => {
|
||||
const { thresholds } = regime({ reitYield: 4.0 }).getInflatedOverrides(
|
||||
test('REIT inflated minYield = reitYield × 0.85 in NORMAL rate regime', () => {
|
||||
const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'NORMAL' }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.REIT,
|
||||
);
|
||||
assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40
|
||||
});
|
||||
|
||||
test('bond inflated minSpread = igSpread × 0.80', () => {
|
||||
const { thresholds } = regime({ igSpread: 1.5 }).getInflatedOverrides(
|
||||
test('REIT inflated minYield = reitYield × 0.95 in HIGH rate regime', () => {
|
||||
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,
|
||||
SECTOR.GENERAL,
|
||||
);
|
||||
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', () => {
|
||||
const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF);
|
||||
assert.equal(gates.maxExpenseRatio, 0.75);
|
||||
|
||||
@@ -47,3 +47,46 @@ test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
||||
const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000);
|
||||
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
@@ -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
@@ -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
|
||||
Generated
+1456
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 (15–25), 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
export default {
|
||||
kit: { adapter: adapter() },
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user