Compare commits
2 Commits
19fc052d14
...
3513024fc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3513024fc6 | |||
| cd74497de6 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "market-screener-ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--prefix", "ui"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# ── SimpleFIN personal finance ───────────────────────────────────────────────
|
||||
#
|
||||
# FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org
|
||||
# (Settings → Connect an app → copy the token)
|
||||
#
|
||||
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3Jn...
|
||||
#
|
||||
# AFTER FIRST RUN: the Access URL is written here automatically.
|
||||
# Remove SIMPLEFIN_SETUP_TOKEN once this appears.
|
||||
#
|
||||
# SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin
|
||||
+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
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
npx lint-staged
|
||||
npm test
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
npm test
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
`market-screener` is a Node.js project with two modes:
|
||||
|
||||
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
|
||||
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 (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 ← 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)
|
||||
|
||||
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 + rate regime
|
||||
|
||||
screener/ ← core screening domain
|
||||
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 + _mapToStandardSector (8 sectors detected)
|
||||
Etf.js ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets
|
||||
Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric
|
||||
scorers/
|
||||
StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf)
|
||||
EtfScorer.js ← expense gate + registry (cost, yield, volume, fiveYearReturn)
|
||||
BondScorer.js ← credit gate + spread/duration scoring
|
||||
|
||||
analyst/
|
||||
CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }.
|
||||
LLMAnalyst.js ← uses Claude Haiku (ANTHROPIC_API_KEY) to analyze headlines → summary,
|
||||
sentiment (BULLISH/NEUTRAL/BEARISH), affectedIndustries, relatedTickers.
|
||||
Returns null gracefully if API key is not set. Accepts { logger }.
|
||||
|
||||
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 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
|
||||
|
||||
reporters/
|
||||
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
|
||||
|
||||
```
|
||||
Yahoo Finance API
|
||||
↓
|
||||
BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD
|
||||
builds marketContext { sp500Price, riskFreeRate, vixLevel,
|
||||
rateRegime, volatilityRegime, benchmarks { marketPE, techPE, reitYield, igSpread } }
|
||||
↓
|
||||
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND)
|
||||
uses trailingPE as primary; preserves negative FCF yield; infers bond duration
|
||||
↓
|
||||
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
|
||||
↓
|
||||
Scorer × 2 — StockScorer / EtfScorer / BondScorer, fully stateless
|
||||
↓
|
||||
ScreenerEngine — derives Signal from comparing both verdicts
|
||||
↓
|
||||
├── 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) | 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 |
|
||||
|---|---|
|
||||
| ✅ Strong Buy | Passes both fundamental AND inflated gates |
|
||||
| ⚡ Momentum | Passes inflated, holds fundamentally |
|
||||
| ⚠️ Speculation | Passes inflated, fails fundamental |
|
||||
| 🔄 Neutral | Hold territory in one or both lenses |
|
||||
| ❌ Avoid | Fails both |
|
||||
|
||||
---
|
||||
|
||||
## ScoringConfig Key Values
|
||||
|
||||
`src/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds.
|
||||
|
||||
**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 overrides** (structural — apply in both modes):
|
||||
|
||||
| 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 and current rate regime:
|
||||
|
||||
| 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` 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 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
|
||||
|
||||
```json
|
||||
{
|
||||
"holdings": [
|
||||
{ "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" },
|
||||
{ "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" },
|
||||
{ "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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 needed.
|
||||
|
||||
```
|
||||
tests/
|
||||
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 overrides including HIGH/NORMAL rate regime variants
|
||||
StockScorer.test.js ← gate failures, scoring labels, risk flags
|
||||
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, 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
|
||||
```
|
||||
|
||||
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, and ScoringRules.
|
||||
- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers.
|
||||
- BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch.
|
||||
- All entry points live in `bin/`. Do not add logic to entry points — they call into `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()`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Roadmap
|
||||
|
||||
Planned improvements in priority order. Do not start a later phase before completing earlier ones.
|
||||
|
||||
### Phase 1 — Cleanup ✅ COMPLETE
|
||||
All items completed. Additional features delivered alongside cleanup:
|
||||
|
||||
**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
|
||||
|
||||
1. Create a subclass of `Asset` in `src/screener/assets/` with a flat `metrics` object and `getDisplayMetrics()`.
|
||||
2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `ScoringConfig.js`.
|
||||
3. Add inflated overrides in `MarketRegime.getInflatedOverrides()`.
|
||||
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,46 +0,0 @@
|
||||
# Market News Analysis & Catalyst Screener
|
||||
|
||||
## 1. High-Alpha Catalyst Analysis Prompt
|
||||
|
||||
Copy and paste this into your LLM daily to filter noise into actionable data:
|
||||
|
||||
> **Role:** You are a Quant-driven Financial Analyst specialized in Catalyst-Driven Trading.
|
||||
>
|
||||
> **Task:** Analyze today’s top 3 high-impact news stories and map them to the specific assets that are structurally forced to respond.
|
||||
>
|
||||
> **Instructions:**
|
||||
>
|
||||
> 1. **Identify the Catalyst:** Select one Macro event, one Sector-wide (regulatory/supply-chain) shift, and one Company-specific surprise.
|
||||
> 2. **Correlation Logic:** For each catalyst, identify:
|
||||
> - **Primary Target:** The ticker directly mentioned.
|
||||
> - **Ripple-Effect Target:** A ticker in the supply chain or direct competitor (The "Alpha" play).
|
||||
> 3. **Quantitative Impact Matrix:** Produce a table with: `Catalyst` | `Tickers (Primary/Ripple)` | `Bias` | `Sensitivity (1-5)` | `Mechanics`.
|
||||
> 4. **Constraint:** Exclude "Market Sentiment" or generic analyst upgrades. Only include events with a measurable impact on valuation or supply chain fundamentals.
|
||||
> 5. **Liquidity Filter:** Do not suggest tickers with daily volume below 500k.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quantitative Impact Matrix (Template)
|
||||
|
||||
Use this table to log the results from the prompt above:
|
||||
|
||||
| Catalyst | Tickers (Primary / Ripple) | Bias | Sensitivity (1-5) | Mechanics |
|
||||
| :----------- | :------------------------- | :-------- | :---------------- | :------------------------ |
|
||||
| [Event Name] | [Ticker1] / [Ticker2] | Bull/Bear | [1-5] | [Concise financial logic] |
|
||||
|
||||
---
|
||||
|
||||
## 3. Implementation Workflow
|
||||
|
||||
1. **Fetch:** Run the prompt above using live news sources (e.g., Bloomberg, Nasdaq, Briefing.com).
|
||||
2. **Screen:** Plug the resulting tickers into your `ScreenerEngine.js`.
|
||||
3. **Validate:** Use your "Verdict Justification" table to verify if the fundamentals (PEG, Margins, Debt) support the AI's suggested bias.
|
||||
4. **Execute:** Monitor the "Ripple-Effect" targets, as they often capture volatility before the broader market catches on.
|
||||
|
||||
---
|
||||
|
||||
## 4. June 2026 Focus Areas
|
||||
|
||||
- **Macro:** Watch the ISM Manufacturing PMI (June 1) and Nonfarm Payrolls (June 5).
|
||||
- **Geopolitical:** Monitor US-Iran negotiations regarding the Strait of Hormuz (impacts Oil/Energy supply chains).
|
||||
- **Sectoral:** Continued AI momentum—look for infrastructure and cybersecurity earnings/guidance.
|
||||
@@ -1,58 +1,225 @@
|
||||
# Financial Screener & Personal Finance Assistant
|
||||
# Market Screener
|
||||
|
||||
## Project Overview
|
||||
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.
|
||||
|
||||
This project is a modular, rule-based financial analysis engine designed to evaluate assets and manage personal investment portfolios. It separates data acquisition, strategy configuration, and evaluation logic to provide actionable investment insights.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Structure
|
||||
## Quick Start
|
||||
|
||||
### 1. Data Pipeline (`/src/data/`)
|
||||
```bash
|
||||
# API + Dashboard (recommended)
|
||||
npm install
|
||||
cd ../market-screener-ui && npm install && cd ../market_screener
|
||||
npm run dev # starts API on :3000 + UI on :5173
|
||||
# open http://localhost:5173
|
||||
|
||||
- **Fetcher:** Handles API communication (e.g., Yahoo Finance).
|
||||
- **Mapper:** Normalizes disparate API responses into a unified flat object structure.
|
||||
- **Asset Models (`/models/`):** Defines common properties for `Stock`, `Etf`, and `Bond`.
|
||||
|
||||
### 2. Logic & Configuration (`/src/config/` & `/src/utils/`)
|
||||
|
||||
- **`ScoringConfig.js`:** Houses all thresholds, gates, and weights.
|
||||
- **`RuleMerger.js`:** Dynamically applies sector-specific overrides to base rules.
|
||||
|
||||
### 3. Evaluation & Personal Assistant (`/src/engine/` & `/src/assistant/`)
|
||||
|
||||
- **`ScoringEngine.js`:** Orchestrates evaluation, applying market context and sector overrides.
|
||||
- **`PortfolioManager.js` (NEW):** Tracks individual holdings, cost basis, and performance metrics.
|
||||
- **`AdvisorModule.js` (NEW):** Provides personalized suggestions based on screening results and portfolio health.
|
||||
- **`EventMonitor.js` (NEW):** Tracks calendar events (Earnings Calls) to trigger alerts.
|
||||
# CLI only
|
||||
npm start # screen today's news catalyst tickers → screener-report.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Diagram
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
## How the Screener Works
|
||||
|
||||
### Phase 1: Core Engine & Soft Scoring
|
||||
Every asset is scored **twice** under different rule sets:
|
||||
|
||||
- **Soft Scoring System:** Transition from "Hard Gates" to a weighted point-based system.
|
||||
- **Market Context Integration:** Automate the `marketContext` parameter by fetching real-time 10Y Treasury Yields.
|
||||
### Market-Adjusted mode
|
||||
Gates derived from live Yahoo Finance benchmarks — reflects what is acceptable in today's market:
|
||||
|
||||
### Phase 2: Personal Finance Features
|
||||
| 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× |
|
||||
|
||||
- **Personal Portfolio Tracking:** Implement a `PortfolioManager` to track custom user holdings, monitor unrealized P&L, and calculate weightings relative to total assets.
|
||||
- **Automated Financial Coaching:** Develop an `AdvisorModule` that analyzes the portfolio and provides suggestions (e.g., "Reduce exposure to High-Debt REITs," or "Rebalance to increase Technology allocation").
|
||||
- **Earnings Call Notification System:** \* Integrate an earnings calendar API.
|
||||
- Implement a polling or webhook service to monitor for upcoming calls.
|
||||
- Add a notification service (Email, Push, or CLI log) to alert the user 24 hours prior to a scheduled earnings call.
|
||||
### Fundamental mode
|
||||
Strict Graham/value-investing gates — reflects genuine value regardless of market conditions:
|
||||
|
||||
### Phase 3: Infrastructure & Intelligence
|
||||
| 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 |
|
||||
|
||||
- **Caching Layer:** Use local JSON caching to reduce API overhead.
|
||||
- **Sentiment Analysis:** Integrate news-scrapers to weight "Buy" signals based on recent headlines.
|
||||
- **Backtesting Module:** Run historical simulations to test strategy performance.
|
||||
### Signals
|
||||
|
||||
| Signal | Meaning |
|
||||
|---|---|
|
||||
| ✅ 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 |
|
||||
| ❌ Avoid | Fails both |
|
||||
|
||||
---
|
||||
|
||||
_Maintained by: AI Collaborator_
|
||||
## 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):
|
||||
|
||||
```json
|
||||
{
|
||||
"holdings": [
|
||||
{ "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" },
|
||||
{ "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" },
|
||||
{ "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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 — 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. On first run the Access URL is claimed and saved to `.env` automatically
|
||||
|
||||
### Importing broker holdings
|
||||
|
||||
```bash
|
||||
npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv
|
||||
npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv
|
||||
```
|
||||
|
||||
Broker is auto-detected from CSV headers. Multiple imports merge into `portfolio.json`.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
|
||||
# or on first run:
|
||||
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* bin/finance.js — Personal Finance CLI
|
||||
*
|
||||
* Fetches your accounts from SimpleFIN, screens your portfolio holdings,
|
||||
* and saves a finance-report.html with:
|
||||
* 1. Net worth + account overview (SimpleFIN)
|
||||
* 2. Portfolio 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, saveAccessUrlToEnv } from '../src/finance/clients/SimpleFINClient.js';
|
||||
import { PersonalFinanceAnalyzer } from '../src/finance/PersonalFinanceAnalyzer.js';
|
||||
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js';
|
||||
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js';
|
||||
import { FinanceReporter } from '../src/reporters/FinanceReporter.js';
|
||||
|
||||
const PORTFOLIO_PATH = './portfolio.json';
|
||||
|
||||
async function main() {
|
||||
// ── 1. Load portfolio
|
||||
if (!existsSync(PORTFOLIO_PATH)) {
|
||||
throw new Error('portfolio.json not found — edit it with your holdings and re-run.');
|
||||
}
|
||||
|
||||
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, '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. SimpleFIN accounts (optional)
|
||||
let personalFinance = null;
|
||||
if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) {
|
||||
try {
|
||||
process.stdout.write('💰 Fetching SimpleFIN accounts...');
|
||||
const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv });
|
||||
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('ℹ Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n');
|
||||
}
|
||||
|
||||
// ── 3. Screen stocks & ETFs
|
||||
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. Portfolio advice + crypto prices
|
||||
process.stdout.write('💡 Generating portfolio advice...');
|
||||
const advice = await new PortfolioAdvisor().advise(holdings, results);
|
||||
process.stdout.write(' done\n');
|
||||
|
||||
// ── 5. Report
|
||||
const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext);
|
||||
console.log(`\n✅ Finance report: ${reportPath}\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* bin/screen.js — Market Screener CLI
|
||||
*
|
||||
* Fetches today's catalyst tickers from Yahoo Finance news,
|
||||
* screens them under both Market-Adjusted and Fundamental lenses,
|
||||
* and saves a full HTML report.
|
||||
*
|
||||
* Usage:
|
||||
* npm start → Yahoo news → catalyst tickers → screen
|
||||
* npm start -- watch → default watchlist
|
||||
* npm start -- AAPL MSFT VOO → specific tickers
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { CatalystAnalyst } from '../src/analyst/CatalystAnalyst.js';
|
||||
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js';
|
||||
import { HtmlReporter } from '../src/reporters/HtmlReporter.js';
|
||||
|
||||
const DEFAULT_WATCHLIST = [
|
||||
// Stocks
|
||||
'PLTR',
|
||||
'AAPL',
|
||||
'MSFT',
|
||||
'TSLA',
|
||||
'O',
|
||||
// ETFs
|
||||
'VOO',
|
||||
'QQQ',
|
||||
// Bonds
|
||||
'BND',
|
||||
'LQD',
|
||||
'TLT',
|
||||
'IEF',
|
||||
'SHY',
|
||||
'GOVT',
|
||||
'AGG',
|
||||
'MUB',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let tickers = [];
|
||||
|
||||
if (args.length > 0 && args[0] !== 'watch') {
|
||||
tickers = args.map((t) => t.toUpperCase());
|
||||
console.log(`📋 Screening: ${tickers.join(', ')}\n`);
|
||||
} else if (args[0] === 'watch') {
|
||||
tickers = DEFAULT_WATCHLIST;
|
||||
console.log(`📋 Screening default watchlist (${tickers.length} tickers)\n`);
|
||||
} else {
|
||||
try {
|
||||
const { tickers: newsTickers, stories } = await new CatalystAnalyst().run();
|
||||
|
||||
if (newsTickers.length === 0) {
|
||||
console.warn("⚠ No tickers in today's news — using default watchlist\n");
|
||||
tickers = DEFAULT_WATCHLIST;
|
||||
} else {
|
||||
tickers = newsTickers;
|
||||
console.log("\n📰 Stories driving today's screen:");
|
||||
stories.slice(0, 5).forEach((s) => {
|
||||
const tags = s.relatedTickers.slice(0, 3).join(', ');
|
||||
console.log(` • ${s.title}${tags ? ` [${tags}]` : ''}`);
|
||||
});
|
||||
console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`⚠ Catalyst analysis failed (${err.message}) — using default watchlist\n`);
|
||||
tickers = DEFAULT_WATCHLIST;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { STOCK, ETF, BOND, ERROR, marketContext } =
|
||||
await new ScreenerEngine().screenWithProgress(tickers);
|
||||
const reportPath = new HtmlReporter().generate({ STOCK, ETF, BOND, ERROR }, marketContext);
|
||||
console.log(`\n✅ Done — report saved to: ${reportPath}\n`);
|
||||
} catch (err) {
|
||||
console.error('Screener failed:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ScreenerEngine } from './src/core/engine/ScreenerEngine.js';
|
||||
|
||||
const tickers = [
|
||||
'PLTR',
|
||||
'AAPL',
|
||||
'VOO',
|
||||
'MSFT',
|
||||
'TSLA',
|
||||
'QQQ',
|
||||
'O',
|
||||
'BND',
|
||||
'AGG',
|
||||
'LQD',
|
||||
'GOVT',
|
||||
'MUB',
|
||||
'SHY',
|
||||
'IEF',
|
||||
'TLT',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Starting Screener Evaluation...');
|
||||
const engine = new ScreenerEngine();
|
||||
|
||||
try {
|
||||
await engine.runParallelScreener(tickers);
|
||||
} catch (err) {
|
||||
console.error('\n Execution Failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
-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
+1402
-4
File diff suppressed because it is too large
Load Diff
+27
-3
@@ -1,11 +1,35 @@
|
||||
{
|
||||
"name": "stock-screener",
|
||||
"version": "1.0.0",
|
||||
"name": "market-screener",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
"start": "node bin/screen.js",
|
||||
"server": "node bin/server.js",
|
||||
"dev": "concurrently -n api,ui -c cyan,magenta \"node bin/server.js\" \"npm run dev --prefix ui\"",
|
||||
"ui:install": "npm install --prefix ui --legacy-peer-deps",
|
||||
"finance": "node bin/finance.js",
|
||||
"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\"",
|
||||
"format:check": "prettier --check \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.js": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Market News Analysis & Catalyst Screener
|
||||
|
||||
A structured workflow for converting daily news into actionable trade ideas, validated by the screener's fundamental and market-adjusted analysis.
|
||||
|
||||
---
|
||||
|
||||
## 1. How This Fits Into the Screener Workflow
|
||||
|
||||
```
|
||||
Daily News
|
||||
↓
|
||||
Catalyst Prompt (Section 2) → Generates tickers + bias + horizon
|
||||
↓
|
||||
market_screener (npm start) → Fundamental + Market-Adjusted scoring
|
||||
↓
|
||||
Validation (Section 4) → Is the fundamental thesis intact?
|
||||
↓
|
||||
Decision → Act / Monitor / Discard
|
||||
```
|
||||
|
||||
**Key principle:** The screener doesn't tell you *when* to trade — catalysts do that. The screener tells you whether the *underlying business* supports the trade or whether you're purely momentum-chasing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Catalyst Analysis Prompt
|
||||
|
||||
Copy and paste this into your LLM daily. Provide it with 3–5 news headlines.
|
||||
|
||||
> **Role:** You are a quantitative financial analyst specialising in catalyst-driven trading.
|
||||
>
|
||||
> **Task:** Analyse the provided news and map each story to the assets structurally forced to respond.
|
||||
>
|
||||
> **For each catalyst, identify:**
|
||||
> 1. **Type:** Macro (Fed, rates, GDP) | Sector (regulatory, supply chain, commodity) | Company (earnings, guidance, M&A)
|
||||
> 2. **Primary ticker:** The asset directly impacted.
|
||||
> 3. **Ripple-effect ticker:** A supply chain partner, direct competitor, or sector peer that moves *before* the market catches on. This is the alpha play.
|
||||
> 4. **Bias:** Bull or Bear — with a one-sentence mechanistic reason (not sentiment).
|
||||
> 5. **Horizon:** Short (1–5 days) | Medium (1–4 weeks) | Long (1+ quarter).
|
||||
> 6. **Sensitivity:** How exposed is this ticker to the catalyst?
|
||||
> - **5** — Direct revenue impact > 20% of annual sales
|
||||
> - **4** — Direct revenue impact 10–20%
|
||||
> - **3** — Indirect exposure via cost structure or supply chain
|
||||
> - **2** — Sector correlation, limited direct exposure
|
||||
> - **1** — Macro tailwind/headwind only
|
||||
>
|
||||
> **Constraints:**
|
||||
> - Exclude generic analyst upgrades and "market sentiment" stories.
|
||||
> - Only include events with a measurable impact on valuation or supply chain fundamentals.
|
||||
> - Do not suggest tickers with average daily volume below 500k.
|
||||
> - For Bear plays: require at least one of — elevated short interest (>5% of float), negative earnings revision trend, or sector rotation evidence.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quantitative Impact Matrix
|
||||
|
||||
Output from the prompt above. Log results here before running the screener.
|
||||
|
||||
| Catalyst | Type | Primary | Ripple | Bias | Sensitivity | Horizon | Mechanics |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| [Event] | Macro/Sector/Co. | [TICKER] | [TICKER] | Bull/Bear | 1–5 | Short/Med/Long | [One-line financial logic] |
|
||||
|
||||
---
|
||||
|
||||
## 4. Ripple-Effect Reference Map
|
||||
|
||||
When a catalyst hits a primary ticker, these are the typical second-order targets by category.
|
||||
|
||||
| Primary Event | Ripple Targets | Logic |
|
||||
| :--- | :--- | :--- |
|
||||
| **Semis beat** (NVDA, AMD) | TSMC, ASML, AMAT, KLAC | Fab capacity demand follows chip demand |
|
||||
| **Semis miss** | INTC, MU, WDC | Inventory builds at competitors |
|
||||
| **Cloud CapEx guidance up** (MSFT, GOOGL, AMZN) | EQIX, DLR (data center REITs), NFLX infra | Power + cooling demand, bandwidth |
|
||||
| **Oil supply shock** | XOM, CVX (Bull); DAL, UAL (Bear) | Energy input costs hit airlines directly |
|
||||
| **Fed rate hike** | TLT, IEF (Bear); XLF, BRK (Bull) | Long-duration bonds reprice; bank margins expand |
|
||||
| **Fed rate cut** | TLT, XLRE (Bull); XLF (Bear) | REITs re-rate; bank NIM compresses |
|
||||
| **Strong USD** | EEM, multinational exporters (Bear) | Revenue headwind for USD-earners abroad |
|
||||
| **Retail sales miss** | WMT, TGT (Bear); AMZN (neutral/Bull) | Discretionary demand shift to e-commerce |
|
||||
| **Pharma approval** | Competitor biotech (Bear) | Market share displacement |
|
||||
| **Cybersecurity breach (major)** | CRWD, PANW, FTNT (Bull) | Accelerates enterprise security spend |
|
||||
|
||||
---
|
||||
|
||||
## 5. Validation Checklist
|
||||
|
||||
Before acting on a catalyst, run the tickers through the screener and answer:
|
||||
|
||||
### For Bull plays:
|
||||
- [ ] Does it pass the **Market-Adjusted** analysis? (minimum bar — if not, it's pure momentum)
|
||||
- [ ] Does it pass **Fundamental** analysis? (if yes → Strong Buy conviction; if not → Speculation)
|
||||
- [ ] Is FCF yield positive? (sustains the business through the catalyst period)
|
||||
- [ ] Is D/E manageable? (high leverage + catalyst = binary outcome, size accordingly)
|
||||
- [ ] Is the 52-week position below 85%? (if near highs, the market may have priced it in)
|
||||
|
||||
### For Bear plays:
|
||||
- [ ] Does it **fail both** analyses? (confirms the fundamental short thesis)
|
||||
- [ ] Is short interest > 5% of float? (existing agreement in the market)
|
||||
- [ ] Is the horizon realistic? (overvalued stocks can stay overvalued — Bear plays need a catalyst *timeframe*)
|
||||
|
||||
### Horizon vs screener relevance:
|
||||
| Horizon | Use screener for... |
|
||||
| :--- | :--- |
|
||||
| Short (1–5 days) | Confirm the stock isn't already broken (avoid catching falling knives on longs) |
|
||||
| Medium (1–4 weeks) | Gate check — does fundamental quality support a re-rating? |
|
||||
| Long (1+ quarter) | Full weight on both analyses — you need the fundamentals on your side |
|
||||
|
||||
---
|
||||
|
||||
## 6. Current Market Regime Context
|
||||
|
||||
> **This section should be refreshed from `npm start` output before each session.**
|
||||
|
||||
The screener derives the current regime from live Yahoo Finance data on startup:
|
||||
|
||||
| Signal | What it means for catalysts |
|
||||
| :--- | :--- |
|
||||
| **Rate Regime: HIGH** (10Y > 5%) | Long-duration trades are punished. Favour cash-generative, short-horizon plays. Short TLT, long XLF. |
|
||||
| **Rate Regime: NORMAL** (2–5%) | Standard playbook applies. |
|
||||
| **Rate Regime: LOW** (< 2%) | Growth and duration trades work. REITs and long bonds are viable longs. |
|
||||
| **Volatility: HIGH** (VIX > 25) | Position sizes down. Mean-reversion trades outperform momentum. |
|
||||
| **Volatility: NORMAL** (VIX 15–25) | Trend-following works. |
|
||||
| **Volatility: LOW** (VIX < 15) | Risk-on. Momentum and growth outperform. Watch for complacency reversals. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Bear Catalyst Template
|
||||
|
||||
A structured short thesis requires more rigour than a bull thesis. Use this template.
|
||||
|
||||
> **Ticker:** [TICKER]
|
||||
>
|
||||
> **Catalyst:** [What event breaks the bull narrative?]
|
||||
>
|
||||
> **Fundamental support:**
|
||||
> - Fails screener gate: [which gate, e.g. "P/E 120x > inflated gate of 57x"]
|
||||
> - Trend: [revenue decelerating / margins compressing / FCF turning negative]
|
||||
>
|
||||
> **Market structure support (need at least one):**
|
||||
> - Short interest: [X% of float]
|
||||
> - Earnings revision trend: [# of downward revisions last 90 days]
|
||||
> - Sector rotation: [which sector ETF is seeing outflows]
|
||||
>
|
||||
> **Risk to thesis:** [What would invalidate the short — e.g. "earnings beat with raised guidance"]
|
||||
>
|
||||
> **Horizon:** [Short / Medium / Long]
|
||||
> **Stop:** [Price level or event that closes the trade]
|
||||
|
||||
---
|
||||
|
||||
## 8. Adding Catalyst Tickers to the Screener
|
||||
|
||||
Edit `index.js` and add tickers from the Impact Matrix to the `tickers` array, then run:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The screener will score each ticker under both the **Market-Adjusted** and **Fundamental** lenses and open `screener-report.html` with the full breakdown. Cross-reference the Signal column with your catalyst thesis:
|
||||
|
||||
| Signal | Catalyst interpretation |
|
||||
| :--- | :--- |
|
||||
| ✅ Strong Buy | Fundamental quality + catalyst momentum aligned. Highest conviction. |
|
||||
| ⚡ Momentum | Catalyst works in today's market but price is stretched on fundamentals. Respect the stop. |
|
||||
| ⚠️ Speculation | Pure catalyst play — fundamentals don't support it. Small size, tight stop. |
|
||||
| 🔄 Neutral | Catalyst may be already priced in. Wait for a better entry or skip. |
|
||||
| ❌ Avoid | Screener and catalyst are both negative. Only valid as a Bear trade. |
|
||||
@@ -0,0 +1,37 @@
|
||||
// Minimal test reporter: silent on pass, prints failures in full, ends with one summary line.
|
||||
export default async function* summaryReporter(source) {
|
||||
const failures = [];
|
||||
let passed = 0,
|
||||
failed = 0,
|
||||
totalMs = 0;
|
||||
|
||||
for await (const event of source) {
|
||||
// Skip file-level wrapper events (name ends in .js) — only count individual tests.
|
||||
if (event.data?.name?.endsWith('.js')) continue;
|
||||
|
||||
if (event.type === 'test:pass') {
|
||||
passed++;
|
||||
totalMs += event.data.details?.duration_ms ?? 0;
|
||||
} else if (event.type === 'test:fail') {
|
||||
failed++;
|
||||
totalMs += event.data.details?.duration_ms ?? 0;
|
||||
const err = event.data.details?.error;
|
||||
failures.push({
|
||||
name: event.data.name,
|
||||
reason: err?.cause?.message ?? err?.message ?? 'unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
yield '\nFailed tests:\n';
|
||||
for (const f of failures) yield ` ❌ ${f.name}\n ${f.reason}\n`;
|
||||
yield '\n';
|
||||
}
|
||||
|
||||
const status = failed === 0 ? '✅' : '❌';
|
||||
const time = (totalMs / 1000).toFixed(2);
|
||||
yield `${status} ${passed + failed} tests: ${passed} passed`;
|
||||
if (failed) yield `, ${failed} failed`;
|
||||
yield ` (${time}s)\n`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
|
||||
const NEWS_QUERIES = ['stock market today', 'earnings report', 'market news'];
|
||||
const MAX_STORIES = 15;
|
||||
const TICKER_REGEX = /^[A-Z]{1,6}$/;
|
||||
|
||||
export class CatalystAnalyst {
|
||||
constructor({ logger } = {}) {
|
||||
this.client = new YahooClient();
|
||||
this.logger = logger ?? { write: (msg) => process.stdout.write(msg) };
|
||||
}
|
||||
|
||||
async run() {
|
||||
this.logger.write('🔍 Fetching market news...');
|
||||
const stories = await this._fetchNews();
|
||||
const tickers = this._extractTickers(stories);
|
||||
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
|
||||
return { tickers, stories };
|
||||
}
|
||||
|
||||
async _fetchNews() {
|
||||
const seen = new Map();
|
||||
for (const query of NEWS_QUERIES) {
|
||||
try {
|
||||
const { news = [] } = await this.client.yf.search(query, { newsCount: 8, 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 failed query */
|
||||
}
|
||||
}
|
||||
return [...seen.values()].slice(0, MAX_STORIES);
|
||||
}
|
||||
|
||||
_extractTickers(stories) {
|
||||
const tickers = new Set();
|
||||
for (const { relatedTickers } of stories) {
|
||||
for (const t of relatedTickers) {
|
||||
const clean = t.split(':')[0].toUpperCase();
|
||||
if (TICKER_REGEX.test(clean)) tickers.add(clean);
|
||||
}
|
||||
}
|
||||
return [...tickers];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { YahooClient } from './YahooClient.js';
|
||||
|
||||
export class BenchmarkProvider {
|
||||
constructor() {
|
||||
this.client = new YahooClient();
|
||||
this.cache = { data: null, expiresAt: 0 };
|
||||
this.TTL_MS = 60 * 60 * 1000; // Cache for 1 hour
|
||||
}
|
||||
|
||||
async getMarketContext() {
|
||||
// 1. Return cached data if still valid
|
||||
if (this.cache.data && Date.now() < this.cache.expiresAt) {
|
||||
return this.cache.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const [sp500, tn10y] = await Promise.all([
|
||||
this.client.fetchSummary('^GSPC'),
|
||||
this.client.fetchSummary('^TNX'),
|
||||
]);
|
||||
|
||||
const context = {
|
||||
sp500Price: sp500.price?.regularMarketPrice ?? 0,
|
||||
riskFreeRate: tn10y.price?.regularMarketPrice ?? 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 2. Validate data sanity (prevent 0-value errors)
|
||||
if (context.sp500Price === 0 || context.riskFreeRate === 0) {
|
||||
throw new Error('Invalid market data received (zero values)');
|
||||
}
|
||||
|
||||
// 3. Update cache
|
||||
this.cache = { data: context, expiresAt: Date.now() + this.TTL_MS };
|
||||
return context;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Market data fetch failed, using last known or empty state:',
|
||||
error,
|
||||
);
|
||||
// If we have stale cache, use it even if expired, otherwise return safe defaults
|
||||
return this.cache.data || { sp500Price: 4500, riskFreeRate: 4.0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+193
-29
@@ -1,54 +1,218 @@
|
||||
// Credit rating scale (S&P convention).
|
||||
// Bond.js converts letter ratings to these numbers; BondScorer uses them for gate checks.
|
||||
// Investment grade = BBB (7) and above.
|
||||
export const CREDIT_RATING_SCALE = {
|
||||
AAA: 10,
|
||||
AA: 9,
|
||||
A: 8,
|
||||
BBB: 7,
|
||||
BB: 6,
|
||||
B: 5,
|
||||
CCC: 4,
|
||||
CC: 3,
|
||||
C: 2,
|
||||
D: 1,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Fundamental baseline — Graham / value-investing style.
|
||||
// MarketRegime.js overrides the valuation gates for INFLATED-mode analysis.
|
||||
// Sector overrides are structural — they apply in both modes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const ScoringRules = {
|
||||
// --- BASE ASSET CLASSES ---
|
||||
STOCK: {
|
||||
meta: { version: '1.2', description: 'Value & Growth Hybrid' },
|
||||
gates: {
|
||||
maxDebtToEquity: 3.0,
|
||||
minQuickRatio: 0.25,
|
||||
maxPERatio: 80,
|
||||
maxPegGate: 5.0,
|
||||
maxDebtToEquity: 1.5, // Graham ceiling; 3.0 was too permissive — most distress starts above 2x
|
||||
minQuickRatio: 0.8, // Raised from 0.5: below 0.8 signals real liquidity stress in non-tech
|
||||
maxPERatio: 15, // Graham's actual rule: never pay more than 15x trailing earnings
|
||||
maxPegGate: 1.0, // PEG > 1.0 means you're paying full price for growth (Lynch standard)
|
||||
},
|
||||
weights: {
|
||||
margin: 2, // net profit margin
|
||||
opMargin: 2, // operating margin (pricing power)
|
||||
roe: 3, // return on equity — Buffett's primary quality metric
|
||||
peg: 2, // valuation relative to growth
|
||||
revenue: 2, // revenue growth
|
||||
fcf: 3, // raised: FCF is the most manipulation-resistant quality signal
|
||||
},
|
||||
weights: { margin: 3, peg: 2, revenue: 2, fcf: 1 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
pegHigh: 1.3,
|
||||
pegMed: 2.0,
|
||||
revHigh: 15,
|
||||
marginHigh: 15, // lowered from 20: 15% net margin is genuinely excellent across most sectors
|
||||
marginMed: 8, // lowered from 10: 8% is the realistic mid-tier for industrials/retail
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 15, // lowered from 20: sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting
|
||||
roeMed: 10, // kept — 10% is the cost-of-equity floor for most businesses
|
||||
pegHigh: 0.75, // raised bar: PEG < 0.75 is genuinely cheap relative to growth
|
||||
pegMed: 1.0,
|
||||
revHigh: 10, // lowered from 15: 10% organic revenue growth is strong for mature cos
|
||||
revMed: 5,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
|
||||
// Sector-specific intelligence
|
||||
SECTOR_OVERRIDE: {
|
||||
// Large-cap tech borrows to fund buybacks — D/E 2.0 is structural, not distress.
|
||||
// AAPL quick ratio runs ~0.9 by design (aggressive working capital management).
|
||||
// Raised maxPERatio from 30→35: mega-cap tech comps (MSFT, GOOG) trade 28-35x sustainably.
|
||||
// Tightened maxPegGate from 2.0→1.5: paying >1.5x PEG for tech rarely ends well long-term.
|
||||
TECHNOLOGY: {
|
||||
gates: { maxDebtToEquity: 1.0, minQuickRatio: 1.2, maxPegGate: 2.5 },
|
||||
weights: { margin: 2, peg: 3, revenue: 4 },
|
||||
thresholds: { marginHigh: 30, pegHigh: 1.5, revHigh: 25 },
|
||||
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 },
|
||||
weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 },
|
||||
thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 },
|
||||
},
|
||||
|
||||
// REITs: P/E and PEG are distorted by depreciation — score on yield and P/FFO.
|
||||
// Raised minYield from 4.0→4.5: 10Y yield at 4.5%+ means REITs must clear that bar to add value.
|
||||
// Tightened maxPFFO from 15→18: 15 was too tight; well-run REITs (O, VICI) trade 17-22x P/FFO.
|
||||
// Explicitly zero out weights that don't apply to REITs.
|
||||
REIT: {
|
||||
gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1 },
|
||||
weights: { yield: 5, fcf: 3 },
|
||||
thresholds: { minYield: 0.05 },
|
||||
gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 },
|
||||
weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 },
|
||||
thresholds: { minYield: 4.5, maxPFFO: 20 },
|
||||
},
|
||||
|
||||
// Banks: P/E and PEG are distorted by loan loss provisions.
|
||||
// Price-to-Book is the primary valuation metric.
|
||||
// Lowered maxPriceToBook from 2.0→1.5: P/B > 1.5 for banks outside crisis recovery is expensive.
|
||||
// Tightened ROE threshold: 12% is the realistic cost-of-equity for US banks; 10% is break-even.
|
||||
FINANCIAL: {
|
||||
gates: { minQuickRatio: 0.5 },
|
||||
weights: { margin: 1, revenue: 1 },
|
||||
thresholds: { marginHigh: 10 },
|
||||
gates: {
|
||||
maxDebtToEquity: 9999,
|
||||
minQuickRatio: 0.1,
|
||||
maxPERatio: 9999,
|
||||
maxPegGate: 9999,
|
||||
maxPriceToBook: 1.5,
|
||||
},
|
||||
weights: { margin: 0, opMargin: 0, peg: 0, roe: 5, revenue: 1, fcf: 1, priceToBook: 3 },
|
||||
thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 },
|
||||
},
|
||||
|
||||
// Energy: capital-heavy, cyclical. D/E up to 1.5 is normal.
|
||||
// FCF yield is the primary quality signal (replaces margin); opMargin matters for integrated cos.
|
||||
// Div yield is scored because energy majors return capital via dividends.
|
||||
ENERGY: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 },
|
||||
weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 },
|
||||
thresholds: {
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 15,
|
||||
roeMed: 8,
|
||||
fcfHigh: 8,
|
||||
fcfMed: 4,
|
||||
},
|
||||
},
|
||||
|
||||
// Healthcare: high R&D burn distorts net margin; focus on revenue growth and FCF.
|
||||
// P/E can be elevated for pipeline names — gate loosened slightly.
|
||||
HEALTHCARE: {
|
||||
gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 },
|
||||
weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 12,
|
||||
revHigh: 15,
|
||||
revMed: 8,
|
||||
fcfHigh: 8,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ETF: {
|
||||
meta: { version: '1.0', description: 'Cost & Yield Efficiency' },
|
||||
gates: { maxExpenseRatio: 0.75 },
|
||||
weights: { yield: 2, lowCost: 3 },
|
||||
thresholds: { minYield: 0.02, maxExpense: 0.2 },
|
||||
// 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, fiveYearReturn: 2 }, // cost is #1 predictive factor; 5Y return rewards consistency
|
||||
thresholds: {
|
||||
minYield: 1.5,
|
||||
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
|
||||
},
|
||||
},
|
||||
|
||||
BOND: {
|
||||
meta: { version: '1.0', description: 'Credit Quality & Duration' },
|
||||
gates: { minCreditRating: 5 },
|
||||
// Kept investment-grade floor at BBB — still correct. Below BBB is speculative.
|
||||
// Raised minSpread from 1.0→1.5: with risk-free at 4.5%, you need >1.5% spread
|
||||
// to be compensated for credit risk vs just buying Treasuries.
|
||||
// Tightened maxDuration from 10→7: in a HIGH rate regime, duration > 7 carries
|
||||
// meaningful rate-sensitivity risk (every 1% rate rise ≈ 7% price loss).
|
||||
gates: { minCreditRating: 7 }, // BBB = investment-grade floor
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.5, maxDuration: 10 },
|
||||
thresholds: { minSpread: 1.5, maxDuration: 7 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
export const SIGNAL = {
|
||||
STRONG_BUY: '✅ Strong Buy',
|
||||
MOMENTUM: '⚡ Momentum',
|
||||
SPECULATION: '⚠️ Speculation',
|
||||
NEUTRAL: '🔄 Neutral',
|
||||
AVOID: '❌ Avoid',
|
||||
};
|
||||
|
||||
export const ASSET_TYPE = {
|
||||
STOCK: 'STOCK',
|
||||
ETF: 'ETF',
|
||||
BOND: 'BOND',
|
||||
CRYPTO: 'crypto',
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export const SCORE_MODE = {
|
||||
FUNDAMENTAL: 'FUNDAMENTAL',
|
||||
INFLATED: 'INFLATED',
|
||||
};
|
||||
|
||||
export const REGIME = {
|
||||
LOW: 'LOW',
|
||||
NORMAL: 'NORMAL',
|
||||
HIGH: 'HIGH',
|
||||
};
|
||||
|
||||
export const YAHOO_MODULES = [
|
||||
'assetProfile',
|
||||
'financialData',
|
||||
'defaultKeyStatistics',
|
||||
'price',
|
||||
'summaryDetail',
|
||||
];
|
||||
|
||||
export const SIGNAL_ORDER = {
|
||||
[SIGNAL.STRONG_BUY]: 0,
|
||||
[SIGNAL.MOMENTUM]: 1,
|
||||
[SIGNAL.NEUTRAL]: 2,
|
||||
[SIGNAL.SPECULATION]: 3,
|
||||
[SIGNAL.AVOID]: 4,
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
export class Asset {
|
||||
constructor(data) {
|
||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||
this.currentPrice = data.currentPrice || 0;
|
||||
this.type = (data.type || 'STOCK').toUpperCase(); // STOCK, ETF, or BOND
|
||||
|
||||
// Store all raw data as a property so it's accessible but not "logic-heavy"
|
||||
this.rawData = data;
|
||||
}
|
||||
|
||||
// Pure Formatting Helpers - These are the only "logic" this class should own
|
||||
formatCurrency(val) {
|
||||
return val ? `$${val.toFixed(2)}` : 'N/A';
|
||||
}
|
||||
|
||||
formatLargeNumber(num) {
|
||||
if (!num) return 'N/A';
|
||||
if (num >= 1e12) return (num / 1e12).toFixed(2) + 'T';
|
||||
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
|
||||
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
|
||||
return num.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Stock extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
// console.log('Data:', data);
|
||||
this.sector = this._mapToStandardSector(data || {});
|
||||
|
||||
// Financial Metrics - These are now just "state"
|
||||
this.metrics = {
|
||||
sector: this.sector,
|
||||
quickRatio: parseFloat(data.quickRatio) || 0,
|
||||
debtToEquity: parseFloat(data.debtToEquity) || 0,
|
||||
fcfGrowth: data.fcfGrowth ?? 'neutral',
|
||||
revenueGrowth: parseFloat(data.revenueGrowth) || 0,
|
||||
netProfitMargin: parseFloat(data.netProfitMargin) || 0,
|
||||
pegRatio: parseFloat(data.pegRatio) || null,
|
||||
peRatio: parseFloat(data.peRatio) || null,
|
||||
};
|
||||
}
|
||||
|
||||
_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';
|
||||
if (combined.includes('real estate') || combined.includes('reit'))
|
||||
return 'REIT';
|
||||
if (combined.includes('financial') || combined.includes('bank'))
|
||||
return 'FINANCIAL';
|
||||
|
||||
return 'GENERAL';
|
||||
}
|
||||
|
||||
// Helper for dashboard display
|
||||
getDisplayMetrics() {
|
||||
const formatFcf = (s) =>
|
||||
({ positive: '🟢', neutral: '🟠', negative: '🔴' })[s] || 'N/A';
|
||||
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
Sector: this.sector,
|
||||
'PE Ratio': this.metrics.peRatio?.toFixed(2) ?? 'N/A',
|
||||
'FCF%': formatFcf(this.metrics.fcfGrowth),
|
||||
'PEG/Fee': this.metrics.pegRatio?.toFixed(2) ?? 'N/A',
|
||||
'Rev%': `${this.metrics.revenueGrowth.toFixed(1)}%`,
|
||||
'Marg%': `${this.metrics.netProfitMargin.toFixed(1)}%`,
|
||||
Quick: this.metrics.quickRatio?.toFixed(2) ?? 'N/A',
|
||||
'D/E': this.metrics.debtToEquity.toFixed(2),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { StockScorer } from '../scorers/StockScorer.js';
|
||||
import { EtfScorer } from '../scorers/EtfScorer.js';
|
||||
import { BondScorer } from '../scorers/BondScorer.js';
|
||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
|
||||
export const ScoringEngine = {
|
||||
// Registry of available strategies
|
||||
_scorers: {
|
||||
STOCK: StockScorer,
|
||||
ETF: EtfScorer,
|
||||
BOND: BondScorer,
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} type - 'STOCK', 'ETF', or 'BOND'
|
||||
* @param {Object} data - The raw metric data
|
||||
* @param {Object} context - Optional market context (for bonds)
|
||||
*/
|
||||
// In ScoringEngine.js
|
||||
evaluate(type, assetInstance, marketContext = {}) {
|
||||
const scorer = this._scorers[type];
|
||||
|
||||
// 1. Get the metrics (this assumes assetInstance has the metrics object)
|
||||
const metrics = assetInstance.metrics;
|
||||
|
||||
// 2. MERGE: Get sector-specific, merged rules
|
||||
const finalRules = RuleMerger.getRulesForAsset(type, metrics);
|
||||
|
||||
// 3. ADAPT: Apply market context (Yields, etc.)
|
||||
const adaptedRules = this._applyMarketContext(finalRules, marketContext);
|
||||
|
||||
// 4. SCORE: Pass the adapted rules to the scorer
|
||||
return scorer.score(metrics, adaptedRules, marketContext);
|
||||
},
|
||||
|
||||
_applyMarketContext(rules, context) {
|
||||
if (context.tenYearYield > 4.0) {
|
||||
// Tighten valuation expectations when rates are high
|
||||
return {
|
||||
...rules,
|
||||
gates: {
|
||||
...rules.gates,
|
||||
maxPERatio: Math.floor(rules.gates.maxPERatio * 0.8),
|
||||
},
|
||||
};
|
||||
}
|
||||
return rules;
|
||||
},
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import { YahooClient } from '../../api/YahooClient.js';
|
||||
import { mapToStandardFormat } from '../../utils/DataMapper.js';
|
||||
import { Stock } from '../assets/Stock.js';
|
||||
import { Etf } from '../assets/Etf.js';
|
||||
import { Bond } from '../assets/Bond.js';
|
||||
import { chunkArray } from '../../utils/Chunker.js';
|
||||
import { BenchmarkProvider } from '../../api/BenchmarkProvider.js';
|
||||
import { RuleMerger } from '../../utils/RulesMerger.js';
|
||||
import { StockScorer } from '../scorers/StockScorer.js';
|
||||
import { EtfScorer } from '../scorers/EtfScorer.js';
|
||||
import { BondScorer } from '../scorers/BondScorer.js';
|
||||
|
||||
export class ScreenerEngine {
|
||||
constructor() {
|
||||
this.client = new YahooClient();
|
||||
this.benchmarkProvider = new BenchmarkProvider();
|
||||
}
|
||||
|
||||
_createAssetInstance(data) {
|
||||
const type = (data.type || 'STOCK').toUpperCase();
|
||||
switch (type) {
|
||||
case 'BOND':
|
||||
return new Bond(data);
|
||||
case 'ETF':
|
||||
return new Etf(data);
|
||||
default:
|
||||
return new Stock(data);
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchAndProcess(ticker) {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(ticker);
|
||||
if (!summary?.price) throw new Error('Invalid Payload');
|
||||
return mapToStandardFormat(ticker, summary);
|
||||
} catch (error) {
|
||||
// Return a structured error object that mimics the successful data format
|
||||
return {
|
||||
isError: true,
|
||||
Ticker: ticker.toUpperCase(),
|
||||
Type: 'STOCK',
|
||||
Verdict: `🔴 ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async runParallelScreener(tickerList) {
|
||||
const marketContext = await this.benchmarkProvider.getMarketContext();
|
||||
console.log(
|
||||
`📊 Market Context Loaded: 10Y Yield at ${marketContext.riskFreeRate}%`,
|
||||
);
|
||||
|
||||
// Map types to their respective Scorers
|
||||
const scorers = { STOCK: StockScorer, ETF: EtfScorer, BOND: BondScorer };
|
||||
const chunks = chunkArray(tickerList, 5);
|
||||
const results = {};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const rawDataBatch = await Promise.all(
|
||||
chunk.map((t) => this._fetchAndProcess(t)),
|
||||
);
|
||||
|
||||
rawDataBatch.forEach((data) => {
|
||||
if (data.isError) {
|
||||
if (!results['ERROR']) results['ERROR'] = [];
|
||||
results['ERROR'].push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Instantiate the lean Data Container (Stock, Etf, or Bond)
|
||||
const asset = this._createAssetInstance(data);
|
||||
const type = asset.type;
|
||||
|
||||
// 2. Merge rules (Utility handles sector overrides)
|
||||
const rules = RuleMerger.getRulesForAsset(type, asset.metrics);
|
||||
|
||||
// 3. Direct Scoring (Bypassing the old circular evaluate() call)
|
||||
const scorer = scorers[type];
|
||||
const scoreResult = scorer.score(asset.metrics, rules, marketContext);
|
||||
|
||||
// 4. Combine display data with the final verdict
|
||||
const finalResult = {
|
||||
asset: asset,
|
||||
Verdict: scoreResult.label,
|
||||
'G/O/R': scoreResult.scoreSummary,
|
||||
};
|
||||
|
||||
if (!results[type]) results[type] = [];
|
||||
results[type].push(finalResult);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
this._display(results);
|
||||
}
|
||||
|
||||
_display(results) {
|
||||
Object.keys(results).forEach((type) => {
|
||||
if (type === 'ERROR') {
|
||||
console.log('--- ERRORS ---', results.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the raw objects (not the mapped objects)
|
||||
const sortedData = [...results[type]].sort((a, b) =>
|
||||
b.Verdict.localeCompare(a.Verdict),
|
||||
);
|
||||
|
||||
console.log(`\n--- ${type} MATRIX ---`);
|
||||
|
||||
// Use the class method to get the display-ready object
|
||||
const tableData = sortedData.map((item) => ({
|
||||
...item.asset.getDisplayMetrics(), // <--- THIS RE-USES YOUR CLASS METHOD
|
||||
Verdict: item.Verdict, // Injected from the Engine result
|
||||
'G/O/R': item['G/O/R'], // Injected from the Engine result
|
||||
}));
|
||||
|
||||
console.table(tableData);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
|
||||
export const BondScorer = {
|
||||
/**
|
||||
* @param {Object} m - Metrics (ytm, duration, creditRating)
|
||||
* @param {Object} context - Market environment (riskFreeRate)
|
||||
*/
|
||||
score(m, rules, context) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// Safety check for riskFreeRate (ensure it's a decimal, e.g., 0.04)
|
||||
const riskFreeRate = context?.riskFreeRate ?? 0.04;
|
||||
const spread = metrics.ytm - riskFreeRate;
|
||||
|
||||
let score = 0;
|
||||
const breakdown = {};
|
||||
|
||||
// 1. Spread Logic: If spread is >= 0, it's at least neutral
|
||||
breakdown.spread =
|
||||
spread >= thresholds.minSpread ? weights.yieldSpread : -2;
|
||||
|
||||
// 2. Duration Logic
|
||||
breakdown.duration =
|
||||
metrics.duration <= thresholds.maxDuration ? weights.duration : -1;
|
||||
|
||||
// 3. Credit Rating Logic (Handling 'N/A')
|
||||
if (metrics.creditRating === 'Junk') {
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: this._getLabel(score),
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
_sanitize(m) {
|
||||
// Convert percentage string '3.95%' to decimal 0.0395
|
||||
const parsePercent = (val) => {
|
||||
if (typeof val === 'string') val = val.replace('%', '');
|
||||
return parseFloat(val) / 100 || 0;
|
||||
};
|
||||
|
||||
return {
|
||||
ytm: parsePercent(m.ytm),
|
||||
duration: parseFloat(m.duration) || 0,
|
||||
creditRating:
|
||||
m.creditRating === 'N/A' ? 'InvestmentGrade' : m.creditRating, // Treat N/A as safe
|
||||
};
|
||||
},
|
||||
|
||||
_getLabel(score) {
|
||||
if (score >= 4) return '🟢 Attractive';
|
||||
if (score >= 1) return '🟡 Neutral';
|
||||
return '🔴 Avoid';
|
||||
},
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
|
||||
/**
|
||||
* EtfScorer: Evaluates ETFs with mandatory fee gates and weighted scoring.
|
||||
*/
|
||||
export const EtfScorer = {
|
||||
score(m, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// 1. GATE KEEPING: High fees trigger an automatic reject
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: 'GATE FAILED: High Expense Ratio',
|
||||
};
|
||||
}
|
||||
|
||||
// 2. SCORING REGISTRY
|
||||
const scoringRegistry = [
|
||||
{
|
||||
key: 'cost',
|
||||
fn: () =>
|
||||
metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
},
|
||||
{
|
||||
key: 'yield',
|
||||
fn: () => (metrics.yield >= thresholds.minYield ? weights.yield : -1),
|
||||
},
|
||||
{ key: 'vol', fn: () => (metrics.volume >= 100000 ? 0 : -2) },
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const totalScore = scoringRegistry.reduce((sum, item) => {
|
||||
breakdown[item.key] = item.fn();
|
||||
return sum + breakdown[item.key];
|
||||
}, 0);
|
||||
|
||||
// 3. RESULT
|
||||
return {
|
||||
label: this._getLabel(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
_sanitize(m) {
|
||||
return {
|
||||
expenseRatio: parseFloat(m.expenseRatio) || 0,
|
||||
yield: parseFloat(m.yield) || 0,
|
||||
volume: parseFloat(m.volume) || 0,
|
||||
};
|
||||
},
|
||||
|
||||
_getLabel(score) {
|
||||
if (score >= 3) return '🟢 Efficient';
|
||||
if (score >= 0) return '🟡 Neutral';
|
||||
return '🔴 Expensive/Low Yield';
|
||||
},
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
import { ScoringRules } from '../../config/ScoringConfig.js';
|
||||
|
||||
export const StockScorer = {
|
||||
score(m, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
|
||||
// 1. DYNAMIC GATE KEEPING
|
||||
const gateResult = this._checkGates(metrics, gates);
|
||||
if (!gateResult.passed)
|
||||
return { label: '🔴 REJECT', scoreSummary: gateResult.reason };
|
||||
|
||||
// 2. DYNAMIC SCORING REGISTRY
|
||||
const scoringRegistry = [
|
||||
{
|
||||
key: 'margin',
|
||||
fn: () =>
|
||||
this._scoreValue(
|
||||
metrics.netProfitMargin,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
fn: () =>
|
||||
this._scorePeg(
|
||||
metrics.pegRatio,
|
||||
thresholds.pegHigh,
|
||||
thresholds.pegMed,
|
||||
weights.peg,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rev',
|
||||
fn: () =>
|
||||
this._scoreValue(
|
||||
metrics.revenueGrowth,
|
||||
thresholds.revHigh,
|
||||
thresholds.revMed,
|
||||
weights.revenue,
|
||||
),
|
||||
},
|
||||
{ key: 'fcf', fn: () => (metrics.fcfGrowth === 'positive' ? 2 : -2) },
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const totalScore = scoringRegistry.reduce((sum, item) => {
|
||||
breakdown[item.key] = item.fn();
|
||||
return sum + breakdown[item.key];
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
label: this._getLabel(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
analystRating: m.analystConsensus || 'N/A', // Add this
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
_checkGates(m, g) {
|
||||
const failures = [];
|
||||
if (m.debtToEquity > g.maxDebtToEquity) failures.push('Debt/Equity');
|
||||
if (m.quickRatio < g.minQuickRatio) failures.push('QuickRatio');
|
||||
if (m.peRatio > g.maxPERatio) failures.push('PERatio');
|
||||
if (m.pegRatio > g.maxPegGate) failures.push('High Valuation');
|
||||
|
||||
return {
|
||||
passed: failures.length === 0,
|
||||
reason: `GATE FAILED: ${failures.join(', ')}`,
|
||||
};
|
||||
},
|
||||
|
||||
_scoreValue: (val, high, med, weight) =>
|
||||
val >= high ? weight : val >= med ? 1 : -2,
|
||||
|
||||
_scorePeg: (val, high, med, weight) =>
|
||||
val > 0 && val <= high ? weight : val <= med ? 0 : -2,
|
||||
|
||||
_scoreGradient: (val, high, med, weight) => {
|
||||
if (val >= high) return weight;
|
||||
if (val >= med) return Math.round(weight * 0.5); // Partial credit for mid-tier
|
||||
return -1; // Less punitive than -2
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
return {
|
||||
debtToEquity: parseFloat(m.debtToEquity) || 0,
|
||||
quickRatio: parseFloat(m.quickRatio) || 0,
|
||||
peRatio: parseFloat(m.peRatio) || 0,
|
||||
netProfitMargin: parseFloat(m.netProfitMargin) || 0,
|
||||
pegRatio: parseFloat(m.pegRatio) || 999,
|
||||
revenueGrowth: parseFloat(m.revenueGrowth) || 0,
|
||||
fcfGrowth: m.fcfGrowth ?? 'neutral',
|
||||
dividendYield: parseFloat(m.dividendYield) || 0,
|
||||
roe: parseFloat(m.roe) || 0,
|
||||
};
|
||||
},
|
||||
|
||||
_getLabel(score) {
|
||||
if (score >= 5) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 2) return '🟢 BUY (Speculative)';
|
||||
if (score < -2) return '🔴 REJECT';
|
||||
return '🟡 HOLD';
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
// PersonalFinanceAnalyzer
|
||||
//
|
||||
// Takes normalised SimpleFIN account data and computes:
|
||||
// - Net worth (assets - liabilities)
|
||||
// - Cash vs investment allocation
|
||||
// - Spending by category (last 30 days)
|
||||
// - Top spending categories
|
||||
// - Income vs expenses summary
|
||||
|
||||
export class PersonalFinanceAnalyzer {
|
||||
analyse(accounts) {
|
||||
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
|
||||
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
|
||||
|
||||
const totalAssets = assets.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalLiabilities = liabilities.reduce((s, a) => s + Math.abs(Math.min(0, a.balance)), 0);
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
|
||||
const cash = accounts.filter((a) => ['CHECKING', 'SAVINGS'].includes(a.type));
|
||||
const investments = accounts.filter((a) => a.type === 'INVESTMENT');
|
||||
const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||
|
||||
// Aggregate all transactions across accounts
|
||||
const allTx = accounts.flatMap((a) => a.transactions);
|
||||
|
||||
const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer');
|
||||
const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income');
|
||||
|
||||
const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0);
|
||||
const totalIncome = income.reduce((s, tx) => s + tx.amount, 0);
|
||||
|
||||
// Spending by category
|
||||
const byCategory = {};
|
||||
for (const tx of spending) {
|
||||
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount);
|
||||
}
|
||||
const categoryBreakdown = Object.entries(byCategory)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([category, amount]) => ({
|
||||
category,
|
||||
amount,
|
||||
pct: totalSpend > 0 ? ((amount / totalSpend) * 100).toFixed(1) : '0',
|
||||
}));
|
||||
|
||||
return {
|
||||
netWorth,
|
||||
totalAssets,
|
||||
totalLiabilities,
|
||||
totalCash,
|
||||
totalInvestments: totalInvest,
|
||||
cashPct: totalAssets > 0 ? ((totalCash / totalAssets) * 100).toFixed(1) : '0',
|
||||
investPct: totalAssets > 0 ? ((totalInvest / totalAssets) * 100).toFixed(1) : '0',
|
||||
totalIncome,
|
||||
totalSpend,
|
||||
savingsRate:
|
||||
totalIncome > 0 ? (((totalIncome - totalSpend) / totalIncome) * 100).toFixed(1) : null,
|
||||
categoryBreakdown,
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { SIGNAL } from '../config/constants.js';
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
|
||||
export class PortfolioAdvisor {
|
||||
constructor() {
|
||||
this.client = new YahooClient();
|
||||
}
|
||||
|
||||
async advise(holdings, screenedResults) {
|
||||
// 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'));
|
||||
|
||||
return holdings.map((holding) => {
|
||||
const type = (holding.type ?? 'stock').toLowerCase();
|
||||
const source = holding.source ?? '—';
|
||||
const price =
|
||||
type === 'crypto'
|
||||
? cryptoPrices[holding.ticker.toUpperCase()]
|
||||
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
|
||||
|
||||
return type === 'crypto'
|
||||
? this._row(holding, price, source, '—', '—', '—', this._cryptoAdvice(holding, price))
|
||||
: this._stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
|
||||
});
|
||||
}
|
||||
|
||||
_stockRow(holding, price, source, result) {
|
||||
if (!result) {
|
||||
return this._row(holding, price, source, '—', '—', '—', {
|
||||
action: '⚪ Not screened',
|
||||
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
|
||||
});
|
||||
}
|
||||
return this._row(
|
||||
holding,
|
||||
price,
|
||||
source,
|
||||
result.signal,
|
||||
result.inflated.label,
|
||||
result.fundamental.label,
|
||||
this._advice(result.signal, holding, price),
|
||||
);
|
||||
}
|
||||
|
||||
_row(holding, currentPrice, source, signal, inflated, fundamental, { action, reason }) {
|
||||
const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice);
|
||||
return {
|
||||
ticker: holding.ticker,
|
||||
type: holding.type ?? 'stock',
|
||||
source,
|
||||
shares: holding.shares,
|
||||
costBasis: holding.costBasis,
|
||||
currentPrice,
|
||||
marketValue,
|
||||
totalCost,
|
||||
gainLossPct,
|
||||
signal,
|
||||
inflated,
|
||||
fundamental,
|
||||
advice: action,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
_position(holding, currentPrice) {
|
||||
const totalCost = (holding.costBasis * holding.shares).toFixed(2);
|
||||
const marketValue = currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null;
|
||||
const gainLossPct =
|
||||
currentPrice != null && holding.costBasis > 0
|
||||
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
|
||||
: null;
|
||||
return { totalCost, marketValue, gainLossPct };
|
||||
}
|
||||
|
||||
_cryptoAdvice(holding, price) {
|
||||
const { gainLossPct } = this._position(holding, price);
|
||||
const g = parseFloat(gainLossPct);
|
||||
if (gainLossPct == null)
|
||||
return {
|
||||
action: '⚪ No price data',
|
||||
reason: 'Crypto — track price and manage risk manually.',
|
||||
};
|
||||
if (g > 100)
|
||||
return {
|
||||
action: '🟠 Consider taking profits',
|
||||
reason: 'Up significantly — no fundamental analysis for crypto.',
|
||||
};
|
||||
if (g < -30)
|
||||
return {
|
||||
action: '🔴 Review position',
|
||||
reason: 'Down significantly — no fundamental analysis for crypto.',
|
||||
};
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason: 'Crypto — no fundamental analysis. Track price and manage risk manually.',
|
||||
};
|
||||
}
|
||||
|
||||
_advice(signal, holding, price) {
|
||||
const { gainLossPct } = this._position(holding, price);
|
||||
const gain = parseFloat(gainLossPct);
|
||||
switch (signal) {
|
||||
case SIGNAL.STRONG_BUY:
|
||||
return {
|
||||
action: '🟢 Hold & Add',
|
||||
reason: 'Passes both analyses. Strong conviction.',
|
||||
};
|
||||
case SIGNAL.MOMENTUM:
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason:
|
||||
gain > 30
|
||||
? 'Up on momentum — consider partial profit-taking.'
|
||||
: 'Set a stop-loss — not fundamentally justified.',
|
||||
};
|
||||
case SIGNAL.SPECULATION:
|
||||
return {
|
||||
action: gain > 20 ? '🟠 Reduce Position' : '🟡 Hold (small size)',
|
||||
reason:
|
||||
gain > 20
|
||||
? 'In profit on speculation — take partial profits.'
|
||||
: 'Overvalued fundamentally. Keep position small.',
|
||||
};
|
||||
case SIGNAL.NEUTRAL:
|
||||
return {
|
||||
action: '🟡 Hold',
|
||||
reason: 'No clear edge. Review on any catalyst.',
|
||||
};
|
||||
case SIGNAL.AVOID:
|
||||
return {
|
||||
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
|
||||
reason:
|
||||
gain > 0
|
||||
? "Fails both analyses — you're in profit, take it."
|
||||
: 'Fails both analyses — stop the loss from growing.',
|
||||
};
|
||||
default:
|
||||
return { action: '⚪ Review', reason: 'Signal unclear.' };
|
||||
}
|
||||
}
|
||||
|
||||
async _cryptoPrices(cryptoHoldings) {
|
||||
const prices = {};
|
||||
for (const h of cryptoHoldings) {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(h.ticker);
|
||||
prices[h.ticker.toUpperCase()] = summary.price?.regularMarketPrice ?? null;
|
||||
} catch {
|
||||
prices[h.ticker.toUpperCase()] = null;
|
||||
}
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
|
||||
// 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 CLI saves it to .env; a server would store it in its own secret store.
|
||||
// 4. All subsequent requests use the Access URL directly.
|
||||
//
|
||||
// .env configuration:
|
||||
// First run: SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly8...
|
||||
// After that: SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
|
||||
|
||||
export class SimpleFINClient {
|
||||
// logger: object with .write() / .log() / .warn() — defaults to console.
|
||||
// onAccessUrlClaimed(url): optional callback so the caller can persist the URL
|
||||
// (CLI uses it to write .env; a server would store it elsewhere).
|
||||
constructor({ logger, onAccessUrlClaimed } = {}) {
|
||||
this.accessUrl = null;
|
||||
this.logger = logger ?? {
|
||||
write: (msg) => process.stdout.write(msg),
|
||||
log: (...args) => console.log(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
};
|
||||
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (process.env.SIMPLEFIN_ACCESS_URL) {
|
||||
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
|
||||
this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
|
||||
if (this.onAccessUrlClaimed) {
|
||||
await this.onAccessUrlClaimed(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.',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors?.length) {
|
||||
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
|
||||
}
|
||||
|
||||
return this._normalise(data);
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async _claimAccessUrl(setupToken) {
|
||||
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
|
||||
this.logger.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',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.write('✅ Access URL received\n');
|
||||
return accessUrl.trim();
|
||||
}
|
||||
|
||||
_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();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI helper — saves the access URL to .env after the setup token is claimed.
|
||||
// Pass this as `onAccessUrlClaimed` when constructing SimpleFINClient in CLI context.
|
||||
export function saveAccessUrlToEnv(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 {
|
||||
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { YahooClient } from './YahooClient.js';
|
||||
import { REGIME } from '../config/constants.js';
|
||||
|
||||
const TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const DEFAULTS = {
|
||||
sp500Price: 5000,
|
||||
riskFreeRate: 4.5,
|
||||
vixLevel: 20,
|
||||
rateRegime: REGIME.HIGH,
|
||||
volatilityRegime: REGIME.NORMAL,
|
||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||
};
|
||||
|
||||
const rateRegime = (rate) => (rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH);
|
||||
const volRegime = (vix) => (vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH);
|
||||
|
||||
const pe = (summary) =>
|
||||
summary.summaryDetail?.trailingPE ?? summary.defaultKeyStatistics?.forwardPE;
|
||||
|
||||
export class BenchmarkProvider {
|
||||
// logger: object with .warn() — defaults to console so CLI behaviour is unchanged.
|
||||
constructor({ logger = console } = {}) {
|
||||
this.client = new YahooClient();
|
||||
this.cache = { data: null, expiresAt: 0 };
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async getMarketContext() {
|
||||
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
|
||||
|
||||
try {
|
||||
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
|
||||
this.client.fetchSummary('^GSPC'),
|
||||
this.client.fetchSummary('^TNX'),
|
||||
this.client.fetchSummary('^VIX'),
|
||||
this.client.fetchSummary('SPY'),
|
||||
this.client.fetchSummary('XLK'),
|
||||
this.client.fetchSummary('XLRE'),
|
||||
this.client.fetchSummary('LQD'),
|
||||
]);
|
||||
|
||||
const riskFreeRate = tn10y.price?.regularMarketPrice ?? 0;
|
||||
const sp500Price = sp500.price?.regularMarketPrice ?? 0;
|
||||
const vixLevel = vix.price?.regularMarketPrice ?? 0;
|
||||
|
||||
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
|
||||
|
||||
const lqdYield = (lqd.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
|
||||
|
||||
const context = {
|
||||
sp500Price,
|
||||
riskFreeRate,
|
||||
vixLevel,
|
||||
rateRegime: rateRegime(riskFreeRate),
|
||||
volatilityRegime: volRegime(vixLevel),
|
||||
benchmarks: {
|
||||
marketPE: pe(spy) ?? 22,
|
||||
techPE: pe(xlk) ?? 30,
|
||||
reitYield: (xlre.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
|
||||
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.cache = { data: context, expiresAt: Date.now() + TTL_MS };
|
||||
return context;
|
||||
} catch (err) {
|
||||
this.logger.warn('Market data fetch failed, using defaults:', err.message);
|
||||
return this.cache.data ?? DEFAULTS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js';
|
||||
|
||||
export class MarketRegime {
|
||||
constructor(marketContext) {
|
||||
const b = marketContext?.benchmarks ?? {};
|
||||
this.marketPE = b.marketPE ?? 22;
|
||||
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) {
|
||||
if (type === ASSET_TYPE.STOCK) return this._stock(sector);
|
||||
if (type === ASSET_TYPE.ETF) return this._etf();
|
||||
if (type === ASSET_TYPE.BOND) return this._bond();
|
||||
return { gates: {}, thresholds: {} };
|
||||
}
|
||||
|
||||
_stock(sector) {
|
||||
if (sector === SECTOR.REIT) {
|
||||
return {
|
||||
gates: {},
|
||||
// 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) {
|
||||
return {
|
||||
gates: {
|
||||
maxPERatio: Math.round(this.techPE * 1.3),
|
||||
maxPegGate: +(this.techPE / 15).toFixed(1),
|
||||
},
|
||||
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 * peMultiplier),
|
||||
maxPegGate: +(this.marketPE / 12).toFixed(1),
|
||||
},
|
||||
thresholds: {},
|
||||
};
|
||||
}
|
||||
|
||||
_etf() {
|
||||
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
|
||||
}
|
||||
|
||||
_bond() {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export class FinanceReporter {
|
||||
// Returns the HTML string — useful for server responses.
|
||||
render(advice, personalFinance, marketContext) {
|
||||
return this._build(advice, personalFinance, marketContext);
|
||||
}
|
||||
|
||||
// Writes to disk and returns the absolute path — used by the CLI.
|
||||
generate(advice, personalFinance, marketContext, outputPath = './finance-report.html') {
|
||||
const html = this._build(advice, personalFinance, marketContext);
|
||||
fs.writeFileSync(outputPath, html, 'utf8');
|
||||
return path.resolve(outputPath);
|
||||
}
|
||||
|
||||
_build(advice, pf, ctx) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Personal Finance — ${date}</title>
|
||||
<style>
|
||||
*, *::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; }
|
||||
h1 { font-size: 20px; font-weight: 600; }
|
||||
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
|
||||
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
|
||||
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; margin-left: auto; }
|
||||
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
|
||||
.content { padding: 24px 32px; }
|
||||
.section { margin-bottom: 40px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
||||
.card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
|
||||
.card-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.card-value { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
|
||||
.card-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid #1a2233; }
|
||||
tbody tr:hover { background: #1e293b; }
|
||||
tbody td { padding: 10px 12px; vertical-align: middle; }
|
||||
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
|
||||
.green { color: #4ade80; }
|
||||
.yellow { color: #facc15; }
|
||||
.orange { color: #fb923c; }
|
||||
.red { color: #f87171; }
|
||||
.gray { color: #64748b; }
|
||||
.advice-green { color: #4ade80; font-weight: 600; }
|
||||
.advice-yellow { color: #facc15; font-weight: 600; }
|
||||
.advice-orange { color: #fb923c; font-weight: 600; }
|
||||
.advice-red { color: #f87171; font-weight: 600; }
|
||||
.reason { color: #94a3b8; font-size: 11px; }
|
||||
.bar-bg { background: #1e293b; border-radius: 4px; height: 8px; }
|
||||
.bar-fill { background: #3b82f6; border-radius: 4px; height: 8px; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>💰 Personal Finance</h1>
|
||||
<div class="pill">Date <span>${date}</span></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
${pf ? this._netWorthSection(pf) : ''}
|
||||
|
||||
${this._portfolioSection(advice, ctx)}
|
||||
|
||||
${pf ? this._spendingSection(pf) : ''}
|
||||
|
||||
${pf ? this._accountsSection(pf) : ''}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Net worth ──────────────────────────────────────────────────────────────
|
||||
|
||||
_netWorthSection(pf) {
|
||||
const f = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Net Worth</h2>
|
||||
<div class="grid">
|
||||
${this._card('Net Worth', f(pf.netWorth), pf.netWorth >= 0 ? 'green' : 'red')}
|
||||
${this._card('Total Assets', f(pf.totalAssets))}
|
||||
${this._card('Liabilities', f(pf.totalLiabilities), 'red')}
|
||||
${this._card('Cash & Savings', `${f(pf.totalCash)}`, null, `${pf.cashPct}% of assets`)}
|
||||
${this._card('Investments', `${f(pf.totalInvestments)}`, null, `${pf.investPct}% of assets`)}
|
||||
${pf.savingsRate != null ? this._card('Savings Rate', `${pf.savingsRate}%`, parseFloat(pf.savingsRate) > 20 ? 'green' : 'yellow') : ''}
|
||||
${this._card('Monthly Income', f(pf.totalIncome))}
|
||||
${this._card('Monthly Spend', f(pf.totalSpend))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Portfolio with hold/sell advice ───────────────────────────────────────
|
||||
|
||||
_portfolioSection(advice, ctx) {
|
||||
const f = (n) =>
|
||||
n != null
|
||||
? new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(n)
|
||||
: '—';
|
||||
const f2 = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
const b = ctx?.benchmarks ?? {};
|
||||
|
||||
const stocks = advice.filter((a) => a.type !== 'crypto');
|
||||
const crypto = advice.filter((a) => a.type === 'crypto');
|
||||
|
||||
const totalValue = advice.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0);
|
||||
const totalCost = advice.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0);
|
||||
const totalGL = totalValue - totalCost;
|
||||
const totalGLPct = totalCost > 0 ? ((totalGL / totalCost) * 100).toFixed(1) : null;
|
||||
|
||||
const sourceColors = {
|
||||
Robinhood: '#22c55e',
|
||||
Vanguard: '#3b82f6',
|
||||
Fidelity: '#f59e0b',
|
||||
Coinbase: '#8b5cf6',
|
||||
};
|
||||
const sourcePill = (s) => {
|
||||
const color = sourceColors[s] ?? '#64748b';
|
||||
return `<span style="background:${color}22;color:${color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">${s}</span>`;
|
||||
};
|
||||
|
||||
const stockRows = stocks
|
||||
.map((a) => {
|
||||
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
|
||||
const advClass = this._adviceClass(a.advice);
|
||||
return `<tr>
|
||||
<td class="ticker">${a.ticker}</td>
|
||||
<td>${sourcePill(a.source)}</td>
|
||||
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
|
||||
<td>${a.shares}</td>
|
||||
<td>${f(a.costBasis)}</td>
|
||||
<td>${f(parseFloat(a.currentPrice))}</td>
|
||||
<td>${f(parseFloat(a.marketValue))}</td>
|
||||
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td class="gray" style="font-size:11px">${a.signal ?? '—'}</td>
|
||||
<td class="${advClass}">${a.advice}</td>
|
||||
<td class="reason">${a.reason}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const cryptoRows = crypto
|
||||
.map((a) => {
|
||||
const glClass = parseFloat(a.gainLossPct) >= 0 ? 'green' : 'red';
|
||||
const advClass = this._adviceClass(a.advice);
|
||||
return `<tr>
|
||||
<td class="ticker">${a.ticker}</td>
|
||||
<td>${sourcePill(a.source)}</td>
|
||||
<td>${a.shares}</td>
|
||||
<td>${f(a.costBasis)}</td>
|
||||
<td>${f(parseFloat(a.currentPrice))}</td>
|
||||
<td>${f(parseFloat(a.marketValue))}</td>
|
||||
<td class="${glClass}">${a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
|
||||
<td class="${advClass}">${a.advice}</td>
|
||||
<td class="reason">${a.reason}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Portfolio — Hold / Sell / Add Advice</h2>
|
||||
<div class="grid" style="margin-bottom:16px">
|
||||
${this._card('Total Value', f2(totalValue))}
|
||||
${this._card('Total Cost', f2(totalCost))}
|
||||
${this._card('Total G/L', f2(totalGL), totalGL >= 0 ? 'green' : 'red', totalGLPct != null ? totalGLPct + '%' : '')}
|
||||
${this._card('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x', null, 'Live benchmark')}
|
||||
</div>
|
||||
|
||||
${
|
||||
stocks.length > 0
|
||||
? `
|
||||
<h2 style="margin-bottom:10px">Stocks & ETFs</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Ticker</th><th>Source</th><th>Type</th><th>Shares</th>
|
||||
<th>Cost Basis</th><th>Current</th><th>Value</th>
|
||||
<th>G/L</th><th>Signal</th><th>Advice</th><th>Reason</th>
|
||||
</tr></thead>
|
||||
<tbody>${stockRows}</tbody>
|
||||
</table>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
crypto.length > 0
|
||||
? `
|
||||
<h2 style="margin-top:24px;margin-bottom:10px">Crypto</h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Ticker</th><th>Source</th><th>Shares</th>
|
||||
<th>Cost Basis</th><th>Current</th><th>Value</th>
|
||||
<th>G/L</th><th>Advice</th><th>Note</th>
|
||||
</tr></thead>
|
||||
<tbody>${cryptoRows}</tbody>
|
||||
</table>`
|
||||
: ''
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Spending breakdown ─────────────────────────────────────────────────────
|
||||
|
||||
_spendingSection(pf) {
|
||||
const f = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(n);
|
||||
const rows = pf.categoryBreakdown
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
(c) => `
|
||||
<tr>
|
||||
<td>${c.category}</td>
|
||||
<td style="text-align:right">${f(c.amount)}</td>
|
||||
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
|
||||
<td style="width:120px">
|
||||
<div class="bar-bg"><div class="bar-fill" style="width:${Math.min(c.pct, 100)}%"></div></div>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Spending by Category — Last 30 Days</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">Share</th><th></th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||
|
||||
_accountsSection(pf) {
|
||||
const f = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(n);
|
||||
const rows = pf.accounts
|
||||
.map(
|
||||
(a) => `
|
||||
<tr>
|
||||
<td class="ticker">${a.name}</td>
|
||||
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">${a.type}</span></td>
|
||||
<td class="gray">${a.org}</td>
|
||||
<td style="text-align:right" class="${a.balance >= 0 ? 'green' : 'red'}">${f(a.balance)}</td>
|
||||
<td class="gray" style="text-align:right">${a.balanceDate}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th style="text-align:right">Balance</th><th style="text-align:right">Updated</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
_card(label, value, colorClass = null, sub = null) {
|
||||
return `<div class="card">
|
||||
<div class="card-label">${label}</div>
|
||||
<div class="card-value ${colorClass ? colorClass : ''}">${value}</div>
|
||||
${sub ? `<div class="card-sub">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_adviceClass(advice) {
|
||||
if (advice?.includes('🟢')) return 'advice-green';
|
||||
if (advice?.includes('🟡')) return 'advice-yellow';
|
||||
if (advice?.includes('🟠')) return 'advice-orange';
|
||||
if (advice?.includes('🔴')) return 'advice-red';
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Generates a self-contained HTML report saved to ./screener-report.html
|
||||
// Console output shows only the signal summary — full breakdown lives here.
|
||||
|
||||
export class HtmlReporter {
|
||||
// Returns the HTML string — useful for server responses.
|
||||
render(results, marketContext, personalFinance = null) {
|
||||
return this._buildHtml(results, marketContext, personalFinance);
|
||||
}
|
||||
|
||||
// Writes to disk and returns the absolute path — used by the CLI.
|
||||
generate(results, marketContext, personalFinance = null, outputPath = './screener-report.html') {
|
||||
const html = this._buildHtml(results, marketContext, personalFinance);
|
||||
fs.writeFileSync(outputPath, html, 'utf8');
|
||||
return path.resolve(outputPath);
|
||||
}
|
||||
|
||||
// ── HTML builder ────────────────────────────────────────────────────────────
|
||||
|
||||
_buildHtml(results, ctx, pf = null) {
|
||||
const b = ctx.benchmarks ?? {};
|
||||
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Market Screener — ${ctx.timestamp?.slice(0, 10) ?? ''}</title>
|
||||
<style>
|
||||
*, *::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; }
|
||||
h1 { font-size: 20px; font-weight: 600; }
|
||||
h2 { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
|
||||
.header-meta { display: flex; gap: 24px; margin-left: auto; }
|
||||
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; }
|
||||
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
|
||||
|
||||
.content { padding: 24px 32px; }
|
||||
|
||||
.ctx-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 32px; }
|
||||
.ctx-card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
|
||||
.ctx-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.ctx-value { font-size: 18px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
|
||||
|
||||
.section { margin-bottom: 40px; }
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #1e293b; margin-bottom: 16px; }
|
||||
.tab { padding: 8px 20px; cursor: pointer; border-bottom: 2px solid transparent; font-size: 12px; font-weight: 600; color: #64748b; transition: color 0.15s; }
|
||||
.tab.active { color: #e2e8f0; border-bottom-color: #3b82f6; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
|
||||
tbody tr { border-bottom: 1px solid #1a2233; transition: background 0.1s; }
|
||||
tbody tr:hover { background: #1e293b; }
|
||||
tbody td { padding: 10px 12px; vertical-align: middle; white-space: nowrap; }
|
||||
|
||||
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
|
||||
.price { color: #94a3b8; font-variant-numeric: tabular-nums; }
|
||||
.sector { font-size: 11px; color: #64748b; background: #1e293b; padding: 2px 8px; border-radius: 4px; }
|
||||
.score { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.verdict-green { color: #4ade80; }
|
||||
.verdict-yellow { color: #facc15; }
|
||||
.verdict-red { color: #f87171; }
|
||||
|
||||
.signal-strong { color: #4ade80; font-weight: 700; }
|
||||
.signal-momentum{ color: #60a5fa; font-weight: 700; }
|
||||
.signal-neutral { color: #94a3b8; }
|
||||
.signal-spec { color: #fb923c; font-weight: 700; }
|
||||
.signal-avoid { color: #f87171; font-weight: 700; }
|
||||
|
||||
.pass { color: #4ade80; }
|
||||
.fail { color: #f87171; }
|
||||
.flag { color: #fb923c; font-size: 11px; display: block; margin-top: 2px; }
|
||||
|
||||
.risk-flags { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
.no-data { color: #334155; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>📊 Market Screener</h1>
|
||||
<div class="header-meta">
|
||||
<div class="pill">Date <span>${ctx.timestamp?.slice(0, 10) ?? '—'}</span></div>
|
||||
<div class="pill">Rate <span>${ctx.rateRegime}</span></div>
|
||||
<div class="pill">Volatility <span>${ctx.volatilityRegime}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<div class="ctx-grid">
|
||||
${this._ctxCard('10Y Yield', (ctx.riskFreeRate?.toFixed(2) ?? '—') + '%')}
|
||||
${this._ctxCard('VIX', ctx.vixLevel?.toFixed(1) ?? '—')}
|
||||
${this._ctxCard('S&P 500', ctx.sp500Price?.toLocaleString() ?? '—')}
|
||||
${this._ctxCard('S&P 500 P/E', (b.marketPE?.toFixed(1) ?? '—') + 'x')}
|
||||
${this._ctxCard('Tech P/E', (b.techPE?.toFixed(1) ?? '—') + 'x')}
|
||||
${this._ctxCard('REIT Yield', (b.reitYield?.toFixed(2) ?? '—') + '%')}
|
||||
${this._ctxCard('IG Spread', (b.igSpread?.toFixed(2) ?? '—') + '%')}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Signal Summary</h2>
|
||||
<table>
|
||||
<thead><tr><th>Ticker</th><th>Type</th><th>Signal</th><th>Inflated Verdict</th><th>Fundamental Verdict</th></tr></thead>
|
||||
<tbody>${all
|
||||
.sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal))
|
||||
.map((r) => this._summaryRow(r))
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${['STOCK', 'ETF', 'BOND']
|
||||
.map((type) => (results[type]?.length ? this._assetSection(type, results[type], b) : ''))
|
||||
.join('')}
|
||||
|
||||
${pf ? this._personalFinanceSection(pf) : ''}
|
||||
|
||||
${
|
||||
results.ERROR?.length
|
||||
? `
|
||||
<div class="section">
|
||||
<h2>Errors</h2>
|
||||
<table>
|
||||
<thead><tr><th>Ticker</th><th>Reason</th></tr></thead>
|
||||
<tbody>${results.ERROR.map((e) => `<tr><td class="ticker">${e.ticker}</td><td class="verdict-red">${e.message}</td></tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.tabs').forEach((tabs) => {
|
||||
tabs.querySelectorAll('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const section = tabs.closest('.section');
|
||||
tabs.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||
section.querySelectorAll('.tab-content').forEach((c) => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
section.querySelector('#' + tab.dataset.target).classList.add('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ── Section builders ────────────────────────────────────────────────────────
|
||||
|
||||
_assetSection(type, items, benchmarks) {
|
||||
const sorted = [...items].sort((a, b) => this._sigOrd(a.signal) - this._sigOrd(b.signal));
|
||||
const inflatedId = `${type}-inflated`;
|
||||
const fundamentalId = `${type}-fundamental`;
|
||||
|
||||
const inflatedLabel =
|
||||
type === 'STOCK'
|
||||
? `Market-Adjusted (P/E gate: ~${benchmarks.marketPE != null ? Math.round(benchmarks.marketPE * 1.5) : '—'}x from live data)`
|
||||
: 'Market-Adjusted';
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>${type}S</h2>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-target="${inflatedId}">${inflatedLabel}</div>
|
||||
<div class="tab" data-target="${fundamentalId}">Fundamental (Graham-style)</div>
|
||||
</div>
|
||||
<div id="${inflatedId}" class="tab-content active">
|
||||
${this._table(type, sorted, 'inflated')}
|
||||
</div>
|
||||
<div id="${fundamentalId}" class="tab-content">
|
||||
${this._table(type, sorted, 'fundamental')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_table(type, items, mode) {
|
||||
const headers = this._headers(type, items, mode);
|
||||
const rows = items.map((r) => this._row(type, r, mode, headers)).join('');
|
||||
return `<table>
|
||||
<thead><tr>${headers.map((h) => `<th>${h}</th>`).join('')}</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
// Collect only headers that have at least one non-null value across all items
|
||||
_headers(type, items, mode) {
|
||||
const base = ['Ticker', 'Price', 'Verdict', 'Score'];
|
||||
if (type === 'STOCK') {
|
||||
const metricKeys = [
|
||||
'Sector',
|
||||
'P/E',
|
||||
'PEG',
|
||||
'P/B',
|
||||
'ROE%',
|
||||
'OpMgn%',
|
||||
'NetMgn%',
|
||||
'Rev%',
|
||||
'FCF Yld%',
|
||||
'Div%',
|
||||
'D/E',
|
||||
'Quick',
|
||||
'Beta',
|
||||
'52W Pos',
|
||||
'P/FFO',
|
||||
];
|
||||
const present = metricKeys.filter((k) =>
|
||||
items.some((r) => r.asset.getDisplayMetrics()[k] != null),
|
||||
);
|
||||
return [...base, ...present, 'Risk Flags'];
|
||||
}
|
||||
if (type === 'ETF') return [...base, 'Expense', 'Yield', 'AUM', '5Y Ret'];
|
||||
if (type === 'BOND') return [...base, 'YTM', 'Duration', 'Rating'];
|
||||
return base;
|
||||
}
|
||||
|
||||
_row(type, result, mode, headers) {
|
||||
const m = result.asset.getDisplayMetrics();
|
||||
const bd = result[mode]?.audit?.breakdown ?? {};
|
||||
const rf = result[mode]?.audit?.riskFlags ?? [];
|
||||
const v = result[mode]?.label ?? '';
|
||||
const s = result[mode]?.scoreSummary ?? '';
|
||||
const p = (key) =>
|
||||
bd[key] != null
|
||||
? `<span class="${bd[key] > 0 ? 'pass' : 'fail'}">${bd[key] > 0 ? '✅' : '❌'}</span>`
|
||||
: '';
|
||||
|
||||
const cells = {
|
||||
Ticker: `<td class="ticker">${m.Ticker}</td>`,
|
||||
Price: `<td class="price">${m.Price}</td>`,
|
||||
Verdict: `<td class="${this._verdictClass(v)}">${v}</td>`,
|
||||
Score: `<td class="score">${s}</td>`,
|
||||
Sector: `<td><span class="sector">${m.Sector ?? ''}</span></td>`,
|
||||
'P/E': `<td>${m['P/E'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
PEG: `<td>${m.PEG != null ? m.PEG + ' ' + p('peg') : '<span class="no-data">—</span>'}</td>`,
|
||||
'P/B': `<td>${m['P/B'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'ROE%': `<td>${m['ROE%'] != null ? m['ROE%'] + ' ' + p('roe') : '<span class="no-data">—</span>'}</td>`,
|
||||
'OpMgn%': `<td>${m['OpMgn%'] != null ? m['OpMgn%'] + ' ' + p('opMargin') : '<span class="no-data">—</span>'}</td>`,
|
||||
'NetMgn%': `<td>${m['NetMgn%'] != null ? m['NetMgn%'] + ' ' + p('margin') : '<span class="no-data">—</span>'}</td>`,
|
||||
'Rev%': `<td>${m['Rev%'] != null ? m['Rev%'] + ' ' + p('revenue') : '<span class="no-data">—</span>'}</td>`,
|
||||
'FCF Yld%': `<td>${m['FCF Yld%'] != null ? m['FCF Yld%'] + ' ' + p('fcf') : '<span class="no-data">—</span>'}</td>`,
|
||||
'Div%': `<td>${m['Div%'] != null ? m['Div%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
|
||||
'D/E': `<td>${m['D/E'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
Quick: `<td>${m.Quick ?? '<span class="no-data">—</span>'}</td>`,
|
||||
Beta: `<td>${m.Beta ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'52W Pos': `<td>${m['52W Pos'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'P/FFO': `<td>${m['P/FFO'] != null ? m['P/FFO'] + ' ' + p('pFFO') : '<span class="no-data">—</span>'}</td>`,
|
||||
'Risk Flags': `<td class="risk-flags">${rf.map((f) => `<span class="flag">⚠ ${f}</span>`).join('') || '<span class="no-data">—</span>'}</td>`,
|
||||
// ETF
|
||||
Expense: `<td>${m['Exp Ratio%'] != null ? m['Exp Ratio%'] + ' ' + p('cost') : '<span class="no-data">—</span>'}</td>`,
|
||||
Yield: `<td>${m['Yield%'] != null ? m['Yield%'] + ' ' + p('yield') : '<span class="no-data">—</span>'}</td>`,
|
||||
AUM: `<td>${m.AUM ?? '<span class="no-data">—</span>'}</td>`,
|
||||
'5Y Ret': `<td>${m['5Y Return%'] ?? '<span class="no-data">—</span>'}</td>`,
|
||||
// BOND
|
||||
YTM: `<td>${m['YTM%'] != null ? m['YTM%'] + ' ' + p('spread') : '<span class="no-data">—</span>'}</td>`,
|
||||
Duration: `<td>${m.Duration != null ? m.Duration + ' ' + p('duration') : '<span class="no-data">—</span>'}</td>`,
|
||||
Rating: `<td>${m.Rating ?? '<span class="no-data">—</span>'}</td>`,
|
||||
};
|
||||
|
||||
return `<tr>${headers.map((h) => cells[h] ?? `<td>—</td>`).join('')}</tr>`;
|
||||
}
|
||||
|
||||
_summaryRow(r) {
|
||||
return `<tr>
|
||||
<td class="ticker">${r.asset.ticker}</td>
|
||||
<td><span class="sector">${r.asset.type}</span></td>
|
||||
<td class="${this._signalClass(r.signal)}">${r.signal}</td>
|
||||
<td class="${this._verdictClass(r.inflated.label)}">${r.inflated.label}</td>
|
||||
<td class="${this._verdictClass(r.fundamental.label)}">${r.fundamental.label}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_ctxCard(label, value) {
|
||||
return `<div class="ctx-card"><div class="ctx-label">${label}</div><div class="ctx-value">${value}</div></div>`;
|
||||
}
|
||||
|
||||
_verdictClass(label) {
|
||||
if (label?.startsWith('🟢')) return 'verdict-green';
|
||||
if (label?.startsWith('🟡')) return 'verdict-yellow';
|
||||
return 'verdict-red';
|
||||
}
|
||||
|
||||
_signalClass(signal) {
|
||||
if (signal?.includes('Strong')) return 'signal-strong';
|
||||
if (signal?.includes('Momentum')) return 'signal-momentum';
|
||||
if (signal?.includes('Neutral')) return 'signal-neutral';
|
||||
if (signal?.includes('Speculation')) return 'signal-spec';
|
||||
return 'signal-avoid';
|
||||
}
|
||||
|
||||
_personalFinanceSection(pf) {
|
||||
const fmt = (n) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(n);
|
||||
const sign = (n) =>
|
||||
n >= 0
|
||||
? `<span class="verdict-green">${fmt(n)}</span>`
|
||||
: `<span class="verdict-red">${fmt(n)}</span>`;
|
||||
|
||||
const accountRows = pf.accounts
|
||||
.map(
|
||||
(a) => `
|
||||
<tr>
|
||||
<td class="ticker">${a.name}</td>
|
||||
<td><span class="sector">${a.type}</span></td>
|
||||
<td class="price">${a.org}</td>
|
||||
<td style="text-align:right">${sign(a.balance)}</td>
|
||||
<td class="price" style="text-align:right">${a.balanceDate}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const categoryRows = pf.categoryBreakdown
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
(c) => `
|
||||
<tr>
|
||||
<td>${c.category}</td>
|
||||
<td style="text-align:right">${fmt(c.amount)}</td>
|
||||
<td style="text-align:right; color:#94a3b8">${c.pct}%</td>
|
||||
<td>
|
||||
<div style="background:#1e293b;border-radius:4px;height:8px;width:100%;max-width:120px">
|
||||
<div style="background:#3b82f6;border-radius:4px;height:8px;width:${c.pct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<h2>Personal Finance — SimpleFIN</h2>
|
||||
|
||||
<div class="ctx-grid" style="margin-bottom:24px">
|
||||
${this._ctxCard('Net Worth', fmt(pf.netWorth))}
|
||||
${this._ctxCard('Total Assets', fmt(pf.totalAssets))}
|
||||
${this._ctxCard('Liabilities', fmt(pf.totalLiabilities))}
|
||||
${this._ctxCard('Cash', `${fmt(pf.totalCash)} (${pf.cashPct}%)`)}
|
||||
${this._ctxCard('Investments', `${fmt(pf.totalInvestments)} (${pf.investPct}%)`)}
|
||||
${this._ctxCard('Monthly Income', fmt(pf.totalIncome))}
|
||||
${this._ctxCard('Monthly Spend', fmt(pf.totalSpend))}
|
||||
${pf.savingsRate != null ? this._ctxCard('Savings Rate', `${pf.savingsRate}%`) : ''}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
|
||||
<div>
|
||||
<h2 style="margin-bottom:12px">Accounts</h2>
|
||||
<table>
|
||||
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th style="text-align:right">Balance</th><th style="text-align:right">Updated</th></tr></thead>
|
||||
<tbody>${accountRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h2 style="margin-bottom:12px">Spending by Category (Last 30 Days)</h2>
|
||||
<table>
|
||||
<thead><tr><th>Category</th><th style="text-align:right">Amount</th><th style="text-align:right">%</th><th>Share</th></tr></thead>
|
||||
<tbody>${categoryRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_sigOrd(signal) {
|
||||
return (
|
||||
{
|
||||
'✅ Strong Buy': 0,
|
||||
'⚡ Momentum': 1,
|
||||
'🔄 Neutral': 2,
|
||||
'⚠️ Speculation': 3,
|
||||
'❌ Avoid': 4,
|
||||
}[signal] ?? 5
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const chunkArray = (array, size) =>
|
||||
Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
|
||||
array.slice(i * size, i * size + size),
|
||||
);
|
||||
@@ -0,0 +1,153 @@
|
||||
export const mapToStandardFormat = (ticker, summary) => {
|
||||
const quoteType = summary.price?.quoteType;
|
||||
const category = (summary.assetProfile?.category || '').toLowerCase();
|
||||
const yieldVal = summary.summaryDetail?.trailingAnnualDividendYield ?? 0;
|
||||
// Logic to determine type
|
||||
const isBond =
|
||||
category.includes('bond') ||
|
||||
category.includes('fixed income') ||
|
||||
category.includes('treasury') ||
|
||||
(quoteType === 'ETF' && yieldVal > 0.02 && category === ''); // Heuristic fallback
|
||||
if (quoteType === 'ETF') {
|
||||
return isBond
|
||||
? {
|
||||
type: 'BOND',
|
||||
ticker,
|
||||
...mapBondData(summary),
|
||||
}
|
||||
: {
|
||||
type: 'ETF',
|
||||
ticker,
|
||||
...mapEtfData(summary),
|
||||
};
|
||||
}
|
||||
// Default to STOCK (covers 'EQUITY' or missing types)
|
||||
return {
|
||||
type: 'STOCK',
|
||||
ticker,
|
||||
...mapStockData(summary),
|
||||
};
|
||||
};
|
||||
|
||||
const mapStockData = (summary) => {
|
||||
const fd = summary.financialData ?? {};
|
||||
const ks = summary.defaultKeyStatistics ?? {};
|
||||
const sd = summary.summaryDetail ?? {};
|
||||
const pr = summary.price ?? {};
|
||||
|
||||
const currentPrice = pr.regularMarketPrice ?? 0;
|
||||
const sharesOutstanding = ks.sharesOutstanding ?? 0;
|
||||
const operatingCashflow = fd.operatingCashflow ?? 0;
|
||||
const freeCashflow = fd.freeCashflow ?? 0;
|
||||
|
||||
// P/FFO proxy (price / operating cash flow per share) — used for REIT scoring
|
||||
const pFFO =
|
||||
operatingCashflow > 0 && sharesOutstanding > 0
|
||||
? currentPrice / (operatingCashflow / sharesOutstanding)
|
||||
: null;
|
||||
|
||||
// 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 / sharesOutstanding / currentPrice) * 100
|
||||
: null;
|
||||
|
||||
// PEG computation: use Yahoo's value first; fall back to trailingPE / earningsGrowth
|
||||
// earningsGrowth from Yahoo is a decimal (e.g. 0.15 = 15%), convert to whole number first
|
||||
const yahoosPEG = ks.pegRatio ?? null;
|
||||
const trailingPE = sd.trailingPE ?? null;
|
||||
const earningsGrowth = fd.earningsGrowth != null ? fd.earningsGrowth * 100 : null; // now in %
|
||||
const computedPEG =
|
||||
trailingPE != null && earningsGrowth > 0 ? +(trailingPE / earningsGrowth).toFixed(2) : null;
|
||||
const pegRatio = yahoosPEG ?? computedPEG; // prefer Yahoo's, fall back to computed
|
||||
|
||||
// Quick ratio — fall back to currentRatio when quickRatio is missing
|
||||
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
|
||||
|
||||
return {
|
||||
// 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,
|
||||
evToEbitda: ks.enterpriseToEbitda ?? null,
|
||||
|
||||
// Profitability
|
||||
netProfitMargin: fd.profitMargins != null ? fd.profitMargins * 100 : null,
|
||||
operatingMargin: fd.operatingMargins != null ? fd.operatingMargins * 100 : null,
|
||||
returnOnEquity: fd.returnOnEquity != null ? fd.returnOnEquity * 100 : null,
|
||||
|
||||
// Growth
|
||||
revenueGrowth: fd.revenueGrowth != null ? fd.revenueGrowth * 100 : null,
|
||||
earningsGrowth,
|
||||
|
||||
// Financial health
|
||||
debtToEquity: fd.debtToEquity != null ? fd.debtToEquity / 100 : null,
|
||||
quickRatio,
|
||||
|
||||
// Cash flow
|
||||
fcfYield,
|
||||
pFFO,
|
||||
|
||||
// Income
|
||||
dividendYield:
|
||||
sd.trailingAnnualDividendYield != null ? sd.trailingAnnualDividendYield * 100 : null,
|
||||
|
||||
// Risk & momentum
|
||||
beta: sd.beta ?? null,
|
||||
week52High: sd.fiftyTwoWeekHigh ?? null,
|
||||
week52Low: sd.fiftyTwoWeekLow ?? null,
|
||||
|
||||
currentPrice,
|
||||
assetProfile: summary.assetProfile || {},
|
||||
};
|
||||
};
|
||||
|
||||
const mapEtfData = (summary) => ({
|
||||
expenseRatio: (summary.summaryDetail?.expenseRatio ?? 0) * 100,
|
||||
totalAssets: summary.summaryDetail?.totalAssets ?? 0,
|
||||
yield: (summary.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100,
|
||||
// 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,
|
||||
});
|
||||
|
||||
/**
|
||||
* Infer credit rating from ETF category string (Yahoo Finance doesn't expose
|
||||
* bond credit ratings directly). Defaults to BBB (investment grade) when unknown.
|
||||
*/
|
||||
const inferCreditRating = (category) => {
|
||||
const cat = (category || '').toLowerCase();
|
||||
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
|
||||
if (cat.includes('muni')) return 'AA';
|
||||
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
|
||||
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
|
||||
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,
|
||||
// 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,
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ScoringRules } from '../config/ScoringConfig.js';
|
||||
import { MarketRegime } from '../market/MarketRegime.js';
|
||||
import { SCORE_MODE } from '../config/constants.js';
|
||||
|
||||
export const RuleMerger = {
|
||||
getRulesForAsset(type, metrics, marketContext = {}, mode = SCORE_MODE.FUNDAMENTAL) {
|
||||
const base = ScoringRules[type];
|
||||
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
|
||||
|
||||
let rules = JSON.parse(JSON.stringify(base));
|
||||
|
||||
if (type === 'STOCK' && metrics.sector) {
|
||||
const override = base.SECTOR_OVERRIDE?.[metrics.sector.toUpperCase()];
|
||||
if (override) {
|
||||
rules.gates = { ...rules.gates, ...override.gates };
|
||||
rules.weights = { ...rules.weights, ...override.weights };
|
||||
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
|
||||
}
|
||||
}
|
||||
delete rules.SECTOR_OVERRIDE;
|
||||
|
||||
if (mode === SCORE_MODE.INFLATED) {
|
||||
const { gates, thresholds } = new MarketRegime(marketContext).getInflatedOverrides(
|
||||
type,
|
||||
metrics.sector,
|
||||
);
|
||||
rules.gates = { ...rules.gates, ...gates };
|
||||
rules.thresholds = { ...rules.thresholds, ...thresholds };
|
||||
}
|
||||
|
||||
return rules;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { YahooClient } from '../market/YahooClient.js';
|
||||
import { BenchmarkProvider } from '../market/BenchmarkProvider.js';
|
||||
import { mapToStandardFormat } from './DataMapper.js';
|
||||
import { chunkArray } from './Chunker.js';
|
||||
import { RuleMerger } from './RuleMerger.js';
|
||||
import { Stock } from './assets/Stock.js';
|
||||
import { Etf } from './assets/Etf.js';
|
||||
import { Bond } from './assets/Bond.js';
|
||||
import { StockScorer } from './scorers/StockScorer.js';
|
||||
import { EtfScorer } from './scorers/EtfScorer.js';
|
||||
import { BondScorer } from './scorers/BondScorer.js';
|
||||
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants.js';
|
||||
|
||||
const SCORERS = {
|
||||
[ASSET_TYPE.STOCK]: StockScorer,
|
||||
[ASSET_TYPE.ETF]: EtfScorer,
|
||||
[ASSET_TYPE.BOND]: BondScorer,
|
||||
};
|
||||
|
||||
export class ScreenerEngine {
|
||||
// logger: object with .write() / .log() — defaults to a console shim so CLI behaviour is unchanged.
|
||||
// Pass a no-op logger ({ write: () => {}, log: () => {} }) in server context.
|
||||
constructor({ logger } = {}) {
|
||||
this.client = new YahooClient();
|
||||
this.benchmarkProvider = new BenchmarkProvider({ logger: logger ?? console });
|
||||
this.logger = logger ?? {
|
||||
write: (msg) => process.stdout.write(msg),
|
||||
log: (...args) => console.log(...args),
|
||||
};
|
||||
}
|
||||
|
||||
// Pure data method — returns structured results. Safe to use in a server route.
|
||||
async screenTickers(tickers) {
|
||||
const marketContext = await this.benchmarkProvider.getMarketContext();
|
||||
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||
for (const chunk of chunkArray(tickers, 5)) {
|
||||
const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
|
||||
batch.forEach((data) => this._process(data, marketContext, results));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
return { ...results, marketContext };
|
||||
}
|
||||
|
||||
// CLI helper — emits progress to logger, returns structured results.
|
||||
// The caller (bin/screen.js) is responsible for writing the report.
|
||||
async screenWithProgress(tickers) {
|
||||
this.logger.write('⏳ Fetching market context...');
|
||||
const marketContext = await this.benchmarkProvider.getMarketContext();
|
||||
this.logger.write(' done\n');
|
||||
|
||||
const results = { STOCK: [], ETF: [], BOND: [], ERROR: [] };
|
||||
const chunks = chunkArray(tickers, 5);
|
||||
let processed = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const batch = await Promise.all(chunk.map((t) => this._fetch(t)));
|
||||
batch.forEach((data) => this._process(data, marketContext, results));
|
||||
processed += chunk.length;
|
||||
this.logger.write(`\r⏳ Screening tickers... ${processed}/${tickers.length}`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
this.logger.write('\n');
|
||||
return { ...results, marketContext };
|
||||
}
|
||||
|
||||
async _fetch(ticker) {
|
||||
try {
|
||||
const summary = await this.client.fetchSummary(ticker);
|
||||
if (!summary?.price) throw new Error('Empty response from Yahoo');
|
||||
return mapToStandardFormat(ticker, summary);
|
||||
} catch (err) {
|
||||
return { isError: true, ticker: ticker.toUpperCase(), message: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
_process(data, marketContext, results) {
|
||||
if (data.isError) {
|
||||
results.ERROR.push(data);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const asset = this._buildAsset(data);
|
||||
const scorer = SCORERS[asset.type];
|
||||
if (!scorer) throw new Error(`No scorer for type: ${asset.type}`);
|
||||
|
||||
const fundamental = scorer.score(
|
||||
asset.metrics,
|
||||
RuleMerger.getRulesForAsset(
|
||||
asset.type,
|
||||
asset.metrics,
|
||||
marketContext,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
),
|
||||
marketContext,
|
||||
);
|
||||
const inflated = scorer.score(
|
||||
asset.metrics,
|
||||
RuleMerger.getRulesForAsset(asset.type, asset.metrics, marketContext, SCORE_MODE.INFLATED),
|
||||
marketContext,
|
||||
);
|
||||
|
||||
results[asset.type].push({
|
||||
asset,
|
||||
fundamental,
|
||||
inflated,
|
||||
signal: this._signal(fundamental.label, inflated.label),
|
||||
});
|
||||
} catch (err) {
|
||||
results.ERROR.push({
|
||||
ticker: (data.ticker || 'UNKNOWN').toUpperCase(),
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_buildAsset(data) {
|
||||
switch ((data.type || ASSET_TYPE.STOCK).toUpperCase()) {
|
||||
case ASSET_TYPE.BOND:
|
||||
return new Bond(data);
|
||||
case ASSET_TYPE.ETF:
|
||||
return new Etf(data);
|
||||
default:
|
||||
return new Stock(data);
|
||||
}
|
||||
}
|
||||
|
||||
_signal(fundamentalLabel, inflatedLabel) {
|
||||
const green = (l) => l.startsWith('🟢');
|
||||
const yellow = (l) => l.startsWith('🟡');
|
||||
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
|
||||
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
|
||||
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
|
||||
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
|
||||
return SIGNAL.AVOID;
|
||||
}
|
||||
|
||||
signalOrder(signal) {
|
||||
return SIGNAL_ORDER[signal] ?? 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export class Asset {
|
||||
constructor(data) {
|
||||
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
|
||||
this.currentPrice = data.currentPrice || 0;
|
||||
this.type = (data.type || 'STOCK').toUpperCase();
|
||||
}
|
||||
|
||||
formatCurrency(val) {
|
||||
return val ? `$${val.toFixed(2)}` : 'N/A';
|
||||
}
|
||||
|
||||
formatLargeNumber(num) {
|
||||
if (!num) return 'N/A';
|
||||
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
|
||||
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
|
||||
return num.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js';
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Bond extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
|
||||
// Store metrics in a flat object for the ScoringEngine
|
||||
const creditRating = data.creditRating || 'BBB';
|
||||
const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7;
|
||||
|
||||
this.metrics = {
|
||||
ytm: parseFloat(data.yieldToMaturity) || 0,
|
||||
duration: parseFloat(data.duration) || 0,
|
||||
creditRating: data.creditRating || 'N/A',
|
||||
creditRating,
|
||||
creditRatingNumeric,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for dashboard display
|
||||
getDisplayMetrics() {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
@@ -20,7 +23,7 @@ export class Bond extends Asset {
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'YTM%': `${this.metrics.ytm.toFixed(2)}%`,
|
||||
Duration: this.metrics.duration.toFixed(1),
|
||||
Rating: this.metrics.creditRating,
|
||||
Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,28 +3,24 @@ import { Asset } from './Asset.js';
|
||||
export class Etf extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
|
||||
// Store metrics in a flat object for the ScoringEngine
|
||||
this.metrics = {
|
||||
expRatio: parseFloat(data.expenseRatio) || 0,
|
||||
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,
|
||||
};
|
||||
|
||||
// Keep performance metrics for display only
|
||||
this.fiveYearReturn = parseFloat(data.fiveYearReturn) || 0;
|
||||
}
|
||||
|
||||
// Helper for dashboard display
|
||||
getDisplayMetrics() {
|
||||
return {
|
||||
Ticker: this.ticker,
|
||||
Type: 'ETF',
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
'Exp Ratio%': `${this.metrics.expRatio.toFixed(2)}%`,
|
||||
'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)}%`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Asset } from './Asset.js';
|
||||
|
||||
export class Stock extends Asset {
|
||||
constructor(data) {
|
||||
super(data);
|
||||
// console.log('Data:', data);
|
||||
this.sector = this._mapToStandardSector(data || {});
|
||||
|
||||
this.metrics = {
|
||||
sector: this.sector,
|
||||
// Valuation
|
||||
peRatio: data.peRatio ?? null,
|
||||
pegRatio: data.pegRatio ?? null,
|
||||
priceToBook: data.priceToBook ?? null,
|
||||
// Profitability
|
||||
netProfitMargin: data.netProfitMargin ?? null,
|
||||
operatingMargin: data.operatingMargin ?? null,
|
||||
returnOnEquity: data.returnOnEquity ?? null,
|
||||
// Growth
|
||||
revenueGrowth: data.revenueGrowth ?? null,
|
||||
earningsGrowth: data.earningsGrowth ?? null,
|
||||
// Financial health
|
||||
debtToEquity: data.debtToEquity ?? null,
|
||||
quickRatio: data.quickRatio ?? null,
|
||||
// Cash flow
|
||||
fcfYield: data.fcfYield ?? null,
|
||||
pFFO: data.pFFO ?? null,
|
||||
// Income
|
||||
dividendYield: data.dividendYield ?? null,
|
||||
// Risk & momentum
|
||||
beta: data.beta ?? null,
|
||||
week52High: data.week52High ?? null,
|
||||
week52Low: data.week52Low ?? null,
|
||||
currentPrice: data.currentPrice ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
_mapToStandardSector(data) {
|
||||
const profile = data.assetProfile || {};
|
||||
const industry = (profile.industry || '').toLowerCase();
|
||||
const sector = (profile.sector || '').toLowerCase();
|
||||
const combined = `${industry} ${sector}`;
|
||||
|
||||
// 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') ||
|
||||
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';
|
||||
}
|
||||
|
||||
getDisplayMetrics() {
|
||||
const fmt = (v, dec = 1, suffix = '') => (v != null ? `${v.toFixed(dec)}${suffix}` : null);
|
||||
const m = this.metrics;
|
||||
const w52pos =
|
||||
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
|
||||
: null;
|
||||
|
||||
// Only include fields that have actual data — null fields are omitted
|
||||
const display = {
|
||||
Ticker: this.ticker,
|
||||
Price: this.formatCurrency(this.currentPrice),
|
||||
Sector: this.sector,
|
||||
};
|
||||
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
|
||||
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
|
||||
if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2);
|
||||
if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%');
|
||||
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
|
||||
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
|
||||
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
|
||||
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
|
||||
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
|
||||
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
|
||||
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
|
||||
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
|
||||
if (w52pos != null) display['52W Pos'] = w52pos;
|
||||
if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1);
|
||||
|
||||
return display;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const BondScorer = {
|
||||
score(m, rules, context) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = this._sanitize(m);
|
||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||
|
||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||
return {
|
||||
label: '🔴 Avoid',
|
||||
scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: { passedGates: false },
|
||||
};
|
||||
}
|
||||
|
||||
// Convert spread to percentage to match minSpread threshold (e.g. 1.0 = 1%)
|
||||
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
|
||||
|
||||
const breakdown = {
|
||||
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
|
||||
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
|
||||
};
|
||||
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { breakdown },
|
||||
};
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
const pct = (v) => parseFloat(typeof v === 'string' ? v.replace('%', '') : v) / 100 || 0;
|
||||
return {
|
||||
ytm: pct(m.ytm),
|
||||
duration: parseFloat(m.duration) || 0,
|
||||
creditRating: m.creditRating || 'BBB',
|
||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
export const EtfScorer = {
|
||||
score(m, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const metrics = {
|
||||
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) {
|
||||
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
|
||||
}
|
||||
|
||||
const breakdown = {
|
||||
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
|
||||
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
|
||||
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);
|
||||
|
||||
return {
|
||||
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
|
||||
scoreSummary: `Score: ${score}`,
|
||||
audit: { passedGates: true, breakdown },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { SIGNAL } from '../../config/constants.js';
|
||||
|
||||
const n = (v) => {
|
||||
const f = parseFloat(v);
|
||||
return !isNaN(f) && f !== 0 ? f : null;
|
||||
};
|
||||
|
||||
const scoreValue = (val, high, med, weight) => (val >= high ? weight : val >= med ? 1 : -1);
|
||||
const scorePeg = (val, high, med, weight) => (val <= high ? weight : val <= med ? 1 : -1);
|
||||
|
||||
export const StockScorer = {
|
||||
score(metrics, rules) {
|
||||
const { gates, weights, thresholds } = rules;
|
||||
const m = this._sanitize(metrics);
|
||||
|
||||
const failures = [
|
||||
m.debtToEquity != null &&
|
||||
m.debtToEquity > gates.maxDebtToEquity &&
|
||||
`D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`,
|
||||
m.quickRatio != null &&
|
||||
m.quickRatio < gates.minQuickRatio &&
|
||||
`Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`,
|
||||
m.peRatio != null &&
|
||||
m.peRatio > gates.maxPERatio &&
|
||||
`P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`,
|
||||
m.pegRatio != null &&
|
||||
m.pegRatio > gates.maxPegGate &&
|
||||
`PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`,
|
||||
m.priceToBook != null &&
|
||||
gates.maxPriceToBook &&
|
||||
m.priceToBook > gates.maxPriceToBook &&
|
||||
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
|
||||
].filter(Boolean);
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
|
||||
audit: { passedGates: false, failures },
|
||||
};
|
||||
}
|
||||
|
||||
const factors = [
|
||||
{
|
||||
key: 'roe',
|
||||
active: weights.roe > 0 && m.returnOnEquity != null,
|
||||
fn: () => scoreValue(m.returnOnEquity, thresholds.roeHigh, thresholds.roeMed, weights.roe),
|
||||
},
|
||||
{
|
||||
key: 'opMargin',
|
||||
active: weights.opMargin > 0 && m.operatingMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.operatingMargin,
|
||||
thresholds.opMarginHigh,
|
||||
thresholds.opMarginMed,
|
||||
weights.opMargin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
active: weights.margin > 0 && m.netProfitMargin != null,
|
||||
fn: () =>
|
||||
scoreValue(
|
||||
m.netProfitMargin,
|
||||
thresholds.marginHigh,
|
||||
thresholds.marginMed,
|
||||
weights.margin,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'peg',
|
||||
active: weights.peg > 0 && m.pegRatio != null,
|
||||
fn: () => scorePeg(m.pegRatio, thresholds.pegHigh, thresholds.pegMed, weights.peg),
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
active: weights.revenue > 0 && m.revenueGrowth != null,
|
||||
fn: () =>
|
||||
scoreValue(m.revenueGrowth, thresholds.revHigh, thresholds.revMed, weights.revenue),
|
||||
},
|
||||
{
|
||||
key: 'fcf',
|
||||
active: weights.fcf > 0 && m.fcfYield != null,
|
||||
fn: () =>
|
||||
scoreValue(m.fcfYield, thresholds.fcfHigh ?? 5, thresholds.fcfMed ?? 2, weights.fcf),
|
||||
},
|
||||
{
|
||||
key: 'yield',
|
||||
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
|
||||
fn: () => (m.dividendYield >= (thresholds.minYield ?? 4) ? weights.yield : -1),
|
||||
},
|
||||
{
|
||||
key: 'pFFO',
|
||||
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
|
||||
fn: () => (m.pFFO <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
|
||||
},
|
||||
{
|
||||
key: 'priceToBook',
|
||||
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
|
||||
fn: () => scoreValue(1 / m.priceToBook, 1 / 1.0, 1 / 2.0, weights.priceToBook),
|
||||
},
|
||||
];
|
||||
|
||||
const breakdown = {};
|
||||
const totalScore = factors.reduce((sum, f) => {
|
||||
if (!f.active) return sum;
|
||||
breakdown[f.key] = f.fn();
|
||||
return sum + breakdown[f.key];
|
||||
}, 0);
|
||||
|
||||
const riskFlags = [
|
||||
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
|
||||
m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`,
|
||||
m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade',
|
||||
m.week52Position != null &&
|
||||
m.week52Position < 0.1 &&
|
||||
'Near 52-week low — potential opportunity',
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
label: this._label(totalScore),
|
||||
scoreSummary: `Score: ${totalScore}`,
|
||||
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
|
||||
};
|
||||
},
|
||||
|
||||
_label(score) {
|
||||
if (score >= 8) return '🟢 BUY (High Conviction)';
|
||||
if (score >= 4) return '🟢 BUY (Speculative)';
|
||||
if (score >= 0) return '🟡 HOLD';
|
||||
return '🔴 REJECT';
|
||||
},
|
||||
|
||||
_sanitize(m) {
|
||||
const w52 =
|
||||
m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
|
||||
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
|
||||
: null;
|
||||
return {
|
||||
debtToEquity: n(m.debtToEquity),
|
||||
quickRatio: n(m.quickRatio),
|
||||
peRatio: n(m.peRatio),
|
||||
pegRatio: n(m.pegRatio),
|
||||
priceToBook: n(m.priceToBook),
|
||||
netProfitMargin: n(m.netProfitMargin),
|
||||
operatingMargin: n(m.operatingMargin),
|
||||
returnOnEquity: n(m.returnOnEquity),
|
||||
revenueGrowth: n(m.revenueGrowth),
|
||||
fcfYield: n(m.fcfYield),
|
||||
dividendYield: n(m.dividendYield),
|
||||
pFFO: n(m.pFFO),
|
||||
beta: n(m.beta),
|
||||
week52Position: w52,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export const chunkArray = (array, size) => {
|
||||
const result = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
result.push(array.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
export const mapToStandardFormat = (ticker, summary) => {
|
||||
const quoteType = summary.price?.quoteType;
|
||||
const category = (summary.assetProfile?.category || '').toLowerCase();
|
||||
const yieldVal = summary.summaryDetail?.trailingAnnualDividendYield ?? 0;
|
||||
// Logic to determine type
|
||||
const isBond =
|
||||
category.includes('bond') ||
|
||||
category.includes('fixed income') ||
|
||||
category.includes('treasury') ||
|
||||
(quoteType === 'ETF' && yieldVal > 0.02 && category === ''); // Heuristic fallback
|
||||
if (quoteType === 'ETF') {
|
||||
return isBond
|
||||
? {
|
||||
type: 'BOND',
|
||||
ticker,
|
||||
...mapBondData(summary),
|
||||
}
|
||||
: {
|
||||
type: 'ETF',
|
||||
ticker,
|
||||
...mapEtfData(summary),
|
||||
};
|
||||
}
|
||||
// Default to STOCK (covers 'EQUITY' or missing types)
|
||||
return {
|
||||
type: 'STOCK',
|
||||
ticker,
|
||||
...mapStockData(summary),
|
||||
};
|
||||
};
|
||||
|
||||
const mapStockData = (summary) => ({
|
||||
quickRatio: summary.financialData?.quickRatio ?? 0,
|
||||
debtToEquity: (summary.financialData?.debtToEquity ?? 0) / 100,
|
||||
fcfGrowth:
|
||||
(summary.financialData?.freeCashflow ?? 0) > 0 ? 'positive' : 'negative',
|
||||
revenueGrowth: (summary.financialData?.revenueGrowth ?? 0) * 100,
|
||||
netProfitMargin: (summary.financialData?.profitMargins ?? 0) * 100,
|
||||
pegRatio: summary.defaultKeyStatistics?.pegRatio ?? 0,
|
||||
peRatio: summary.defaultKeyStatistics?.forwardPE ?? 0,
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
assetProfile: summary.assetProfile || {},
|
||||
});
|
||||
|
||||
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,
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
});
|
||||
|
||||
const mapBondData = (summary) => ({
|
||||
yieldToMaturity: (summary.summaryDetail?.yield ?? 0) * 100,
|
||||
duration: summary.defaultKeyStatistics?.fiveYearAverageReturn ?? 0,
|
||||
creditRating: summary.assetProfile?.governanceEpochDate ? 'Rated' : 'N/A',
|
||||
currentPrice: summary.price?.regularMarketPrice ?? 0,
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ScoringRules } from '../config/ScoringConfig.js';
|
||||
|
||||
/**
|
||||
* RuleMerger ensures that we apply sector-specific overrides
|
||||
* to base asset rules without polluting the individual Asset or Scorer logic.
|
||||
*/
|
||||
export const RuleMerger = {
|
||||
getRulesForAsset(type, metrics) {
|
||||
// 1. Start with a deep clone of the base rules for this asset type (STOCK, ETF, etc.)
|
||||
const baseRules = ScoringRules[type];
|
||||
if (!baseRules) throw new Error(`No configuration found for type: ${type}`);
|
||||
|
||||
let finalRules = JSON.parse(JSON.stringify(baseRules));
|
||||
|
||||
// 2. If it's a stock and we have a sector, merge the overrides
|
||||
if (type === 'STOCK' && metrics.sector) {
|
||||
const sectorKey = metrics.sector.toUpperCase();
|
||||
const overrides = baseRules.SECTOR_OVERRIDE?.[sectorKey];
|
||||
|
||||
if (overrides) {
|
||||
// Merge gates, weights, and thresholds deeply
|
||||
finalRules.gates = { ...finalRules.gates, ...overrides.gates };
|
||||
finalRules.weights = { ...finalRules.weights, ...overrides.weights };
|
||||
finalRules.thresholds = {
|
||||
...finalRules.thresholds,
|
||||
...overrides.thresholds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cleanup: Remove the override configuration from the final object
|
||||
// so the Scorer works with a clean, flat rule set.
|
||||
delete finalRules.SECTOR_OVERRIDE;
|
||||
|
||||
return finalRules;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BondScorer } from '../src/screener/scorers/BondScorer.js';
|
||||
|
||||
// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it.
|
||||
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
||||
|
||||
const rules = {
|
||||
gates: { minCreditRating: 7 },
|
||||
weights: { yieldSpread: 3, duration: 2 },
|
||||
thresholds: { minSpread: 1.0, maxDuration: 10 },
|
||||
};
|
||||
const ctx = { riskFreeRate: 4.5 };
|
||||
|
||||
test('rejects bond below investment-grade floor', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 8.0, duration: 5, creditRating: 'BB', creditRatingNumeric: 6 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.label, '🔴 Avoid');
|
||||
assert(result.scoreSummary.includes('Gate failed'));
|
||||
});
|
||||
|
||||
test('attractive for wide spread and short duration', () => {
|
||||
// ytm=6.5%, riskFree=4.5% → spreadPct=(0.065-0.045)*100=2.0% >= minSpread 1.0%
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 4, creditRating: 'AA', creditRatingNumeric: 9 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.label, '🟢 Attractive');
|
||||
});
|
||||
|
||||
test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.spread, rules.weights.yieldSpread);
|
||||
});
|
||||
|
||||
test('fails spread when yield barely above risk-free', () => {
|
||||
// ytm=4.7%, riskFree=4.5% → spreadPct=0.2% < minSpread 1.0%
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 4.7, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.spread, -2);
|
||||
});
|
||||
|
||||
test('penalises long duration', () => {
|
||||
const result = BondScorer.score(
|
||||
{ ytm: 6.5, duration: 15, creditRating: 'AA', creditRatingNumeric: 9 },
|
||||
rules,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(result.audit.breakdown.duration, -1);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mapToStandardFormat } from '../src/screener/DataMapper.js';
|
||||
|
||||
const base = {
|
||||
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
||||
assetProfile: { sector: 'Technology', industry: 'Software', category: '' },
|
||||
financialData: {
|
||||
quickRatio: 1.2,
|
||||
debtToEquity: 150,
|
||||
freeCashflow: 5e9,
|
||||
revenueGrowth: 0.15,
|
||||
profitMargins: 0.25,
|
||||
operatingMargins: 0.3,
|
||||
returnOnEquity: 0.2,
|
||||
earningsGrowth: 0.12,
|
||||
operatingCashflow: 8e9,
|
||||
},
|
||||
defaultKeyStatistics: { pegRatio: null, forwardPE: 28, sharesOutstanding: 1e9, priceToBook: 12 },
|
||||
summaryDetail: {
|
||||
trailingAnnualDividendYield: 0.005,
|
||||
trailingPE: 30,
|
||||
beta: 1.2,
|
||||
fiftyTwoWeekHigh: 200,
|
||||
fiftyTwoWeekLow: 120,
|
||||
},
|
||||
};
|
||||
|
||||
test('maps EQUITY quote type to STOCK', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.type, 'STOCK');
|
||||
assert.equal(result.ticker, 'AAPL');
|
||||
});
|
||||
|
||||
test('computes PEG from trailingPE / earningsGrowth when Yahoo returns null', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12%
|
||||
assert.equal(result.pegRatio, expected);
|
||||
});
|
||||
|
||||
test('uses Yahoo pegRatio when available', () => {
|
||||
const summary = {
|
||||
...base,
|
||||
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
|
||||
};
|
||||
const result = mapToStandardFormat('AAPL', summary);
|
||||
assert.equal(result.pegRatio, 1.5);
|
||||
});
|
||||
|
||||
test('debtToEquity is divided by 100', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
assert.equal(result.debtToEquity, 1.5); // 150 / 100
|
||||
});
|
||||
|
||||
test('maps ETF quoteType to ETF', () => {
|
||||
const etfSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Large Blend' },
|
||||
};
|
||||
const result = mapToStandardFormat('VOO', etfSummary);
|
||||
assert.equal(result.type, 'ETF');
|
||||
});
|
||||
|
||||
test('classifies bond ETF from category keyword', () => {
|
||||
const bondSummary = {
|
||||
...base,
|
||||
price: { ...base.price, quoteType: 'ETF' },
|
||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
||||
};
|
||||
const result = mapToStandardFormat('BND', bondSummary);
|
||||
assert.equal(result.type, 'BOND');
|
||||
});
|
||||
|
||||
test('FCF yield is computed when data available', () => {
|
||||
const result = mapToStandardFormat('AAPL', base);
|
||||
assert.notEqual(result.fcfYield, null);
|
||||
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 },
|
||||
financialData: {},
|
||||
defaultKeyStatistics: {},
|
||||
summaryDetail: {},
|
||||
assetProfile: {},
|
||||
};
|
||||
const result = mapToStandardFormat('X', sparse);
|
||||
assert.equal(result.pegRatio, null);
|
||||
assert.equal(result.quickRatio, null);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EtfScorer } from '../src/screener/scorers/EtfScorer.js';
|
||||
|
||||
const rules = {
|
||||
gates: { maxExpenseRatio: 0.5 },
|
||||
weights: { yield: 2, lowCost: 3 },
|
||||
thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 },
|
||||
};
|
||||
|
||||
test('rejects ETF with expense ratio above gate', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.8, yield: 2.0 }, rules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('efficient label for low-cost, high-yield ETF', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules);
|
||||
assert.equal(result.label, '🟢 Efficient');
|
||||
});
|
||||
|
||||
test('neutral when yield is below threshold', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 0.4, volume: 1000000 }, rules);
|
||||
assert.equal(result.label, '🟡 Neutral');
|
||||
});
|
||||
|
||||
test('audit breakdown includes cost, yield, vol keys', () => {
|
||||
const result = EtfScorer.score({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }, rules);
|
||||
assert(result.audit.breakdown.cost != null);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { test } from 'node:test';
|
||||
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, extra = {}) => new MarketRegime({ benchmarks, ...extra });
|
||||
|
||||
test('stock inflated P/E = marketPE × 1.5', () => {
|
||||
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||
assert.equal(gates.maxPERatio, Math.round(24 * 1.5)); // 36
|
||||
});
|
||||
|
||||
test('tech inflated P/E = techPE × 1.3', () => {
|
||||
const { gates } = regime({ techPE: 40 }).getInflatedOverrides(
|
||||
ASSET_TYPE.STOCK,
|
||||
SECTOR.TECHNOLOGY,
|
||||
);
|
||||
assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52
|
||||
});
|
||||
|
||||
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('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);
|
||||
});
|
||||
|
||||
test('falls back to defaults when benchmarks missing', () => {
|
||||
const { gates } = new MarketRegime({}).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||
assert.equal(gates.maxPERatio, Math.round(22 * 1.5)); // default marketPE = 22
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js';
|
||||
import { SIGNAL } from '../src/config/constants.js';
|
||||
|
||||
const advisor = new PortfolioAdvisor();
|
||||
|
||||
test('_position: computes gain/loss correctly', () => {
|
||||
const pos = advisor._position({ costBasis: 100, shares: 10 }, 150);
|
||||
assert.equal(pos.gainLossPct, '50.0');
|
||||
assert.equal(pos.marketValue, '1500.00');
|
||||
assert.equal(pos.totalCost, '1000.00');
|
||||
});
|
||||
|
||||
test('_position: returns null gainLoss when price unavailable', () => {
|
||||
const pos = advisor._position({ costBasis: 100, shares: 10 }, null);
|
||||
assert.equal(pos.gainLossPct, null);
|
||||
assert.equal(pos.marketValue, null);
|
||||
});
|
||||
|
||||
test('_advice: Strong Buy → Hold & Add', () => {
|
||||
const { action } = advisor._advice(SIGNAL.STRONG_BUY, { costBasis: 100, shares: 10 }, 150);
|
||||
assert.equal(action, '🟢 Hold & Add');
|
||||
});
|
||||
|
||||
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
||||
const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 150, shares: 10 }, 100);
|
||||
assert.equal(action, '🔴 Sell (Cut Loss)');
|
||||
});
|
||||
|
||||
test('_advice: Avoid + profit → Sell (Take Profits)', () => {
|
||||
const { action } = advisor._advice(SIGNAL.AVOID, { costBasis: 100, shares: 10 }, 150);
|
||||
assert.equal(action, '🔴 Sell (Take Profits)');
|
||||
});
|
||||
|
||||
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
||||
const { action } = advisor._advice(SIGNAL.SPECULATION, { costBasis: 100, shares: 10 }, 125);
|
||||
assert.equal(action, '🟠 Reduce Position');
|
||||
});
|
||||
|
||||
test('_cryptoAdvice: no price → No price data', () => {
|
||||
const { action } = advisor._cryptoAdvice({ costBasis: 100, shares: 1 }, null);
|
||||
assert.equal(action, '⚪ No price data');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RuleMerger } from '../src/screener/RuleMerger.js';
|
||||
import { SCORE_MODE } from '../src/config/constants.js';
|
||||
|
||||
const ctx = {
|
||||
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
||||
};
|
||||
|
||||
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x
|
||||
assert.equal(rules.gates.maxPegGate, 1.0); // updated: Lynch PEG standard
|
||||
});
|
||||
|
||||
test('INFLATED mode loosens P/E gate from live SPY data', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37
|
||||
assert(rules.gates.maxPERatio > 15, 'Inflated P/E should exceed fundamental 15x');
|
||||
});
|
||||
|
||||
test('INFLATED tech P/E gate uses XLK benchmark', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'TECHNOLOGY' },
|
||||
ctx,
|
||||
SCORE_MODE.INFLATED,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42
|
||||
});
|
||||
|
||||
test('Sector override applied before inflated overrides', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'REIT' },
|
||||
ctx,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.gates.maxPERatio, 9999);
|
||||
assert.equal(rules.weights.yield, 5);
|
||||
assert.equal(rules.weights.margin, 0);
|
||||
});
|
||||
|
||||
test('SECTOR_OVERRIDE is deleted from returned rules', () => {
|
||||
const rules = RuleMerger.getRulesForAsset(
|
||||
'STOCK',
|
||||
{ sector: 'GENERAL' },
|
||||
ctx,
|
||||
SCORE_MODE.FUNDAMENTAL,
|
||||
);
|
||||
assert.equal(rules.SECTOR_OVERRIDE, undefined);
|
||||
});
|
||||
|
||||
test('throws for unknown asset type', () => {
|
||||
assert.throws(() => RuleMerger.getRulesForAsset('CRYPTO', {}, ctx), /No rules configured/);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CREDIT_RATING_SCALE, ScoringRules } from '../src/config/ScoringConfig.js';
|
||||
|
||||
test('CREDIT_RATING_SCALE covers full spectrum', () => {
|
||||
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
|
||||
assert.equal(CREDIT_RATING_SCALE.BBB, 7);
|
||||
assert.equal(CREDIT_RATING_SCALE.BB, 6);
|
||||
assert.equal(CREDIT_RATING_SCALE.D, 1);
|
||||
});
|
||||
|
||||
test('STOCK base gates are fundamental (Graham-style)', () => {
|
||||
const { gates } = ScoringRules.STOCK;
|
||||
assert.equal(gates.maxPERatio, 15); // Graham's actual rule: 15x trailing earnings
|
||||
assert.equal(gates.maxPegGate, 1.0); // Lynch standard: PEG > 1.0 is paying full price
|
||||
assert.equal(gates.minQuickRatio, 0.8); // below 0.8 signals liquidity stress
|
||||
});
|
||||
|
||||
test('REIT sector override zeroes out irrelevant weights', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
||||
assert.equal(reit.weights.margin, 0);
|
||||
assert.equal(reit.weights.peg, 0);
|
||||
assert.equal(reit.weights.revenue, 0);
|
||||
assert.equal(reit.weights.yield, 5);
|
||||
});
|
||||
|
||||
test('REIT gates disable P/E and PEG', () => {
|
||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
||||
assert.equal(reit.gates.maxPERatio, 9999);
|
||||
assert.equal(reit.gates.maxPegGate, 9999);
|
||||
});
|
||||
|
||||
test('TECHNOLOGY gates are realistic for mega-cap', () => {
|
||||
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY;
|
||||
assert.equal(tech.gates.maxDebtToEquity, 2.0);
|
||||
assert.equal(tech.gates.minQuickRatio, 0.8);
|
||||
});
|
||||
|
||||
test('BOND requires investment-grade floor (BBB = 7)', () => {
|
||||
assert.equal(ScoringRules.BOND.gates.minCreditRating, 7);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StockScorer } from '../src/screener/scorers/StockScorer.js';
|
||||
|
||||
const baseRules = {
|
||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
||||
weights: { margin: 2, opMargin: 2, roe: 3, peg: 2, revenue: 2, fcf: 2 },
|
||||
thresholds: {
|
||||
marginHigh: 20,
|
||||
marginMed: 10,
|
||||
opMarginHigh: 20,
|
||||
opMarginMed: 10,
|
||||
roeHigh: 20,
|
||||
roeMed: 10,
|
||||
pegHigh: 1.0,
|
||||
pegMed: 1.5,
|
||||
revHigh: 15,
|
||||
revMed: 5,
|
||||
fcfHigh: 5,
|
||||
fcfMed: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const pass = {
|
||||
peRatio: 15,
|
||||
pegRatio: 1.2,
|
||||
debtToEquity: 1.0,
|
||||
quickRatio: 1.0,
|
||||
returnOnEquity: 22,
|
||||
operatingMargin: 25,
|
||||
netProfitMargin: 18,
|
||||
revenueGrowth: 16,
|
||||
fcfYield: 6,
|
||||
};
|
||||
|
||||
test('rejects on high D/E', () => {
|
||||
const result = StockScorer.score({ ...pass, debtToEquity: 4.0 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
assert(result.scoreSummary.includes('D/E'));
|
||||
});
|
||||
|
||||
test('rejects on high P/E', () => {
|
||||
const result = StockScorer.score({ ...pass, peRatio: 25 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
assert(result.scoreSummary.includes('P/E'));
|
||||
});
|
||||
|
||||
test('rejects on high PEG', () => {
|
||||
const result = StockScorer.score({ ...pass, pegRatio: 2.0 }, baseRules);
|
||||
assert.equal(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('skips gate when metric is null (missing data)', () => {
|
||||
const result = StockScorer.score({ ...pass, pegRatio: null, peRatio: null }, baseRules);
|
||||
assert.notEqual(result.label, '🔴 REJECT');
|
||||
});
|
||||
|
||||
test('high-conviction BUY on strong metrics', () => {
|
||||
const result = StockScorer.score(pass, baseRules);
|
||||
assert.equal(result.label, '🟢 BUY (High Conviction)');
|
||||
});
|
||||
|
||||
test('audit breakdown contains scored factors', () => {
|
||||
const result = StockScorer.score(pass, baseRules);
|
||||
assert(result.audit.passedGates);
|
||||
assert(result.audit.breakdown.roe != null);
|
||||
assert(result.audit.breakdown.margin != null);
|
||||
});
|
||||
|
||||
test('beta > 1.5 surfaces as risk flag', () => {
|
||||
const result = StockScorer.score({ ...pass, beta: 2.0 }, baseRules);
|
||||
assert(result.audit.riskFlags?.some((f) => f.includes('High volatility')));
|
||||
});
|
||||
|
||||
test('near 52-week high surfaces as risk flag', () => {
|
||||
const result = StockScorer.score(
|
||||
{ ...pass, week52High: 200, week52Low: 100, currentPrice: 195 },
|
||||
baseRules,
|
||||
);
|
||||
assert(result.audit.riskFlags?.some((f) => f.includes('52-week high')));
|
||||
});
|
||||
+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