Compare commits

..

2 Commits

Author SHA1 Message Date
Kazuma 3513024fc6 phase-1: optimize code 2026-06-04 01:32:05 -04:00
Kazuma cd74497de6 refactor: restructure to clean architecture
fix: restore ScoringConfig improvements lost in refactor commit

docs: rewrite README and CLAUDE.md to reflect current architecture

code-format

code fixes
2026-06-03 01:36:21 -04:00
89 changed files with 11189 additions and 845 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "market-screener-ui",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--prefix", "ui"],
"port": 5173
}
]
}
+11
View File
@@ -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
+15
View File
@@ -1 +1,16 @@
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
+2
View File
@@ -0,0 +1,2 @@
npx lint-staged
npm test
+1
View File
@@ -0,0 +1 @@
npm test
+12
View File
@@ -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"
}
+464
View File
@@ -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 | LQDTNX × 0.80 | LQDTNX × 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`.
-46
View File
@@ -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 todays 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.
+203 -36
View File
@@ -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 (25%) |
| **Consumer Discretionary** | Revenue growth primary, P/E up to 25× |
---
## API Server
```
GET /health → { status: "ok" }
POST /api/screen → screen tickers
body: { tickers: string[] }
GET /api/screen/catalysts → Yahoo news → { tickers, stories }
GET /api/finance/portfolio → portfolio advice + net worth
GET /api/finance/market-context → live benchmark data
```
Set `CLIENT_ORIGIN` env var to allow a different CORS origin (default: `http://localhost:5173`).
---
## Personal Finance
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.
+84
View File
@@ -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);
});
+83
View File
@@ -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);
+14
View File
@@ -0,0 +1,14 @@
import 'dotenv/config';
import { buildApp } from '../src/server/app.js';
const PORT = process.env.PORT ?? 3000;
const HOST = process.env.HOST ?? '0.0.0.0';
const app = await buildApp();
try {
await app.listen({ port: Number(PORT), host: HOST });
} catch (err) {
app.log.error(err);
process.exit(1);
}
-32
View File
@@ -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
View File
@@ -1,47 +0,0 @@
### Request: Optimize Investment Strategy Configuration
I am updating my investment strategy configuration. You are acting as a Senior Quantitative Financial Strategist. Please analyze my current market thesis and update the configuration parameters to align with this view.
**Market Thesis:** [INSERT YOUR THESIS HERE]
### Reasoning Phase (Before the JSON)
1. Briefly summarize your logic for the changes (e.g., "Raising the `maxDebtToEquity` gate because high-interest environments make capital-intensive businesses riskier").
2. Ensure all values are mathematically sound and consistent with the requested thesis.
### JSON Output Requirements
- Return a valid JSON object matching the schema below.
- Ensure all numbers are appropriate for the asset class (e.g., Debt/Equity usually 0-5, P/E usually 0-100).
- **Crucial:** Provide _only_ the JSON inside a single code block. No conversational text after the code block.
```json
{
"STOCK": {
"gates": {
"maxDebtToEquity": 0.0,
"minQuickRatio": 0.0,
"maxPERatio": 0.0
},
"weights": { "margin": 0, "peg": 0, "revenue": 0, "fcf": 0 },
"thresholds": {
"marginHigh": 0,
"marginMed": 0,
"pegHigh": 0,
"pegMed": 0,
"revHigh": 0,
"revMed": 0
}
},
"ETF": {
"gates": { "maxExpenseRatio": 0.0 },
"weights": { "yield": 0, "lowCost": 0 },
"thresholds": { "minYield": 0.0, "maxExpense": 0.0 }
},
"BOND": {
"gates": { "minCreditRating": 0 },
"weights": { "yieldSpread": 0, "duration": 0 },
"thresholds": { "minSpread": 0.0, "maxDuration": 0 }
}
}
```
+1402 -4
View File
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -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"
}
}
+165
View File
@@ -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 35 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 (15 days) | Medium (14 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 1020%
> - **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 | 15 | 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 (15 days) | Confirm the stock isn't already broken (avoid catching falling knives on longs) |
| Medium (14 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** (25%) | 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 1525) | 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. |
+37
View File
@@ -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`;
}
+53
View File
@@ -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];
}
}
+78
View File
@@ -0,0 +1,78 @@
import Anthropic from '@anthropic-ai/sdk';
// LLMAnalyst — uses Claude Haiku to analyze news catalyst stories.
//
// Given a list of news headlines and the tickers already identified,
// it produces:
// - A concise market summary (2-3 sentences)
// - Industries likely to be affected (beyond the directly mentioned tickers)
// - Up to 5 related tickers worth watching
// - A risk sentiment assessment (BULLISH / NEUTRAL / BEARISH)
//
// Requires ANTHROPIC_API_KEY in environment.
const SYSTEM_PROMPT = `You are a professional equity analyst. You will be given a list of today's market news headlines and the tickers already identified as catalysts.
Your job is to:
1. Write a 2-3 sentence market summary capturing the dominant theme
2. Identify up to 4 industries that are likely to be secondarily affected (not directly mentioned but impacted by contagion, supply chain, regulation, or macro effects)
3. Suggest up to 5 related ticker symbols worth screening that are NOT already in the provided list
4. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH based on the news
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
{
"summary": "string",
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
"affectedIndustries": [
{ "name": "string", "reason": "string (one sentence)" }
],
"relatedTickers": [
{ "ticker": "string", "reason": "string (one sentence)" }
]
}`;
export class LLMAnalyst {
constructor({ logger } = {}) {
this.logger = logger ?? { log: console.log, warn: console.warn };
this.client = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null;
}
// Analyzes news stories and returns structured market intelligence.
// Returns null if ANTHROPIC_API_KEY is not set (graceful degradation).
async analyze(stories, existingTickers = []) {
if (!this.client) {
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
return null;
}
if (!stories?.length) return null;
const headlines = stories
.slice(0, 15)
.map((s, i) => `${i + 1}. ${s.title} (${s.publisher ?? 'unknown'})`)
.join('\n');
const userMessage = `Today's market news headlines:\n\n${headlines}\n\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
try {
const response = await this.client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMessage }],
});
const raw = response.content[0]?.text ?? '';
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned);
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', err.message);
return null;
}
}
}
-45
View File
@@ -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 };
}
}
}
+80
View File
@@ -0,0 +1,80 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
const STORE_PATH = './market-calls.json';
// MarketCallStore — persists quarterly market thesis entries to market-calls.json.
//
// A market call captures:
// - A written thesis (the reasoning behind the call)
// - Tickers to watch
// - A snapshot of each ticker's price + signal at the time of the call
// - Performance tracking (current vs snapshot price) computed on read
//
// Format:
// {
// "calls": [
// {
// "id": "uuid",
// "title": "Q3 2025 — Rate pivot & tech rotation",
// "quarter": "Q3 2025",
// "date": "2025-07-01",
// "thesis": "The Fed is expected to begin cutting...",
// "tickers": ["AAPL", "MSFT", "TLT"],
// "snapshot": {
// "AAPL": { "price": 195.00, "signal": "✅ Strong Buy", "verdict": "BUY (High Conviction)" }
// },
// "createdAt": "2025-07-01T14:22:00.000Z"
// }
// ]
// }
export class MarketCallStore {
_load() {
if (!existsSync(STORE_PATH)) return { calls: [] };
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8'));
} catch {
return { calls: [] };
}
}
_save(data) {
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
}
list() {
return this._load().calls.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
get(id) {
return this._load().calls.find((c) => c.id === id) ?? null;
}
// Create a new call. snapshot is an object keyed by ticker with { price, signal, verdict }.
create({ title, quarter, date, thesis, tickers, snapshot }) {
const data = this._load();
const call = {
id: randomUUID(),
title,
quarter,
date: date ?? new Date().toISOString().slice(0, 10),
thesis,
tickers,
snapshot: snapshot ?? {},
createdAt: new Date().toISOString(),
};
data.calls.push(call);
this._save(data);
return call;
}
delete(id) {
const data = this._load();
const before = data.calls.length;
data.calls = data.calls.filter((c) => c.id !== id);
if (data.calls.length === before) return false;
this._save(data);
return true;
}
}
+193 -29
View File
@@ -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 },
},
};
+53
View File
@@ -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,
};
-23
View File
@@ -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();
}
}
-60
View File
@@ -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),
};
}
}
-49
View File
@@ -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;
},
};
-122
View File
@@ -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);
});
}
}
-63
View File
@@ -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';
},
};
-62
View File
@@ -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';
},
};
-107
View File
@@ -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';
},
};
+62
View File
@@ -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,
};
}
}
+167
View File
@@ -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;
}
}
+201
View File
@@ -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`);
}
}
+73
View File
@@ -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;
}
}
}
+63
View File
@@ -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;
}
}
}
+304
View File
@@ -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 &amp; 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';
}
}
+392
View File
@@ -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
);
}
}
+4
View File
@@ -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),
);
+153
View File
@@ -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,
});
+33
View File
@@ -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;
},
};
+141
View File
@@ -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;
}
}
+19
View File
@@ -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)}%`,
};
}
}
+134
View File
@@ -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;
}
}
+40
View File
@@ -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,
};
},
};
+36
View File
@@ -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 },
};
},
};
+157
View File
@@ -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,
};
},
};
+77
View File
@@ -0,0 +1,77 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import screenerRoutes from './routes/screener.js';
import financeRoutes from './routes/finance.js';
import callsRoutes from './routes/calls.js';
import { YahooClient } from '../market/YahooClient.js';
import { LLMAnalyst } from '../analyst/LLMAnalyst.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
export async function buildApp({ logger = true } = {}) {
const app = Fastify({ logger });
await app.register(cors, {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
});
await app.register(screenerRoutes);
await app.register(financeRoutes);
await app.register(callsRoutes);
// POST /api/analyze — fetch Yahoo news for tickers and run LLM analysis
app.post('/api/analyze', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
handler: async (req, reply) => {
if (!process.env.ANTHROPIC_API_KEY) {
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
}
const tickers = req.body.tickers.map((t) => t.toUpperCase());
const client = new YahooClient();
const llm = new LLMAnalyst({ logger: noopLogger });
const seen = new Map();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
try {
const { news = [] } = await client.yf.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const s of news) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip */
}
}),
);
const stories = [...seen.values()].slice(0, 15);
if (!stories.length) {
return reply.code(200).send({ analysis: null, reason: 'no_stories' });
}
const analysis = await llm.analyze(stories, tickers);
return { analysis };
},
});
app.get('/health', async () => ({ status: 'ok' }));
return app;
}
+187
View File
@@ -0,0 +1,187 @@
import { MarketCallStore } from '../../calls/MarketCallStore.js';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { YahooClient } from '../../market/YahooClient.js';
import { chunkArray } from '../../screener/Chunker.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
const store = new MarketCallStore();
// Takes a screener result entry and flattens it to a snapshot record
const toSnapshot = (r) => {
if (!r) return null;
const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {};
return {
price: r.asset?.currentPrice ?? null,
signal: r.signal ?? null,
inflatedVerdict: r.inflated?.label ?? null,
fundamentalVerdict: r.fundamental?.label ?? null,
pe: m['P/E'] ?? null,
roe: m['ROE%'] ?? null,
fcf: m['FCF Yld%'] ?? null,
};
};
export default async function callsRoutes(app) {
// GET /api/calls — list all market calls (newest first)
app.get('/api/calls', async () => {
return { calls: store.list() };
});
// GET /api/calls/:id — get one call + enrich with current prices for comparison
app.get('/api/calls/:id', async (req, reply) => {
const call = store.get(req.params.id);
if (!call) return reply.code(404).send({ error: 'Call not found' });
// Re-screen the tickers to get current prices for comparison
let current = {};
if (call.tickers.length > 0) {
try {
const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(call.tickers);
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
for (const r of all) {
current[r.asset.ticker] = toSnapshot(r);
}
} catch {
// Non-fatal — return call without current prices
}
}
return { ...call, current };
});
// POST /api/calls — create a new market call and snapshot current prices
app.post('/api/calls', {
schema: {
body: {
type: 'object',
required: ['title', 'quarter', 'thesis', 'tickers'],
properties: {
title: { type: 'string', minLength: 3 },
quarter: { type: 'string', minLength: 2 },
date: { type: 'string' },
thesis: { type: 'string', minLength: 10 },
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 },
},
},
},
handler: async (req, reply) => {
const { title, quarter, date, thesis, tickers } = req.body;
const upperTickers = tickers.map((t) => t.toUpperCase());
// Snapshot current screener data for each ticker
let snapshot = {};
try {
const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(upperTickers);
const all = [...results.STOCK, ...results.ETF, ...results.BOND];
for (const r of all) {
snapshot[r.asset.ticker] = toSnapshot(r);
}
} catch (err) {
app.log.warn('Could not snapshot prices for market call:', err.message);
}
const call = store.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot });
return reply.code(201).send(call);
},
});
// DELETE /api/calls/:id
app.delete('/api/calls/:id', async (req, reply) => {
const deleted = store.delete(req.params.id);
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
return { ok: true };
});
// GET /api/calls/calendar?tickers=AAPL,MSFT (or omit to use all call tickers)
// Returns upcoming earnings dates, ex-dividend dates and dividend dates per ticker.
// Fetched in parallel batches of 5 with rate-limit delay.
app.get('/api/calls/calendar', async (req) => {
const client = new YahooClient();
// Resolve tickers: from query param, or aggregate all unique tickers across all calls
let tickers;
if (req.query.tickers) {
tickers = req.query.tickers
.split(',')
.map((t) => t.trim().toUpperCase())
.filter(Boolean);
} else {
const allCalls = store.list();
const set = new Set(allCalls.flatMap((c) => c.tickers));
tickers = [...set];
}
if (tickers.length === 0) return { events: [] };
// Fetch calendarEvents in parallel batches
const results = {};
for (const batch of chunkArray(tickers, 5)) {
await Promise.all(
batch.map(async (ticker) => {
const cal = await client.fetchCalendarEvents(ticker);
if (cal) results[ticker] = cal;
}),
);
await new Promise((r) => setTimeout(r, 500));
}
// Flatten into a sorted event list
const events = [];
const now = Date.now();
for (const [ticker, cal] of Object.entries(results)) {
// Upcoming earnings dates
for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal);
events.push({
ticker,
type: 'earnings',
date: d.toISOString().slice(0, 10),
label: 'Earnings',
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
epsEstimate: cal.earnings.earningsAverage ?? null,
revEstimate: cal.earnings.revenueAverage ?? null,
isPast: d.getTime() < now,
});
}
// Ex-dividend date
if (cal.exDividendDate) {
const d = new Date(cal.exDividendDate);
events.push({
ticker,
type: 'exdividend',
date: d.toISOString().slice(0, 10),
label: 'Ex-Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
// Dividend payment date
if (cal.dividendDate) {
const d = new Date(cal.dividendDate);
events.push({
ticker,
type: 'dividend',
date: d.toISOString().slice(0, 10),
label: 'Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
}
// Sort: upcoming first, then past
events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast
? new Date(b.date) - new Date(a.date) // most recent past first
: new Date(a.date) - new Date(b.date); // soonest upcoming first
});
return { events, tickers };
});
}
+111
View File
@@ -0,0 +1,111 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js';
import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js';
import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
const PORTFOLIO_PATH = './portfolio.json';
export default async function financeRoutes(app) {
// GET /api/finance/portfolio
// Returns: { advice, personalFinance, marketContext }
app.get('/api/finance/portfolio', async (req, reply) => {
if (!existsSync(PORTFOLIO_PATH)) {
return reply.code(404).send({ error: 'portfolio.json not found' });
}
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
// SimpleFIN is optional — omit if not configured
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
}
// Normalize dot-notation tickers to Yahoo Finance format (BRK.B → BRK-B)
const normalizeYahoo = (t) => t.toUpperCase().replace(/\./g, '-');
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => normalizeYahoo(h.ticker));
const engine = new ScreenerEngine({ logger: noopLogger });
const results =
screenable.length > 0
? await engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} };
const advice = await new PortfolioAdvisor().advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
});
// POST /api/finance/holdings
// Add or update a single holding in portfolio.json.
// Body: { ticker, shares, costBasis, type, source }
app.post('/api/finance/holdings', {
schema: {
body: {
type: 'object',
required: ['ticker', 'shares'],
properties: {
ticker: { type: 'string', minLength: 1, maxLength: 10 },
shares: { type: 'number', exclusiveMinimum: 0 },
costBasis: { type: 'number', minimum: 0 },
type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] },
source: { type: 'string' },
},
},
},
handler: async (req, reply) => {
const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body;
const normalized = ticker.toUpperCase().trim();
const portfolio = existsSync(PORTFOLIO_PATH)
? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'))
: { holdings: [] };
const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
const entry = { ticker: normalized, shares, costBasis, type, source };
if (idx >= 0) {
portfolio.holdings[idx] = entry; // update existing
} else {
portfolio.holdings.push(entry); // add new
}
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return reply.code(201).send(entry);
},
});
// DELETE /api/finance/holdings/:ticker
// Remove a holding from portfolio.json.
app.delete('/api/finance/holdings/:ticker', async (req, reply) => {
const ticker = req.params.ticker.toUpperCase();
if (!existsSync(PORTFOLIO_PATH))
return reply.code(404).send({ error: 'portfolio.json not found' });
const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
const before = portfolio.holdings.length;
portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker);
if (portfolio.holdings.length === before)
return reply.code(404).send({ error: 'Holding not found' });
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return { ok: true };
});
// GET /api/finance/market-context
// Returns live benchmark data without running a full screen
app.get('/api/finance/market-context', async () => {
const engine = new ScreenerEngine({ logger: noopLogger });
return engine.benchmarkProvider.getMarketContext();
});
}
+59
View File
@@ -0,0 +1,59 @@
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
const noopLogger = { write: () => {}, log: () => {}, warn: () => {} };
// Class instances don't survive JSON.stringify — call getDisplayMetrics() on the
// server so the browser receives plain serializable objects.
const serializeAssets = (arr) =>
arr.map((r) => ({
...r,
asset: {
ticker: r.asset.ticker,
type: r.asset.type,
currentPrice: r.asset.currentPrice,
metrics: r.asset.metrics,
displayMetrics: r.asset.getDisplayMetrics(),
},
}));
export default async function screenerRoutes(app) {
// Shared engine — BenchmarkProvider caches for 1 hour across requests.
const engine = new ScreenerEngine({ logger: noopLogger });
// POST /api/screen
// Body: { tickers: string[] }
// Returns: { STOCK, ETF, BOND, ERROR, marketContext }
app.post('/api/screen', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
handler: async (req) => {
const tickers = req.body.tickers.map((t) => t.toUpperCase());
const results = await engine.screenTickers(tickers);
return {
...results,
STOCK: serializeAssets(results.STOCK),
ETF: serializeAssets(results.ETF),
BOND: serializeAssets(results.BOND),
};
},
});
// GET /api/screen/catalysts
// Returns: { tickers, stories, analysis? }
// analysis is present only when ANTHROPIC_API_KEY is set.
app.get('/api/screen/catalysts', async () => {
const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js');
const catalyst = new CatalystAnalyst({ logger: noopLogger });
const { tickers, stories } = await catalyst.run();
return { tickers, stories };
});
}
-7
View File
@@ -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;
};
-58
View File
@@ -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,
});
-37
View File
@@ -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;
},
};
+61
View File
@@ -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);
});
+149
View File
@@ -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);
});
+54
View File
@@ -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');
});
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Test the markdown fence stripping logic in isolation —
// we don't instantiate LLMAnalyst (requires Anthropic SDK + API key).
// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim()
function stripFences(raw) {
return raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
}
const VALID_JSON =
'{"summary":"test","sentiment":"BULLISH","affectedIndustries":[],"relatedTickers":[]}';
test('stripFences: passes clean JSON through unchanged', () => {
assert.equal(stripFences(VALID_JSON), VALID_JSON);
});
test('stripFences: strips ```json ... ``` fences', () => {
const wrapped = '```json\n' + VALID_JSON + '\n```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
test('stripFences: strips ``` ... ``` fences (no language tag)', () => {
const wrapped = '```\n' + VALID_JSON + '\n```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
test('stripFences: result is valid parseable JSON', () => {
const wrapped = '```json\n' + VALID_JSON + '\n```';
const parsed = JSON.parse(stripFences(wrapped));
assert.equal(parsed.sentiment, 'BULLISH');
assert.equal(parsed.summary, 'test');
});
test('stripFences: handles no trailing newline before closing fence', () => {
const wrapped = '```json\n' + VALID_JSON + '```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
test('stripFences: case-insensitive fence tag', () => {
const wrapped = '```JSON\n' + VALID_JSON + '\n```';
assert.equal(stripFences(wrapped), VALID_JSON);
});
+69
View File
@@ -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
});
+92
View File
@@ -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);
});
+66
View File
@@ -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/);
});
+41
View File
@@ -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);
});
+81
View File
@@ -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
View File
@@ -0,0 +1,170 @@
# CLAUDE.md
Guidance for working in this repository.
## Overview
`market-screener-ui` is a SvelteKit 5 single-page application (CSR, no SSR) that serves as the interactive dashboard for the `market_screener` Fastify API.
- All data comes from the API at `http://localhost:3000` (proxied through Vite in dev)
- No SSR — `+layout.js` exports `ssr = false`
- Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`)
---
## Commands
```bash
npm install # install dependencies (SvelteKit, Vite, Svelte 5)
npm run dev # dev server on port 5173
npm run build # production build → .svelte-kit/output
npm run preview # preview production build
```
To run the full stack, use `npm run dev` from the **API repo** (`market_screener/`) instead — it starts both servers together using `concurrently`.
---
## Architecture
### No SSR
`src/routes/+layout.js` exports `ssr = false`. All data fetching happens in the browser. This avoids Svelte 5 SSR compatibility issues and makes sense for a live-data dashboard.
### API Proxy
`vite.config.js` proxies `/api/*``http://localhost:3000` in dev. In production, configure your reverse proxy (nginx/Caddy) to do the same.
### Data Loading
- **Screener page** (`/`): data loaded client-side on button click via `$lib/api.js`
- **Portfolio page** (`/portfolio`): data loaded via SvelteKit `+page.js` `load()` function — this fires on navigation and is the correct SvelteKit pattern for CSR page data
**Do not use `onMount` for initial data fetching** — use `load()` in `+page.js` instead. `onMount` does not reliably fire in SvelteKit CSR for page-level data.
---
## Project Structure
```
src/
app.html ← HTML shell
app.css ← Global reset + body styles (no :global() in .svelte files)
routes/
+layout.js ← exports ssr = false
+layout.svelte ← nav bar (Screener / Portfolio links)
+page.svelte ← Screener page
portfolio/
+page.js ← load() function — fetches /api/finance/portfolio
+page.svelte ← Portfolio + SimpleFIN page
lib/
api.js ← All fetch calls to the Fastify API
SignalBadge.svelte ← Signal pill component (Strong Buy / Avoid / etc.)
MarketContext.svelte ← Benchmark strip component
.claude/
launch.json ← Preview server config for Claude Code
vite.config.js ← Vite config with /api proxy
svelte.config.js ← SvelteKit config (adapter-auto)
```
---
## Key Files
### `src/lib/api.js`
All API calls in one place. If the API base URL changes, change it here only.
```js
screenTickers(tickers) // POST /api/screen
fetchCatalysts() // GET /api/screen/catalysts
fetchPortfolio() // GET /api/finance/portfolio
fetchMarketContext() // GET /api/finance/market-context
```
### `src/routes/+page.svelte` (Screener)
- Ticker input pre-filled with a default watchlist
- `screen()` calls API and stores results in `$state`
- `loadCatalysts()` fetches news tickers then **immediately calls `screen()`** — one click, full results
- `results` is `null` until first screen — nothing renders below the toolbar
- `verdictShort()` abbreviates long verdict strings (`"🟢 BUY (High Conviction)"``"Strong"`)
### `src/routes/portfolio/+page.svelte`
- Receives `data` from `+page.js` load function via `let { data } = $props()`
- Shows `data.error` if load failed, `data.advice` for holdings, `data.personalFinance` for SimpleFIN section
---
## Svelte 5 Patterns Used
```svelte
<!-- Reactive state -->
let loading = $state(false);
<!-- Derived values -->
const totalGL = $derived(totalValue - totalCost);
<!-- Derived with block -->
const cards = $derived.by(() => { ... return [...] });
<!-- Props -->
let { ctx } = $props();
let { data } = $props();
<!-- Event handlers (no on:click, use onclick) -->
<button onclick={screen}>Screen</button>
<!-- Conditionals in template -->
{@const mode = getTab(type)}
```
---
## API Response Shape
The Fastify API serializes asset class instances before sending — `asset.getDisplayMetrics()` is called server-side and included as `asset.displayMetrics`. In the browser, use `r.asset.displayMetrics` directly (not `r.asset.getDisplayMetrics()` which doesn't exist on plain JSON objects).
```js
// Screener response shape
{
STOCK: [{ asset: { ticker, type, currentPrice, metrics, displayMetrics }, fundamental, inflated, signal }],
ETF: [...],
BOND: [...],
ERROR: [...],
marketContext: { sp500Price, riskFreeRate, vixLevel, rateRegime, volatilityRegime, benchmarks }
}
```
---
## Styling Conventions
- Dark theme throughout: page background `#0f1117`, card sections `#0d1117`/`#111827`
- All colors are CSS custom values inline (no CSS variables yet — keep consistent with existing palette)
- Tables: `width: max-content; min-width: 100%` inside a `.table-wrap { overflow-x: auto }` container
- First column sticky: `position: sticky; left: 0; background: inherit`
- Verdict pills: `.verdict-pill.green/yellow/red` — colored background tint + text
- Monospace font for the ticker input field
- `white-space: nowrap` on `tbody td` — tables scroll horizontally, not wrap
**Color palette:**
```
page bg: #0f1117
card bg: #0d1117 / #111827 (header rows)
border: #1e293b
muted: #64748b / #475569
text: #e2e8f0 / #f1f5f9
green: #4ade80 (bg tint: #14532d33)
yellow: #facc15 (bg tint: #71350033)
red: #f87171 (bg tint: #450a0a33)
blue accent: #2563eb / #3b82f6
```
---
## Conventions
- Do not use `:global()` in `<style>` blocks — put global styles in `src/app.css`
- Use `load()` in `+page.js` for page-level data, not `onMount`
- `$derived` for computed values — do not recalculate in templates
- Keep `api.js` as the single place for fetch calls
- If adding a new page: create `+page.js` with a `load()` that fetches the needed API endpoint, receive via `$props()` in the component
+114
View File
@@ -0,0 +1,114 @@
# Market Screener UI
SvelteKit 5 dashboard for the [Market Screener](../market_screener) API. Provides an interactive interface for screening stocks, ETFs, and bonds — and tracking your portfolio with live hold/sell/add advice.
---
## Quick Start
This UI requires the Market Screener API running on port 3000.
```bash
# Recommended: start both from the API repo
cd ../market_screener
npm run dev # starts API (:3000) + this UI (:5173) together
# Or start the UI independently
npm install
npm run dev # http://localhost:5173
```
---
## Pages
### Screener (`/`)
- Enter any tickers (comma or space separated) and click **Screen**
- Click **📰 Catalysts** to load today's news-driven tickers and screen them automatically (one click)
- Market context strip shows live benchmarks: 10Y yield, VIX, S&P 500, P/E ratios, rate regime
- Signal Summary table ranks all assets by signal strength
- Drill-down tables for Stocks, ETFs, and Bonds with **Mkt-Adjusted** / **Graham** tab toggle
- Ticker column stays pinned while scrolling wide tables
### Portfolio (`/portfolio`)
- Reads `portfolio.json` from the API server
- Shows total value, cost basis, and unrealised G/L
- Per-holding advice: ✅ Hold & Add, 🟡 Reduce, 🔴 Sell
- If SimpleFIN is configured: net worth, account balances, 30-day spending breakdown
---
## Signals Explained
| Signal | Meaning |
|---|---|
| ✅ Strong Buy | Passes both Market-Adjusted AND Fundamental gates |
| ⚡ Momentum | Passes market-adjusted, holds fundamentally |
| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection |
| 🔄 Neutral | Hold territory in one or both lenses |
| ❌ Avoid | Fails both gates |
The **Mkt-Adjusted** tab uses gates derived from live market data (e.g. S&P P/E × 1.5 for the P/E gate). The **Graham** tab uses strict historical value-investing gates (P/E < 15×, PEG < 1.0).
---
## Tech Stack
| Layer | Choice |
|---|---|
| Framework | SvelteKit 2 + Svelte 5 |
| Build tool | Vite 6 |
| Adapter | `@sveltejs/adapter-auto` |
| Rendering | Client-side only (SSR disabled) |
| API | Proxied via Vite dev server → Fastify on :3000 |
---
## Project Structure
```
src/
app.html HTML shell
app.css Global reset + dark theme base
routes/
+layout.js ssr = false
+layout.svelte Nav bar
+page.svelte Screener page
portfolio/
+page.js load() → fetches /api/finance/portfolio
+page.svelte Portfolio + SimpleFIN page
lib/
api.js All API fetch functions
SignalBadge.svelte Signal pill component
MarketContext.svelte Benchmark strip component
vite.config.js /api proxy → localhost:3000
svelte.config.js SvelteKit config
```
---
## Configuration
### API URL
In `vite.config.js`, the Vite dev server proxies `/api/*` to `http://localhost:3000`. To point at a different API host, update the `proxy` target there.
For production, configure your reverse proxy (nginx, Caddy, etc.) to route `/api/*` to the Fastify server.
### CORS
The Fastify API allows `http://localhost:5173` by default. If you deploy the UI to a different origin, set `CLIENT_ORIGIN` in the API's `.env`:
```
CLIENT_ORIGIN=https://your-deployed-ui.example.com
```
---
## Development Notes
- Uses Svelte 5 runes: `$state`, `$derived`, `$derived.by`, `$props`
- `onclick={handler}` not `on:click` — Svelte 5 syntax
- Page data loaded via `+page.js` `load()` function, not `onMount`
- Global CSS lives in `src/app.css` — no `:global()` in component `<style>` blocks
- `asset.displayMetrics` (plain object from API) — never call `getDisplayMetrics()` in the browser
+1456
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "market-screener-ui",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1117;
color: #e2e8f0;
font-size: 13px;
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+191
View File
@@ -0,0 +1,191 @@
<script>
let { ctx, collapsible = false } = $props();
let expanded = $state(!collapsible); // collapsed by default when collapsible=true
const cards = $derived.by(() => {
const b = ctx?.benchmarks ?? {};
return [
{
label: '10Y Yield',
value: ctx?.riskFreeRate != null ? ctx.riskFreeRate.toFixed(2) + '%' : '—',
tip: 'US 10-year Treasury yield — the risk-free rate benchmark. Higher = tighter conditions for stocks and bonds.',
},
{
label: 'VIX',
value: ctx?.vixLevel?.toFixed(1) ?? '—',
tip: 'CBOE Volatility Index — measures expected market volatility. Above 20 = elevated fear; above 30 = high stress.',
},
{
label: 'S&P 500',
value: ctx?.sp500Price?.toLocaleString() ?? '—',
tip: 'Live S&P 500 index price — broad US large-cap benchmark.',
},
{
label: 'S&P P/E',
value: b.marketPE != null ? b.marketPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E ratio of SPY. Used to set the INFLATED mode P/E gate (S&P P/E × 1.5 in normal rates).',
},
{
label: 'Tech P/E',
value: b.techPE != null ? b.techPE.toFixed(1) + 'x' : '—',
tip: 'Trailing P/E of XLK (tech sector ETF). Sets the tech-sector gate in INFLATED mode (XLK P/E × 1.3).',
},
{
label: 'REIT Yield',
value: b.reitYield != null ? b.reitYield.toFixed(2) + '%' : '—',
tip: 'Dividend yield of XLRE (real estate ETF). Used as the REIT minimum yield gate in INFLATED mode.',
},
{
label: 'IG Spread',
value: b.igSpread != null ? b.igSpread.toFixed(2) + '%' : '—',
tip: 'Investment-grade bond spread (LQD yield 10Y yield). Sets the bond minimum spread gate in INFLATED mode.',
},
{
label: 'Rate Regime',
value: ctx?.rateRegime ?? '—',
tip: 'HIGH (>4.5%) compresses P/E gates and tightens bond/REIT requirements. NORMAL uses looser INFLATED gates.',
},
{
label: 'Volatility',
value: ctx?.volatilityRegime ?? '—',
tip: 'Derived from VIX level — LOW (<15), NORMAL (1525), HIGH (>25). Informational; not currently gating scores.',
},
];
});
</script>
<div class="ctx-wrap">
{#if collapsible}
<button class="ctx-toggle" onclick={() => expanded = !expanded}>
<span class="ctx-toggle-label">Market Context</span>
<span class="ctx-toggle-chevron">{expanded ? '▲' : '▼'}</span>
</button>
{/if}
{#if expanded}
<div class="grid">
{#each cards as c}
<div class="card">
<div class="label-row">
<span class="label">{c.label}</span>
<span class="tip-wrap">
<span class="tip-anchor">?</span>
<span class="tip-box">{c.tip}</span>
</span>
</div>
<div class="value">{c.value}</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.ctx-wrap { margin-bottom: 20px; }
/* ── Collapsible toggle ─────────────────────────────────────────── */
.ctx-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
margin-bottom: 10px;
}
.ctx-toggle-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #475569;
}
.ctx-toggle-chevron {
font-size: 9px;
color: #334155;
}
/* ── Cards grid ────────────────────────────────────────────────── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 8px;
}
.card { background: #1e293b; border-radius: 8px; padding: 12px 14px; }
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.label {
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Tooltip ──────────────────────────────────────────────────── */
.tip-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.tip-anchor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
background: #1e293b;
border: 1px solid #334155;
color: #475569;
font-size: 9px;
font-weight: 700;
cursor: help;
}
.tip-box {
display: none;
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 220px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
padding: 8px 10px;
font-size: 11px;
color: #94a3b8;
line-height: 1.5;
z-index: 50;
pointer-events: none;
white-space: normal;
}
.tip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #334155;
}
.tip-wrap:hover .tip-box { display: block; }
.value { font-size: 17px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
</style>
+29
View File
@@ -0,0 +1,29 @@
<script>
let { signal } = $props();
const cls = () => {
if (signal?.includes('Strong')) return 'strong';
if (signal?.includes('Momentum')) return 'momentum';
if (signal?.includes('Speculation')) return 'spec';
if (signal?.includes('Neutral')) return 'neutral';
return 'avoid';
};
</script>
<span class="badge {cls()}">{signal ?? '—'}</span>
<style>
.badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
white-space: nowrap;
}
.strong { background: #14532d33; color: #4ade80; }
.momentum { background: #1e3a5f33; color: #60a5fa; }
.spec { background: #7c2d1233; color: #fb923c; }
.neutral { background: #1e293b; color: #94a3b8; }
.avoid { background: #450a0a33; color: #f87171; }
</style>
+139
View File
@@ -0,0 +1,139 @@
<script>
// size: 'sm' | 'md' | 'lg'
// label: optional text shown below (lg only)
let { size = 'md', label = null } = $props();
</script>
{#if size === 'sm'}
<!-- Compact dot-pulse for buttons -->
<span class="dot-pulse">
<span></span><span></span><span></span>
</span>
{:else}
<!-- Market chart line animation for md / lg -->
<div class="chart-wrap" data-size={size}>
<svg
viewBox="0 0 160 60"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="chart-svg"
aria-hidden="true"
>
<!-- Faint grid lines -->
<line x1="0" y1="15" x2="160" y2="15" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="30" x2="160" y2="30" stroke="#1e293b" stroke-width="1" />
<line x1="0" y1="45" x2="160" y2="45" stroke="#1e293b" stroke-width="1" />
<!-- The market line — rises, dips, spikes, recovers -->
<polyline
class="chart-line"
points="
0,45
12,38
22,42
32,28
42,32
52,18
62,24
72,14
82,20
92,10
104,22
114,16
124,28
134,20
148,8
160,12
"
/>
<!-- Glowing dot at the leading edge -->
<circle class="chart-dot" cx="160" cy="12" r="3" />
</svg>
{#if label}
<span class="chart-label">{label}</span>
{/if}
</div>
{/if}
<style>
/* ── Dot pulse (sm) ─────────────────────────────────────────────── */
.dot-pulse {
display: inline-flex;
align-items: center;
gap: 3px;
}
.dot-pulse span {
display: block;
width: 4px;
height: 4px;
border-radius: 50%;
background: #60a5fa;
animation: dot-bounce 0.9s ease-in-out infinite;
}
.dot-pulse span:nth-child(2) { animation-delay: 0.15s; }
.dot-pulse span:nth-child(3) { animation-delay: 0.30s; }
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ── Chart wrap (md / lg) ───────────────────────────────────────── */
.chart-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.chart-wrap[data-size="md"] .chart-svg { width: 120px; height: 45px; }
.chart-wrap[data-size="lg"] .chart-svg { width: 200px; height: 75px; }
.chart-svg { overflow: visible; }
/* The animated line */
.chart-line {
stroke: #3b82f6;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
/* total path length ≈ 220 — animate draw-in then loop */
stroke-dasharray: 220;
stroke-dashoffset: 220;
animation: draw-line 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes draw-line {
0% { stroke-dashoffset: 220; opacity: 1; }
70% { stroke-dashoffset: 0; opacity: 1; }
85% { stroke-dashoffset: 0; opacity: 0; }
100% { stroke-dashoffset: 220; opacity: 0; }
}
/* Glowing dot that appears when the line finishes drawing */
.chart-dot {
fill: #3b82f6;
filter: drop-shadow(0 0 4px #3b82f6);
opacity: 0;
animation: dot-appear 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@keyframes dot-appear {
0% { opacity: 0; }
60% { opacity: 0; }
70% { opacity: 1; }
85% { opacity: 1; }
100% { opacity: 0; }
}
.chart-label {
font-size: 12px;
color: #475569;
letter-spacing: 0.02em;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
const BASE = '/api';
export async function screenTickers(tickers) {
const res = await fetch(`${BASE}/screen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCatalysts() {
const res = await fetch(`${BASE}/screen/catalysts`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function analyzeTickers(tickers) {
const res = await fetch(`${BASE}/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchPortfolio() {
const res = await fetch(`${BASE}/finance/portfolio`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function addHolding(holding) {
const res = await fetch(`${BASE}/finance/holdings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(holding),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function removeHolding(ticker) {
const res = await fetch(`${BASE}/finance/holdings/${ticker}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchMarketContext() {
const res = await fetch(`${BASE}/finance/market-context`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// ── Market Calls ──────────────────────────────────────────────────────────────
export async function fetchCalls() {
const res = await fetch(`${BASE}/calls`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCall(id) {
const res = await fetch(`${BASE}/calls/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createCall(payload) {
const res = await fetch(`${BASE}/calls`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteCall(id) {
const res = await fetch(`${BASE}/calls/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCallsCalendar(tickers = null) {
const url = tickers?.length
? `${BASE}/calls/calendar?tickers=${tickers.join(',')}`
: `${BASE}/calls/calendar`;
const res = await fetch(url);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
+1
View File
@@ -0,0 +1 @@
export const ssr = false;
+132
View File
@@ -0,0 +1,132 @@
<script>
import { page, navigating } from '$app/stores';
import '../app.css';
let { children } = $props();
// Label shown under the nav progress bar while loading a page
const navLabel = $derived(
$navigating?.to?.url?.pathname === '/portfolio' ? 'Loading portfolio…' :
$navigating?.to?.url?.pathname?.startsWith('/calls') ? 'Loading market calls…' :
$navigating?.to?.url?.pathname === '/safe-buys' ? 'Screening safe buys…' :
'Loading…'
);
</script>
<div class="shell">
<nav>
<span class="brand">📊 Market Screener</span>
<div class="links">
<a href="/" class:active={$page.url.pathname === '/'}>Screener</a>
<a href="/portfolio" class:active={$page.url.pathname === '/portfolio'}>Portfolio</a>
<a href="/calls" class:active={$page.url.pathname.startsWith('/calls')}>Market Calls</a>
<a href="/safe-buys" class:active={$page.url.pathname === '/safe-buys'}>🛡 Safe Buys</a>
</div>
</nav>
<!-- Thin progress bar at top of screen — always visible even on first nav -->
{#if $navigating}
<div class="nav-progress">
<div class="nav-bar"></div>
</div>
{/if}
<main>
{#if $navigating}
<!-- Replace old page content immediately — old page disappears, spinner takes over -->
<div class="nav-overlay">
<div class="nav-spinner"></div>
<span class="nav-label">{navLabel}</span>
</div>
{:else}
{@render children()}
{/if}
</main>
</div>
<style>
.shell { min-height: 100vh; display: flex; flex-direction: column; }
nav {
display: flex;
align-items: center;
gap: 24px;
padding: 14px 32px;
border-bottom: 1px solid #1e293b;
background: #0f1117;
position: sticky;
top: 0;
z-index: 10;
}
.brand { font-size: 15px; font-weight: 700; color: #f1f5f9; }
.links { display: flex; gap: 4px; margin-left: auto; }
.links a {
color: #64748b;
text-decoration: none;
padding: 6px 14px;
border-radius: 6px;
font-weight: 500;
transition: color 0.15s, background 0.15s;
}
.links a:hover { color: #e2e8f0; background: #1e293b; }
.links a.active { color: #e2e8f0; background: #1e293b; }
main { flex: 1; padding: 28px 32px; }
/* ── Navigation progress ─────────────────────────────────────────── */
.nav-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 2px;
z-index: 100;
background: #1e293b;
overflow: hidden;
}
.nav-bar {
height: 100%;
background: #3b82f6;
animation: progress 1.5s ease-in-out infinite;
transform-origin: left;
}
@keyframes progress {
0% { transform: translateX(-100%) scaleX(0.3); }
50% { transform: translateX(0%) scaleX(0.7); }
100% { transform: translateX(100%) scaleX(0.3); }
}
/* Centered spinner + label in the page body */
.nav-overlay {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 100px 0;
flex: 1;
}
.nav-spinner {
width: 40px;
height: 40px;
border: 3px solid #1e293b;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.nav-label {
font-size: 12px;
color: #475569;
letter-spacing: 0.02em;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+858
View File
@@ -0,0 +1,858 @@
<script>
import { screenTickers, fetchCatalysts, analyzeTickers } from '$lib/api.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
let input = $state('');
let searchOpen = $state(false); // collapsed by default
let loading = $state(false);
let loadingCats = $state(false);
let error = $state(null);
let results = $state(null);
let activeTab = $state({});
let screenedAt = $state(null);
// Auto-load catalysts once on mount
let _booted = false;
$effect(() => {
if (!_booted) { _booted = true; loadCatalysts(); }
});
// ── Per-tab LLM Analysis sidebar ────────────────────────────────────────────
let sidebar = $state({ open: false, loading: false, analysis: null, type: null, error: null });
async function runTabAnalysis(type) {
const tickers = (results?.[type] ?? []).map(r => r.asset.ticker);
if (!tickers.length) return;
sidebar = { open: true, loading: true, analysis: null, type, error: null };
try {
const res = await analyzeTickers(tickers);
const reason = res.reason === 'no_stories' ? 'No recent news found for these tickers.' : null;
sidebar = { open: true, loading: false, analysis: res.analysis, type, error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.') };
} catch (e) {
sidebar = { open: true, loading: false, analysis: null, type, error: e.message };
}
}
function closeSidebar() {
sidebar = { ...sidebar, open: false };
}
async function screen() {
error = null;
loading = true;
try {
const tickers = input.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean);
results = await screenTickers(tickers);
screenedAt = new Date().toLocaleTimeString();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
// Load catalysts then immediately screen — no extra click needed.
// LLM analysis (if available) is shown alongside the results.
async function loadCatalysts() {
loadingCats = true;
error = null;
try {
const cat = await fetchCatalysts();
const catInput = cat.tickers.join(', ');
loading = true;
results = await screenTickers(cat.tickers);
screenedAt = new Date().toLocaleTimeString();
if (!input) input = catInput;
} catch (e) {
error = e.message;
} finally {
loading = false;
loadingCats = false;
}
}
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...(arr ?? [])].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const verdictShort = label => {
if (!label) return '—';
if (label.includes('High Conviction')) return 'Strong';
if (label.includes('Speculative')) return 'Speculative';
if (label.includes('BUY')) return 'Buy';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const getTab = type => activeTab[type] ?? 'inflated';
const setTab = (type, tab) => activeTab = { ...activeTab, [type]: tab };
const ctx = $derived(results?.marketContext ?? null);
const allAssets = $derived(results
? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
: []);
const fmtPE = v => v != null ? v + 'x' : '—';
</script>
<div class="page">
<!-- ── Toolbar ──────────────────────────────────────────────────── -->
<div class="toolbar">
<div class="toolbar-top">
<button onclick={loadCatalysts} disabled={loading || loadingCats} class="btn-catalyst">
{#if loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
</button>
<button
onclick={() => searchOpen = !searchOpen}
class="btn-search-toggle"
title="Screen custom tickers"
>
🔍 {searchOpen ? 'Hide search' : 'Search tickers'}
</button>
{#if screenedAt}
<span class="screened-at">Last screened {screenedAt}</span>
{/if}
</div>
{#if searchOpen}
<div class="search-row">
<input
bind:value={input}
placeholder="AAPL, MSFT, VOO …"
onkeydown={e => e.key === 'Enter' && screen()}
/>
<button onclick={screen} disabled={loading || loadingCats} class="btn-screen">
{#if loading}<Spinner size="sm" />{:else}Screen{/if}
</button>
</div>
{/if}
</div>
{#if error}
<div class="error-banner">{error}</div>
{/if}
{#if loading || loadingCats}
<div class="loading-area">
<Spinner size="lg" label={loadingCats ? 'Fetching news catalysts…' : loading ? `Screening tickers…` : ''} />
</div>
{/if}
{#if ctx}
<!-- ── Market Context Strip ────────────────────────────────────── -->
<div class="ctx-strip">
<div class="ctx-chip">
<span class="ctx-label">10Y</span>
<span class="ctx-val">{ctx.riskFreeRate?.toFixed(2)}%</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">VIX</span>
<span class="ctx-val">{ctx.vixLevel?.toFixed(1)}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">S&P</span>
<span class="ctx-val">{ctx.sp500Price?.toLocaleString()}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">S&P P/E</span>
<span class="ctx-val">{fmtPE(ctx.benchmarks?.marketPE?.toFixed(1))}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">Tech P/E</span>
<span class="ctx-val">{fmtPE(ctx.benchmarks?.techPE?.toFixed(1))}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">REIT Yld</span>
<span class="ctx-val">{ctx.benchmarks?.reitYield?.toFixed(2)}%</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">IG Sprd</span>
<span class="ctx-val">{ctx.benchmarks?.igSpread?.toFixed(2)}%</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">Rates</span>
<span class="ctx-val ctx-regime" data-regime={ctx.rateRegime}>{ctx.rateRegime}</span>
</div>
<div class="ctx-chip">
<span class="ctx-label">Vol</span>
<span class="ctx-val ctx-regime" data-regime={ctx.volatilityRegime}>{ctx.volatilityRegime}</span>
</div>
</div>
<!-- ── Signal Summary ─────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2>Signal Summary</h2>
<span class="count">{allAssets.length} assets</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Type</th>
<th>Signal</th>
<th>Mkt-Adjusted</th>
<th>Fundamental</th>
</tr>
</thead>
<tbody>
{#each allAssets as r}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td><span class="tag">{r.asset.type}</span></td>
<td><SignalBadge signal={r.signal} /></td>
<td>
<span class="verdict-pill {vClass(r.inflated.label)}">
{verdictShort(r.inflated.label)}
</span>
</td>
<td>
<span class="verdict-pill {vClass(r.fundamental.label)}">
{verdictShort(r.fundamental.label)}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<!-- ── Detail Sections ────────────────────────────────────────── -->
{#each ['STOCK', 'ETF', 'BOND'] as type}
{#if results[type]?.length}
{@const count = results[type].length}
<section class="section">
<div class="section-header">
<h2>{type}S</h2>
<span class="count">{count}</span>
<div class="mode-tabs">
<button
class:active={getTab(type) === 'inflated'}
onclick={() => setTab(type, 'inflated')}
>Mkt-Adjusted</button>
<button
class:active={getTab(type) === 'fundamental'}
onclick={() => setTab(type, 'fundamental')}
>Graham</button>
</div>
<button
class="btn-analyze"
onclick={() => runTabAnalysis(type)}
disabled={sidebar.loading && sidebar.type === type}
title="AI analysis of news for these tickers"
>
{#if sidebar.loading && sidebar.type === type}
<Spinner size="sm" />
{:else}
✦ Analyze
{/if}
</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Verdict</th>
<th>Score</th>
{#if type === 'STOCK'}
<th>Sector</th>
<th>P/E</th><th>PEG</th><th>ROE%</th>
<th>OpMgn%</th><th>FCF%</th><th>D/E</th>
<th>Flags</th>
{:else if type === 'ETF'}
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
{:else}
<th>YTM</th><th>Duration</th><th>Rating</th>
{/if}
</tr>
</thead>
<tbody>
{#each sorted(results[type]) as r}
{@const mode = getTab(type)}
{@const m = r.asset.displayMetrics ?? {}}
{@const v = r[mode]}
<tr class="data-row" data-signal={sigOrd(r.signal)}>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td>
<span class="verdict-pill {vClass(v.label)}">
{verdictShort(v.label)}
</span>
</td>
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
{#if type === 'STOCK'}
<td><span class="tag sm">{m.Sector ?? '—'}</span></td>
<td class="num">{m['P/E'] ?? '—'}</td>
<td class="num">{m['PEG'] ?? '—'}</td>
<td class="num">{m['ROE%'] ?? '—'}</td>
<td class="num">{m['OpMgn%'] ?? '—'}</td>
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
<td class="num">{m['D/E'] ?? '—'}</td>
<td class="flags">
{#each v.audit?.riskFlags ?? [] as flag}
<span class="flag">{flag}</span>
{/each}
</td>
{:else if type === 'ETF'}
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
{:else}
<td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{/each}
{#if results.ERROR?.length}
<section class="section">
<h2>Failed <span class="count">{results.ERROR.length}</span></h2>
<div class="error-list">
{#each results.ERROR as e}
<div class="error-item"><span class="ticker">{e.ticker}</span> {e.message}</div>
{/each}
</div>
</section>
{/if}
{/if}
</div>
<!-- ── LLM Analysis Sidebar ─────────────────────────────────────────────── -->
{#if sidebar.open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="sidebar-backdrop" onclick={closeSidebar}></div>
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-title">
<span>🤖 LLM Analysis</span>
{#if sidebar.type}<span class="sidebar-type">{sidebar.type}S</span>{/if}
</div>
<button class="sidebar-close" onclick={closeSidebar}>✕</button>
</div>
<div class="sidebar-body">
{#if sidebar.loading}
<div class="sidebar-loading">
<Spinner size="lg" label="Analyzing tickers…" />
</div>
{:else if sidebar.error}
<div class="sidebar-error">{sidebar.error}</div>
{:else if sidebar.analysis}
{@const a = sidebar.analysis}
<div class="sb-sentiment-row">
<span class="sentiment-pill" data-sentiment={a.sentiment}>{a.sentiment}</span>
</div>
<p class="sb-summary">{a.summary}</p>
<h3 class="sb-sub">Affected Industries</h3>
<div class="sb-list">
{#each a.affectedIndustries ?? [] as ind}
<div class="sb-item">
<span class="sb-name">{ind.name}</span>
<span class="sb-reason">{ind.reason}</span>
</div>
{/each}
</div>
<h3 class="sb-sub">Related Tickers to Watch</h3>
<div class="sb-list">
{#each a.relatedTickers ?? [] as rt}
<div class="sb-item">
<span class="sb-name ticker">{rt.ticker}</span>
<span class="sb-reason">{rt.reason}</span>
</div>
{/each}
</div>
{/if}
</div>
</aside>
{/if}
<style>
/* ── Page ──────────────────────────────────────────────────────── */
.page { max-width: 1400px; padding-bottom: 60px; }
/* ── Toolbar ────────────────────────────────────────────────────── */
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
.toolbar-top {
display: flex;
align-items: center;
gap: 8px;
}
.search-row {
display: flex;
gap: 8px;
align-items: center;
}
input {
flex: 1;
min-width: 0;
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 8px;
color: #e2e8f0;
padding: 10px 14px;
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace;
letter-spacing: 0.02em;
outline: none;
transition: border-color 0.15s;
}
input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f620; }
button {
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
white-space: nowrap;
transition: background 0.15s, opacity 0.15s;
}
button:disabled { opacity: 0.45; cursor: default; }
/* Primary catalyst button */
.btn-catalyst {
background: #2563eb;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
}
.btn-catalyst:hover:not(:disabled) { background: #1d4ed8; }
/* Secondary search toggle */
.btn-search-toggle {
background: #1e293b;
color: #64748b;
border: 1px solid #2d3f55;
font-size: 12px;
padding: 8px 14px;
}
.btn-search-toggle:hover { background: #263347; color: #94a3b8; }
/* Screen button inside the expanded search row */
.btn-screen {
background: #1e3a5f;
color: #60a5fa;
border: 1px solid #1e3a5f;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 80px;
}
.btn-screen:hover:not(:disabled) { background: #163356; }
.screened-at {
margin-left: auto;
font-size: 11px;
color: #475569;
}
.loading-area {
display: flex;
justify-content: center;
align-items: center;
padding: 80px 0;
}
.error-banner {
background: #450a0a55;
border: 1px solid #7f1d1d;
border-radius: 8px;
color: #f87171;
padding: 10px 14px;
margin-bottom: 16px;
font-size: 13px;
}
/* ── Market Context Strip ───────────────────────────────────────── */
.ctx-strip {
display: flex;
gap: 1px;
background: #1e293b;
border: 1px solid #1e293b;
border-radius: 10px;
overflow: hidden;
margin-bottom: 20px;
}
.ctx-chip {
flex: 1;
min-width: 70px;
background: #0f1117;
padding: 10px 14px;
display: flex;
flex-direction: column;
gap: 3px;
}
.ctx-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #475569;
}
.ctx-val {
font-size: 15px;
font-weight: 700;
color: #f1f5f9;
font-variant-numeric: tabular-nums;
}
.ctx-regime[data-regime="HIGH"] { color: #f87171; }
.ctx-regime[data-regime="NORMAL"] { color: #94a3b8; }
.ctx-regime[data-regime="LOW"] { color: #4ade80; }
/* ── Section ────────────────────────────────────────────────────── */
.section {
background: #0d1117;
border: 1px solid #1e293b;
border-radius: 10px;
margin-bottom: 16px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px 12px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #64748b;
margin: 0;
}
.count {
font-size: 10px;
font-weight: 600;
color: #334155;
background: #1e293b;
padding: 2px 7px;
border-radius: 20px;
}
/* ── Mode Tabs ──────────────────────────────────────────────────── */
.mode-tabs {
display: flex;
gap: 4px;
margin-left: auto;
}
.mode-tabs button {
background: transparent;
color: #475569;
border: 1px solid #1e293b;
font-size: 11px;
padding: 4px 12px;
border-radius: 6px;
}
.mode-tabs button.active {
background: #1e3a5f;
color: #60a5fa;
border-color: #1e3a5f;
}
/* ── Table ──────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { width: max-content; min-width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 8px 14px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #334155;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
background: #111827;
}
tbody tr { border-bottom: 1px solid #161f2e; }
tbody tr:hover { background: #131c2b; }
tbody td {
padding: 10px 14px;
vertical-align: middle;
white-space: nowrap;
font-size: 13px;
}
/* Sticky ticker column */
.col-ticker,
tbody td:first-child {
position: sticky;
left: 0;
background: inherit;
z-index: 1;
}
thead .col-ticker { background: #111827; }
tbody td:first-child { background: #0d1117; }
tbody tr:hover td:first-child { background: #131c2b; }
.ticker {
font-weight: 700;
font-size: 13px;
color: #f1f5f9;
letter-spacing: 0.02em;
}
.num {
color: #64748b;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
/* Score cell: truncates gate failure text, shown in full via title tooltip */
.score-cell {
color: #64748b;
font-size: 11px;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Verdict Pill ───────────────────────────────────────────────── */
.verdict-pill {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.verdict-pill.green { background: #14532d33; color: #4ade80; }
.verdict-pill.yellow { background: #71350033; color: #facc15; }
.verdict-pill.red { background: #450a0a33; color: #f87171; }
/* ── Tags ───────────────────────────────────────────────────────── */
.tag {
display: inline-block;
background: #1e293b;
color: #64748b;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.tag.sm { font-size: 10px; padding: 1px 6px; }
/* ── Risk Flags ─────────────────────────────────────────────────── */
.flags { display: flex; flex-direction: column; gap: 2px; }
.flag { color: #fb923c; font-size: 11px; }
/* ── Errors ─────────────────────────────────────────────────────── */
.error-list { padding: 12px 18px; display: flex; flex-direction: column; gap: 6px; }
.error-item { color: #64748b; font-size: 12px; }
.error-item .ticker { color: #f87171; font-weight: 700; margin-right: 8px; }
/* ── Analyze button ─────────────────────────────────────────────── */
.btn-analyze {
background: transparent;
color: #7c93b0;
border: 1px solid #1e293b;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
padding: 4px 12px;
border-radius: 6px;
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: 8px;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.btn-analyze:hover:not(:disabled) {
background: #0f2240;
color: #93c5fd;
border-color: #1e3a5f;
}
.btn-analyze:disabled { opacity: 0.4; cursor: default; }
/* ── LLM Sidebar ────────────────────────────────────────────────── */
.sidebar-backdrop {
position: fixed;
inset: 0;
background: #00000055;
z-index: 100;
}
.sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 380px;
background: #0d1117;
border-left: 1px solid #1e3a5f;
z-index: 101;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid #1e293b;
background: #0d1e30;
flex-shrink: 0;
}
.sidebar-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 700;
color: #e2e8f0;
}
.sidebar-type {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
background: #1e3a5f;
color: #60a5fa;
padding: 2px 8px;
border-radius: 20px;
}
.sidebar-close {
background: none;
border: none;
color: #475569;
font-size: 14px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
}
.sidebar-close:hover { color: #94a3b8; background: #1e293b; }
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.sidebar-loading {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 60px 0;
}
.sidebar-error {
color: #f87171;
background: #450a0a33;
border-radius: 8px;
padding: 12px 14px;
font-size: 13px;
}
.sb-sentiment-row { display: flex; align-items: center; gap: 8px; }
.sb-summary {
font-size: 13px;
color: #94a3b8;
line-height: 1.6;
border-left: 3px solid #1e3a5f;
padding-left: 12px;
margin: 0;
}
.sb-sub {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #475569;
margin: 0;
}
.sb-list { display: flex; flex-direction: column; gap: 8px; }
.sb-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
background: #111827;
border-radius: 6px;
border: 1px solid #1e293b;
}
.sb-name {
font-size: 12px;
font-weight: 600;
color: #e2e8f0;
}
.sb-reason {
font-size: 11px;
color: #64748b;
line-height: 1.4;
}
/* ── Sidebar sentiment pill ─────────────────────────────────────── */
.sentiment-pill {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
padding: 3px 10px;
border-radius: 20px;
}
.sentiment-pill[data-sentiment="BULLISH"] { background: #14532d33; color: #4ade80; }
.sentiment-pill[data-sentiment="BEARISH"] { background: #450a0a33; color: #f87171; }
.sentiment-pill[data-sentiment="NEUTRAL"] { background: #1e293b; color: #94a3b8; }
</style>
+8
View File
@@ -0,0 +1,8 @@
export async function load({ fetch }) {
const [callsRes, calRes] = await Promise.all([fetch('/api/calls'), fetch('/api/calls/calendar')]);
const { calls } = callsRes.ok ? await callsRes.json() : { calls: [] };
const { events } = calRes.ok ? await calRes.json() : { events: [] };
return { calls, events };
}
+420
View File
@@ -0,0 +1,420 @@
<script>
import { createCall, deleteCall } from '$lib/api.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
import { invalidateAll } from '$app/navigation';
let { data } = $props();
// New call form state
let showForm = $state(false);
let saving = $state(false);
let formError = $state(null);
let form = $state({
title: '',
quarter: currentQuarter(),
date: today(),
thesis: '',
tickers: '',
});
function currentQuarter() {
const d = new Date();
const q = Math.ceil((d.getMonth() + 1) / 3);
return `Q${q} ${d.getFullYear()}`;
}
function today() {
return new Date().toISOString().slice(0, 10);
}
async function submit() {
formError = null;
saving = true;
try {
await createCall({
title: form.title.trim(),
quarter: form.quarter.trim(),
date: form.date,
thesis: form.thesis.trim(),
tickers: form.tickers.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean),
});
showForm = false;
form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' };
await invalidateAll(); // re-run load() to refresh the list
} catch (e) {
formError = e.message;
} finally {
saving = false;
}
}
async function remove(id) {
if (!confirm('Delete this market call?')) return;
await deleteCall(id);
await invalidateAll();
}
const signalColor = s => {
if (s?.includes('Strong')) return '#4ade80';
if (s?.includes('Momentum')) return '#60a5fa';
if (s?.includes('Neutral')) return '#94a3b8';
if (s?.includes('Speculation')) return '#fb923c';
return '#f87171';
};
const eventIcon = type => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅';
const eventColor = type => ({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[type] ?? '#94a3b8';
const upcoming = $derived((data.events ?? []).filter(e => !e.isPast).slice(0, 20));
const past = $derived((data.events ?? []).filter(e => e.isPast).slice(0, 10));
const fmtMoney = n => n == null ? null :
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
</script>
<div class="page">
<div class="page-header">
<div>
<h1>Market Calls</h1>
<p class="subtitle">Quarterly investment theses tracked from the day you made the call</p>
</div>
<button class="btn-primary" onclick={() => showForm = !showForm}>
{showForm ? 'Cancel' : ' New Call'}
</button>
</div>
<!-- ── New Call Form ──────────────────────────────────────────────── -->
{#if showForm}
<section class="section form-section">
<div class="section-header"><h2>New Market Call</h2></div>
<form class="call-form" onsubmit={e => { e.preventDefault(); submit(); }}>
<div class="form-row">
<label>
<span>Title</span>
<input bind:value={form.title} placeholder="Q3 2025 Rate pivot & tech rotation" required />
</label>
<label class="narrow">
<span>Quarter</span>
<input bind:value={form.quarter} placeholder="Q3 2025" required />
</label>
<label class="narrow">
<span>Date</span>
<input type="date" bind:value={form.date} required />
</label>
</div>
<label>
<span>Thesis</span>
<textarea
bind:value={form.thesis}
rows="4"
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
required
></textarea>
</label>
<label>
<span>Tickers to track</span>
<input
bind:value={form.tickers}
placeholder="AAPL, MSFT, TLT, GLD …"
required
/>
<span class="hint">Comma or space separated. Current prices will be snapshot automatically.</span>
</label>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
<button type="submit" class="btn-primary" disabled={saving}>
{#if saving}
<Spinner size="sm" />
<span>Snapshotting prices…</span>
{:else}
Save Call
{/if}
</button>
</form>
</section>
{/if}
<!-- ── Calendar ──────────────────────────────────────────────────── -->
{#if (data.events ?? []).length > 0}
<section class="section">
<div class="section-header">
<h2>📅 Upcoming Events</h2>
<span class="count">{upcoming.length} upcoming</span>
{#if past.length > 0}
<span class="count" style="margin-left:4px">{past.length} recent</span>
{/if}
</div>
<div class="cal-grid">
{#each upcoming as ev}
<div class="cal-event">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type" style="color:{eventColor(ev.type)}">
{eventIcon(ev.type)} {ev.label}
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
</span>
{#if ev.epsEstimate != null}
<span class="cal-est">EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
{/if}
</div>
</div>
{/each}
{#if past.length > 0}
<div class="cal-divider">— Past —</div>
{#each past as ev}
<div class="cal-event past">
<div class="cal-date">{ev.date}</div>
<div class="cal-content">
<span class="cal-ticker">{ev.ticker}</span>
<span class="cal-type past-type">
{eventIcon(ev.type)} {ev.label}
</span>
</div>
</div>
{/each}
{/if}
</div>
</section>
{/if}
<!-- ── Calls List ────────────────────────────────────────────────── -->
{#if data.error}
<div class="error-banner">{data.error}</div>
{:else if data.calls.length === 0}
<div class="empty">No market calls yet. Create your first one to start tracking.</div>
{:else}
{#each data.calls as call}
<section class="section call-card">
<div class="section-header">
<div class="call-meta">
<a href="/calls/{call.id}" class="call-title">{call.title}</a>
<div class="call-badges">
<span class="tag">{call.quarter}</span>
<span class="date-badge">{call.date}</span>
<span class="count">{call.tickers.length} tickers</span>
</div>
</div>
<button class="btn-delete" onclick={() => remove(call.id)}>✕</button>
</div>
<div class="call-body">
<p class="thesis">{call.thesis}</p>
{#if Object.keys(call.snapshot ?? {}).length}
<div class="snapshot-grid">
{#each call.tickers as ticker}
{@const snap = call.snapshot[ticker]}
{#if snap}
<a href="/calls/{call.id}" class="snap-card">
<div class="snap-ticker">{ticker}</div>
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
</div>
</a>
{/if}
{/each}
</div>
<a href="/calls/{call.id}" class="view-link">View performance → </a>
{/if}
</div>
</section>
{/each}
{/if}
</div>
<style>
.page { max-width: 1100px; padding-bottom: 60px; }
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: #475569; }
/* ── Buttons ─────────────────────────────────────────────────────── */
button {
padding: 9px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: background 0.15s;
}
.btn-primary { background: #2563eb; color: #fff; display: inline-flex; align-items: center; gap: 8px; }
.btn-primary:hover:not(:disabled) { background: #1d4ed8; }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-delete { background: transparent; color: #475569; padding: 4px 8px; font-size: 14px; }
.btn-delete:hover { color: #f87171; }
/* ── Section ─────────────────────────────────────────────────────── */
.section {
background: #0d1117;
border: 1px solid #1e293b;
border-radius: 10px;
margin-bottom: 16px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
/* ── Form ────────────────────────────────────────────────────────── */
.call-form { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.form-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: start; }
.form-row .narrow { min-width: 120px; }
label { display: flex; flex-direction: column; gap: 5px; }
label > span { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; }
input, textarea {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 8px;
color: #e2e8f0;
padding: 9px 12px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
input:focus, textarea:focus { border-color: #3b82f6; }
textarea { resize: vertical; }
.hint { font-size: 11px; color: #475569; }
.form-error { color: #f87171; font-size: 12px; background: #450a0a33; padding: 8px 12px; border-radius: 6px; }
/* ── Call card ───────────────────────────────────────────────────── */
.call-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.call-title {
font-size: 14px;
font-weight: 700;
color: #f1f5f9;
text-decoration: none;
}
.call-title:hover { color: #60a5fa; }
.call-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.tag {
display: inline-block;
background: #1e293b;
color: #64748b;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.date-badge { font-size: 11px; color: #475569; }
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
.call-body { padding: 18px; display: flex; flex-direction: column; gap: 16px; }
.thesis {
font-size: 13px;
color: #94a3b8;
line-height: 1.6;
border-left: 3px solid #1e3a5f;
padding-left: 14px;
margin: 0;
}
/* ── Snapshot grid ───────────────────────────────────────────────── */
.snapshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
.snap-card {
background: #111827;
border: 1px solid #1e293b;
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
text-decoration: none;
transition: border-color 0.15s;
}
.snap-card:hover { border-color: #334155; }
.snap-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
.snap-price { font-size: 11px; color: #64748b; font-variant-numeric: tabular-nums; }
.snap-signal { font-size: 10px; font-weight: 600; }
.view-link { font-size: 12px; color: #60a5fa; text-decoration: none; }
.view-link:hover { text-decoration: underline; }
.empty { color: #475569; font-size: 13px; padding: 40px 0; text-align: center; }
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
/* ── Calendar ───────────────────────────────────────────────────── */
.cal-grid {
padding: 8px 18px 14px;
display: flex;
flex-direction: column;
gap: 2px;
}
.cal-event {
display: grid;
grid-template-columns: 96px 1fr;
gap: 14px;
align-items: start;
padding: 8px 6px;
border-radius: 6px;
transition: background 0.1s;
}
.cal-event:hover { background: #111827; }
.cal-event.past { opacity: 0.45; }
.cal-date {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: #475569;
padding-top: 1px;
white-space: nowrap;
}
.cal-content { display: flex; flex-direction: column; gap: 2px; }
.cal-ticker { font-size: 12px; font-weight: 700; color: #f1f5f9; }
.cal-type { font-size: 11px; font-weight: 600; }
.cal-detail { font-weight: 400; color: #64748b; }
.past-type { color: #475569 !important; }
.cal-est { font-size: 10px; color: #475569; }
.cal-divider {
font-size: 10px;
color: #334155;
text-align: center;
padding: 8px 0 4px;
letter-spacing: 0.06em;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
export async function load({ fetch, params }) {
const res = await fetch(`/api/calls/${params.id}`);
if (!res.ok) return { error: await res.text() };
return res.json();
}
+202
View File
@@ -0,0 +1,202 @@
<script>
let { data } = $props();
const fmt = v => v != null ? '$' + v.toFixed(2) : '—';
const pctChange = (then, now) => {
if (then == null || now == null || then === 0) return null;
return ((now - then) / then) * 100;
};
const pctClass = v => v == null ? '' : v >= 0 ? 'pos' : 'neg';
const fmtPct = v => v == null ? '—' : (v >= 0 ? '+' : '') + v.toFixed(1) + '%';
const verdictColor = label => {
if (!label) return '#64748b';
if (label.startsWith('🟢')) return '#4ade80';
if (label.startsWith('🟡')) return '#facc15';
return '#f87171';
};
const daysSince = dateStr => {
const diff = Date.now() - new Date(dateStr).getTime();
return Math.floor(diff / 86400000);
};
const tickers = $derived(data?.tickers ?? []);
const snapshot = $derived(data?.snapshot ?? {});
const current = $derived(data?.current ?? {});
</script>
<div class="page">
{#if data?.error}
<div class="error-banner">{data.error}</div>
{:else if data}
<div class="breadcrumb"><a href="/calls">← Market Calls</a></div>
<div class="call-hero">
<div class="hero-meta">
<span class="tag">{data.quarter}</span>
<span class="date">{data.date}</span>
<span class="days">({daysSince(data.date)} days ago)</span>
</div>
<h1>{data.title}</h1>
<p class="thesis">{data.thesis}</p>
</div>
<!-- ── Performance Table ─────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2>Performance since call date</h2>
<span class="count">{tickers.length} tickers</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Ticker</th>
<th>Call Price</th>
<th>Now</th>
<th>Return</th>
<th>Call Signal</th>
<th>Now Signal</th>
<th>Call Verdict</th>
<th>Now Verdict</th>
</tr>
</thead>
<tbody>
{#each tickers as ticker}
{@const snap = snapshot[ticker]}
{@const cur = current[ticker]}
{@const ret = pctChange(snap?.price, cur?.price)}
<tr class:best={ret != null && ret >= 10} class:worst={ret != null && ret <= -10}>
<td class="ticker">{ticker}</td>
<td class="num">{fmt(snap?.price)}</td>
<td class="num">{fmt(cur?.price)}</td>
<td class="num {pctClass(ret)}">{fmtPct(ret)}</td>
<td>
{#if snap?.signal}
<span class="signal-text">{snap.signal}</span>
{:else}
<span class="muted"></span>
{/if}
</td>
<td>
{#if cur?.signal}
<span class="signal-text">{cur.signal}</span>
{:else}
<span class="muted"></span>
{/if}
</td>
<td>
{#if snap?.inflatedVerdict}
<span class="verdict-pill" style="color:{verdictColor(snap.inflatedVerdict)}">
{snap.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
</span>
{:else}
<span class="muted"></span>
{/if}
</td>
<td>
{#if cur?.inflatedVerdict}
<span class="verdict-pill" style="color:{verdictColor(cur.inflatedVerdict)}">
{cur.inflatedVerdict.replace(/[🟢🟡🔴]/u, '').trim()}
</span>
{:else}
<span class="muted"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
</div>
<style>
.page { max-width: 1100px; padding-bottom: 60px; }
.breadcrumb { margin-bottom: 20px; }
.breadcrumb a { font-size: 12px; color: #475569; text-decoration: none; }
.breadcrumb a:hover { color: #94a3b8; }
.call-hero { margin-bottom: 24px; }
.hero-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.tag { background: #1e293b; color: #64748b; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
.date { font-size: 12px; color: #475569; }
.days { font-size: 12px; color: #334155; }
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 10px; }
.thesis {
font-size: 13px;
color: #94a3b8;
line-height: 1.7;
border-left: 3px solid #1e3a5f;
padding-left: 14px;
max-width: 800px;
}
/* ── Section ─────────────────────────────────────────────────────── */
.section { background: #0d1117; border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; }
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
/* ── Table ───────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: max-content; min-width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 8px 14px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #334155;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
background: #111827;
}
tbody tr { border-bottom: 1px solid #161f2e; }
tbody tr:hover { background: #131c2b; }
tbody tr.best td { background: #14532d11; }
tbody tr.worst td { background: #450a0a11; }
tbody td { padding: 10px 14px; vertical-align: middle; white-space: nowrap; font-size: 13px; }
.ticker { font-weight: 700; color: #f1f5f9; }
.num { font-variant-numeric: tabular-nums; color: #64748b; }
.pos { color: #4ade80; font-weight: 600; }
.neg { color: #f87171; font-weight: 600; }
.muted { color: #334155; }
.signal-text { font-size: 12px; color: #94a3b8; }
.verdict-pill {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
background: #1e293b;
}
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; font-size: 13px; }
</style>
+8
View File
@@ -0,0 +1,8 @@
// Disable SSR — data is fetched client-side in the component so navigation
// is instant instead of blocking until all Yahoo Finance calls resolve.
export const ssr = false;
export const prerender = false;
export function load() {
return {};
}
+795
View File
@@ -0,0 +1,795 @@
<script>
import SignalBadge from '$lib/SignalBadge.svelte';
import MarketContext from '$lib/MarketContext.svelte';
import Spinner from '$lib/Spinner.svelte';
import { addHolding, removeHolding } from '$lib/api.js';
let { data: _data } = $props(); // unused — we load client-side
let data = $state(null);
let loading = $state(true);
let refreshing = $state(false); // background refresh — keeps page visible
let loadError = $state(null);
// ── Add holding form (new holdings only) ────────────────────────────────────
let formOpen = $state(false);
let saving = $state(false);
let formError = $state(null);
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
// ── Inline row editing ───────────────────────────────────────────────────────
let inlineEdit = $state(null); // { ticker, shares, costBasis, type, source } or null
let inlineSaving = $state(false);
function startInlineEdit(a) {
inlineEdit = {
ticker: a.ticker,
shares: String(a.shares),
costBasis: String(a.costBasis ?? 0),
type: a.type ?? 'stock',
source: a.source ?? 'Robinhood',
};
}
async function saveInlineEdit() {
if (!inlineEdit) return;
inlineSaving = true;
try {
const updated = {
ticker: inlineEdit.ticker,
shares: parseFloat(inlineEdit.shares),
costBasis: parseFloat(inlineEdit.costBasis) || 0,
type: inlineEdit.type,
source: inlineEdit.source,
};
await addHolding(updated);
// Optimistic update — patch the row immediately, don't wait for Yahoo
if (data?.advice) {
data = {
...data,
advice: data.advice.map(a =>
a.ticker === updated.ticker
? { ...a, shares: updated.shares, costBasis: updated.costBasis, type: updated.type, source: updated.source,
marketValue: updated.shares * (parseFloat(a.currentPrice) || 0),
gainLossPct: a.currentPrice ? (((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) * 100).toFixed(1) : null }
: a
),
};
}
inlineEdit = null;
fetchPortfolioData(false); // background: update prices + signals
} catch (e) {
loadError = e.message;
} finally {
inlineSaving = false;
}
}
function openAdd() {
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
formOpen = !formOpen;
formError = null;
inlineEdit = null;
}
async function submitHolding() {
formError = null;
const ticker = form.ticker.trim().toUpperCase();
const shares = parseFloat(form.shares);
const costBasis = parseFloat(form.costBasis) || 0;
if (!ticker) { formError = 'Ticker is required.'; return; }
if (!shares || shares <= 0) { formError = 'Shares must be greater than 0.'; return; }
saving = true;
try {
await addHolding({ ticker, shares, costBasis, type: form.type, source: form.source });
// Optimistic update — add placeholder row immediately
const existing = data?.advice?.find(a => a.ticker === ticker);
if (data?.advice && !existing) {
data = {
...data,
advice: [...data.advice, {
ticker, shares, costBasis, type: form.type, source: form.source,
currentPrice: null, marketValue: null, gainLossPct: null,
signal: null, advice: '⏳ Fetching…', reason: 'Screener data loading in background.',
}],
};
}
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
formOpen = false;
fetchPortfolioData(false); // background: get real price + signal
} catch (e) {
formError = e.message;
} finally {
saving = false;
}
}
async function deleteHolding(ticker) {
if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
// Optimistic remove — drop the row immediately
if (data?.advice) {
data = { ...data, advice: data.advice.filter(a => a.ticker !== ticker) };
}
try {
await removeHolding(ticker);
fetchPortfolioData(false); // background: recalculate totals
} catch (e) {
loadError = e.message;
}
}
function fetchPortfolioData(showFullSpinner = false) {
if (showFullSpinner) loading = true;
else refreshing = true;
loadError = null;
fetch('/api/finance/portfolio')
.then(res => res.ok ? res.json() : res.text().then(t => { throw new Error(t); }))
.then(json => { data = json; })
.catch(e => { loadError = e.message; })
.finally(() => { loading = false; refreshing = false; });
}
let _booted = false;
$effect(() => {
if (_booted) return;
_booted = true;
fetchPortfolioData(true); // initial load — show full spinner
});
// ── Table sorting ────────────────────────────────────────────────────────────
let sortCol = $state('ticker');
let sortDir = $state(1); // 1 = asc, -1 = desc
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
function toggleSort(col) {
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
else { sortCol = col; sortDir = 1; }
}
const sortedAdvice = $derived.by(() => {
if (!data?.advice) return [];
return [...data.advice].sort((a, b) => {
let av, bv;
switch (sortCol) {
case 'ticker': av = a.ticker; bv = b.ticker; break;
case 'type': av = a.type ?? ''; bv = b.type ?? ''; break;
case 'shares': av = a.shares ?? 0; bv = b.shares ?? 0; break;
case 'cost': av = a.costBasis ?? 0; bv = b.costBasis ?? 0; break;
case 'current': av = parseFloat(a.currentPrice) || 0; bv = parseFloat(b.currentPrice) || 0; break;
case 'value': av = parseFloat(a.marketValue) || 0; bv = parseFloat(b.marketValue) || 0; break;
case 'gl': av = parseFloat(a.gainLossPct) || 0; bv = parseFloat(b.gainLossPct) || 0; break;
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
default: return 0;
}
if (av < bv) return -sortDir;
if (av > bv) return sortDir;
return 0;
});
});
const sortIcon = (col) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
const fmt = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n)
: '—';
const fmtShort = (n) => n != null
? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n)
: '—';
const glClass = (pct) => parseFloat(pct) >= 0 ? 'green' : 'red';
const advClass = (a) => {
if (a?.includes('🟢')) return 'green';
if (a?.includes('🟡')) return 'yellow';
if (a?.includes('🟠')) return 'orange';
if (a?.includes('🔴')) return 'red';
return 'gray';
};
const totalValue = $derived(data?.advice?.reduce((s, a) => s + (parseFloat(a.marketValue) || 0), 0) ?? 0);
const totalCost = $derived(data?.advice?.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0) ?? 0);
const totalGL = $derived(totalValue - totalCost);
</script>
<div class="page">
{#if loading}
<div class="loading-area">
<Spinner size="lg" label="Loading portfolio…" />
</div>
{:else if loadError}
<div class="error">{loadError}</div>
{:else if data?.advice}
<!-- ── Toolbar ──────────────────────────────────────────────── -->
<div class="toolbar">
<button class="btn-add" onclick={openAdd}>
{formOpen ? '✕ Cancel' : '+ Add Holding'}
</button>
{#if refreshing}
<span class="refreshing-hint">Updating prices…</span>
{/if}
</div>
<!-- ── Add Holding Form ─────────────────────────────────────── -->
{#if formOpen}
<div class="add-form">
<div class="form-title">Add Holding</div>
<div class="form-row">
<div class="field">
<label>Ticker</label>
<input bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
</div>
<div class="field">
<label>Shares</label>
<input bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
</div>
<div class="field">
<label>Cost Basis / share</label>
<input bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
</div>
<div class="field">
<label>Type</label>
<select bind:value={form.type}>
<option value="stock">Stock</option>
<option value="etf">ETF</option>
<option value="bond">Bond</option>
<option value="crypto">Crypto</option>
</select>
</div>
<div class="field">
<label>Source</label>
<input bind:value={form.source} placeholder="Robinhood" />
</div>
<button class="btn-save" onclick={submitHolding} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
{#if formError}
<div class="form-error">{formError}</div>
{/if}
</div>
{/if}
{#if data.marketContext}
<MarketContext ctx={data.marketContext} collapsible={true} />
{/if}
<!-- P&L Summary -->
<div class="summary-grid">
<div class="scard">
<div class="slabel-row">
<span class="slabel">Total Value</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Current market value of all holdings. Calculated as shares × live price from Yahoo Finance for each position.</span>
</span>
</div>
<div class="svalue">{fmtShort(totalValue)}</div>
</div>
<div class="scard">
<div class="slabel-row">
<span class="slabel">Total Cost</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Total amount invested — sum of (cost basis per share × shares) across all positions. Based on the cost basis you entered.</span>
</span>
</div>
<div class="svalue">{fmtShort(totalCost)}</div>
</div>
<div class="scard">
<div class="slabel-row">
<span class="slabel">Total G/L</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Total unrealised gain or loss — Total Value minus Total Cost. Green means you're up overall; red means you're down.</span>
</span>
</div>
<div class="svalue {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
</div>
</div>
<!-- Holdings -->
<section class="card-section">
<h2>Holdings — Hold / Sell / Add Advice</h2>
<table>
<thead>
<tr>
<th class="sortable" onclick={() => toggleSort('ticker')}>Ticker {sortIcon('ticker')}</th>
<th class="sortable" onclick={() => toggleSort('type')}>Type {sortIcon('type')}</th>
<th class="sortable" onclick={() => toggleSort('shares')}>Shares {sortIcon('shares')}</th>
<th class="sortable" onclick={() => toggleSort('cost')}>Cost {sortIcon('cost')}</th>
<th class="sortable" onclick={() => toggleSort('current')}>Current {sortIcon('current')}</th>
<th class="sortable" onclick={() => toggleSort('value')}>Value {sortIcon('value')}</th>
<th class="sortable" onclick={() => toggleSort('gl')}>G/L {sortIcon('gl')}</th>
<th class="sortable" onclick={() => toggleSort('signal')}>Signal {sortIcon('signal')}</th>
<th>Advice</th><th>Reason</th><th></th>
</tr>
</thead>
<tbody>
{#each sortedAdvice as a}
{@const isEditing = inlineEdit?.ticker === a.ticker}
<tr class:editing={isEditing}>
<td class="ticker">{a.ticker}</td>
<td>
{#if isEditing}
<select class="inline-select" bind:value={inlineEdit.type}>
<option value="stock">stock</option>
<option value="etf">etf</option>
<option value="bond">bond</option>
<option value="crypto">crypto</option>
</select>
{:else}
<span class="tag">{a.type}</span>
{/if}
</td>
<td class="num">
{#if isEditing}
<input class="inline-input" bind:value={inlineEdit.shares} type="number" min="0" step="any" />
{:else}
{a.shares}
{/if}
</td>
<td class="num">
{#if isEditing}
<input class="inline-input" bind:value={inlineEdit.costBasis} type="number" min="0" step="any" />
{:else}
{fmt(a.costBasis)}
{/if}
</td>
<td class="num">{fmt(parseFloat(a.currentPrice))}</td>
<td class="num">{fmt(parseFloat(a.marketValue))}</td>
<td class="num {glClass(a.gainLossPct)}">{a.gainLossPct != null ? a.gainLossPct + '%' : '—'}</td>
<td>{#if a.signal}<SignalBadge signal={a.signal} />{:else}<span class="gray"></span>{/if}</td>
<td class={advClass(a.advice)}>{a.advice}</td>
<td class="reason">{a.reason}</td>
<td class="row-actions">
{#if isEditing}
<button class="btn-save-inline" onclick={saveInlineEdit} disabled={inlineSaving}>
{inlineSaving ? '…' : '✓'}
</button>
<button class="btn-cancel-inline" onclick={() => inlineEdit = null}>✕</button>
{:else}
<button class="btn-edit" onclick={() => startInlineEdit(a)} title="Edit"></button>
<button class="btn-delete" onclick={() => deleteHolding(a.ticker)} title="Remove"></button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
<!-- Personal Finance (SimpleFIN) -->
{#if data.personalFinance}
{@const pf = data.personalFinance}
<div class="summary-grid">
<div class="scard">
<div class="slabel">Net Worth</div>
<div class="svalue {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div>
</div>
<div class="scard">
<div class="slabel">Total Assets</div>
<div class="svalue">{fmtShort(pf.totalAssets)}</div>
</div>
<div class="scard">
<div class="slabel">Liabilities</div>
<div class="svalue red">{fmtShort(pf.totalLiabilities)}</div>
</div>
<div class="scard">
<div class="slabel">Cash ({pf.cashPct}%)</div>
<div class="svalue">{fmtShort(pf.totalCash)}</div>
</div>
<div class="scard">
<div class="slabel">Investments ({pf.investPct}%)</div>
<div class="svalue">{fmtShort(pf.totalInvestments)}</div>
</div>
{#if pf.savingsRate != null}
<div class="scard">
<div class="slabel">Savings Rate</div>
<div class="svalue {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div>
</div>
{/if}
<div class="scard">
<div class="slabel">Monthly Income</div>
<div class="svalue">{fmtShort(pf.totalIncome)}</div>
</div>
<div class="scard">
<div class="slabel">Monthly Spend</div>
<div class="svalue">{fmtShort(pf.totalSpend)}</div>
</div>
</div>
<div class="two-col">
<section class="card-section">
<h2>Accounts</h2>
<table>
<thead><tr><th>Account</th><th>Type</th><th>Institution</th><th class="right">Balance</th></tr></thead>
<tbody>
{#each pf.accounts as a}
<tr>
<td class="ticker">{a.name}</td>
<td><span class="tag">{a.type}</span></td>
<td class="gray">{a.org}</td>
<td class="num right {a.balance >= 0 ? 'green' : 'red'}">{fmt(a.balance)}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section class="card-section">
<h2>Spending — Last 30 Days</h2>
<table>
<thead><tr><th>Category</th><th class="right">Amount</th><th class="right">%</th><th>Share</th></tr></thead>
<tbody>
{#each pf.categoryBreakdown.slice(0, 10) as c}
<tr>
<td>{c.category}</td>
<td class="num right">{fmt(c.amount)}</td>
<td class="num right gray">{c.pct}%</td>
<td style="width:100px">
<div class="bar-bg">
<div class="bar-fill" style="width:{Math.min(c.pct,100)}%"></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
{/if}
{/if}
</div>
<style>
.page { max-width: 1400px; }
/* ── Toolbar ─────────────────────────────────────────────────────── */
.toolbar { margin-bottom: 12px; }
.btn-add {
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
padding: 9px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-add:hover { background: #1d4ed8; }
.refreshing-hint {
font-size: 11px;
color: #475569;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ── Add holding form ────────────────────────────────────────────── */
.add-form {
background: #111827;
border: 1px solid #1e293b;
border-radius: 10px;
padding: 18px;
margin-bottom: 16px;
}
.form-row {
display: flex;
gap: 12px;
align-items: flex-end;
flex-wrap: wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
}
.field label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #475569;
}
.field input::placeholder { color: #334155; }
.field input {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 6px;
color: #e2e8f0;
padding: 8px 12px;
font-size: 13px;
outline: none;
min-width: 100px;
height: 38px;
box-sizing: border-box;
}
.field input:focus { border-color: #3b82f6; }
.field select {
background: #1e293b url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%2364748b' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 10px center;
border: 1px solid #2d3f55;
border-radius: 6px;
color: #e2e8f0;
padding: 8px 32px 8px 12px;
font-size: 13px;
outline: none;
min-width: 100px;
height: 38px;
box-sizing: border-box;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
}
.field select:focus { border-color: #3b82f6; }
.btn-save {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
align-self: flex-end;
}
.btn-save:hover:not(:disabled) { background: #1d4ed8; }
.btn-save:disabled { opacity: 0.5; cursor: default; }
.form-error {
color: #f87171;
font-size: 12px;
margin-top: 10px;
}
/* ── Delete button ───────────────────────────────────────────────── */
.form-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #475569;
margin-bottom: 14px;
}
.field input.readonly {
opacity: 0.5;
cursor: not-allowed;
}
.btn-cancel-edit {
background: transparent;
border: 1px solid #2d3f55;
color: #64748b;
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
align-self: flex-end;
}
.btn-cancel-edit:hover { color: #94a3b8; }
tr.editing { background: #0d1e30; }
.inline-input {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 4px;
color: #e2e8f0;
padding: 3px 6px;
font-size: 12px;
width: 80px;
outline: none;
}
.inline-input:focus { border-color: #3b82f6; }
.inline-select {
background: #1e293b;
border: 1px solid #2d3f55;
border-radius: 4px;
color: #e2e8f0;
padding: 3px 6px;
font-size: 11px;
outline: none;
}
.btn-save-inline {
background: #14532d55;
border: none;
color: #4ade80;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-weight: 700;
}
.btn-save-inline:hover:not(:disabled) { background: #14532d99; }
.btn-save-inline:disabled { opacity: 0.5; cursor: default; }
.btn-cancel-inline {
background: none;
border: none;
color: #475569;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.btn-cancel-inline:hover { color: #94a3b8; }
.row-actions { display: flex; gap: 4px; align-items: center; }
.btn-edit {
background: none;
border: none;
color: #334155;
font-size: 13px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.btn-edit:hover { color: #60a5fa; background: #0f2240; }
.btn-delete {
background: none;
border: none;
color: #334155;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
}
.btn-delete:hover { color: #f87171; background: #450a0a33; }
.loading-area {
display: flex;
justify-content: center;
align-items: center;
padding: 100px 0;
}
.error { color: #f87171; background: #450a0a33; border-radius: 8px; padding: 10px 14px; }
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.scard { background: #1e293b; border-radius: 8px; padding: 12px 14px; }
.slabel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.slabel { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.svalue { font-size: 18px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
/* ── Summary card tooltips ───────────────────────────────────────── */
.stip-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
.stip-anchor {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 50%;
background: #0f1117;
border: 1px solid #334155;
color: #475569;
font-size: 9px;
font-weight: 700;
cursor: help;
}
.stip-box {
display: none;
position: fixed;
width: 220px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
padding: 8px 10px;
font-size: 11px;
color: #94a3b8;
line-height: 1.5;
z-index: 200;
pointer-events: none;
white-space: normal;
/* anchor via JS-free trick: use absolute + translate to float above icon */
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
}
.stip-box::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #334155;
}
.stip-wrap:hover .stip-box { display: block; }
.card-section {
background: #111827;
border: 1px solid #1e293b;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
h2 {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #64748b;
margin-bottom: 14px;
}
table { width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 7px 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #475569;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
}
tbody tr { border-bottom: 1px solid #1a2233; }
tbody tr:hover { background: #1e293b55; }
tbody td { padding: 9px 10px; vertical-align: middle; white-space: nowrap; }
th.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th.sortable:hover { color: #94a3b8; }
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
.num { font-variant-numeric: tabular-nums; color: #94a3b8; }
.tag { background: #1e293b; color: #94a3b8; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
.reason { color: #94a3b8; font-size: 11px; white-space: normal; max-width: 260px; }
.right { text-align: right; }
.green { color: #4ade80; font-weight: 600; }
.yellow { color: #facc15; font-weight: 600; }
.orange { color: #fb923c; font-weight: 600; }
.red { color: #f87171; font-weight: 600; }
.gray { color: #64748b; }
.bar-bg { background: #1e293b; border-radius: 4px; height: 6px; }
.bar-fill { background: #3b82f6; border-radius: 4px; height: 6px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
</style>
+60
View File
@@ -0,0 +1,60 @@
// Curated watchlist of well-established, low-cost ETFs and investment-grade bond funds.
// Screened for Strong Buy signal under both Market-Adjusted and Fundamental lenses.
const SAFE_WATCHLIST = [
// ── Broad Market ETFs
'VOO', // S&P 500 — Vanguard (0.03%)
'IVV', // S&P 500 — iShares (0.03%)
'VTI', // Total US Market — Vanguard (0.03%)
'SPY', // S&P 500 — SPDR (0.0945%)
'QQQ', // Nasdaq-100 — Invesco (0.20%)
'VEA', // Developed Markets ex-US — Vanguard
'VWO', // Emerging Markets — Vanguard
// ── Dividend / Quality ETFs
'VIG', // Dividend Appreciation — Vanguard
'SCHD', // Dividend — Schwab (0.06%)
'DGRO', // Dividend Growth — iShares
'VYM', // High Dividend Yield — Vanguard
// ── Sector ETFs (established)
'XLK', // Technology
'XLV', // Healthcare
'XLF', // Financials
'XLE', // Energy
// ── Investment-Grade Bond ETFs
'BND', // Total Bond Market — Vanguard
'AGG', // US Aggregate Bond — iShares
'LQD', // IG Corporate Bond — iShares
'VCIT', // Intermediate Corp Bond — Vanguard
// ── Treasury ETFs
'TLT', // 20+ Year Treasury — iShares
'IEF', // 7-10 Year Treasury — iShares
'SHY', // 1-3 Year Treasury — iShares
'GOVT', // US Treasury — iShares
'SGOV', // 0-3 Month T-Bill — iShares
// ── Municipal / TIPS
'MUB', // Muni Bond — iShares
'TIP', // TIPS — iShares
];
export async function load({ fetch }) {
const res = await fetch('/api/screen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tickers: SAFE_WATCHLIST }),
});
if (!res.ok)
return { ETF: [], BOND: [], ERROR: [], marketContext: null, error: await res.text() };
const data = await res.json();
return {
ETF: data.ETF ?? [],
BOND: data.BOND ?? [],
ERROR: data.ERROR ?? [],
marketContext: data.marketContext ?? null,
};
}
+368
View File
@@ -0,0 +1,368 @@
<script>
import MarketContext from '$lib/MarketContext.svelte';
import SignalBadge from '$lib/SignalBadge.svelte';
let { data } = $props();
const SIGNAL_STRONG = '✅ Strong Buy';
// Filter to only Strong Buy in both modes — the safest picks
const strongEtfs = $derived((data.ETF ?? []).filter(r => r.signal === SIGNAL_STRONG));
const strongBonds = $derived((data.BOND ?? []).filter(r => r.signal === SIGNAL_STRONG));
// All other non-error results — "watch" tier (pass one mode but not both)
const watchEtfs = $derived((data.ETF ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const watchBonds = $derived((data.BOND ?? []).filter(r => r.signal !== SIGNAL_STRONG));
const sigOrd = s => ({'✅ Strong Buy':0,'⚡ Momentum':1,'🔄 Neutral':2,'⚠️ Speculation':3,'❌ Avoid':4})[s] ?? 5;
const sorted = arr => [...arr].sort((a, b) => sigOrd(a.signal) - sigOrd(b.signal));
const vClass = label =>
label?.startsWith('🟢') ? 'green' : label?.startsWith('🟡') ? 'yellow' : 'red';
const verdictShort = label => {
if (!label) return '—';
if (label.includes('Efficient')) return 'Efficient';
if (label.includes('Attractive')) return 'Attractive';
if (label.includes('Neutral')) return 'Hold';
if (label.includes('REJECT')) return 'Reject';
if (label.includes('Avoid')) return 'Avoid';
return label.replace(/[🟢🟡🔴]/u, '').trim();
};
const totalScreened = $derived((data.ETF?.length ?? 0) + (data.BOND?.length ?? 0));
const totalStrong = $derived(strongEtfs.length + strongBonds.length);
</script>
<div class="page">
<div class="page-header">
<div>
<h1>🛡 Safe Buys</h1>
<p class="subtitle">
Low-cost ETFs and investment-grade bonds passing <strong>both</strong> Market-Adjusted and Fundamental gates.
{totalStrong} of {totalScreened} screened assets qualify.
</p>
</div>
</div>
{#if data.error}
<div class="error-banner">{data.error}</div>
{/if}
{#if data.marketContext}
<MarketContext ctx={data.marketContext} />
{/if}
<!-- ── Strong Buy ─────────────────────────────────────────────────── -->
{#if strongEtfs.length || strongBonds.length}
<div class="strong-header">
<span class="strong-badge">✅ Strong Buy</span>
<span class="strong-sub">Pass both Market-Adjusted and Fundamental gates</span>
</div>
{#if strongEtfs.length}
<section class="section">
<div class="section-header">
<h2>ETFs</h2>
<span class="count">{strongEtfs.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>Expense</th>
<th>Yield</th>
<th>AUM</th>
<th>5Y Ret</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{#each sorted(strongEtfs) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
<td class="score">{r.inflated.scoreSummary}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if strongBonds.length}
<section class="section">
<div class="section-header">
<h2>Bond ETFs</h2>
<span class="count">{strongBonds.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>YTM</th>
<th>Duration</th>
<th>Rating</th>
<th>Score</th>
</tr>
</thead>
<tbody>
{#each sorted(strongBonds) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
<td class="score">{r.inflated.scoreSummary}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{:else}
<div class="empty-strong">
No assets currently pass both gates — market conditions may be elevated.
Check the Watch List below for assets passing at least one mode.
</div>
{/if}
<!-- ── Watch List ─────────────────────────────────────────────────── -->
{#if watchEtfs.length || watchBonds.length}
<div class="watch-header">
<span class="watch-label">👀 Watch List</span>
<span class="watch-sub">Pass one gate — monitor for entry</span>
</div>
{#if watchEtfs.length}
<section class="section watch-section">
<div class="section-header">
<h2>ETFs</h2>
<span class="count">{watchEtfs.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Signal</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>Expense</th>
<th>Yield</th>
<th>AUM</th>
<th>5Y Ret</th>
</tr>
</thead>
<tbody>
{#each sorted(watchEtfs) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><SignalBadge signal={r.signal} /></td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['Exp Ratio%'] ?? '—'}</td>
<td class="num">{m['Yield%'] ?? '—'}</td>
<td class="num">{m['AUM'] ?? '—'}</td>
<td class="num">{m['5Y Return%'] ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if watchBonds.length}
<section class="section watch-section">
<div class="section-header">
<h2>Bond ETFs</h2>
<span class="count">{watchBonds.length}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="col-ticker">Ticker</th>
<th>Price</th>
<th>Signal</th>
<th>Mkt-Adj</th>
<th>Graham</th>
<th>YTM</th>
<th>Duration</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
{#each sorted(watchBonds) as r}
{@const m = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><SignalBadge signal={r.signal} /></td>
<td><span class="vpill {vClass(r.inflated.label)}">{verdictShort(r.inflated.label)}</span></td>
<td><span class="vpill {vClass(r.fundamental.label)}">{verdictShort(r.fundamental.label)}</span></td>
<td class="num">{m['YTM%'] ?? '—'}</td>
<td class="num">{m['Duration'] ?? '—'}</td>
<td class="num">{m['Rating'] ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{/if}
</div>
<style>
.page { max-width: 1100px; padding-bottom: 60px; }
.page-header { margin-bottom: 20px; }
h1 { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-bottom: 6px; }
.subtitle { font-size: 12px; color: #475569; line-height: 1.5; }
.subtitle strong { color: #94a3b8; }
/* ── Strong Buy banner ───────────────────────────────────────────── */
.strong-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.strong-badge {
font-size: 12px;
font-weight: 700;
color: #4ade80;
background: #14532d33;
padding: 4px 14px;
border-radius: 20px;
}
.strong-sub { font-size: 11px; color: #475569; }
.empty-strong {
padding: 32px 20px;
background: #111827;
border: 1px solid #1e293b;
border-radius: 10px;
font-size: 13px;
color: #64748b;
text-align: center;
margin-bottom: 24px;
line-height: 1.6;
}
/* ── Watch List ──────────────────────────────────────────────────── */
.watch-header {
display: flex;
align-items: center;
gap: 12px;
margin-top: 28px;
margin-bottom: 12px;
}
.watch-label {
font-size: 12px;
font-weight: 700;
color: #94a3b8;
background: #1e293b;
padding: 4px 14px;
border-radius: 20px;
}
.watch-sub { font-size: 11px; color: #475569; }
/* ── Section ─────────────────────────────────────────────────────── */
.section {
background: #0d1117;
border: 1px solid #1e293b;
border-radius: 10px;
margin-bottom: 14px;
overflow: hidden;
}
.watch-section { opacity: 0.75; }
.watch-section:hover { opacity: 1; transition: opacity 0.2s; }
.section-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid #1e293b;
background: #111827;
}
h2 { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 0; }
.count { font-size: 10px; color: #334155; background: #1e293b; padding: 2px 7px; border-radius: 20px; }
/* ── Table ───────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: max-content; min-width: 100%; border-collapse: collapse; }
thead th {
text-align: left;
padding: 7px 14px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #334155;
border-bottom: 1px solid #1e293b;
white-space: nowrap;
background: #111827;
}
tbody tr { border-bottom: 1px solid #161f2e; }
tbody tr:hover { background: #131c2b; }
tbody td { padding: 10px 14px; vertical-align: middle; white-space: nowrap; font-size: 13px; }
.col-ticker,
tbody td:first-child { position: sticky; left: 0; background: #0d1117; z-index: 1; }
thead .col-ticker { background: #111827; }
tbody tr:hover td:first-child { background: #131c2b; }
.ticker { font-weight: 700; color: #f1f5f9; letter-spacing: 0.02em; }
.num { color: #64748b; font-variant-numeric: tabular-nums; font-size: 12px; }
.score { color: #475569; font-size: 11px; }
/* ── Verdict pills ───────────────────────────────────────────────── */
.vpill {
display: inline-block;
padding: 2px 9px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
}
.vpill.green { background: #14532d33; color: #4ade80; }
.vpill.yellow { background: #71350033; color: #facc15; }
.vpill.red { background: #450a0a33; color: #f87171; }
.error-banner { background: #450a0a55; border: 1px solid #7f1d1d; border-radius: 8px; color: #f87171; padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
</style>
+5
View File
@@ -0,0 +1,5 @@
import adapter from '@sveltejs/adapter-auto';
export default {
kit: { adapter: adapter() },
};
+11
View File
@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://127.0.0.1:3000',
},
},
});