phase-7: code restructure

This commit is contained in:
Sai Kiran Vella
2026-06-05 22:05:55 -04:00
parent c1b3b26caa
commit 5185f03c12
108 changed files with 8931 additions and 3434 deletions
+68
View File
@@ -0,0 +1,68 @@
{
"env": {
"node": true,
"es2020": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript"
],
"ignorePatterns": [
"node_modules",
"dist",
"ui"
],
"rules": {
"no-var": "error",
"prefer-const": "error",
"prefer-arrow-callback": "warn",
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
}
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-explicit-any": "off",
"no-undef": "off",
"import/order": "off",
"import/no-unresolved": "off"
},
"overrides": [
{
"files": [
"bin/**/*.ts",
"tests/**/*.ts"
],
"rules": {
"no-console": "off"
}
},
{
"files": [
"server/types/**/*.ts",
"server/schemas/**/*.ts"
],
"rules": {
"no-unused-vars": "off"
}
}
]
}
+433 -161
View File
@@ -45,10 +45,9 @@ npm run ui:install # install UI dependencies (ui/ sub
```
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
screen.ts ← CLI screener entry point
finance.ts ← CLI personal finance entry point
server.ts ← Fastify API server entry point (imports buildApp from server/app.ts)
scripts/
summary-reporter.js ← custom node:test reporter (silent on pass, summary line at end)
@@ -57,66 +56,95 @@ prompts/
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
server/
config/
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth)
constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER
app.ts ← Fastify app factory (buildApp). Registers CORS + all controllers.
NOTE: lives at server/app.ts, NOT inside server/controllers/.
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
controllers/ ← HTTP only: parse request, call service, return response
screener.controller.ts ← POST /api/screen, GET /api/screen/catalysts
finance.controller.ts ← GET /api/finance/portfolio, POST|DELETE /api/finance/holdings
calls.controller.ts ← CRUD for market calls + GET /api/calls/calendar
analyze.controller.ts ← POST /api/analyze (LLM analysis for a ticker set)
screener/ ← core screening domain
ScreenerEngine.js ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data),
services/ ← business logic, no HTTP or I/O concerns
ScreenerEngine.ts ← 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
DataMapper.ts ← normalises Yahoo payload → flat asset data object.
Computes: DCF intrinsic value, analyst upside, 52W movement fields,
grossMargin, marketCap. Uses trailingPE. Preserves negative FCF.
RuleMerger.ts ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
BenchmarkProvider.ts ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext.
In-memory cache: 1 hr TTL. Resets on server restart.
MarketRegime.tsderives INFLATED gate overrides from live benchmarks + rate regime
CatalystAnalyst.tsfetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }.
LLMAnalyst.ts ← uses AnthropicClient to analyze headlines → summary, sentiment,
affectedIndustries, relatedTickers. Returns null if API key not set.
PersonalFinanceAnalyzer.ts ← net worth, cash vs investments, spending by category
PortfolioAdvisor.ts ← cross-references holdings with screener signals → hold/sell/add advice
index.ts ← barrel re-export (import services from here, not individual files)
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 }.
repositories/ ← data persistence only (JSON file read/write)
MarketCallRepository.ts ← persists market thesis entries to market-calls.json.
CRUD: list/get/create/delete.
PortfolioRepository.ts ← read/write portfolio.json. Methods: read, upsert, remove.
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.
clients/ ← external API connectors, one class per third-party system
YahooFinanceClient.ts ← wraps yahoo-finance2 v3, retry + backoff. Methods: fetchSummary,
fetchCalendarEvents, search. Typed via YahooFinanceLib interface.
SimpleFINClient.ts ← claims setup token → access URL, fetches /accounts via Basic Auth.
AnthropicClient.ts ← wraps Anthropic SDK. complete(system, user) → raw text response.
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
models/ ← domain entity classes with metrics + display logic
Asset.ts ← abstract base: ticker, currentPrice, type, formatting helpers
Stock.ts ← metrics + _mapToStandardSector (8 sectors) + _classifyMarketCap
(Mega/Large/Mid/Small/Micro) + _classifyGrowth (style classification).
Holds: valuation, quality, risk, 52W movement, analyst consensus, DCF.
Etf.ts ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets
Bond.ts ← metrics: ytm, duration, creditRating, creditRatingNumeric
reporters/
HtmlReporter.jsrender() → HTML string (server), generate() → writes file (CLI)
FinanceReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
scorers/ ← stateless pure scoring functions, no I/O
StockScorer.ts gate checks + weighted registry:
core: ROE, opMargin, margin, peg, revenue, fcf
expert: analyst consensus (inverted Yahoo 15 scale), DCF margin of safety
riskFlags: beta, 52W position, 52W momentum, analyst divergence, DCF divergence
EtfScorer.ts ← expense gate + registry (cost, yield, volume, fiveYearReturn)
BondScorer.ts ← credit gate + spread/duration scoring
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)
reporters/ ← HTML rendering, no business logic
HtmlReporter.ts ← render() → HTML string (server), generate() → writes file (CLI)
FinanceReporter.ts ← render() → HTML string (server), generate() → writes file (CLI)
config/
ScoringConfig.ts ← CREDIT_RATING_SCALE + ScoringRules (single source of truth for all
gates, weights, thresholds including analyst and dcf weights)
constants.ts ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER,
CAP_CATEGORY (Mega/Large/Mid/Small/Micro),
GROWTH_CATEGORY (High Growth/Growth/Stable/Value/Turnaround/Declining)
types/ ← all domain types, one file per domain
asset.model.ts ← Signal, AssetType, ScoreMode, ScoringRules, ScoreResult, AssetResult,
ScreenerResult, GateSet, WeightSet, ThresholdSet, RuleBlock, StockRules
market.model.ts ← RateRegime, VolatilityRegime, Benchmarks, MarketContext
portfolio.model.ts ← HoldingType, PortfolioHolding, PortfolioAdvice, AdviceRow
calls.model.ts ← TickerSnapshot, MarketCall, SnapshotEntry, CreateCallInput
finance.model.ts ← LLMAnalysis, CatalystStory, CalendarEvent, SimpleFINAccount,
SimpleFINTransaction, SimpleFINData, YahooNewsItem, YahooFinanceLib
logger.model.ts ← Logger
models.model.ts ← AssetData, StockData, StockMetrics, EtfData, EtfMetrics, BondData, BondMetrics
repositories.model.ts ← StoreData, PortfolioData
scorers.model.ts ← NumVal, SanitizedMetrics, SanitizedBondMetrics
services.model.ts ← BenchmarkProviderOptions, InflatedOverrides, ErrorResult, RuleSet,
ScreenerEngineOptions, CatalystResult, MappedData, and other service shapes
schemas.ts ← Fastify JSON Schema objects for request body validation
index.ts ← re-exports all of the above (use this barrel for imports)
types.ts ← thin barrel: export type * from './types/index.js'
utils/
Chunker.ts ← splits ticker list into batches
logger.ts ← noopLogger constant for silencing output in server context
ui/ ← SvelteKit dashboard (lives inside this repo, not a separate repo)
src/
styles/ ← global SCSS design-token system (Phase 4)
styles/ ← global SCSS design-token system
app.scss ← root file — @use all partials
_tokens.scss ← CSS custom properties generated from SCSS maps ($bg, $text, $blues, $signals…)
_reset.scss ← box-sizing reset + body base
@@ -126,24 +154,30 @@ ui/ ← SvelteKit dashboard (lives inside this repo, not a
_buttons.scss ← button base, .btn-primary, .btn-catalyst, .btn-ghost, .btn-screen, .btn-analyze
_badges.scss ← .verdict-pill, .sentiment-pill, .text-* helpers (SCSS @each maps)
lib/
api.js ← typed fetch wrappers for all API routes
api.ts ← backward-compat shim; re-exports from api/index.ts
api/
screener.ts ← screenTickers, fetchCatalysts, analyzeTickers
finance.ts ← fetchPortfolio, addHolding, removeHolding, fetchMarketContext
calls.ts ← fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar
index.ts ← barrel re-export
types.ts ← re-exports shared types from $types (server/types/); UI-only types defined here
utils.ts ← shared pure functions: sigOrd, sorted, verdictShort, vClass, fmtPE, fmt…
MarketContext.svelte ← collapsible card-grid context (used in portfolio + safe-buys)
MarketContextStrip.svelte ← horizontal chip strip (used in screener — Phase 5)
AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table (Phase 5)
AnalysisSidebar.svelte ← LLM analysis slide-over panel (Phase 5)
VerdictPill.svelte ← verdict-pill span; props: label (Phase 5)
MarketContextStrip.svelte ← horizontal chip strip (used in screener)
AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table
AnalysisSidebar.svelte ← LLM analysis slide-over panel
VerdictPill.svelte ← verdict-pill span; props: label
SignalBadge.svelte ← signal emoji + label badge
Spinner.svelte ← sm: dot-pulse | md/lg: chart-line animation
routes/
+page.js ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount (Phase 5)
+page.svelte ← main screener UI (~230 lines after Phase 5 decomposition)
+page.ts ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount
+page.svelte ← main screener UI
+layout.svelte ← shell, nav, nav-progress bar, nav-overlay with Spinner
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)
market-calls.json ← persisted market thesis calls (written by MarketCallRepository)
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY
```
@@ -161,8 +195,11 @@ BenchmarkProvider — fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND)
uses trailingPE as primary; preserves negative FCF yield; infers bond duration
computes: DCF intrinsic value, analyst upside, 52W movement, grossMargin, marketCap
Asset subclass — Stock / Etf / Bond holds metrics + getDisplayMetrics()
Stock also derives capCategory (Mega/Large/Mid/Small/Micro)
and growthCategory (High Growth/Growth/Stable/Value/Turnaround/Declining)
RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style)
INFLATED mode: sector override + MarketRegime live gate overrides
@@ -182,7 +219,7 @@ ScreenerEngine — derives Signal from comparing both verdicts
| 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 |
| POST | `/api/screen` | Screen tickers. Body: `{ tickers: string[] }` (max 50). 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 |
@@ -195,6 +232,8 @@ ScreenerEngine — derives Signal from comparing both verdicts
CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`).
Request body validation uses Fastify JSON Schema, defined in `server/types/schemas.ts`.
---
## Scoring Modes
@@ -221,7 +260,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
## ScoringConfig Key Values
`server/config/ScoringConfig.js` — single source of truth for all gates, weights, thresholds.
`server/config/ScoringConfig.ts` — single source of truth for all gates, weights, thresholds.
**STOCK base gates (Fundamental mode):**
- `maxPERatio: 15` — Graham's actual rule (trailing P/E)
@@ -229,6 +268,12 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
- `maxDebtToEquity: 1.5` — most distress starts above 2x
- `minQuickRatio: 0.8` — below this signals real liquidity stress
**STOCK base weights:**
- `roe: 3`, `fcf: 3` — primary quality signals
- `opMargin: 2`, `margin: 2`, `peg: 2`, `revenue: 2` — secondary factors
- `analyst: 2` — Wall Street consensus (Yahoo 15 scale inverted; requires ≥3 analysts)
- `dcf: 2` — DCF margin of safety (positive = undervalued; only fires when FCF > 0)
**Sector overrides** (structural — apply in both modes):
| Sector | Key difference |
@@ -256,7 +301,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
## MarketRegime (INFLATED overrides)
`server/market/MarketRegime.js` derives gate overrides from live benchmarks and current rate regime:
`server/services/MarketRegime.ts` derives gate overrides from live benchmarks and current rate regime:
| Gate | Formula (NORMAL rates) | Formula (HIGH rates) |
|---|---|---|
@@ -267,6 +312,76 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
| Bond minSpread | LQDTNX × 0.80 | LQDTNX × 0.90 |
| ETF maxExpenseRatio | 0.75% | 0.75% |
Rate regime thresholds: `< 2%` = LOW, `25%` = NORMAL, `> 5%` = HIGH (10Y Treasury yield).
**Known issue**: sharp break at 5% can flip the scoring regime between two back-to-back requests when the 10Y hovers near the threshold. Consider smoothing or making the threshold configurable via env var.
---
## Expert Scoring Features (Stock)
Added to `DataMapper.ts` (extraction) and `StockScorer.ts` (scoring).
All data comes from Yahoo's existing modules — no extra API calls required.
### DCF Intrinsic Value
Two-stage discounted cash flow model computed per stock with positive TTM FCF:
- **Stage 1**: FCF per share grows at the earnings/revenue growth rate for 5 years, discounted at 9.5% (4% risk-free + 5.5% equity risk premium)
- **Stage 2**: Terminal value at 2.5% perpetuity growth (Gordon Growth Model)
- Growth rate capped at 30%, floored at -5%
- Returns `dcfIntrinsicValue` ($ per share) and `dcfMarginOfSafety` (% undervaluation)
Scoring: ≥20% margin of safety → +dcf weight; 020% → +1; -20% to 0 → -1; < -20% → -dcf weight.
Only fires when FCF > 0. Risk flags trigger at ±30% divergence.
### Analyst Consensus
From `financialData.recommendationMean` (Yahoo scale: 1.0 = Strong Buy, 5.0 = Strong Sell):
- Requires ≥3 analysts to avoid noise from thin coverage
- ≤2.0 → full weight; ≤3.0 → +1; ≤4.0 → -1; >4.0 → -full weight
- `analystTargetPrice`, `analystUpside` (% to target), `numberOfAnalysts` surfaced in display
- Risk flags trigger at ≥25% upside or ≤-15% downside vs analyst target
### 52-Week Movement
Three fields replace the single `52W Pos` position metric:
| Field | Meaning |
|---|---|
| `52W Chg` | Total % price return over last 52 weeks (`ks['52WeekChange']`) |
| `From High` | % current price is below 52-week high (negative = below peak) |
| `From Low` | % current price is above 52-week low (positive = recovered) |
Risk flags: strong uptrend (≥+50%), significant drawdown (≤-30%), >20% off 52W high.
### Market Cap Segmentation
`Stock._classifyMarketCap()` derives `capCategory` from `price.marketCap`:
| Tier | Threshold |
|---|---|
| Mega Cap | > $200B |
| Large Cap | $10B $200B |
| Mid Cap | $2B $10B |
| Small Cap | $300M $2B |
| Micro Cap | < $300M |
### Growth / Style Classification
`Stock._classifyGrowth()` derives `growthCategory` from revenue growth, earnings growth, and dividend yield:
| Category | Condition |
|---|---|
| High Growth | revenueGrowth ≥ 15% OR earningsGrowth ≥ 20% |
| Growth | revenueGrowth 515% |
| Value | revenueGrowth < 5% AND dividendYield ≥ 3% |
| Stable | Low growth, modest or no dividend |
| Turnaround | earningsGrowth < 0% AND revenueGrowth ≥ 0% |
| Declining | revenueGrowth < -5% |
Both `Cap Tier` and `Style` appear in `getDisplayMetrics()` and the screener table.
---
## Sector Detection
@@ -292,8 +407,14 @@ GENERAL → fallback
- **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
- **grossMargin**: `financialData.grossMargins * 100` — exposed in display; not yet a scoring factor
- **marketCap**: `price.marketCap` — used for cap tier classification
- **analystRating**: `financialData.recommendationMean` (1=Strong Buy, 5=Strong Sell). `targetMeanPrice` and `numberOfAnalystOpinions` also extracted
- **52W movement**: `defaultKeyStatistics['52WeekChange']` for annual return; `From High`/`From Low` computed from `fiftyTwoWeekHigh`/`fiftyTwoWeekLow`
- **DCF growth rate**: uses `earningsGrowth` (TTM decimal) first, falls back to `revenueGrowth * 0.7`. Capped at 30%, floored at -5%
- **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
- **D/E ratio**: Yahoo returns `financialData.debtToEquity` as a percentage (e.g. 175.56 for 1.7556×); dividing by 100 normalises to the standard ratio
- **Quick ratio**: falls back to `currentRatio` when missing — known methodological issue; current ratio includes inventory and can overstate liquidity for retailers/manufacturers
---
@@ -301,31 +422,31 @@ GENERAL → fallback
- 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.
- DCF, analyst, and 52W scoring factors are all optional — they activate only when the underlying data is non-null.
---
## Logger Injection Pattern
Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context:
Classes that produce output accept an optional `{ logger }` constructor option so they work cleanly in server context. Pass `noopLogger` from `server/utils/logger.ts` to silence all output.
```js
Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.
```ts
// CLI (default) — writes to stdout
new ScreenerEngine()
// Server — fully silent
new ScreenerEngine({ logger: { write: () => {}, log: () => {}, warn: () => {} } })
new ScreenerEngine({ logger: noopLogger })
```
Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.
---
## Reporter Pattern
Both reporters have two methods:
```js
```ts
reporter.render(...) // → HTML string (use in server route responses)
reporter.generate(...) // → writes file to disk, returns path (use in CLI)
```
@@ -371,7 +492,7 @@ tests/
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
PortfolioAdvisor.test.js ← position gain/loss calc, advice signal mapping, BRK.B normalisation
LLMAnalyst.test.js ← markdown fence stripping, JSON parse correctness
```
@@ -381,121 +502,272 @@ Test output: silent on pass, shows only failures + one summary line (`scripts/su
**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.
**Coverage gaps (known):**
- `MarketCallStore.js` — no tests; CRUD against `market-calls.json` is untested
- `MarketCallRepository.ts` — no tests; CRUD against `market-calls.json` is untested
- `LLMAnalyst.test.js` — tests a local copy of the fence-stripping regex rather than importing from source; will silently drift if the regex changes
- API routes (`server/server/routes/`) — no integration tests; covered implicitly by manual testing only
- UI components — not tested at the unit level (Phases 45 are pure UI; no server logic changed)
- API controllers (`server/controllers/`) — no integration tests; covered implicitly by manual testing only
- Expert scoring features (analyst, DCF, 52W) — not yet covered in `StockScorer.test.js`
- UI components — not tested at the unit level
---
## Conventions
## Architecture Guide
- Asset `type` (uppercased) is the routing key across DataMapper, asset classes, `SCORERS` map, and ScoringRules.
This section is the single reference for where code lives and how to add features. Read this before touching any file.
### Server layer map
| Folder | Role | Rule |
|---|---|---|
| `server/app.ts` | Fastify bootstrap — wires DI, registers controllers | No business logic |
| `server/controllers/` | HTTP only — parse request, call service, return response | No business logic, no file I/O, no external API calls |
| `server/services/` | Business logic and orchestration | No HTTP concerns (`req`/`reply`), no direct file I/O |
| `server/repositories/` | Data persistence — JSON file read/write | No business logic; one class per data file |
| `server/clients/` | External API connectors — one class per third-party system | No business logic; only I/O and protocol handling |
| `server/models/` | Domain entity classes — hold metrics and `getDisplayMetrics()` | No I/O; pure data + formatting |
| `server/scorers/` | Stateless pure scoring functions | No I/O, no state; `score(metrics, rules, marketContext)` only |
| `server/reporters/` | HTML rendering | No business logic; `render()` → string, `generate()` → file |
| `server/config/` | Constants and scoring gates/weights | No logic; change numbers here, not in scorers |
| `server/types/` | TypeScript interfaces and types | No logic; one `*.model.ts` per domain |
| `server/utils/` | Shared pure utilities | No domain knowledge |
| `bin/` | CLI entry points | Call into services only; the only place `process.exit()` is allowed |
### UI layer map
| Path | Role |
|---|---|
| `ui/src/routes/*/+page.ts` | SvelteKit load functions — fetch data for the page |
| `ui/src/routes/*/+page.svelte` | Route component — composition only; minimal logic |
| `ui/src/lib/api/screener.ts` | Fetch wrappers for `/api/screen*` |
| `ui/src/lib/api/finance.ts` | Fetch wrappers for `/api/finance/*` |
| `ui/src/lib/api/calls.ts` | Fetch wrappers for `/api/calls/*` |
| `ui/src/lib/stores/` | Reactive state shared across components (Svelte 5 runes) |
| `ui/src/lib/components/` | Generic shared components (VerdictPill, SignalBadge, Spinner) |
| `ui/src/lib/portfolio/` | Portfolio-specific components |
| `ui/src/lib/calls/` | Calls-specific components |
| `ui/src/lib/types.ts` | Re-exports server types via `$types` alias; UI-only types defined here |
| `ui/src/lib/utils.ts` | Pure formatting and sorting helpers |
| `ui/src/styles/` | SCSS partials — global design tokens, layout, tables, forms |
### Where to put a new type
- Shared domain type (used by server + UI): `server/types/<domain>.model.ts` and re-export from `server/types/index.ts`
- UI-only type (component state, display shape): `ui/src/lib/types.ts`
- Private implementation detail used only within one file: inline in that file
### Where to put new code — decision table
| What you're adding | Where it goes |
|---|---|
| New API endpoint | `server/controllers/<domain>.controller.ts` + register in `server/app.ts` |
| Business logic for that endpoint | New method in `server/services/<Domain>.ts` |
| Call to a new external API | New class in `server/clients/<Service>Client.ts` |
| New data stored in a JSON file | New class in `server/repositories/<Domain>Repository.ts` |
| New scoring rule or gate value | `server/config/ScoringConfig.ts` |
| New market regime override | `server/services/MarketRegime.ts``getInflatedOverrides()` |
| New stock metric (mapped from Yahoo) | `server/services/DataMapper.ts``mapStockData()` + `StockData`/`StockMetrics` interfaces in `server/types/models.model.ts` + `server/models/Stock.ts` constructor + `getDisplayMetrics()` |
| New scoring factor | `server/config/ScoringConfig.ts` (add weight + threshold) + `server/scorers/StockScorer.ts` (add factor to array) |
| New UI page | `ui/src/routes/<name>/+page.ts` + `+page.svelte` |
| New UI fetch call | `ui/src/lib/api/<domain>.ts` + re-export from `api/index.ts` |
| Reactive state shared by >1 component | `ui/src/lib/stores/<domain>.store.ts` |
| New shared UI component | `ui/src/lib/components/` (generic) or domain subfolder |
| New global style | `ui/src/styles/_<domain>.scss` + `@use` in `app.scss` |
### Conventions
- Asset `type` (uppercased: `STOCK` / `ETF` / `BOND`) is the routing key across `DataMapper`, model classes, `SCORERS` map, and `ScoringRules`. Keep it consistent everywhere.
- 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 `server/`.
- `bin/server.js` starts Fastify; `server/server/` contains all route logic.
- `BenchmarkProvider` caches for 1 hour in memory — cache is lost on server restart. A persistent cache is planned (see Phase 8).
- All entry points live in `bin/`. Do not add logic there — they call into `services/` and controllers.
- **Never** call `process.exit()` inside `server/` — only `bin/` may do that.
- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `server/server/routes/screener.js` `serializeAssets()`).
- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `serializeAssets()` in `screener.controller.ts`).
- Controllers use constructor injection — dependencies are wired in `server/app.ts`, not created inside handlers.
- The `$types` alias in the UI resolves to `server/types/` — use it instead of duplicating type definitions.
- Ticker normalisation (`BRK.B → BRK-B`) currently only happens in `FinanceController.normalizeYahoo()`. Submitting `BRK.B` directly to `/api/screen` will fail. Fix target: move normalisation into `YahooFinanceClient.fetchSummary()`.
### Adding a new scoring metric — step-by-step
When adding a new data point that flows from Yahoo → scoring:
1. Extract field in `DataMapper.mapStockData()` from the Yahoo payload
2. Add field to `StockData` and `StockMetrics` interfaces in `server/types/models.model.ts`
3. Add field to `Stock` constructor assignment and `getDisplayMetrics()` in `server/models/Stock.ts`
4. Add weight + threshold to `ScoringConfig.ts` base weights/thresholds (and any relevant sector overrides)
5. Add the field to `SanitizedMetrics` and `_sanitize()` in `StockScorer.ts`
6. Add a factor entry to the `factors` array in `StockScorer.score()`
7. Add a test case to `StockScorer.test.js`
**Warning**: The `scorer.score(asset.metrics as never, ...)` cast in `ScreenerEngine._process()` bypasses TypeScript for the scorer dispatch. If you add a field to `StockMetrics` but forget to add it to `SanitizedMetrics`, the compiler will not catch it — the scorer will silently receive `undefined`. Always update `_sanitize()` when adding metrics.
---
## Architecture Roadmap
Planned improvements in priority order. Do not start a later phase before completing earlier ones.
### Phases 17 ✅ COMPLETE
### Phase 1 — Cleanup ✅ COMPLETE
All items completed. Additional features delivered alongside cleanup:
All phases of the original roadmap are done. Summary of what was delivered:
- Phase 14: CLI cleanup, shared utilities, TypeScript migration, SCSS design tokens
- Phase 5: `+page.svelte` decomposed into `AssetTable`, `AnalysisSidebar`, `MarketContextStrip`, `VerdictPill`
- Phase 6: Full TypeScript conversion across `server/` and `bin/`
- Phase 7a7b: Type domain split, API module split
- Phase 7f: Server layer restructured to layered architecture (controllers / services / repositories / clients / models / scorers)
- Phase 7g: Controllers converted to classes with DI, types moved to domain files, `YahooFinanceClient` properly typed
**Cleanup done:**
- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md`
- Deleted `server/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
**Pending UI work (Phase 7c7e):**
- 7c: Decompose `portfolio/+page.svelte` (751 lines) into `AddHoldingForm`, `InlineEditRow`, `AdviceTable`, `AccountsTable`; decompose `calls/+page.svelte` (385 lines) into `CallForm`, `CallCard`, `CalendarSection`
- 7d: Add `ui/src/lib/stores/` layer — `screener.store.ts`, `portfolio.store.ts`
- 7e: Extract inline `<style>` blocks from portfolio, calls, and AnalysisSidebar into `_forms.scss`, `_sidebar.scss`, `_calls.scss`, `_portfolio.scss`
**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 8 — Server Hardening & Quality
### Phase 2 — Extract Shared Utilities ✅ COMPLETE
Priority order. Complete earlier items before starting later ones.
**Done:**
- Created `ui/src/lib/utils.ts` — typed shared pure functions: `sigOrd`, `sorted`, `verdictShort`, `vClass`, `fmtPE`, `fmt`, `fmtShort`, `glClass`, `advClass`. Exports `Signal` type.
- Created `server/server/utils/logger.js` — shared `noopLogger` constant, imported by `screener.js`, `app.js`, `finance.js`, and `calls.js`
- Added TypeScript support to `ui/``tsconfig.json` extending SvelteKit's generated config, `typescript` and `svelte-check` added as dev dependencies
- All three pages (`+page.svelte`, `safe-buys/+page.svelte`, `portfolio/+page.svelte`) now import from `$lib/utils.js` instead of duplicating logic
#### 8a — Fix `as never` scorer dispatch in `ScreenerEngine`
### Phase 3 — Rename `src/` → `server/` ✅ COMPLETE
`scorer.score(asset.metrics as never, ...)` bypasses TypeScript. Replace with a properly typed discriminated union or per-type dispatch so the compiler can verify that `StockMetrics` matches `StockScorer.score()`'s signature. This is the highest-risk cast in the scoring pipeline.
**Done:**
- Renamed `src/` to `server/``src/server/` is now `server/server/`
- Updated all import paths in `bin/`, `tests/`, and `CLAUDE.md`
#### 8b — Inject dependencies into `ScreenerEngine` and `PortfolioAdvisor`
### Phase 4 — SCSS Migration ✅ COMPLETE
Both classes self-construct their `YahooFinanceClient` and `BenchmarkProvider` in the constructor, making unit testing impossible without monkey-patching. Target:
**Done:**
- Created `ui/src/styles/` with 7 partials + `app.scss` root (imported by `+layout.svelte`)
- `_tokens.scss` uses SCSS maps (`$bg`, `$borders`, `$text`, `$blues`, `$signals`) with `@each` loops to emit CSS custom properties — adding a token is one line in the map
- `_badges.scss` uses `@each` + `map.get` for verdict/sentiment color variants and `.text-*` helpers; shared `%pill-base` placeholder
- `_buttons.scss` uses `%btn-disabled` / `%btn-inline-flex` placeholders + nested `&:hover` / `&:disabled`
- `_section.scss`, `_table.scss`, `_layout.scss` use SCSS nesting throughout
- `.vpill` (safe-buys) unified with `.verdict-pill` (screener) — inconsistency resolved
- All component `<style>` blocks trimmed to component-unique rules only; `+layout.svelte` style block removed entirely
- Nav links now highlight immediately on click via `activePath` derived from `$navigating` (not `$page`)
- `+layout.svelte` nav-overlay uses `<Spinner>` component instead of legacy CSS spinner
**Note:** `sass` must be installed in `ui/` (`npm install -D sass --legacy-peer-deps`). Map keys that are CSS color names (`'green'`, `'red'`, `'blue'`, etc.) must be quoted to avoid Sass color-value interpolation warnings.
### Phase 5 — Decompose `+page.svelte` ✅ COMPLETE
**Done:**
- `VerdictPill.svelte` — wraps `<span class="verdict-pill {vClass(label)}">`. Used in screener summary, detail tables, and safe-buys (replacing inline spans + removing `verdictShort`/`vClass` imports from safe-buys)
- `MarketContextStrip.svelte` — horizontal chip strip extracted from `+page.svelte`. Uses a `$derived` chips array so the template is declarative (no repeated markup blocks)
- `AssetTable.svelte` — full STOCK/ETF/BOND section: section-header, mode tabs (owns `mode` state internally), Analyze button, complete table per type. Props: `type`, `rows`, `analyzeLoading`, `onAnalyze`
- `AnalysisSidebar.svelte` — LLM slide-over panel. Props: `sidebar` (state object from parent), `onClose`. All `sb-*` styles live here
- `+page.js``export const ssr = false`; `load()` fetches catalysts then screens them. Component receives `data.results` + `data.catalystInput` as props — replaces `_booted` / `$effect` hack entirely
- `loadCatalysts()` split: initial load handled by `+page.js`, user-triggered refresh is `reloadCatalysts()` in the component
- `+page.svelte` reduced from ~600 lines to ~230 lines
### 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 }
export class ScreenerEngine {
constructor(
private readonly client: YahooFinanceClient,
private readonly benchmarkProvider: BenchmarkProvider,
{ logger }: ScreenerEngineOptions = {},
) {}
}
```
SvelteKit supports TypeScript natively — components just need `<script lang="ts">`.
Wire in `server/app.ts`. This unblocks proper service-layer unit tests.
### 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.
#### 8c — Controller integration tests
Add one Fastify `inject()` smoke test per route using a fixture for `ScreenerEngine.screenTickers()`. Catches schema validation regressions and response shape changes without needing live Yahoo access. Target: `tests/screener.controller.test.js`, `tests/calls.controller.test.js`.
#### 8d — Repository tests
`MarketCallRepository` has zero test coverage. Add `tests/MarketCallRepository.test.js` using a temp file path (inject via constructor or env var) to test `list`, `create`, `delete`, and concurrent-write safety.
#### 8e — Ticker normalisation in `YahooFinanceClient`
`BRK.B → BRK-B` normalisation lives only in `FinanceController`. Move it to `YahooFinanceClient.fetchSummary()` so it applies to all callers including `/api/screen`.
```ts
async fetchSummary(ticker: string, ...): Promise<any> {
const normalized = ticker.replace(/\./g, '-');
return await this.lib.quoteSummary(normalized, { modules: YAHOO_MODULES });
}
```
#### 8f — Persistent benchmark cache
`BenchmarkProvider`'s 1-hour cache is in-memory only — cold start after every restart adds 24s latency to the first request. Write the cached `MarketContext` to `.benchmark-cache.json` (or a single-row SQLite table). Read it on boot; only re-fetch if stale.
#### 8g — Rate limiting + API key auth
Add `@fastify/rate-limit` on `/api/screen` and `/api/analyze` (e.g. 10 req/min per IP). Add a simple `Authorization: Bearer <key>` check against an `API_KEY` env var as middleware in `server/app.ts`. Both are single-digit line additions.
#### 8h — Extract `CalendarService`
`CallsController.calendar()` is 80+ lines of inline event construction, date parsing, and sorting — all inside a controller method. Extract to `server/services/CalendarService.ts` to make it testable and keep the controller under 50 lines.
#### 8i — SQLite migration for repositories
Both `market-calls.json` and `portfolio.json` use `writeFileSync` with no concurrency guard. Two concurrent writes within the same event loop tick will lose one write. Replace with `better-sqlite3` for both repositories: concurrent-write safe, atomic transactions, no extra infrastructure. At current portfolio sizes the footprint is trivial.
#### 8j — Cache `CatalystAnalyst` results
`CatalystAnalyst.run()` fires fresh Yahoo news queries on every `/api/screen/catalysts` call. Cache the result for 15 minutes. A new `CatalystAnalyst` instance is also created on each call inside `ScreenerController.catalysts()` — hoist it to a class-level singleton wired in `server/app.ts`.
#### 8k — Add expert scoring tests
Update `StockScorer.test.js` to cover the three new scoring factors: analyst consensus scoring (including the `numberOfAnalysts < 3` guard), DCF margin of safety scoring (positive/negative/null cases), and the new 52W risk flags.
---
## Adding a New Asset Type
1. Create a subclass of `Asset` in `server/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 `server/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 `server/server/routes/screener.js`.
1. Create a subclass of `Asset` in `server/models/` with a flat `metrics` object and `getDisplayMetrics()`.
2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `server/config/ScoringConfig.ts`.
3. Add inflated overrides in `server/services/MarketRegime.ts``getInflatedOverrides()`.
4. Create a Scorer in `server/scorers/` exposing `score(metrics, rules, marketContext)`.
5. Add a mapper branch in `server/services/DataMapper.ts`.
6. Wire into `server/services/ScreenerEngine.ts`: add `case` in `_buildAsset`, entry in `SCORERS` map.
7. Add the new type to `serializeAssets()` in `server/controllers/screener.controller.ts`.
---
## Clean Architecture Pattern (Server-Side)
### Core Principles
1. **Types in `server/types/`** — Domain shapes only; one `*.model.ts` per domain
2. **Schemas in `server/types/schemas.ts`** — Fastify JSON Schema objects for request body validation
3. **Class-Based Implementation** — All `.ts` files (except types/schemas) are classes
4. **No Inline Interfaces** — All types in `server/types/`; private-only shapes may stay inline
5. **Module-Level Cleanliness** — No module-level constants or functions outside classes
6. **Direct Imports** — Import directly from source files; use `server/types/index.ts` barrel for types
7. **Service barrel**`server/services/index.ts` re-exports all services so controllers import from one path
### Implementation Pattern
#### Controllers (Classes with DI)
```typescript
export class ScreenerController {
constructor(private readonly engine: ScreenerEngine) {}
register(app: FastifyInstance): void {
app.post('/api/screen', { schema: screenSchema }, this.screen.bind(this));
}
private async screen(req: FastifyRequest) {
const tickers = (req.body as { tickers: string[] }).tickers.map(t => t.toUpperCase());
return this.engine.screenTickers(tickers);
}
}
```
#### Services (Instance methods, static helpers)
```typescript
export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000;
async getMarketContext(): Promise<MarketContext> { ... }
private static rateRegime(rate: number): string {
return rate < 2 ? 'LOW' : rate <= 5 ? 'NORMAL' : 'HIGH';
}
}
```
#### Scorers (Static-only classes)
```typescript
export class StockScorer {
static score(metrics: StockMetrics, rules: ScoringRules): ScoreResult { ... }
private static _sanitize(m: StockMetrics): SanitizedMetrics { ... }
}
```
### Import Guidelines
```typescript
// ✅ CORRECT
import { ScreenerEngine } from '../services/ScreenerEngine'; // direct for implementations
import { ScreenerEngine } from '../services'; // via barrel also acceptable
import type { StockMetrics } from '../types'; // always use types barrel
import { screenSchema } from '../types/schemas'; // direct for schemas
// ❌ WRONG
import type { StockMetrics } from '../types/models.model'; // use barrel instead
```
### Adding a New API Endpoint
1. Define types → `server/types/<domain>.model.ts`
2. Define schema → `server/types/schemas.ts`
3. Create service → `server/services/<Domain>Service.ts`
4. Wire controller → `server/controllers/<domain>.controller.ts`
5. Register → `server/app.ts`
+8 -8
View File
@@ -3,13 +3,13 @@
*/
import 'dotenv/config';
import { readFileSync, existsSync } from 'fs';
import { SimpleFINClient, saveAccessUrlToEnv } from '../server/finance/clients/SimpleFINClient.js';
import { PersonalFinanceAnalyzer } from '../server/finance/PersonalFinanceAnalyzer.js';
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
import { FinanceReporter } from '../server/reporters/FinanceReporter.js';
import type { PortfolioHolding } from '../server/types.js';
import { existsSync, readFileSync } from 'fs';
import { SimpleFINClient, saveAccessUrlToEnv } from '../server/clients/SimpleFINClient';
import { FinanceReporter } from '../server/reporters/FinanceReporter';
import { PersonalFinanceAnalyzer } from '../server/services/PersonalFinanceAnalyzer';
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
import { ScreenerEngine } from '../server/services/ScreenerEngine';
import type { PortfolioHolding } from '../server/types';
const PORTFOLIO_PATH = './portfolio.json';
@@ -40,7 +40,7 @@ async function main(): Promise<void> {
const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv });
await client.init();
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
personalFinance = new PersonalFinanceAnalyzer().analyze(accounts);
process.stdout.write(` ${accounts.length} accounts loaded\n`);
} catch (err) {
process.stdout.write(` skipped — ${(err as Error).message}\n`);
+4 -4
View File
@@ -12,9 +12,9 @@
*/
import 'dotenv/config';
import { CatalystAnalyst } from '../server/analyst/CatalystAnalyst.js';
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
import { HtmlReporter } from '../server/reporters/HtmlReporter.js';
import { CatalystAnalyst } from '../server/services/CatalystAnalyst';
import { ScreenerEngine } from '../server/services/ScreenerEngine';
import { HtmlReporter } from '../server/reporters/HtmlReporter';
const DEFAULT_WATCHLIST: string[] = [
'PLTR',
@@ -54,7 +54,7 @@ async function main(): Promise<void> {
tickers = newsTickers;
console.log("\n📰 Stories driving today's screen:");
stories.slice(0, 5).forEach((s) => {
const tags = s.relatedTickers.slice(0, 3).join(', ');
const tags = s.tickers.slice(0, 3).join(', ');
console.log(`${s.title}${tags ? ` [${tags}]` : ''}`);
});
console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`);
+1 -1
View File
@@ -1,5 +1,5 @@
import 'dotenv/config';
import { buildApp } from '../server/server/app.js';
import { buildApp } from '../server/app';
const PORT = process.env.PORT ?? 3000;
const HOST = process.env.HOST ?? '0.0.0.0';
+737
View File
@@ -0,0 +1,737 @@
{
"info": {
"name": "Market Screener API",
"description": "Full test suite for the market-screener Fastify server.\n\nBase URL is stored in the `baseUrl` collection variable (default: http://localhost:3000).\n\nWorkflow order for a clean session:\n1. Health Check\n2. Screen Tickers (creates results to inspect)\n3. Get Market Context\n4. Get Catalysts\n5. Add Holdings → Get Portfolio\n6. Create Market Call → Get Call → Calendar\n7. Analyze\n8. Cleanup (delete holding, delete call)",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:3000",
"type": "string"
},
{
"key": "callId",
"value": "",
"type": "string",
"description": "Set automatically by the Create Market Call test script"
}
],
"item": [
{
"name": "Health",
"item": [
{
"name": "Health Check",
"request": {
"method": "GET",
"url": "{{baseUrl}}/health",
"description": "Confirms the server is running. Expects { status: 'ok' }."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"pm.test('Body has status ok', () => {",
" const json = pm.response.json();",
" pm.expect(json.status).to.eql('ok');",
"});"
]
}
}
]
}
]
},
{
"name": "Screener",
"item": [
{
"name": "Screen — Mixed (STOCK + ETF + BOND)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\", \"VOO\", \"AGG\"]\n}"
},
"description": "Screens a mixed set of stocks, an ETF (VOO), and a bond ETF (AGG).\n\nExpect each result to have:\n- asset.ticker, asset.type, asset.currentPrice\n- asset.displayMetrics (Cap Tier, Style, Analyst, DCF Safety, 52W fields)\n- fundamental + inflated score labels\n- signal (Strong Buy / Momentum / Speculation / Neutral / Avoid)\n- marketContext (riskFreeRate, rateRegime, benchmarks)"
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('Response shape is valid', () => {",
" const json = pm.response.json();",
" pm.expect(json).to.have.all.keys('STOCK', 'ETF', 'BOND', 'ERROR', 'marketContext');",
"});",
"",
"pm.test('marketContext has required fields', () => {",
" const ctx = pm.response.json().marketContext;",
" pm.expect(ctx).to.have.property('riskFreeRate');",
" pm.expect(ctx).to.have.property('rateRegime');",
" pm.expect(ctx.benchmarks).to.have.all.keys('marketPE', 'techPE', 'reitYield', 'igSpread');",
"});",
"",
"pm.test('Each stock has expert fields in displayMetrics', () => {",
" const stocks = pm.response.json().STOCK;",
" if (stocks.length === 0) return;",
" const dm = stocks[0].asset.displayMetrics;",
" pm.expect(dm).to.have.property('Cap Tier');",
" pm.expect(dm).to.have.property('Style');",
"});",
"",
"pm.test('Each stock has a signal', () => {",
" pm.response.json().STOCK.forEach(r => {",
" pm.expect(r.signal).to.be.oneOf([",
" '✅ Strong Buy', '⚡ Momentum', '⚠️ Speculation', '🔄 Neutral', '❌ Avoid'",
" ]);",
" });",
"});"
]
}
}
]
},
{
"name": "Screen — Tech Stocks (tests TECHNOLOGY sector override)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": [\"NVDA\", \"META\", \"AMZN\", \"TSLA\"]\n}"
},
"description": "High-growth tech tickers to validate DCF margin-of-safety scoring, analyst consensus, and 52W movement fields. Expect 'High Growth' in Style field for most."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('52W fields present when data available', () => {",
" pm.response.json().STOCK.forEach(r => {",
" const dm = r.asset.displayMetrics;",
" // at least one 52W field should be populated",
" const has52W = dm['52W Chg'] || dm['From High'] || dm['52W Pos'];",
" pm.expect(has52W).to.be.ok;",
" });",
"});",
"",
"pm.test('DCF or analyst field present for stocks with FCF', () => {",
" // Not all stocks will have positive FCF, but at least one should have DCF",
" const stocks = pm.response.json().STOCK;",
" const hasDcf = stocks.some(r => r.asset.displayMetrics['DCF Safety'] != null);",
" const hasAnalyst = stocks.some(r => r.asset.displayMetrics['Analyst'] != null);",
" pm.expect(hasDcf || hasAnalyst).to.be.true;",
"});"
]
}
}
]
},
{
"name": "Screen — REIT (tests P/FFO scoring path)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": [\"O\", \"VICI\", \"PLD\"]\n}"
},
"description": "REITs should show sector REIT, P/FFO in displayMetrics (not P/E), and be scored on yield rather than the standard Graham gates."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('REITs land in STOCK bucket with REIT sector', () => {",
" pm.response.json().STOCK.forEach(r => {",
" pm.expect(r.asset.displayMetrics['Sector']).to.eql('REIT');",
" });",
"});"
]
}
}
]
},
{
"name": "Screen — Validation: empty tickers (expect 400)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": []\n}"
},
"description": "Schema validation: minItems: 1. Expect 400 Bad Request."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 400 for empty array', () => pm.response.to.have.status(400));"
]
}
}
]
},
{
"name": "Screen — Validation: over 50 tickers (expect 400)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/screen",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": [\"A\",\"B\",\"C\",\"D\",\"E\",\"F\",\"G\",\"H\",\"I\",\"J\",\"K\",\"L\",\"M\",\"N\",\"O\",\"P\",\"Q\",\"R\",\"S\",\"T\",\"U\",\"V\",\"W\",\"X\",\"Y\",\"Z\",\"AA\",\"BB\",\"CC\",\"DD\",\"EE\",\"FF\",\"GG\",\"HH\",\"II\",\"JJ\",\"KK\",\"LL\",\"MM\",\"NN\",\"OO\",\"PP\",\"QQ\",\"RR\",\"SS\",\"TT\",\"UU\",\"VV\",\"WW\",\"XX\",\"YY\"]\n}"
},
"description": "Schema validation: maxItems: 50. 51 tickers should return 400."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 400 for 51 tickers', () => pm.response.to.have.status(400));"
]
}
}
]
},
{
"name": "Get Catalysts",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/screen/catalysts",
"description": "Fetches today's Yahoo Finance news, extracts ticker symbols mentioned, and returns { tickers, stories }. May take 3-5s as it queries multiple news endpoints."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('Response has tickers and stories arrays', () => {",
" const json = pm.response.json();",
" pm.expect(json.tickers).to.be.an('array');",
" pm.expect(json.stories).to.be.an('array');",
"});"
]
}
}
]
}
]
},
{
"name": "Market Context",
"item": [
{
"name": "Get Market Context",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/finance/market-context",
"description": "Returns live benchmark data: S&P500 price, 10Y rate, VIX, SPY P/E, XLK P/E, XLRE yield, LQD spread. Served from 1-hour in-memory cache."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('All benchmark fields present', () => {",
" const json = pm.response.json();",
" pm.expect(json).to.have.property('sp500Price');",
" pm.expect(json).to.have.property('riskFreeRate');",
" pm.expect(json).to.have.property('vixLevel');",
" pm.expect(json).to.have.property('rateRegime');",
" pm.expect(json.rateRegime).to.be.oneOf(['LOW', 'NORMAL', 'HIGH']);",
" pm.expect(json.benchmarks).to.have.all.keys('marketPE', 'techPE', 'reitYield', 'igSpread');",
"});"
]
}
}
]
}
]
},
{
"name": "Portfolio",
"item": [
{
"name": "Add Holding — AAPL",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"ticker\": \"AAPL\",\n \"shares\": 10,\n \"costBasis\": 150.00,\n \"type\": \"stock\",\n \"source\": \"Robinhood\"\n}"
},
"description": "Adds or updates an AAPL holding in portfolio.json. Returns the saved holding with status 201."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 201', () => pm.response.to.have.status(201));",
"",
"pm.test('Saved holding matches input', () => {",
" const json = pm.response.json();",
" pm.expect(json.ticker).to.eql('AAPL');",
" pm.expect(json.shares).to.eql(10);",
" pm.expect(json.costBasis).to.eql(150);",
"});"
]
}
}
]
},
{
"name": "Add Holding — VOO (ETF)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"ticker\": \"VOO\",\n \"shares\": 5,\n \"costBasis\": 420.00,\n \"type\": \"etf\",\n \"source\": \"Vanguard\"\n}"
}
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 201', () => pm.response.to.have.status(201));"
]
}
}
]
},
{
"name": "Add Holding — BTC-USD (Crypto, no scoring)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"ticker\": \"BTC-USD\",\n \"shares\": 0.1,\n \"costBasis\": 50000,\n \"type\": \"crypto\",\n \"source\": \"Coinbase\"\n}"
},
"description": "Crypto is priced via Yahoo but not fundamentally scored. Advice column shows '—' for signal."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 201', () => pm.response.to.have.status(201));"
]
}
}
]
},
{
"name": "Add Holding — Validation: missing shares (expect 400)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/finance/holdings",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"ticker\": \"MSFT\"\n}"
},
"description": "Schema validation: shares is required. Expect 400."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 400 when shares missing', () => pm.response.to.have.status(400));"
]
}
}
]
},
{
"name": "Get Portfolio",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/finance/portfolio",
"description": "Screens all non-crypto holdings via Yahoo Finance, then cross-references with signals to produce buy/hold/sell advice.\n\nEach row has: ticker, signal, advice, reason, currentPrice, marketValue, gainLossPct.\nAlso returns marketContext.\n\nNote: first call after server start may be slow (benchmark cache cold)."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('Advice array present', () => {",
" const json = pm.response.json();",
" pm.expect(json.advice).to.be.an('array');",
" pm.expect(json.advice.length).to.be.greaterThan(0);",
"});",
"",
"pm.test('Each advice row has required fields', () => {",
" pm.response.json().advice.forEach(row => {",
" pm.expect(row).to.have.property('ticker');",
" pm.expect(row).to.have.property('advice');",
" pm.expect(row).to.have.property('reason');",
" });",
"});"
]
}
}
]
},
{
"name": "Remove Holding — AAPL",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/finance/holdings/AAPL",
"description": "Removes the AAPL holding from portfolio.json. Expect { ok: true }."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"pm.test('ok true', () => pm.expect(pm.response.json().ok).to.be.true);"
]
}
}
]
},
{
"name": "Remove Holding — Non-existent (expect 404)",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/finance/holdings/ZZZZZZ",
"description": "Ticker does not exist in portfolio. Expect 404."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 404', () => pm.response.to.have.status(404));"
]
}
}
]
}
]
},
{
"name": "Market Calls",
"item": [
{
"name": "List Calls (empty or existing)",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/calls",
"description": "Returns all market calls sorted newest first. Returns { calls: [] } if none exist yet."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"pm.test('calls is array', () => pm.expect(pm.response.json().calls).to.be.an('array'));"
]
}
}
]
},
{
"name": "Create Market Call",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/calls",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"AI Infrastructure Supercycle\",\n \"quarter\": \"Q3 2025\",\n \"thesis\": \"Hyperscaler capex remains elevated through 2026 driven by LLM training demand. NVDA, MSFT and AMD are the primary beneficiaries. Entry here as NVDA pulled back 15% from high.\",\n \"tickers\": [\"NVDA\", \"MSFT\", \"AMD\"]\n}"
},
"description": "Creates a market thesis call. Snapshots current prices + screener signals at creation time for future comparison.\n\nThe test script saves the returned ID to the {{callId}} collection variable for use in subsequent requests."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 201', () => pm.response.to.have.status(201));",
"",
"pm.test('Call has id, snapshot, and createdAt', () => {",
" const json = pm.response.json();",
" pm.expect(json).to.have.property('id');",
" pm.expect(json).to.have.property('snapshot');",
" pm.expect(json).to.have.property('createdAt');",
" // Save for downstream tests",
" pm.collectionVariables.set('callId', json.id);",
"});"
]
}
}
]
},
{
"name": "Get Call by ID (with current re-screen)",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/calls/{{callId}}",
"description": "Fetches the call and re-screens all tickers to show how signal/price has changed since creation.\n\nReturns: original call fields + `current` map of ticker → { price, signal, inflatedVerdict, fundamentalVerdict, pe, roe, fcf }.\n\nDepends on {{callId}} being set by the Create Market Call request."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('Response has snapshot and current', () => {",
" const json = pm.response.json();",
" pm.expect(json).to.have.property('snapshot');",
" pm.expect(json).to.have.property('current');",
"});"
]
}
}
]
},
{
"name": "Get Call — Non-existent ID (expect 404)",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/calls/00000000-0000-0000-0000-000000000000",
"description": "A UUID that doesn't exist. Expect 404."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 404', () => pm.response.to.have.status(404));"
]
}
}
]
},
{
"name": "Get Earnings Calendar (call tickers)",
"request": {
"method": "GET",
"url": "{{baseUrl}}/api/calls/calendar",
"description": "Returns upcoming earnings dates and dividend events for all tickers across all saved calls.\n\nOptional query param ?tickers=AAPL,MSFT to restrict to specific tickers."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"",
"pm.test('events is array', () => {",
" pm.expect(pm.response.json().events).to.be.an('array');",
"});",
"",
"pm.test('Events have expected shape', () => {",
" pm.response.json().events.forEach(e => {",
" pm.expect(e).to.have.property('ticker');",
" pm.expect(e).to.have.property('type');",
" pm.expect(e.type).to.be.oneOf(['earnings', 'dividend', 'exdividend']);",
" pm.expect(e).to.have.property('date');",
" pm.expect(e).to.have.property('isPast');",
" });",
"});"
]
}
}
]
},
{
"name": "Get Earnings Calendar — Specific Tickers",
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/api/calls/calendar?tickers=AAPL,MSFT",
"query": [
{ "key": "tickers", "value": "AAPL,MSFT" }
]
},
"description": "Calendar for specific tickers regardless of saved calls."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"pm.test('Only AAPL and MSFT events', () => {",
" pm.response.json().events.forEach(e => {",
" pm.expect(e.ticker).to.be.oneOf(['AAPL', 'MSFT']);",
" });",
"});"
]
}
}
]
},
{
"name": "Create Call — Validation: short thesis (expect 400)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/calls",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Test\",\n \"quarter\": \"Q1\",\n \"thesis\": \"short\",\n \"tickers\": [\"AAPL\"]\n}"
},
"description": "Schema: thesis minLength: 10. Expect 400."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 400 for short thesis', () => pm.response.to.have.status(400));"
]
}
}
]
},
{
"name": "Delete Call",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/calls/{{callId}}",
"description": "Deletes the call created earlier. Returns { ok: true }. Requires {{callId}} to be set."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200', () => pm.response.to.have.status(200));",
"pm.test('ok true', () => pm.expect(pm.response.json().ok).to.be.true);"
]
}
}
]
},
{
"name": "Delete Call — Already Deleted (expect 404)",
"request": {
"method": "DELETE",
"url": "{{baseUrl}}/api/calls/{{callId}}",
"description": "Second delete of the same ID. Expect 404."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 404 on double delete', () => pm.response.to.have.status(404));"
]
}
}
]
}
]
},
{
"name": "LLM Analysis",
"item": [
{
"name": "Analyze Tickers",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/analyze",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"]\n}"
},
"description": "Fetches Yahoo Finance news for the given tickers, then sends headlines to Claude (Haiku) for analysis.\n\nReturns: { analysis: { summary, sentiment, affectedIndustries, relatedTickers } }\n\nReturns 400 if ANTHROPIC_API_KEY is not set in .env.\nReturns { analysis: null, reason: 'no_stories' } if no news found."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 200 or 400', () => {",
" pm.expect(pm.response.code).to.be.oneOf([200, 400]);",
"});",
"",
"if (pm.response.code === 200) {",
" pm.test('Analysis shape valid (if present)', () => {",
" const json = pm.response.json();",
" if (json.analysis) {",
" pm.expect(json.analysis).to.have.property('summary');",
" pm.expect(json.analysis).to.have.property('sentiment');",
" pm.expect(json.analysis.sentiment).to.be.oneOf(['BULLISH', 'NEUTRAL', 'BEARISH']);",
" pm.expect(json.analysis.affectedIndustries).to.be.an('array');",
" pm.expect(json.analysis.relatedTickers).to.be.an('array');",
" }",
" });",
"} else {",
" pm.test('400 means API key not set', () => {",
" pm.expect(pm.response.json().error).to.include('ANTHROPIC_API_KEY');",
" });",
"}"
]
}
}
]
},
{
"name": "Analyze — Validation: empty tickers (expect 400)",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/analyze",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"tickers\": []\n}"
},
"description": "Schema validation: minItems: 1. Expect 400."
},
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test('Status 400', () => pm.response.to.have.status(400));"
]
}
}
]
}
]
}
]
}
+3132
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -9,14 +9,17 @@
"ui:install": "npm install --prefix ui --legacy-peer-deps",
"finance": "tsx bin/finance.ts",
"typecheck": "tsc --noEmit",
"test": "tsx --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js",
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.js",
"format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.js\"",
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.js\"",
"test": "tsx --test --test-reporter=./scripts/summary-reporter.ts tests/*.test.ts",
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts",
"lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix",
"format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"",
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,js}": [
"eslint --fix",
"prettier --write"
]
},
@@ -29,7 +32,11 @@
},
"devDependencies": {
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"concurrently": "^10.0.3",
"eslint": "^8.0.0",
"eslint-plugin-import": "^2.32.0",
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",
+4
View File
@@ -0,0 +1,4 @@
// Central export point for all system prompts
// Add new prompts here as they're created
export { LLM_ANALYST_PROMPT } from './llm-analyst';
+45
View File
@@ -0,0 +1,45 @@
You are a professional equity analyst specialising in catalyst-driven trading.
You will be given today's market news headlines (with Yahoo-tagged tickers per story) and a ranked ticker frequency list showing how many stories mention each ticker.
Your job:
1. Write a 23 sentence market summary capturing the dominant theme and tone.
2. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH.
3. Identify up to 4 industries secondarily affected — not directly mentioned, but impacted via contagion, supply chain, regulation, or macro.
4. Suggest up to 6 tickers worth screening. For each one provide:
- **ticker** — must have ADV > 500k; exclude generic analyst upgrades with no valuation catalyst
- **reason** — one mechanistic sentence (revenue/cost/supply-chain logic, not sentiment)
- **bias** — BULL or BEAR
- **horizon** — SHORT (15 days) | MEDIUM (14 weeks) | LONG (1+ quarter)
- **sensitivity** — how exposed this ticker is 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:
- Prioritise tickers that appear multiple times in the frequency list — repeated mentions signal broader market awareness.
- For BEAR picks: require at least one of — elevated short interest, negative earnings revision trend, or sector rotation evidence.
- Do not suggest tickers already in the "already identified" list unless the story adds a new directional angle.
- Prefer ripple-effect tickers (supply chain partners, direct competitors, sector peers) over the primary ticker already in the news — those are where the alpha is.
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
```json
{
"summary": "string",
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
"affectedIndustries": [
{ "name": "string", "reason": "string" }
],
"relatedTickers": [
{
"ticker": "string",
"reason": "string",
"bias": "BULL" | "BEAR",
"horizon": "SHORT" | "MEDIUM" | "LONG",
"sensitivity": 1 | 2 | 3 | 4 | 5
}
]
}
```
@@ -1,23 +1,34 @@
// Minimal test reporter: silent on pass, prints failures in full, ends with one summary line.
export default async function* summaryReporter(source) {
const failures = [];
import type { TestEvent } from 'node:test/reporters';
interface Failure {
name: string;
reason: string;
}
export default async function* summaryReporter(
source: AsyncIterable<TestEvent>,
): AsyncGenerator<string> {
const failures: Failure[] = [];
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;
// Skip file-level wrapper events (name ends in .ts) — only count individual tests.
if ((event.data as { name?: string })?.name?.endsWith('.ts')) continue;
if (event.type === 'test:pass') {
passed++;
totalMs += event.data.details?.duration_ms ?? 0;
totalMs += (event.data as { details?: { duration_ms?: number } }).details?.duration_ms ?? 0;
} else if (event.type === 'test:fail') {
failed++;
totalMs += event.data.details?.duration_ms ?? 0;
const err = event.data.details?.error;
totalMs += (event.data as { details?: { duration_ms?: number } }).details?.duration_ms ?? 0;
const err = (
event.data as { details?: { error?: { cause?: { message?: string }; message?: string } } }
).details?.error;
failures.push({
name: event.data.name,
name: (event.data as { name?: string }).name ?? 'unknown',
reason: err?.cause?.message ?? err?.message ?? 'unknown',
});
}
-72
View File
@@ -1,72 +0,0 @@
import { YahooClient } from '../market/YahooClient.js';
import type { Logger } from '../types.js';
interface Story {
title: string;
publisher: string;
link: string;
relatedTickers: string[];
}
interface CatalystResult {
tickers: string[];
stories: Story[];
}
const NEWS_QUERIES = ['stock market today', 'earnings report', 'market news'];
const MAX_STORIES = 15;
const TICKER_REGEX = /^[A-Z]{1,6}$/;
export class CatalystAnalyst {
private client: YahooClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooClient();
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run(): Promise<CatalystResult> {
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 };
}
private async _fetchNews(): Promise<Story[]> {
const seen = new Map<string, Story>();
for (const query of NEWS_QUERIES) {
try {
const { news = [] } = await (this.client as any).yf.search(query, {
newsCount: 8,
quotesCount: 0,
});
for (const s of news as any[]) {
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);
}
private _extractTickers(stories: Story[]): string[] {
const tickers = new Set<string>();
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];
}
}
-73
View File
@@ -1,73 +0,0 @@
import Anthropic from '@anthropic-ai/sdk';
import type { Logger, LLMAnalysis } from '../types.js';
interface Story {
title: string;
publisher?: string;
}
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 {
private logger: Pick<Logger, 'log' | 'warn'>;
private client: Anthropic | null;
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
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;
}
async analyze(stories: Story[], existingTickers: string[] = []): Promise<LLMAnalysis | null> {
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] as { text?: string })?.text ?? '';
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned) as LLMAnalysis;
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
return null;
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { ScreenerController } from './controllers/screener.controller';
import { FinanceController } from './controllers/finance.controller';
import { CallsController } from './controllers/calls.controller';
import { AnalyzeController } from './controllers/analyze.controller';
import { ScreenerEngine } from './services/ScreenerEngine';
import { LLMAnalyst } from './services/LLMAnalyst';
import { CatalystAnalyst } from './services/CatalystAnalyst';
import { YahooFinanceClient } from './clients/YahooFinanceClient';
import { MarketCallRepository } from './repositories/MarketCallRepository';
import { PortfolioRepository } from './repositories/PortfolioRepository';
import { noopLogger } from './utils/logger';
interface BuildAppOptions {
logger?: boolean;
}
// ── Adding a new domain ───────────────────────────────────────────────────
// 1. server/types/<domain>.model.ts — define request/response shapes
// 2. server/services/<Domain>.ts — business logic
// 3. server/controllers/<domain>.controller.ts — HTTP wiring (class + register)
// 4. Register: new <Domain>Controller(...).register(app) ← add below
// ───────────────────────────────────────────────────────────────────────────
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
const app = Fastify({ logger });
await app.register(cors, {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
});
const engine = new ScreenerEngine({ logger: noopLogger });
const yahoo = new YahooFinanceClient();
const llm = new LLMAnalyst({ logger: noopLogger });
const catalyst = new CatalystAnalyst({ logger: noopLogger });
new ScreenerController(engine).register(app);
new FinanceController(engine, new PortfolioRepository()).register(app);
new CallsController(new MarketCallRepository(), engine, yahoo).register(app);
new AnalyzeController(catalyst, llm).register(app);
app.get('/health', async () => ({ status: 'ok' }));
return app;
}
+31
View File
@@ -0,0 +1,31 @@
import Anthropic from '@anthropic-ai/sdk';
/**
* Thin wrapper around the Anthropic SDK.
* Handles initialisation and raw message completion only —
* prompt construction and response parsing stay in LLMAnalyst (service layer).
*/
export class AnthropicClient {
private client: Anthropic | null;
constructor() {
this.client = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null;
}
get isAvailable(): boolean {
return this.client !== null;
}
async complete(system: string, userMessage: string): Promise<string | null> {
if (!this.client) return null;
const response = await this.client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system,
messages: [{ role: 'user', content: userMessage }],
});
return (response.content[0] as { text?: string })?.text ?? null;
}
}
@@ -1,46 +1,12 @@
import fs from 'fs';
import https from 'https';
import http from 'http';
import type { Logger } from '../../types.js';
interface SimpleFINOptions {
logger?: Logger;
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
}
interface GetAccountsOptions {
startDate?: number;
endDate?: number;
}
interface Transaction {
id: string;
date: string;
amount: number;
description: string;
category: string;
}
interface Account {
id: string;
name: string;
currency: string;
balance: number;
balanceDate: string;
org: string;
type: string;
transactions: Transaction[];
}
interface SimpleFINData {
accounts: Account[];
errors: string[];
}
import type { Logger, GetAccountsOptions, SimpleFINData, SimpleFINOptions } from '../types';
export class SimpleFINClient {
private accessUrl: string | null;
private logger: Logger;
private onAccessUrlClaimed: ((url: string) => Promise<void> | void) | null;
private onAccessUrlClaimed: ((_url: string) => Promise<void> | void) | null;
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
this.accessUrl = null;
+38
View File
@@ -0,0 +1,38 @@
import YahooFinance from 'yahoo-finance2';
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib } from '../types';
import { YAHOO_MODULES } from '../config/constants';
export class YahooFinanceClient {
private lib: YahooFinanceLib;
constructor() {
this.lib = new (YahooFinance as unknown as new (_opts: object) => YahooFinanceLib)({
suppressNotices: ['yahooSurvey'],
});
}
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await this.lib.quoteSummary(ticker, { modules: YAHOO_MODULES });
} catch (error) {
if (attempt === retries - 1) throw error;
await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
}
}
}
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const result = await this.lib.quoteSummary(ticker, { modules: ['calendarEvents'] });
return result.calendarEvents ?? null;
} catch {
return null;
}
}
async search(query: string, opts: YahooSearchOptions = {}): Promise<YahooNewsItem[]> {
const { news = [] } = await this.lib.search(query, opts);
return news;
}
}
+11 -21
View File
@@ -1,4 +1,4 @@
import type { Sector } from './constants.js';
import type { ScoringRulesShape } from '../types';
// ── Credit rating scale (S&P convention) ─────────────────────────────────
// Bond.ts converts letter ratings to these numbers; BondScorer uses them for gate checks.
@@ -17,26 +17,8 @@ export const CREDIT_RATING_SCALE: Record<string, number> = {
};
// ── Scoring rule shape ────────────────────────────────────────────────────
interface GateSet extends Record<string, number> {}
interface WeightSet extends Record<string, number> {}
interface ThresholdSet extends Record<string, number> {}
interface RuleBlock {
gates: GateSet;
weights: WeightSet;
thresholds: ThresholdSet;
}
interface StockRules extends RuleBlock {
SECTOR_OVERRIDE: Partial<Record<Sector, Partial<RuleBlock>>>;
}
interface ScoringRulesShape {
STOCK: StockRules;
ETF: RuleBlock;
BOND: RuleBlock;
}
// Structural shapes (GateSet/WeightSet/ThresholdSet/RuleBlock/StockRules/
// ScoringRulesShape) live in server/types/asset.model.ts.
// ─────────────────────────────────────────────────────────────────────────────
// Fundamental baseline — Graham / value-investing style.
@@ -58,6 +40,8 @@ export const ScoringRules: ScoringRulesShape = {
peg: 2, // valuation relative to growth
revenue: 2, // revenue growth
fcf: 3, // FCF is the most manipulation-resistant quality signal
analyst: 2, // Wall Street consensus (1=Strong Buy … 5=Strong Sell, inverted in scorer)
dcf: 2, // DCF margin of safety: positive = undervalued vs intrinsic value
},
thresholds: {
marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
@@ -72,6 +56,12 @@ export const ScoringRules: ScoringRulesShape = {
revMed: 5,
fcfHigh: 5,
fcfMed: 2,
// Analyst consensus thresholds (Yahoo recommendationMean scale: 1=Strong Buy, 5=Strong Sell)
analystBuy: 2.0, // ≤ 2.0 → consensus is Buy or better
analystHold: 3.0, // ≤ 3.0 → consensus is Hold or better
// DCF margin-of-safety thresholds (% undervaluation vs intrinsic value)
dcfUndervalued: 20, // ≥ 20% margin of safety → undervalued
dcfFairValue: 0, // 020% → fairly valued; negative → overvalued
},
SECTOR_OVERRIDE: {
+44 -4
View File
@@ -1,4 +1,4 @@
import type { Signal, AssetType, RateRegime } from '../types.js';
import type { Signal, AssetType, RateRegime } from '../types';
export const SIGNAL = {
STRONG_BUY: '✅ Strong Buy' as Signal,
@@ -6,14 +6,28 @@ export const SIGNAL = {
SPECULATION: '⚠️ Speculation' as Signal,
NEUTRAL: '🔄 Neutral' as Signal,
AVOID: '❌ Avoid' as Signal,
} as const;
};
export const ASSET_TYPE = {
STOCK: 'STOCK' as AssetType,
ETF: 'ETF' as AssetType,
BOND: 'BOND' as AssetType,
CRYPTO: 'crypto',
} as const;
};
// ── Why some constants use `as const` and others don't ────────────────────
//
// SIGNAL / ASSET_TYPE / REGIME — each member is individually cast to its
// named type (e.g. `'✅ Strong Buy' as Signal`). TypeScript already knows
// the exact literal type of each value, so `as const` on the object would
// be redundant.
//
// SECTOR / SCORE_MODE / CAP_CATEGORY / GROWTH_CATEGORY — these use
// `as const` because their public type aliases are *derived* from the
// object itself via `(typeof X)[keyof typeof X]`. Without `as const`,
// TypeScript widens every value to `string`, and the derived union
// collapses to `string` instead of `'TECHNOLOGY' | 'REIT' | ...`.
// ──────────────────────────────────────────────────────────────────────────
export const SECTOR = {
TECHNOLOGY: 'TECHNOLOGY',
@@ -38,7 +52,7 @@ export const REGIME = {
LOW: 'LOW' as RateRegime,
NORMAL: 'NORMAL' as RateRegime,
HIGH: 'HIGH' as RateRegime,
} as const;
};
export const YAHOO_MODULES: string[] = [
'assetProfile',
@@ -55,3 +69,29 @@ export const SIGNAL_ORDER: Record<string, number> = {
[SIGNAL.SPECULATION]: 3,
[SIGNAL.AVOID]: 4,
};
// ── Market capitalisation tiers ───────────────────────────────────────────
// Thresholds follow institutional convention (MSCI/Russell definitions).
export const CAP_CATEGORY = {
MEGA: 'Mega Cap', // > $200B
LARGE: 'Large Cap', // $10B $200B
MID: 'Mid Cap', // $2B $10B
SMALL: 'Small Cap', // $300M $2B
MICRO: 'Micro Cap', // < $300M
} as const;
export type CapCategory = (typeof CAP_CATEGORY)[keyof typeof CAP_CATEGORY];
// ── Growth / style classification ─────────────────────────────────────────
// Derived from revenue growth, earnings growth, and dividend yield.
// Used for display and to contextualise signals within each cap tier.
export const GROWTH_CATEGORY = {
HIGH_GROWTH: 'High Growth', // rev >15% or earnings >20%
MODERATE_GROWTH: 'Growth', // rev 515%
STABLE: 'Stable', // low growth, modest or no dividend
VALUE: 'Value', // low growth + dividend yield ≥ 3%
TURNAROUND: 'Turnaround', // negative earnings, positive revenue
DECLINING: 'Declining', // negative revenue growth
} as const;
export type GrowthCategory = (typeof GROWTH_CATEGORY)[keyof typeof GROWTH_CATEGORY];
+30
View File
@@ -0,0 +1,30 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { LLMAnalyst } from '../services/LLMAnalyst';
import { CatalystAnalyst } from '../services/CatalystAnalyst';
import { analyzeSchema } from '../types/schemas';
export class AnalyzeController {
constructor(
private readonly catalyst: CatalystAnalyst,
private readonly llm: LLMAnalyst,
) {}
register(app: FastifyInstance): void {
app.post('/api/analyze', { schema: analyzeSchema }, this.analyze.bind(this));
}
private async analyze(req: FastifyRequest, reply: FastifyReply) {
if (!this.llm.isAvailable) {
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
}
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const stories = await this.catalyst.fetchStoriesForTickers(tickers);
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
const { tickerFrequency } = CatalystAnalyst.rankTickers(stories);
const analysis = await this.llm.analyze(stories, tickers, tickerFrequency);
return { analysis };
}
}
+172
View File
@@ -0,0 +1,172 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { MarketCallRepository } from '../repositories/MarketCallRepository';
import { ScreenerEngine } from '../services/index';
import type { SnapshotEntry } from '../types';
import { callSchema } from '../types/schemas';
import { chunkArray } from '../utils/Chunker';
export class CallsController {
constructor(
private readonly repo: MarketCallRepository,
private readonly engine: ScreenerEngine,
private readonly yahoo: YahooFinanceClient,
) {}
private static toSnapshot(r: any): SnapshotEntry | null {
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,
};
}
register(app: FastifyInstance): void {
app.get('/api/calls', this.list.bind(this));
app.get('/api/calls/calendar', this.calendar.bind(this));
app.get('/api/calls/:id', this.get.bind(this));
app.post('/api/calls', { schema: callSchema }, this.create.bind(this));
app.delete('/api/calls/:id', this.remove.bind(this));
}
private async list() {
return { calls: this.repo.list() };
}
private async get(req: FastifyRequest, reply: FastifyReply) {
const call = this.repo.get((req.params as { id: string }).id);
if (!call) return reply.code(404).send({ error: 'Call not found' });
const current: Record<string, SnapshotEntry | null> = {};
if (call.tickers.length > 0) {
try {
const results = await this.engine.screenTickers(call.tickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
current[r.asset.ticker] = CallsController.toSnapshot(r);
}
} catch {
/* non-fatal */
}
}
return { ...call, current };
}
private async create(req: FastifyRequest, reply: FastifyReply) {
const { title, quarter, date, thesis, tickers } = req.body as {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
};
const upperTickers = tickers.map((t) => t.toUpperCase());
const snapshot: Record<string, SnapshotEntry | null> = {};
try {
const results = await this.engine.screenTickers(upperTickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
snapshot[r.asset.ticker] = CallsController.toSnapshot(r);
}
} catch (err) {
req.log.warn(`Could not snapshot prices for market call: ${(err as Error).message}`);
}
const call = this.repo.create({
title,
quarter,
date,
thesis,
tickers: upperTickers,
snapshot: snapshot as any,
});
return reply.code(201).send(call);
}
private async remove(req: FastifyRequest, reply: FastifyReply) {
const deleted = this.repo.delete((req.params as { id: string }).id);
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
return { ok: true };
}
private async calendar(req: FastifyRequest) {
let tickers: string[];
if ((req.query as any).tickers) {
tickers = String((req.query as any).tickers)
.split(',')
.map((t) => t.trim().toUpperCase())
.filter(Boolean);
} else {
const set = new Set(this.repo.list().flatMap((c) => c.tickers));
tickers = [...set];
}
if (tickers.length === 0) return { events: [] };
const results: Record<string, any> = {};
for (const batch of chunkArray(tickers, 5)) {
await Promise.all(
batch.map(async (ticker) => {
const cal = await this.yahoo.fetchCalendarEvents(ticker);
if (cal) results[ticker] = cal;
}),
);
await new Promise<void>((r) => setTimeout(r, 500));
}
const events: any[] = [];
const now = Date.now();
for (const [ticker, cal] of Object.entries(results)) {
for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal as string);
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,
});
}
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,
});
}
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,
});
}
}
events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast
? new Date(b.date).getTime() - new Date(a.date).getTime()
: new Date(a.date).getTime() - new Date(b.date).getTime();
});
return { events, tickers };
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { SimpleFINClient } from '../clients/SimpleFINClient';
import { PortfolioRepository } from '../repositories/PortfolioRepository';
import { PersonalFinanceAnalyzer, PortfolioAdvisor, ScreenerEngine } from '../services/index';
import type { PortfolioHolding } from '../types';
import { holdingSchema } from '../types/schemas';
import { noopLogger } from '../utils/logger';
export class FinanceController {
constructor(
private readonly engine: ScreenerEngine,
private readonly repo: PortfolioRepository,
) {}
private static normalizeYahoo(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', this.portfolio.bind(this));
app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this));
app.delete('/api/finance/holdings/:ticker', this.removeHolding.bind(this));
app.get('/api/finance/market-context', this.marketContext.bind(this));
}
private async portfolio(_req: FastifyRequest, reply: FastifyReply) {
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
const { holdings } = this.repo.read();
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyze(accounts);
}
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => FinanceController.normalizeYahoo(h.ticker));
const results =
screenable.length > 0
? await this.engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
const advice = await new PortfolioAdvisor().advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
}
private async addHolding(req: FastifyRequest, reply: FastifyReply) {
const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const entry = this.repo.upsert({ ticker, shares, costBasis, type, source });
return reply.code(201).send(entry);
}
private async removeHolding(req: FastifyRequest, reply: FastifyReply) {
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
const removed = this.repo.remove(ticker);
if (!removed) return reply.code(404).send({ error: 'Holding not found' });
return { ok: true };
}
private async marketContext() {
return this.engine.getMarketContext();
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ScreenerEngine, CatalystAnalyst } from '../services/index';
import { noopLogger } from '../utils/logger';
import type { LiveAssetResult } from '../types';
import { screenSchema } from '../types/schemas';
export class ScreenerController {
constructor(private readonly engine: ScreenerEngine) {}
register(app: FastifyInstance): void {
app.post('/api/screen', { schema: screenSchema }, this.screen.bind(this));
app.get('/api/screen/catalysts', this.catalysts.bind(this));
}
private static serializeAssets(arr: LiveAssetResult[]) {
return 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(),
},
}));
}
private async screen(req: FastifyRequest) {
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const results = await this.engine.screenTickers(tickers);
return {
...results,
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
};
}
private async catalysts() {
const catalyst = new CatalystAnalyst({ logger: noopLogger });
const { tickers, stories } = await catalyst.run();
return { tickers, stories };
}
}
-42
View File
@@ -1,42 +0,0 @@
import YahooFinance from 'yahoo-finance2';
export class YahooClient {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private yf: any;
constructor() {
this.yf = new (YahooFinance as unknown as new (opts: object) => unknown)({
suppressNotices: ['yahooSurvey'],
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
for (let i = 0; i < retries; i++) {
try {
return await (this.yf as any).quoteSummary(ticker, {
modules: [
'assetProfile',
'financialData',
'defaultKeyStatistics',
'price',
'summaryDetail',
],
});
} catch (error) {
if (i === retries - 1) throw error;
await new Promise<void>((res) => setTimeout(res, backoff * (i + 1)));
}
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const r = await (this.yf as any).quoteSummary(ticker, { modules: ['calendarEvents'] });
return r.calendarEvents ?? null;
} catch {
return null;
}
}
}
@@ -1,11 +1,5 @@
import type { AssetType } from '../../types.js';
interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
import type { AssetType } from '../types';
import type { AssetData } from '../types/models.model';
export class Asset {
ticker: string;
@@ -1,21 +1,6 @@
import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js';
import { Asset } from './Asset.js';
interface BondData {
ticker?: string;
currentPrice?: number;
creditRating?: string;
yieldToMaturity?: string | number;
duration?: string | number;
[key: string]: unknown;
}
export interface BondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
import { CREDIT_RATING_SCALE } from '../config/ScoringConfig';
import { Asset } from './Asset';
import type { BondData, BondMetrics } from '../types/models.model';
export class Bond extends Asset {
metrics: BondMetrics;
@@ -1,23 +1,5 @@
import { Asset } from './Asset.js';
interface EtfData {
ticker?: string;
currentPrice?: number;
expenseRatio?: string | number;
totalAssets?: string | number;
yield?: string | number;
volume?: string | number;
fiveYearReturn?: string | number;
[key: string]: unknown;
}
export interface EtfMetrics {
expenseRatio: number;
totalAssets: number;
yield: number;
volume: number;
fiveYearReturn: number;
}
import { Asset } from './Asset';
import type { EtfData, EtfMetrics } from '../types/models.model';
export class Etf extends Asset {
metrics: EtfMetrics;
@@ -1,49 +1,7 @@
import { Asset } from './Asset.js';
import type { Sector } from '../../config/constants.js';
interface StockData {
ticker?: string;
currentPrice?: number;
assetProfile?: { industry?: string; sector?: string };
peRatio?: number | null;
pegRatio?: number | null;
priceToBook?: number | null;
netProfitMargin?: number | null;
operatingMargin?: number | null;
returnOnEquity?: number | null;
revenueGrowth?: number | null;
earningsGrowth?: number | null;
debtToEquity?: number | null;
quickRatio?: number | null;
fcfYield?: number | null;
pFFO?: number | null;
dividendYield?: number | null;
beta?: number | null;
week52High?: number | null;
week52Low?: number | null;
[key: string]: unknown;
}
export interface StockMetrics {
sector: Sector;
peRatio: number | null;
pegRatio: number | null;
priceToBook: number | null;
netProfitMargin: number | null;
operatingMargin: number | null;
returnOnEquity: number | null;
revenueGrowth: number | null;
earningsGrowth: number | null;
debtToEquity: number | null;
quickRatio: number | null;
fcfYield: number | null;
pFFO: number | null;
dividendYield: number | null;
beta: number | null;
week52High: number | null;
week52Low: number | null;
currentPrice: number;
}
import { Asset } from './Asset';
import { CAP_CATEGORY, GROWTH_CATEGORY } from '../config/constants';
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
import type { StockData, StockMetrics } from '../types/models.model';
export class Stock extends Asset {
sector: Sector;
@@ -55,9 +13,16 @@ export class Stock extends Asset {
this.metrics = {
sector: this.sector,
capCategory: this._classifyMarketCap(data.marketCap ?? null),
growthCategory: this._classifyGrowth(
data.revenueGrowth ?? null,
data.earningsGrowth ?? null,
data.dividendYield ?? null,
),
peRatio: data.peRatio ?? null,
pegRatio: data.pegRatio ?? null,
priceToBook: data.priceToBook ?? null,
grossMargin: data.grossMargin ?? null,
netProfitMargin: data.netProfitMargin ?? null,
operatingMargin: data.operatingMargin ?? null,
returnOnEquity: data.returnOnEquity ?? null,
@@ -71,10 +36,51 @@ export class Stock extends Asset {
beta: data.beta ?? null,
week52High: data.week52High ?? null,
week52Low: data.week52Low ?? null,
week52Change: data.week52Change ?? null,
week52FromHigh: data.week52FromHigh ?? null,
week52FromLow: data.week52FromLow ?? null,
marketCap: data.marketCap ?? null,
analystRating: data.analystRating ?? null,
analystTargetPrice: data.analystTargetPrice ?? null,
analystUpside: data.analystUpside ?? null,
numberOfAnalysts: data.numberOfAnalysts ?? null,
dcfIntrinsicValue: data.dcfIntrinsicValue ?? null,
dcfMarginOfSafety: data.dcfMarginOfSafety ?? null,
currentPrice: (data.currentPrice as number) || 0,
};
}
// ── Market cap tier classification ──────────────────────────────────────
// Thresholds follow MSCI/Russell institutional convention.
_classifyMarketCap(marketCap: number | null): CapCategory {
if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default
if (marketCap >= 200e9) return CAP_CATEGORY.MEGA;
if (marketCap >= 10e9) return CAP_CATEGORY.LARGE;
if (marketCap >= 2e9) return CAP_CATEGORY.MID;
if (marketCap >= 300e6) return CAP_CATEGORY.SMALL;
return CAP_CATEGORY.MICRO;
}
// ── Growth / style classification ───────────────────────────────────────
// revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%).
// dividendYield is also in percentage form (e.g. 3.5 = 3.5%).
_classifyGrowth(
revenueGrowth: number | null,
earningsGrowth: number | null,
dividendYield: number | null,
): GrowthCategory {
const rev = revenueGrowth ?? 0;
const earn = earningsGrowth ?? 0;
const div = dividendYield ?? 0;
if (rev < -5) return GROWTH_CATEGORY.DECLINING;
if (earn < 0 && rev >= 0) return GROWTH_CATEGORY.TURNAROUND;
if (rev >= 15 || earn >= 20) return GROWTH_CATEGORY.HIGH_GROWTH;
if (rev >= 5) return GROWTH_CATEGORY.MODERATE_GROWTH;
if (div >= 3 && rev < 5) return GROWTH_CATEGORY.VALUE;
return GROWTH_CATEGORY.STABLE;
}
_mapToStandardSector(data: StockData): Sector {
const profile = data.assetProfile ?? {};
const industry = (profile.industry || '').toLowerCase();
@@ -140,6 +146,8 @@ export class Stock extends Asset {
getDisplayMetrics(): Record<string, string | null> {
const fmt = (v: number | null, dec = 1, suffix = '') =>
v != null ? `${v.toFixed(dec)}${suffix}` : null;
const fmtSign = (v: number | null, suffix = '%') =>
v != null ? `${v >= 0 ? '+' : ''}${v.toFixed(1)}${suffix}` : null;
const m = this.metrics;
const w52pos =
@@ -147,27 +155,68 @@ export class Stock extends Asset {
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
: null;
// Analyst label: convert Yahoo's 15 scale to a readable string
const analystLabel = (rating: number | null): string | null => {
if (rating == null) return null;
if (rating <= 1.5) return 'Strong Buy';
if (rating <= 2.5) return 'Buy';
if (rating <= 3.5) return 'Hold';
if (rating <= 4.5) return 'Sell';
return 'Strong Sell';
};
const display: Record<string, string | null> = {
Ticker: this.ticker,
Price: this.formatCurrency(this.currentPrice),
Sector: this.sector,
'Cap Tier': m.capCategory,
Style: m.growthCategory,
};
// Valuation
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);
// Quality
if (m.grossMargin != null) display['GrossM%'] = fmt(m.grossMargin, 1, '%');
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, '%');
// Risk
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);
// 52-week movement
if (w52pos != null) display['52W Pos'] = w52pos;
if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%');
if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%');
if (m.week52FromLow != null) display['From Low'] = fmtSign(m.week52FromLow, '%');
// REIT-specific
if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1);
// Analyst consensus
if (m.analystRating != null) {
display['Analyst'] = analystLabel(m.analystRating);
display['# Analysts'] = m.numberOfAnalysts != null ? String(m.numberOfAnalysts) : null;
display['Target'] =
m.analystTargetPrice != null ? this.formatCurrency(m.analystTargetPrice) : null;
display['Upside'] = fmtSign(m.analystUpside, '%');
}
// DCF
if (m.dcfIntrinsicValue != null) {
display['DCF Value'] = this.formatCurrency(m.dcfIntrinsicValue);
display['DCF Safety'] =
m.dcfMarginOfSafety != null ? fmtSign(m.dcfMarginOfSafety, '%') : null;
}
return display;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import type { MarketContext } from '../types.js';
import type { MarketContext } from '../types';
export class FinanceReporter {
render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string {
+2 -2
View File
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import type { MarketContext } from '../types.js';
import type { MarketContext } from '../types';
// Generates a self-contained HTML report saved to ./screener-report.html
// Console output shows only the signal summary — full breakdown lives here.
@@ -204,7 +204,7 @@ export class HtmlReporter {
}
// Collect only headers that have at least one non-null value across all items
_headers(type, items, mode) {
_headers(type, items, _mode) {
const base = ['Ticker', 'Price', 'Verdict', 'Score'];
if (type === 'STOCK') {
const metricKeys = [
@@ -1,34 +1,21 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { randomUUID } from 'crypto';
import type { MarketCall, Signal, TickerSnapshot } from '../types.js';
import type { MarketCall, CreateCallInput, StoreData } from '../types';
const STORE_PATH = './market-calls.json';
export class MarketCallRepository {
private static readonly STORE_PATH = './market-calls.json';
interface StoreData {
calls: (MarketCall & { createdAt: string })[];
}
interface CreateCallInput {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
snapshot?: Record<string, TickerSnapshot>;
}
export class MarketCallStore {
private _load(): StoreData {
if (!existsSync(STORE_PATH)) return { calls: [] };
if (!existsSync(MarketCallRepository.STORE_PATH)) return { calls: [] };
try {
return JSON.parse(readFileSync(STORE_PATH, 'utf8')) as StoreData;
return JSON.parse(readFileSync(MarketCallRepository.STORE_PATH, 'utf8')) as StoreData;
} catch {
return { calls: [] };
}
}
private _save(data: StoreData): void {
writeFileSync(STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
writeFileSync(MarketCallRepository.STORE_PATH, JSON.stringify(data, null, 2), 'utf8');
}
list(): (MarketCall & { createdAt: string })[] {
@@ -0,0 +1,39 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import type { PortfolioData, PortfolioHolding } from '../types';
export class PortfolioRepository {
private static readonly PORTFOLIO_PATH = './portfolio.json';
exists(): boolean {
return existsSync(PortfolioRepository.PORTFOLIO_PATH);
}
read(): PortfolioData {
if (!this.exists()) return { holdings: [] };
return JSON.parse(readFileSync(PortfolioRepository.PORTFOLIO_PATH, 'utf8')) as PortfolioData;
}
write(data: PortfolioData): void {
writeFileSync(PortfolioRepository.PORTFOLIO_PATH, JSON.stringify(data, null, 2), 'utf8');
}
upsert(entry: PortfolioHolding): PortfolioHolding {
const data = this.read();
const normalized = entry.ticker.toUpperCase().trim();
const idx = data.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
const record: PortfolioHolding = { ...entry, ticker: normalized };
if (idx >= 0) data.holdings[idx] = record;
else data.holdings.push(record);
this.write(data);
return record;
}
remove(ticker: string): boolean {
const data = this.read();
const before = data.holdings.length;
data.holdings = data.holdings.filter((h) => h.ticker.toUpperCase() !== ticker.toUpperCase());
if (data.holdings.length === before) return false;
this.write(data);
return true;
}
}
@@ -1,21 +1,7 @@
import type { BondMetrics } from '../assets/Bond.js';
import type { MarketContext } from '../../types.js';
import type { BondMetrics, MarketContext, ScoreResult, SanitizedBondMetrics } from '../types';
interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
interface ScoreOutput {
label: string;
scoreSummary: string;
audit: Record<string, unknown>;
}
export const BondScorer = {
score(
export class BondScorer {
static score(
m: BondMetrics,
rules: {
gates: Record<string, number>;
@@ -23,9 +9,9 @@ export const BondScorer = {
thresholds: Record<string, number>;
},
context?: MarketContext | null,
): ScoreOutput {
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = this._sanitize(m);
const metrics = BondScorer._sanitize(m);
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
if (metrics.creditRatingNumeric < gates.minCreditRating) {
@@ -47,11 +33,11 @@ export const BondScorer = {
return {
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
scoreSummary: `Score: ${score}`,
audit: { breakdown },
audit: { passedGates: true, breakdown },
};
},
}
_sanitize(m: BondMetrics): SanitizedBondMetrics {
private static _sanitize(m: BondMetrics): SanitizedBondMetrics {
const pct = (v: unknown): number =>
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
return {
@@ -60,5 +46,5 @@ export const BondScorer = {
creditRating: m.creditRating || 'BBB',
creditRatingNumeric: m.creditRatingNumeric ?? 7,
};
},
};
}
}
@@ -1,20 +1,14 @@
import type { EtfMetrics } from '../assets/Etf.js';
import type { EtfMetrics, ScoreResult } from '../types';
interface ScoreOutput {
label: string;
scoreSummary: string;
audit?: Record<string, unknown>;
}
export const EtfScorer = {
score(
export class EtfScorer {
static score(
m: EtfMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreOutput {
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = {
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
@@ -24,7 +18,11 @@ export const EtfScorer = {
};
if (metrics.expenseRatio > gates.maxExpenseRatio) {
return { label: '🔴 REJECT', scoreSummary: 'Gate failed: High Expense Ratio' };
return {
label: '🔴 REJECT',
scoreSummary: 'Gate failed: High Expense Ratio',
audit: { passedGates: false },
};
}
const breakdown: Record<string, number> = {
@@ -46,5 +44,5 @@ export const EtfScorer = {
scoreSummary: `Score: ${score}`,
audit: { passedGates: true, breakdown },
};
},
};
}
}
+251
View File
@@ -0,0 +1,251 @@
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../types';
export class StockScorer {
private static n(v: unknown): NumVal {
const f = parseFloat(String(v));
return !isNaN(f) && f !== 0 ? f : null;
}
private static scoreValue(val: number, high: number, med: number, weight: number): number {
return val >= high ? weight : val >= med ? 1 : -1;
}
private static scorePeg(val: number, high: number, med: number, weight: number): number {
return val <= high ? weight : val <= med ? 1 : -1;
}
static score(
metrics: StockMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreResult {
const { gates, weights, thresholds } = rules;
const m = StockScorer._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) as string[];
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: () =>
StockScorer.scoreValue(
m.returnOnEquity!,
thresholds.roeHigh,
thresholds.roeMed,
weights.roe,
),
},
{
key: 'opMargin',
active: weights.opMargin > 0 && m.operatingMargin != null,
fn: () =>
StockScorer.scoreValue(
m.operatingMargin!,
thresholds.opMarginHigh,
thresholds.opMarginMed,
weights.opMargin,
),
},
{
key: 'margin',
active: weights.margin > 0 && m.netProfitMargin != null,
fn: () =>
StockScorer.scoreValue(
m.netProfitMargin!,
thresholds.marginHigh,
thresholds.marginMed,
weights.margin,
),
},
{
key: 'peg',
active: weights.peg > 0 && m.pegRatio != null,
fn: () =>
StockScorer.scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
},
{
key: 'revenue',
active: weights.revenue > 0 && m.revenueGrowth != null,
fn: () =>
StockScorer.scoreValue(
m.revenueGrowth!,
thresholds.revHigh,
thresholds.revMed,
weights.revenue,
),
},
{
key: 'fcf',
active: weights.fcf > 0 && m.fcfYield != null,
fn: () =>
StockScorer.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: () => StockScorer.scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
},
// ── Expert features ────────────────────────────────────────────────
{
// Analyst consensus: Yahoo recommendationMean 1=Strong Buy → 5=Strong Sell.
// We invert and score: ≤ analystBuy gets full weight, ≤ analystHold gets 1pt,
// above Hold loses weight. Requires ≥ 3 analysts to avoid noise from thin coverage.
key: 'analyst',
active:
(weights.analyst ?? 0) > 0 &&
m.analystRating != null &&
(metrics.numberOfAnalysts ?? 0) >= 3,
fn: (): number => {
const r = m.analystRating!;
const buyThreshold = thresholds.analystBuy ?? 2.0;
const holdThreshold = thresholds.analystHold ?? 3.0;
if (r <= buyThreshold) return weights.analyst ?? 2;
if (r <= holdThreshold) return 1;
if (r <= 4.0) return -1;
return -(weights.analyst ?? 2); // Strong Sell
},
},
{
// DCF margin of safety: how undervalued the stock is vs. 2-stage FCF model.
// Positive = undervalued (good), negative = overvalued (bad).
// Only fires when DCF could be computed (positive FCF required).
key: 'dcf',
active: (weights.dcf ?? 0) > 0 && m.dcfMarginOfSafety != null,
fn: (): number => {
const mos = m.dcfMarginOfSafety!;
const undervalued = thresholds.dcfUndervalued ?? 20;
const fairValue = thresholds.dcfFairValue ?? 0;
if (mos >= undervalued) return weights.dcf ?? 2;
if (mos >= fairValue) return 1;
if (mos >= -20) return -1;
return -(weights.dcf ?? 2); // significantly overvalued
},
},
];
const breakdown: Record<string, number> = {};
const totalScore = factors.reduce((sum, f) => {
if (!f.active) return sum;
breakdown[f.key] = f.fn() as number;
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)})`,
// 52-week position flags
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',
// 52-week momentum flags
m.week52Change != null &&
m.week52Change >= 50 &&
`Strong uptrend: +${m.week52Change.toFixed(0)}% in 52 weeks`,
m.week52Change != null &&
m.week52Change <= -30 &&
`Significant drawdown: ${m.week52Change.toFixed(0)}% in 52 weeks`,
// Distance from 52-week high
m.week52FromHigh != null &&
m.week52FromHigh <= -20 &&
`${Math.abs(m.week52FromHigh).toFixed(0)}% off 52-week high`,
// Analyst/DCF divergence signal
m.analystUpside != null &&
m.analystUpside >= 25 &&
`Analyst consensus: ${m.analystUpside.toFixed(0)}% upside to target`,
m.analystUpside != null &&
m.analystUpside <= -15 &&
`Analyst consensus: target ${Math.abs(m.analystUpside).toFixed(0)}% below current price`,
m.dcfMarginOfSafety != null &&
m.dcfMarginOfSafety >= 30 &&
`DCF: ${m.dcfMarginOfSafety.toFixed(0)}% margin of safety`,
m.dcfMarginOfSafety != null &&
m.dcfMarginOfSafety <= -30 &&
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
].filter(Boolean) as string[];
return {
label: StockScorer._label(totalScore),
scoreSummary: `Score: ${totalScore}`,
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
};
}
private static _label(score: number): string {
if (score >= 8) return '🟢 BUY (High Conviction)';
if (score >= 4) return '🟢 BUY (Speculative)';
if (score >= 0) return '🟡 HOLD';
return '🔴 REJECT';
}
private static _sanitize(m: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
: null;
return {
debtToEquity: StockScorer.n(m.debtToEquity),
quickRatio: StockScorer.n(m.quickRatio),
peRatio: StockScorer.n(m.peRatio),
pegRatio: StockScorer.n(m.pegRatio),
priceToBook: StockScorer.n(m.priceToBook),
netProfitMargin: StockScorer.n(m.netProfitMargin),
operatingMargin: StockScorer.n(m.operatingMargin),
returnOnEquity: StockScorer.n(m.returnOnEquity),
revenueGrowth: StockScorer.n(m.revenueGrowth),
fcfYield: StockScorer.n(m.fcfYield),
dividendYield: StockScorer.n(m.dividendYield),
pFFO: StockScorer.n(m.pFFO),
beta: StockScorer.n(m.beta),
week52Position: w52,
week52Change: StockScorer.n(m.week52Change),
week52FromHigh: StockScorer.n(m.week52FromHigh),
analystRating: StockScorer.n(m.analystRating),
analystUpside: StockScorer.n(m.analystUpside),
dcfMarginOfSafety: StockScorer.n(m.dcfMarginOfSafety),
};
}
}
-4
View File
@@ -1,4 +0,0 @@
export const chunkArray = <T>(array: T[], size: number): T[][] =>
Array.from({ length: Math.ceil(array.length / size) }, (_, i) =>
array.slice(i * size, i * size + size),
);
-137
View File
@@ -1,137 +0,0 @@
import type { AssetType } from '../types.js';
// Shape of the raw Yahoo Finance summary payload (loosely typed — fields vary by asset)
type YahooSummary = Record<string, Record<string, unknown>>;
interface MappedData {
type: AssetType;
ticker: string;
[key: string]: unknown;
}
export const mapToStandardFormat = (ticker: string, summary: YahooSummary): MappedData => {
const quoteType = summary.price?.quoteType as string | undefined;
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury') ||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
if (quoteType === 'ETF') {
return isBond
? { type: 'BOND', ticker, ...mapBondData(summary) }
: { type: 'ETF', ticker, ...mapEtfData(summary) };
}
return { type: 'STOCK', ticker, ...mapStockData(summary) };
};
const mapStockData = (summary: YahooSummary) => {
const fd = (summary.financialData ?? {}) as Record<string, number | null>;
const ks = (summary.defaultKeyStatistics ?? {}) as Record<string, number | null>;
const sd = (summary.summaryDetail ?? {}) as Record<string, number | null>;
const pr = (summary.price ?? {}) as Record<string, number | null>;
const currentPrice = pr.regularMarketPrice ?? 0;
const sharesOutstanding = ks.sharesOutstanding ?? 0;
const operatingCashflow = fd.operatingCashflow ?? 0;
const freeCashflow = fd.freeCashflow ?? 0;
// P/FFO proxy — used for REIT scoring
const pFFO =
operatingCashflow != null &&
operatingCashflow > 0 &&
sharesOutstanding != null &&
sharesOutstanding > 0
? (currentPrice as number) / (operatingCashflow / sharesOutstanding)
: null;
// FCF yield — negative FCF preserved so cash-burning companies fail the gate
const fcfYield =
freeCashflow !== 0 &&
sharesOutstanding != null &&
sharesOutstanding > 0 &&
currentPrice != null &&
currentPrice > 0
? ((freeCashflow as number) / (sharesOutstanding as number) / (currentPrice as number)) * 100
: null;
// PEG: prefer Yahoo's value, fall back to trailingPE / earningsGrowth
const yahoosPEG = ks.pegRatio ?? null;
const trailingPE = sd.trailingPE ?? null;
const earningsGrowth = fd.earningsGrowth != null ? (fd.earningsGrowth as number) * 100 : null;
const computedPEG =
trailingPE != null && earningsGrowth != null && earningsGrowth > 0
? +((trailingPE as number) / earningsGrowth).toFixed(2)
: null;
const pegRatio = yahoosPEG ?? computedPEG;
// Quick ratio — fall back to currentRatio when missing
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
return {
peRatio: trailingPE ?? ks.forwardPE,
trailingPE,
pegRatio,
priceToBook: ks.priceToBook ?? null,
evToEbitda: ks.enterpriseToEbitda ?? null,
netProfitMargin: fd.profitMargins != null ? (fd.profitMargins as number) * 100 : null,
operatingMargin: fd.operatingMargins != null ? (fd.operatingMargins as number) * 100 : null,
returnOnEquity: fd.returnOnEquity != null ? (fd.returnOnEquity as number) * 100 : null,
revenueGrowth: fd.revenueGrowth != null ? (fd.revenueGrowth as number) * 100 : null,
earningsGrowth,
debtToEquity: fd.debtToEquity != null ? (fd.debtToEquity as number) / 100 : null,
quickRatio,
fcfYield,
pFFO,
dividendYield:
sd.trailingAnnualDividendYield != null
? (sd.trailingAnnualDividendYield as number) * 100
: null,
beta: sd.beta ?? null,
week52High: sd.fiftyTwoWeekHigh ?? null,
week52Low: sd.fiftyTwoWeekLow ?? null,
currentPrice,
assetProfile: summary.assetProfile || {},
};
};
const mapEtfData = (summary: YahooSummary) => ({
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
volume:
(summary.summaryDetail?.averageVolume as number) ??
(summary.price?.averageVolume as number) ??
0,
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
});
const inferCreditRating = (category: string | undefined): string => {
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';
};
const inferDuration = (category: string | undefined): number => {
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;
};
const mapBondData = (summary: YahooSummary) => ({
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
duration: inferDuration(summary.assetProfile?.category as string),
creditRating: inferCreditRating(summary.assetProfile?.category as string),
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
});
-180
View File
@@ -1,180 +0,0 @@
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';
import type { Logger, MarketContext, Signal, AssetType, ScreenerResult } from '../types.js';
const SCORERS: Record<AssetType, typeof StockScorer | typeof EtfScorer | typeof BondScorer> = {
[ASSET_TYPE.STOCK]: StockScorer,
[ASSET_TYPE.ETF]: EtfScorer,
[ASSET_TYPE.BOND]: BondScorer,
};
interface ScreenerEngineOptions {
logger?: Logger;
}
interface ErrorResult {
isError: true;
ticker: string;
message: string;
}
type FetchResult = ReturnType<typeof mapToStandardFormat> | ErrorResult;
export class ScreenerEngine {
private client: YahooClient;
private benchmarkProvider: BenchmarkProvider;
private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) {
this.client = new YahooClient();
this.benchmarkProvider = new BenchmarkProvider({
logger: logger ?? (console as unknown as Logger),
});
this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg),
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => console.warn(...args),
};
}
// Pure data method — returns structured results. Safe to use in a server route.
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
const marketContext = await this.benchmarkProvider.getMarketContext();
const results: Omit<ScreenerResult, 'marketContext'> = {
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<void>((r) => setTimeout(r, 1000));
}
return { ...results, marketContext };
}
// CLI helper — emits progress to logger, returns structured results.
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
this.logger.write('⏳ Fetching market context...');
const marketContext = await this.benchmarkProvider.getMarketContext();
this.logger.write(' done\n');
const results: Omit<ScreenerResult, 'marketContext'> = {
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<void>((r) => setTimeout(r, 1000));
}
this.logger.write('\n');
return { ...results, marketContext };
}
private async _fetch(ticker: string): Promise<FetchResult> {
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 as Error).message };
}
}
private _process(
data: FetchResult,
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): void {
if ('isError' in data && data.isError) {
results.ERROR.push({ ticker: data.ticker, message: data.message });
return;
}
try {
const asset = this._buildAsset(data as ReturnType<typeof mapToStandardFormat>);
const scorer = SCORERS[asset.type as AssetType];
if (!scorer) throw new Error(`No scorer for type: ${asset.type}`);
const fundamental = scorer.score(
asset.metrics as never,
RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.FUNDAMENTAL,
),
marketContext,
);
const inflated = scorer.score(
asset.metrics as never,
RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
SCORE_MODE.INFLATED,
),
marketContext,
);
(results[asset.type as AssetType] as unknown[]).push({
asset,
fundamental,
inflated,
signal: this._signal(fundamental.label, inflated.label),
});
} catch (err) {
results.ERROR.push({
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: (err as Error).message,
});
}
}
private _buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
case ASSET_TYPE.BOND:
return new Bond(data as never);
case ASSET_TYPE.ETF:
return new Etf(data as never);
default:
return new Stock(data as never);
}
}
private _signal(fundamentalLabel: string, inflatedLabel: string): Signal {
const green = (l: string) => l.startsWith('🟢');
const yellow = (l: string) => 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: Signal): number {
return SIGNAL_ORDER[signal] ?? 5;
}
}
-193
View File
@@ -1,193 +0,0 @@
import { SIGNAL } from '../../config/constants.js';
import type { StockMetrics } from '../assets/Stock.js';
type NumVal = number | null;
const n = (v: unknown): NumVal => {
const f = parseFloat(String(v));
return !isNaN(f) && f !== 0 ? f : null;
};
const scoreValue = (val: number, high: number, med: number, weight: number): number =>
val >= high ? weight : val >= med ? 1 : -1;
const scorePeg = (val: number, high: number, med: number, weight: number): number =>
val <= high ? weight : val <= med ? 1 : -1;
interface SanitizedMetrics {
debtToEquity: NumVal;
quickRatio: NumVal;
peRatio: NumVal;
pegRatio: NumVal;
priceToBook: NumVal;
netProfitMargin: NumVal;
operatingMargin: NumVal;
returnOnEquity: NumVal;
revenueGrowth: NumVal;
fcfYield: NumVal;
dividendYield: NumVal;
pFFO: NumVal;
beta: NumVal;
week52Position: NumVal;
}
interface ScoreOutput {
label: string;
scoreSummary: string;
audit: Record<string, unknown>;
}
export const StockScorer = {
score(
metrics: StockMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreOutput {
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) as string[];
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: Record<string, number> = {};
const totalScore = factors.reduce((sum, f) => {
if (!f.active) return sum;
breakdown[f.key] = f.fn() as number;
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) as string[];
return {
label: this._label(totalScore),
scoreSummary: `Score: ${totalScore}`,
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
};
},
_label(score: number): string {
if (score >= 8) return '🟢 BUY (High Conviction)';
if (score >= 4) return '🟢 BUY (Speculative)';
if (score >= 0) return '🟡 HOLD';
return '🔴 REJECT';
},
_sanitize(m: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High != null && 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,
};
},
};
-83
View File
@@ -1,83 +0,0 @@
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';
import { noopLogger } from './utils/logger.js';
interface BuildAppOptions {
logger?: boolean;
}
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
const app = Fastify({ logger });
await app.register(cors, {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
});
await app.register(screenerRoutes as any);
await app.register(financeRoutes as any);
await app.register(callsRoutes as any);
// POST /api/analyze
app.post('/api/analyze', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
handler: async (req: any, reply: any) => {
if (!process.env.ANTHROPIC_API_KEY) {
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
}
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const client = new YahooClient();
const llm = new LLMAnalyst({ logger: noopLogger });
const seen = new Map<
string,
{ title: string; publisher: string; link: string; relatedTickers: string[] }
>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker: string) => {
try {
const { news = [] } = await (client as any).yf.search(ticker, {
newsCount: 3,
quotesCount: 0,
});
for (const s of news as any[]) {
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;
}
-190
View File
@@ -1,190 +0,0 @@
import { MarketCallStore } from '../../calls/MarketCallStore.js';
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { YahooClient } from '../../market/YahooClient.js';
import { chunkArray } from '../../screener/Chunker.js';
import { noopLogger } from '../utils/logger.js';
const store = new MarketCallStore();
interface SnapshotEntry {
price: number | null;
signal: string | null;
inflatedVerdict: string | null;
fundamentalVerdict: string | null;
pe: string | null;
roe: string | null;
fcf: string | null;
}
const toSnapshot = (r: any): SnapshotEntry | null => {
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: any) {
// GET /api/calls
app.get('/api/calls', async () => ({ calls: store.list() }));
// GET /api/calls/:id
app.get('/api/calls/:id', async (req: any, reply: any) => {
const call = store.get((req.params as { id: string }).id);
if (!call) return reply.code(404).send({ error: 'Call not found' });
const current: Record<string, SnapshotEntry | null> = {};
if (call.tickers.length > 0) {
try {
const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(call.tickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
current[r.asset.ticker] = toSnapshot(r);
}
} catch {
/* non-fatal */
}
}
return { ...call, current };
});
// POST /api/calls
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: any, reply: any) => {
const { title, quarter, date, thesis, tickers } = req.body as {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
};
const upperTickers = tickers.map((t: string) => t.toUpperCase());
const snapshot: Record<string, SnapshotEntry | null> = {};
try {
const engine = new ScreenerEngine({ logger: noopLogger });
const results = await engine.screenTickers(upperTickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
snapshot[r.asset.ticker] = toSnapshot(r);
}
} catch (err) {
app.log.warn('Could not snapshot prices for market call:', (err as Error).message);
}
const call = store.create({
title,
quarter,
date,
thesis,
tickers: upperTickers,
snapshot: snapshot as any,
});
return reply.code(201).send(call);
},
});
// DELETE /api/calls/:id
app.delete('/api/calls/:id', async (req: any, reply: any) => {
const deleted = store.delete((req.params as { id: string }).id);
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
return { ok: true };
});
// GET /api/calls/calendar
app.get('/api/calls/calendar', async (req: any) => {
const client = new YahooClient();
let tickers: string[];
if ((req.query as any).tickers) {
tickers = String((req.query as any).tickers)
.split(',')
.map((t) => t.trim().toUpperCase())
.filter(Boolean);
} else {
const set = new Set(store.list().flatMap((c) => c.tickers));
tickers = [...set];
}
if (tickers.length === 0) return { events: [] };
const results: Record<string, any> = {};
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<void>((r) => setTimeout(r, 500));
}
const events: any[] = [];
const now = Date.now();
for (const [ticker, cal] of Object.entries(results)) {
for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal as string);
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,
});
}
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,
});
}
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,
});
}
}
events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast
? new Date(b.date).getTime() - new Date(a.date).getTime()
: new Date(a.date).getTime() - new Date(b.date).getTime();
});
return { events, tickers };
});
}
-108
View File
@@ -1,108 +0,0 @@
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';
import { noopLogger } from '../utils/logger.js';
import type { PortfolioHolding } from '../../types.js';
const PORTFOLIO_PATH = './portfolio.json';
const normalizeYahoo = (t: string) => t.toUpperCase().replace(/\./g, '-');
export default async function financeRoutes(app: any) {
// GET /api/finance/portfolio
app.get('/api/finance/portfolio', async (req: any, reply: any) => {
if (!existsSync(PORTFOLIO_PATH))
return reply.code(404).send({ error: 'portfolio.json not found' });
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as {
holdings: PortfolioHolding[];
};
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);
}
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: {} as any };
const advice = await new PortfolioAdvisor().advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
});
// POST /api/finance/holdings
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: any, reply: any) => {
const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const normalized = ticker.toUpperCase().trim();
const portfolio = existsSync(PORTFOLIO_PATH)
? (JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as { holdings: PortfolioHolding[] })
: { holdings: [] as PortfolioHolding[] };
const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
const entry: PortfolioHolding = { ticker: normalized, shares, costBasis, type, source };
if (idx >= 0) portfolio.holdings[idx] = entry;
else portfolio.holdings.push(entry);
writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8');
return reply.code(201).send(entry);
},
});
// DELETE /api/finance/holdings/:ticker
app.delete('/api/finance/holdings/:ticker', async (req: any, reply: any) => {
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
if (!existsSync(PORTFOLIO_PATH))
return reply.code(404).send({ error: 'portfolio.json not found' });
const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) as {
holdings: PortfolioHolding[];
};
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
app.get('/api/finance/market-context', async () => {
const engine = new ScreenerEngine({ logger: noopLogger });
return engine['benchmarkProvider'].getMarketContext();
});
}
-55
View File
@@ -1,55 +0,0 @@
import { ScreenerEngine } from '../../screener/ScreenerEngine.js';
import { noopLogger } from '../utils/logger.js';
import type { AssetResult } from '../../types.js';
type AnyAsset = AssetResult['asset'] & {
getDisplayMetrics: () => Record<string, unknown>;
metrics: unknown;
};
const serializeAssets = (arr: (AssetResult & { asset: AnyAsset })[]) =>
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: any) {
const engine = new ScreenerEngine({ logger: noopLogger });
app.post('/api/screen', {
schema: {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
},
handler: async (req: any) => {
const tickers = (req.body as { tickers: string[] }).tickers.map((t: string) =>
t.toUpperCase(),
);
const results = await engine.screenTickers(tickers);
return {
...results,
STOCK: serializeAssets(results.STOCK as any),
ETF: serializeAssets(results.ETF as any),
BOND: serializeAssets(results.BOND as any),
};
},
});
app.get('/api/screen/catalysts', async () => {
const { CatalystAnalyst } = await import('../../analyst/CatalystAnalyst.js');
const catalyst = new CatalystAnalyst({ logger: noopLogger });
const { tickers, stories } = await catalyst.run();
return { tickers, stories };
});
}
@@ -1,10 +1,11 @@
import { YahooClient } from './YahooClient.js';
import { REGIME } from '../config/constants.js';
import type { MarketContext, Logger } from '../types.js';
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { REGIME } from '../config/constants';
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types';
const TTL_MS = 60 * 60 * 1000;
export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000;
const DEFAULTS: MarketContext = {
private static readonly DEFAULTS: MarketContext = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
@@ -13,27 +14,23 @@ const DEFAULTS: MarketContext = {
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
const rateRegime = (rate: number): MarketContext['rateRegime'] =>
rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
const volRegime = (vix: number): MarketContext['volatilityRegime'] =>
vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pe = (summary: any): number | null =>
summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
interface BenchmarkProviderOptions {
logger?: Logger;
private static rateRegime(rate: number): MarketContext['rateRegime'] {
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
}
export class BenchmarkProvider {
private client: YahooClient;
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
}
private static pe(summary: any): number | null {
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
}
private client: YahooFinanceClient;
private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger;
constructor({ logger }: BenchmarkProviderOptions = {}) {
this.client = new YahooClient();
this.client = new YahooFinanceClient();
this.cache = { data: null, expiresAt: 0 };
this.logger = logger ?? (console as unknown as Logger);
}
@@ -67,21 +64,21 @@ export class BenchmarkProvider {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: rateRegime(riskFreeRate),
volatilityRegime: volRegime(vixLevel),
rateRegime: BenchmarkProvider.rateRegime(riskFreeRate),
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
benchmarks: {
marketPE: pe(spy) ?? 22,
techPE: pe(xlk) ?? 30,
marketPE: BenchmarkProvider.pe(spy) ?? 22,
techPE: BenchmarkProvider.pe(xlk) ?? 30,
reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
},
};
this.cache = { data: context, expiresAt: Date.now() + TTL_MS };
this.cache = { data: context, expiresAt: Date.now() + BenchmarkProvider.TTL_MS };
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
return this.cache.data ?? DEFAULTS;
return this.cache.data ?? BenchmarkProvider.DEFAULTS;
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types';
export class CatalystAnalyst {
private static readonly NEWS_QUERIES = [
'stock market today',
'earnings report today',
'market news catalyst',
'federal reserve interest rates',
'stock upgrade downgrade analyst',
];
private static readonly MAX_STORIES = 20;
private static readonly TICKER_REGEX = /^[A-Z]{1,6}$/;
private client: YahooFinanceClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooFinanceClient();
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...');
const rawStories = await this._fetchNews();
if (!rawStories.length) {
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
return { tickers: [], tickerFrequency: {}, stories: [] };
}
const stories = rawStories.map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
const { tickers, tickerFrequency } = CatalystAnalyst.rankTickers(stories);
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
return { tickers, tickerFrequency, stories };
}
// Search by specific ticker for the /api/analyze endpoint.
async fetchStoriesForTickers(tickers: string[]): Promise<Story[]> {
const seen = new Map<string, YahooNewsItem>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
try {
const news = await this.client.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const item of news) {
if (!seen.has(item.title)) seen.set(item.title, item);
}
} catch {
/* skip tickers Yahoo can't resolve */
}
}),
);
return [...seen.values()].slice(0, 15).map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
}
private async _fetchNews(): Promise<YahooNewsItem[]> {
const seen = new Map<string, YahooNewsItem>();
let successCount = 0;
for (const query of CatalystAnalyst.NEWS_QUERIES) {
try {
const news = await this.client.search(query, { newsCount: 8, quotesCount: 0 });
successCount++;
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 — tracked via successCount */
}
}
if (successCount === 0) return [];
return [...seen.values()].slice(0, CatalystAnalyst.MAX_STORIES);
}
static rankTickers(stories: Story[]): {
tickers: string[];
tickerFrequency: Record<string, number>;
} {
const freq: Record<string, number> = {};
for (const { tickers } of stories) {
for (const t of tickers) {
freq[t] = (freq[t] ?? 0) + 1;
}
}
const tickers = Object.keys(freq).sort((a, b) => freq[b] - freq[a]);
return { tickers, tickerFrequency: freq };
}
}
+227
View File
@@ -0,0 +1,227 @@
import type { MappedData } from '../types';
// Internal: Yahoo Finance API response shape
type YahooSummary = Record<string, Record<string, unknown>>;
export class DataMapper {
// ── Public entry point ────────────────────────────────────────────────────
static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData {
const quoteType = summary.price?.quoteType as string | undefined;
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury') ||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
if (quoteType === 'ETF') {
return isBond
? { type: 'BOND', ticker, ...DataMapper.mapBondData(summary) }
: { type: 'ETF', ticker, ...DataMapper.mapEtfData(summary) };
}
return { type: 'STOCK', ticker, ...DataMapper.mapStockData(summary) };
}
// ── Stock ─────────────────────────────────────────────────────────────────
private static mapStockData(summary: YahooSummary) {
const fd = (summary.financialData ?? {}) as Record<string, number | null>;
const ks = (summary.defaultKeyStatistics ?? {}) as Record<string, number | null>;
const sd = (summary.summaryDetail ?? {}) as Record<string, number | null>;
const pr = (summary.price ?? {}) as Record<string, number | null>;
const currentPrice = pr.regularMarketPrice ?? 0;
const sharesOutstanding = ks.sharesOutstanding ?? 0;
const operatingCashflow = fd.operatingCashflow ?? 0;
const freeCashflow = fd.freeCashflow ?? 0;
// P/FFO proxy — used for REIT scoring
const pFFO =
operatingCashflow > 0 && sharesOutstanding > 0
? (currentPrice as number) / (operatingCashflow / sharesOutstanding)
: null;
// FCF yield — negative FCF preserved so cash-burning companies fail the gate
const fcfYield =
freeCashflow !== 0 && sharesOutstanding > 0 && (currentPrice as number) > 0
? ((freeCashflow as number) / (sharesOutstanding as number) / (currentPrice as number)) *
100
: null;
// PEG: prefer Yahoo's value, fall back to trailingPE / earningsGrowth
const yahoosPEG = ks.pegRatio ?? null;
const trailingPE = sd.trailingPE ?? null;
const earningsGrowth = fd.earningsGrowth != null ? (fd.earningsGrowth as number) * 100 : null;
const computedPEG =
trailingPE != null && earningsGrowth != null && earningsGrowth > 0
? +((trailingPE as number) / earningsGrowth).toFixed(2)
: null;
const pegRatio = yahoosPEG ?? computedPEG;
// Quick ratio — fall back to currentRatio when missing
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
// ── 52-week movement ──────────────────────────────────────────────────
const week52High = sd.fiftyTwoWeekHigh ?? null;
const week52Low = sd.fiftyTwoWeekLow ?? null;
const week52Change =
ks['52WeekChange'] != null ? +((ks['52WeekChange'] as number) * 100).toFixed(1) : null;
const week52FromHigh =
week52High != null && week52High > 0 && (currentPrice as number) > 0
? +(((currentPrice - week52High) / week52High) * 100).toFixed(1)
: null;
const week52FromLow =
week52Low != null && week52Low > 0 && (currentPrice as number) > 0
? +(((currentPrice - week52Low) / week52Low) * 100).toFixed(1)
: null;
// ── Analyst consensus ─────────────────────────────────────────────────
const analystRating = fd.recommendationMean ?? null;
const analystTargetPrice = fd.targetMeanPrice ?? null;
const numberOfAnalysts =
fd.numberOfAnalystOpinions != null ? Math.round(fd.numberOfAnalystOpinions as number) : null;
const analystUpside =
analystTargetPrice != null && (currentPrice as number) > 0
? +(((analystTargetPrice - currentPrice) / currentPrice) * 100).toFixed(1)
: null;
// ── Gross margin ──────────────────────────────────────────────────────
const grossMargin =
fd.grossMargins != null ? +((fd.grossMargins as number) * 100).toFixed(1) : null;
// ── DCF intrinsic value ───────────────────────────────────────────────
const revenueGrowthDecimal = fd.revenueGrowth != null ? (fd.revenueGrowth as number) : null;
const earningsGrowthDecimal = fd.earningsGrowth != null ? (fd.earningsGrowth as number) : null;
const dcfGrowthRate =
earningsGrowthDecimal ?? (revenueGrowthDecimal != null ? revenueGrowthDecimal * 0.7 : null);
const dcf = DataMapper.computeDCF(
freeCashflow as number,
sharesOutstanding as number,
currentPrice as number,
dcfGrowthRate,
);
return {
peRatio: trailingPE ?? ks.forwardPE,
trailingPE,
pegRatio,
priceToBook: ks.priceToBook ?? null,
evToEbitda: ks.enterpriseToEbitda ?? null,
grossMargin,
netProfitMargin: fd.profitMargins != null ? (fd.profitMargins as number) * 100 : null,
operatingMargin: fd.operatingMargins != null ? (fd.operatingMargins as number) * 100 : null,
returnOnEquity: fd.returnOnEquity != null ? (fd.returnOnEquity as number) * 100 : null,
revenueGrowth: fd.revenueGrowth != null ? (fd.revenueGrowth as number) * 100 : null,
earningsGrowth,
debtToEquity: fd.debtToEquity != null ? (fd.debtToEquity as number) / 100 : null,
quickRatio,
fcfYield,
pFFO,
dividendYield:
sd.trailingAnnualDividendYield != null
? (sd.trailingAnnualDividendYield as number) * 100
: null,
beta: sd.beta ?? null,
week52High,
week52Low,
week52Change,
week52FromHigh,
week52FromLow,
marketCap: pr.marketCap ?? null,
analystRating,
analystTargetPrice,
analystUpside,
numberOfAnalysts,
dcfIntrinsicValue: dcf?.intrinsicValue ?? null,
dcfMarginOfSafety: dcf?.marginOfSafety ?? null,
currentPrice,
assetProfile: summary.assetProfile || {},
};
}
// ── ETF ───────────────────────────────────────────────────────────────────
private static mapEtfData(summary: YahooSummary) {
return {
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
volume:
(summary.summaryDetail?.averageVolume as number) ??
(summary.price?.averageVolume as number) ??
0,
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
};
}
// ── Bond ──────────────────────────────────────────────────────────────────
private static mapBondData(summary: YahooSummary) {
return {
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
duration: DataMapper.inferDuration(summary.assetProfile?.category as string),
creditRating: DataMapper.inferCreditRating(summary.assetProfile?.category as string),
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
};
}
private static inferCreditRating(category: string | undefined): string {
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';
}
private static inferDuration(category: string | undefined): number {
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;
}
// ── DCF ───────────────────────────────────────────────────────────────────
// Two-stage model:
// Stage 1 — FCF/share grows at `growthRate` for 5 years, discounted at 9.5% WACC.
// Stage 2 — Terminal value via Gordon Growth Model at 2.5% perpetuity rate.
// Only fires when TTM FCF per share is positive.
private static computeDCF(
freeCashflow: number,
sharesOutstanding: number,
currentPrice: number,
growthRate: number | null,
riskFreeRate = 0.04,
): { intrinsicValue: number; marginOfSafety: number } | null {
if (!freeCashflow || freeCashflow <= 0) return null;
if (!sharesOutstanding || sharesOutstanding <= 0) return null;
if (!currentPrice || currentPrice <= 0) return null;
const fcfPerShare = freeCashflow / sharesOutstanding;
if (fcfPerShare <= 0) return null;
const discountRate = riskFreeRate + 0.055; // WACC proxy
const terminalGrowth = 0.025; // long-run GDP growth
const years = 5;
const g = Math.min(Math.max(growthRate ?? 0.08, -0.05), 0.3);
let pv = 0;
let fcfT = fcfPerShare;
for (let t = 1; t <= years; t++) {
fcfT *= 1 + g;
pv += fcfT / Math.pow(1 + discountRate, t);
}
const terminalValue = (fcfT * (1 + terminalGrowth)) / (discountRate - terminalGrowth);
pv += terminalValue / Math.pow(1 + discountRate, years);
const intrinsicValue = +pv.toFixed(2);
const marginOfSafety = +(((intrinsicValue - currentPrice) / intrinsicValue) * 100).toFixed(1);
return { intrinsicValue, marginOfSafety };
}
}
+66
View File
@@ -0,0 +1,66 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { AnthropicClient } from '../clients/AnthropicClient';
import type { Logger, LLMAnalysis, Story } from '../types';
export class LLMAnalyst {
private logger: Pick<Logger, 'log' | 'warn'>;
private client: AnthropicClient;
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
this.logger = logger ?? { log: console.log, warn: console.warn };
this.client = new AnthropicClient();
}
get isAvailable(): boolean {
return this.client.isAvailable;
}
async analyze(
stories: Story[],
existingTickers: string[] = [],
tickerFrequency: Record<string, number> = {},
): Promise<LLMAnalysis | null> {
if (!this.client.isAvailable) {
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) => {
const tickers = s.tickers.length ? ` [${s.tickers.join(', ')}]` : '';
return `${i + 1}. ${s.title} (${s.source})${tickers}`;
})
.join('\n');
const freqLines = Object.entries(tickerFrequency)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([t, n]) => ` ${t}: ${n} ${n === 1 ? 'story' : 'stories'}`)
.join('\n');
const freqSection = freqLines ? `\nTicker mention frequency (ranked):\n${freqLines}\n` : '';
const userMessage = `Today's market news headlines:\n\n${headlines}\n${freqSection}\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
try {
const PROMPT_FILE = '../../prompts/llm-analyst.md';
const PROMPT_PATH = join(fileURLToPath(import.meta.url), PROMPT_FILE);
const SYSTEM_PROMPT = readFileSync(PROMPT_PATH, 'utf8');
const raw = await this.client.complete(SYSTEM_PROMPT, userMessage);
if (!raw) return null;
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned) as LLMAnalysis;
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
return null;
}
}
}
@@ -1,10 +1,5 @@
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants.js';
import type { MarketContext, AssetType } from '../types.js';
interface InflatedOverrides {
gates: Record<string, number>;
thresholds: Record<string, number>;
}
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants';
import type { MarketContext, AssetType, InflatedOverrides } from '../types';
export class MarketRegime {
private marketPE: number;
@@ -1,38 +1,7 @@
interface Transaction {
amount: number;
category: string;
}
interface Account {
type: string;
balance: number;
transactions: Transaction[];
[key: string]: unknown;
}
interface CategoryBreakdown {
category: string;
amount: number;
pct: string;
}
interface FinanceAnalysis {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
totalCash: number;
totalInvestments: number;
cashPct: string;
investPct: string;
totalIncome: number;
totalSpend: number;
savingsRate: string | null;
categoryBreakdown: CategoryBreakdown[];
accounts: Account[];
}
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../types';
export class PersonalFinanceAnalyzer {
analyse(accounts: Account[]): FinanceAnalysis {
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
@@ -1,40 +1,20 @@
import { SIGNAL } from '../config/constants.js';
import { YahooClient } from '../market/YahooClient.js';
import type { PortfolioHolding, Signal, ScreenerResult, AssetResult } from '../types.js';
interface PositionCalc {
totalCost: string;
marketValue: string | null;
gainLossPct: string | null;
}
interface AdviceOutput {
action: string;
reason: string;
}
interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: number | null;
marketValue: string | null;
totalCost: string;
gainLossPct: string | null;
signal: Signal | '—';
inflated: string;
fundamental: string;
advice: string;
reason: string;
}
import { SIGNAL } from '../config/constants';
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import type {
PortfolioHolding,
Signal,
ScreenerResult,
AssetResult,
AdviceRow,
PositionCalc,
AdviceOutput,
} from '../types';
export class PortfolioAdvisor {
private client: YahooClient;
private client: YahooFinanceClient;
constructor() {
this.client = new YahooClient();
this.client = new YahooFinanceClient();
}
async advise(
@@ -1,16 +1,10 @@
import { ScoringRules } from '../config/ScoringConfig.js';
import { MarketRegime } from '../market/MarketRegime.js';
import { SCORE_MODE } from '../config/constants.js';
import type { AssetType, MarketContext } from '../types.js';
import { ScoringRules } from '../config/ScoringConfig';
import { MarketRegime } from './MarketRegime';
import { SCORE_MODE } from '../config/constants';
import type { AssetType, MarketContext, RuleSet } from '../types';
interface RuleSet {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
export const RuleMerger = {
getRulesForAsset(
export class RuleMerger {
static getRulesForAsset(
type: AssetType,
metrics: { sector?: string },
marketContext: Partial<MarketContext> = {},
@@ -45,5 +39,5 @@ export const RuleMerger = {
}
return rules;
},
};
}
}
+198
View File
@@ -0,0 +1,198 @@
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { BenchmarkProvider } from './BenchmarkProvider';
import { DataMapper } from './DataMapper';
import { chunkArray } from '../utils/Chunker';
import { RuleMerger } from './RuleMerger';
import { Stock } from '../models/Stock';
import { Etf } from '../models/Etf';
import { Bond } from '../models/Bond';
import { StockScorer } from '../scorers/StockScorer';
import { EtfScorer } from '../scorers/EtfScorer';
import { BondScorer } from '../scorers/BondScorer';
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants';
import type {
Logger,
MarketContext,
Signal,
AssetType,
ScoreResult,
ScreenerResult,
ScreenerEngineOptions,
ErrorResult,
MappedData,
StockData,
EtfData,
BondData,
} from '../types';
export class ScreenerEngine {
private static readonly BATCH_SIZE = 5;
private static readonly BATCH_DELAY_MS = 1000;
private client: YahooFinanceClient;
private benchmarkProvider: BenchmarkProvider;
private logger: Logger;
constructor({ logger }: ScreenerEngineOptions = {}) {
this.client = new YahooFinanceClient();
this.benchmarkProvider = new BenchmarkProvider({
logger: logger ?? (console as unknown as Logger),
});
this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg),
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => console.warn(...args),
};
}
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
return this._screenInternal(tickers, false);
}
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
return this._screenInternal(tickers, true);
}
private async _screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
const marketContext = await this._fetchMarketContext(showProgress);
const results = this._initializeResults();
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
let processed = 0;
for (const chunk of chunks) {
await this._processBatch(chunk, marketContext, results);
processed += chunk.length;
this._logProgress(showProgress, processed, tickers.length);
await this._rateLimitDelay();
}
if (showProgress) {
this.logger.write('\n');
}
return { ...results, marketContext };
}
private async _fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
if (showProgress) {
this.logger.write('⏳ Fetching market context...');
}
const context = await this.benchmarkProvider.getMarketContext();
if (showProgress) {
this.logger.write(' done\n');
}
return context;
}
private _initializeResults(): Omit<ScreenerResult, 'marketContext'> {
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
}
private async _processBatch(
tickers: string[],
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): Promise<void> {
const batch = await Promise.all(tickers.map((t) => this._fetch(t)));
batch.forEach((data) => this._process(data, marketContext, results));
}
private _logProgress(showProgress: boolean, processed: number, total: number): void {
if (showProgress) {
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
}
}
private async _rateLimitDelay(): Promise<void> {
await new Promise<void>((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS));
}
private async _fetch(ticker: string): Promise<MappedData | ErrorResult> {
try {
const summary = await this.client.fetchSummary(ticker);
if (!summary?.price) throw new Error('Empty response from Yahoo');
return DataMapper.mapToStandardFormat(ticker, summary);
} catch (err) {
return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
}
}
private _process(
data: MappedData | ErrorResult,
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): void {
if ('isError' in data && data.isError) {
const e = data as ErrorResult;
results.ERROR.push({ ticker: e.ticker, message: e.message });
return;
}
try {
const asset = this._buildAsset(data as MappedData);
const fundamental = this._score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
const inflated = this._score(asset, marketContext, SCORE_MODE.INFLATED);
(results[asset.type as AssetType] as unknown[]).push({
asset,
fundamental,
inflated,
signal: this._signal(fundamental.label, inflated.label),
});
} catch (err) {
results.ERROR.push({
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: (err as Error).message,
});
}
}
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
// its exact metrics type. No `as never` or unsafe casts required.
private _score(
asset: Stock | Etf | Bond,
marketContext: MarketContext,
mode: string,
): ScoreResult {
const rules = RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
mode,
);
if (asset instanceof Stock) return StockScorer.score(asset.metrics, rules);
if (asset instanceof Etf) return EtfScorer.score(asset.metrics, rules);
if (asset instanceof Bond) return BondScorer.score(asset.metrics, rules, marketContext);
// TypeScript exhaustive check: all three branches are handled above.
throw new Error('No scorer for unknown asset type');
}
private _buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
case ASSET_TYPE.BOND:
return new Bond(data as BondData);
case ASSET_TYPE.ETF:
return new Etf(data as EtfData);
default:
return new Stock(data as StockData);
}
}
private _signal(fundamentalLabel: string, inflatedLabel: string): Signal {
const green = (l: string) => l.startsWith('🟢');
const yellow = (l: string) => 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: Signal): number {
return SIGNAL_ORDER[signal] ?? 5;
}
getMarketContext(): Promise<MarketContext> {
return this.benchmarkProvider.getMarketContext();
}
}
+10
View File
@@ -0,0 +1,10 @@
// Barrel — re-exports every service so callers import from one path.
export * from './BenchmarkProvider';
export * from './CatalystAnalyst';
export * from './DataMapper';
export * from './LLMAnalyst';
export * from './MarketRegime';
export * from './PersonalFinanceAnalyzer';
export * from './PortfolioAdvisor';
export * from './RuleMerger';
export * from './ScreenerEngine';
+4 -135
View File
@@ -1,135 +1,4 @@
// ── Shared domain types ───────────────────────────────────────────────────
// Single source of truth for all cross-cutting interfaces and type aliases.
// Server classes import from here; UI imports from $lib/types.ts (mirrored subset).
// ── Primitives ────────────────────────────────────────────────────────────
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
// ── Market context (live benchmarks from BenchmarkProvider) ───────────────
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: RateRegime;
volatilityRegime: VolatilityRegime;
benchmarks: Benchmarks;
}
// ── Scoring ───────────────────────────────────────────────────────────────
export interface ScoringRules {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
export interface ScoreResult {
label: string;
score: number;
scoreSummary: string;
audit: {
gatesPassed: string[];
gatesFailed: string[];
riskFlags: string[];
};
}
// ── Screener results ──────────────────────────────────────────────────────
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: Record<string, string | number | null>;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: MarketContext;
}
// ── LLM analysis ──────────────────────────────────────────────────────────
export interface AffectedIndustry {
name: string;
reason: string;
}
export interface RelatedTicker {
ticker: string;
reason: string;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: AffectedIndustry[];
relatedTickers: RelatedTicker[];
}
// ── Market calls ──────────────────────────────────────────────────────────
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
// ── Portfolio ─────────────────────────────────────────────────────────────
export type HoldingType = 'stock' | 'etf' | 'bond' | 'crypto';
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: HoldingType;
}
// ── Logger ────────────────────────────────────────────────────────────────
export interface Logger {
write: (msg: string) => void;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
// ── Barrel re-export ──────────────────────────────────────────────────────
// All types now live in server/types/*.model.ts — import from there directly
// for clarity, or from here for convenience (existing imports still work).
export type * from './types/index';
+83
View File
@@ -0,0 +1,83 @@
// ── Asset & screener domain types ─────────────────────────────────────────
import type { Sector } from '../config/constants';
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export interface ScoringRules {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
// ── ScoringConfig structural shapes (server/config/ScoringConfig.ts) ───────
export type GateSet = Record<string, number>;
export type WeightSet = Record<string, number>;
export type ThresholdSet = Record<string, number>;
export interface RuleBlock {
gates: GateSet;
weights: WeightSet;
thresholds: ThresholdSet;
}
export interface StockRules extends RuleBlock {
SECTOR_OVERRIDE: Partial<Record<Sector, Partial<RuleBlock>>>;
}
export interface ScoringRulesShape {
STOCK: StockRules;
ETF: RuleBlock;
BOND: RuleBlock;
}
export interface ScoreAudit {
passedGates: boolean;
breakdown?: Record<string, number>;
riskFlags?: string[] | null;
failures?: string[];
}
export interface ScoreResult {
label: string;
scoreSummary: string;
audit: ScoreAudit;
}
// AssetResult with runtime methods still attached — used at the HTTP boundary
// before class instances are serialised to plain objects for API responses.
export type LiveAssetResult = AssetResult & {
asset: AssetResult['asset'] & {
getDisplayMetrics: () => Record<string, unknown>;
metrics: unknown;
};
};
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: Record<string, string | number | null>;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: import('./market.model.js').MarketContext;
}
+39
View File
@@ -0,0 +1,39 @@
// ── Market calls domain types ──────────────────────────────────────────────
import type { Signal } from './asset.model';
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
// Input shape for MarketCallRepository.create()
export interface CreateCallInput {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
snapshot?: Record<string, TickerSnapshot>;
}
// Re-screened snapshot returned by GET /api/calls/:id for price comparison.
export interface SnapshotEntry {
price: number | null;
signal: string | null;
inflatedVerdict: string | null;
fundamentalVerdict: string | null;
pe: string | null;
roe: string | null;
fcf: string | null;
}
+101
View File
@@ -0,0 +1,101 @@
// ── Finance & analyst API response types ──────────────────────────────────
import type { Logger } from './logger.model';
export interface AffectedIndustry {
name: string;
reason: string;
}
export interface RelatedTicker {
ticker: string;
reason: string;
bias: 'BULL' | 'BEAR';
horizon: 'SHORT' | 'MEDIUM' | 'LONG';
sensitivity: 1 | 2 | 3 | 4 | 5;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: AffectedIndustry[];
relatedTickers: RelatedTicker[];
}
export interface CatalystStory {
title: string;
link: string;
publisher: string;
publishedAt: string;
relatedTickers: string[];
}
export interface CalendarEvent {
ticker: string;
type: 'earnings' | 'dividend' | 'exdividend';
date: string;
label?: string;
detail?: string | null;
isPast?: boolean;
epsEstimate?: number | null;
revEstimate?: number | null;
}
// ── Yahoo Finance client types ─────────────────────────────────────────────
// Raw shapes returned by the yahoo-finance2 search endpoint.
// Used by YahooFinanceClient, CatalystAnalyst, and AnalyzeController.
export interface YahooNewsItem {
title: string;
publisher: string;
link: string;
relatedTickers?: string[];
}
export interface YahooSearchOptions {
newsCount?: number;
quotesCount?: number;
}
// Narrow interface over the yahoo-finance2 instance — only the methods this
// codebase actually calls. Keeps `any` contained to this one declaration.
export interface YahooFinanceLib {
quoteSummary(ticker: string, opts: { modules: string[] }): Promise<any>;
search(query: string, opts?: YahooSearchOptions): Promise<{ news?: YahooNewsItem[] }>;
}
// ── SimpleFIN client types ─────────────────────────────────────────────────
export interface SimpleFINOptions {
logger?: Logger;
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
}
export interface SimpleFINTransaction {
id: string;
date: string;
amount: number;
description: string;
category: string;
}
export interface SimpleFINAccount {
id: string;
name: string;
currency: string;
balance: number;
balanceDate: string;
org: string;
type: string;
transactions: SimpleFINTransaction[];
}
export interface SimpleFINData {
accounts: SimpleFINAccount[];
errors: string[];
}
export interface GetAccountsOptions {
startDate?: number;
endDate?: number;
}
+65
View File
@@ -0,0 +1,65 @@
// ── Single source of truth for all domain types ───────────────────────────
// Import from specific model files for clarity, or from here for convenience.
export type {
Signal,
AssetType,
ScoreMode,
ScoringRules,
ScoreAudit,
ScoreResult,
AssetResult,
LiveAssetResult,
ScreenerResult,
GateSet,
WeightSet,
ThresholdSet,
RuleBlock,
StockRules,
ScoringRulesShape,
} from './asset.model';
export type { RateRegime, VolatilityRegime, Benchmarks, MarketContext } from './market.model';
export type { HoldingType, PortfolioHolding, PortfolioAdvice, AdviceRow } from './portfolio.model';
export type { TickerSnapshot, MarketCall, SnapshotEntry, CreateCallInput } from './calls.model';
export type {
AffectedIndustry,
RelatedTicker,
LLMAnalysis,
CatalystStory,
CalendarEvent,
YahooNewsItem,
YahooSearchOptions,
YahooFinanceLib,
SimpleFINOptions,
SimpleFINTransaction,
SimpleFINAccount,
SimpleFINData,
GetAccountsOptions,
} from './finance.model';
export type { Logger } from './logger.model';
export type {
AssetData,
StockData,
StockMetrics,
EtfData,
EtfMetrics,
BondData,
BondMetrics,
} from './models.model';
export type { StoreData, PortfolioData } from './repositories.model';
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
export type {
BenchmarkProviderOptions,
InflatedOverrides,
PositionCalc,
AdviceOutput,
ErrorResult,
Headline,
Story,
CatalystResult,
MappedData,
CategoryBreakdown,
FinanceAnalysis,
RuleSet,
ScreenerEngineOptions,
} from './services.model';
+7
View File
@@ -0,0 +1,7 @@
// ── Logger interface ───────────────────────────────────────────────────────
export interface Logger {
write: (msg: string) => void;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
+21
View File
@@ -0,0 +1,21 @@
// ── Market context types ───────────────────────────────────────────────────
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: RateRegime;
volatilityRegime: VolatilityRegime;
benchmarks: Benchmarks;
}
+121
View File
@@ -0,0 +1,121 @@
// ── Model data input and metrics shapes ────────────────────────────────────
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
// ── Asset base ─────────────────────────────────────────────────────────────
export interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
// ── Stock ──────────────────────────────────────────────────────────────────
export interface StockData {
ticker?: string;
currentPrice?: number;
assetProfile?: { industry?: string; sector?: string };
peRatio?: number | null;
pegRatio?: number | null;
priceToBook?: number | null;
grossMargin?: number | null;
netProfitMargin?: number | null;
operatingMargin?: number | null;
returnOnEquity?: number | null;
revenueGrowth?: number | null;
earningsGrowth?: number | null;
debtToEquity?: number | null;
quickRatio?: number | null;
fcfYield?: number | null;
pFFO?: number | null;
dividendYield?: number | null;
beta?: number | null;
week52High?: number | null;
week52Low?: number | null;
week52Change?: number | null;
week52FromHigh?: number | null;
week52FromLow?: number | null;
marketCap?: number | null;
analystRating?: number | null;
analystTargetPrice?: number | null;
analystUpside?: number | null;
numberOfAnalysts?: number | null;
dcfIntrinsicValue?: number | null;
dcfMarginOfSafety?: number | null;
[key: string]: unknown;
}
export interface StockMetrics {
sector: Sector;
capCategory: CapCategory;
growthCategory: GrowthCategory;
peRatio: number | null;
pegRatio: number | null;
priceToBook: number | null;
grossMargin: number | null;
netProfitMargin: number | null;
operatingMargin: number | null;
returnOnEquity: number | null;
revenueGrowth: number | null;
earningsGrowth: number | null;
debtToEquity: number | null;
quickRatio: number | null;
fcfYield: number | null;
pFFO: number | null;
dividendYield: number | null;
beta: number | null;
week52High: number | null;
week52Low: number | null;
week52Change: number | null;
week52FromHigh: number | null;
week52FromLow: number | null;
marketCap: number | null;
analystRating: number | null;
analystTargetPrice: number | null;
analystUpside: number | null;
numberOfAnalysts: number | null;
dcfIntrinsicValue: number | null;
dcfMarginOfSafety: number | null;
currentPrice: number;
}
// ── ETF ────────────────────────────────────────────────────────────────────
export interface EtfData {
ticker?: string;
currentPrice?: number;
expenseRatio?: string | number;
totalAssets?: string | number;
yield?: string | number;
volume?: string | number;
fiveYearReturn?: string | number;
[key: string]: unknown;
}
export interface EtfMetrics {
expenseRatio: number;
totalAssets: number;
yield: number;
volume: number;
fiveYearReturn: number;
}
// ── Bond ───────────────────────────────────────────────────────────────────
export interface BondData {
ticker?: string;
currentPrice?: number;
creditRating?: string;
yieldToMaturity?: string | number;
duration?: string | number;
[key: string]: unknown;
}
export interface BondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
+40
View File
@@ -0,0 +1,40 @@
// ── Portfolio domain types ─────────────────────────────────────────────────
import type { Signal } from './asset.model';
export type HoldingType = 'stock' | 'etf' | 'bond' | 'crypto';
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: HoldingType;
}
export interface PortfolioAdvice {
ticker: string;
action: 'hold' | 'sell' | 'add' | 'watch';
reason: string;
signal: Signal | null;
currentPrice: number | null;
gainLossPct: number | null;
}
// Public return shape of PortfolioAdvisor.advise() — one row per holding.
export interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: number | null;
marketValue: string | null;
totalCost: string;
gainLossPct: string | null;
signal: Signal | '—';
inflated: string;
fundamental: string;
advice: string;
reason: string;
}
+11
View File
@@ -0,0 +1,11 @@
// ── Repository persistence shapes ────────────────────────────────────────
import type { MarketCall, PortfolioHolding } from './index';
export interface StoreData {
calls: (MarketCall & { createdAt: string })[];
}
export interface PortfolioData {
holdings: PortfolioHolding[];
}
+54
View File
@@ -0,0 +1,54 @@
// ── Fastify request body schemas ──────────────────────────────────────────
// Fastify validates incoming request bodies against these JSON Schemas before
// the handler runs. If validation fails it replies 400 automatically.
// One schema per route that has a body; GET routes need no schema.
import type { FastifySchema } from 'fastify';
export const screenSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const analyzeSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const holdingSchema: FastifySchema = {
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' },
},
},
};
export const callSchema: FastifySchema = {
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 },
},
},
};
+33
View File
@@ -0,0 +1,33 @@
// ── Scorer internal metric shapes ──────────────────────────────────────────
export type NumVal = number | null;
export interface SanitizedMetrics {
debtToEquity: NumVal;
quickRatio: NumVal;
peRatio: NumVal;
pegRatio: NumVal;
priceToBook: NumVal;
netProfitMargin: NumVal;
operatingMargin: NumVal;
returnOnEquity: NumVal;
revenueGrowth: NumVal;
fcfYield: NumVal;
dividendYield: NumVal;
pFFO: NumVal;
beta: NumVal;
week52Position: NumVal;
// Expert features
week52Change: NumVal; // % total return over last 52 weeks
week52FromHigh: NumVal; // % below 52-week high (negative = down from high)
analystRating: NumVal; // Yahoo scale: 1=Strong Buy … 5=Strong Sell
analystUpside: NumVal; // % price upside to consensus analyst target
dcfMarginOfSafety: NumVal; // % undervaluation vs DCF intrinsic value
}
export interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
+93
View File
@@ -0,0 +1,93 @@
// ── Services configuration and result shapes ──────────────────────────────
import type { Logger } from './logger.model';
// ── BenchmarkProvider ───────────────────────────────────────────────────────
export interface BenchmarkProviderOptions {
logger?: Logger;
}
// ── MarketRegime ──────────────────────────────────────────────────────────
export interface InflatedOverrides {
gates: Record<string, number>;
thresholds: Record<string, number>;
}
// ── PortfolioAdvisor ────────────────────────────────────────────────────────
export interface PositionCalc {
totalCost: string;
marketValue: string | null;
gainLossPct: string | null;
}
export interface AdviceOutput {
action: string;
reason: string;
}
// ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ErrorResult {
isError: true;
ticker: string;
message: string;
}
// ── CatalystAnalyst ────────────────────────────────────────────────────────
export interface Headline {
title: string;
publisher?: string;
}
export interface Story {
title: string;
link: string;
source: string;
tickers: string[];
}
export interface CatalystResult {
tickers: string[];
tickerFrequency: Record<string, number>;
stories: Story[];
}
// ── DataMapper ─────────────────────────────────────────────────────────────
export interface MappedData {
type: string;
ticker: string;
[key: string]: unknown;
}
// ── PersonalFinanceAnalyzer ────────────────────────────────────────────────
export interface CategoryBreakdown {
category: string;
amount: number;
pct: string;
}
export interface FinanceAnalysis {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
totalCash: number;
totalInvestments: number;
cashPct: string;
investPct: string;
totalIncome: number;
totalSpend: number;
savingsRate: string | null;
categoryBreakdown: CategoryBreakdown[];
accounts: import('./finance.model').SimpleFINAccount[];
}
// ── RuleMerger ─────────────────────────────────────────────────────────────
export interface RuleSet {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
// ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ScreenerEngineOptions {
logger?: Logger;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Split an array into smaller chunks of specified size.
* @param array The array to split
* @param size The size of each chunk
* @returns Array of chunks
* @example chunkArray([1,2,3,4,5], 2) → [[1,2], [3,4], [5]]
*/
export const chunkArray = <T>(array: T[], size: number): T[][] => {
const chunkCount = Math.ceil(array.length / size);
return Array.from({ length: chunkCount }, (_, index) => {
const start = index * size;
const end = start + size;
return array.slice(start, end);
});
};
@@ -1,4 +1,4 @@
import type { Logger } from '../../types.js';
import type { Logger } from '../types';
/**
* Shared server-side logger utilities.
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BondScorer } from '../server/screener/scorers/BondScorer.js';
import { BondScorer } from '../server/scorers/BondScorer';
import type { MarketContext } from '../server/types';
// 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.
@@ -10,7 +11,8 @@ const rules = {
weights: { yieldSpread: 3, duration: 2 },
thresholds: { minSpread: 1.0, maxDuration: 10 },
};
const ctx = { riskFreeRate: 4.5 };
// BondScorer only uses riskFreeRate from context; cast the partial fixture to satisfy the type.
const ctx = { riskFreeRate: 4.5 } as MarketContext;
test('rejects bond below investment-grade floor', () => {
const result = BondScorer.score(
@@ -38,7 +40,7 @@ test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %
rules,
ctx,
);
assert.equal(result.audit.breakdown.spread, rules.weights.yieldSpread);
assert.equal(result.audit.breakdown!.spread, rules.weights.yieldSpread);
});
test('fails spread when yield barely above risk-free', () => {
@@ -48,7 +50,7 @@ test('fails spread when yield barely above risk-free', () => {
rules,
ctx,
);
assert.equal(result.audit.breakdown.spread, -2);
assert.equal(result.audit.breakdown!.spread, -2);
});
test('penalises long duration', () => {
@@ -57,5 +59,5 @@ test('penalises long duration', () => {
rules,
ctx,
);
assert.equal(result.audit.breakdown.duration, -1);
assert.equal(result.audit.breakdown!.duration, -1);
});
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mapToStandardFormat } from '../server/screener/DataMapper.js';
import { DataMapper } from '../server/services/DataMapper';
const base = {
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
@@ -27,13 +27,13 @@ const base = {
};
test('maps EQUITY quote type to STOCK', () => {
const result = mapToStandardFormat('AAPL', base);
const result = DataMapper.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 result = DataMapper.mapToStandardFormat('AAPL', base);
const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12%
assert.equal(result.pegRatio, expected);
});
@@ -43,12 +43,12 @@ test('uses Yahoo pegRatio when available', () => {
...base,
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
};
const result = mapToStandardFormat('AAPL', summary);
const result = DataMapper.mapToStandardFormat('AAPL', summary);
assert.equal(result.pegRatio, 1.5);
});
test('debtToEquity is divided by 100', () => {
const result = mapToStandardFormat('AAPL', base);
const result = DataMapper.mapToStandardFormat('AAPL', base);
assert.equal(result.debtToEquity, 1.5); // 150 / 100
});
@@ -58,7 +58,7 @@ test('maps ETF quoteType to ETF', () => {
price: { ...base.price, quoteType: 'ETF' },
assetProfile: { category: 'Large Blend' },
};
const result = mapToStandardFormat('VOO', etfSummary);
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
assert.equal(result.type, 'ETF');
});
@@ -68,19 +68,19 @@ test('classifies bond ETF from category keyword', () => {
price: { ...base.price, quoteType: 'ETF' },
assetProfile: { category: 'Intermediate-Term Bond' },
};
const result = mapToStandardFormat('BND', bondSummary);
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
assert.equal(result.type, 'BOND');
});
test('FCF yield is computed when data available', () => {
const result = mapToStandardFormat('AAPL', base);
const result = DataMapper.mapToStandardFormat('AAPL', base);
assert.notEqual(result.fcfYield, null);
assert(result.fcfYield > 0);
assert((result.fcfYield as number) > 0);
});
test('peRatio prefers trailingPE over forwardPE', () => {
// trailingPE=30 in summaryDetail, forwardPE=28 in defaultKeyStatistics
const result = mapToStandardFormat('AAPL', base);
const result = DataMapper.mapToStandardFormat('AAPL', base);
assert.equal(result.peRatio, 30); // trailing should win
});
@@ -89,9 +89,9 @@ test('negative FCF yield is preserved, not nulled', () => {
...base,
financialData: { ...base.financialData, freeCashflow: -2e9 },
};
const result = mapToStandardFormat('AAPL', negativeFcf);
const result = DataMapper.mapToStandardFormat('AAPL', negativeFcf);
assert.notEqual(result.fcfYield, null);
assert(result.fcfYield < 0, 'negative FCF should produce negative yield, not null');
assert((result.fcfYield as number) < 0, 'negative FCF should produce negative yield, not null');
});
test('ETF maps volume from summaryDetail', () => {
@@ -107,7 +107,7 @@ test('ETF maps volume from summaryDetail', () => {
},
defaultKeyStatistics: { fiveYearAverageReturn: 0.12 },
};
const result = mapToStandardFormat('VOO', etfSummary);
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
assert.equal(result.volume, 5000000);
});
@@ -119,7 +119,7 @@ test('bond duration inferred from category — intermediate maps to 5y', () => {
summaryDetail: { yield: 0.045 },
defaultKeyStatistics: {},
};
const result = mapToStandardFormat('BND', bondSummary);
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
assert.equal(result.duration, 5);
});
@@ -131,7 +131,7 @@ test('bond duration inferred from category — short-term maps to 2y', () => {
summaryDetail: { yield: 0.05 },
defaultKeyStatistics: {},
};
const result = mapToStandardFormat('SHY', bondSummary);
const result = DataMapper.mapToStandardFormat('SHY', bondSummary);
assert.equal(result.duration, 2);
});
@@ -143,7 +143,7 @@ test('metrics are null (not 0) when data missing', () => {
summaryDetail: {},
assetProfile: {},
};
const result = mapToStandardFormat('X', sparse);
const result = DataMapper.mapToStandardFormat('X', sparse);
assert.equal(result.pegRatio, null);
assert.equal(result.quickRatio, null);
});
-54
View File
@@ -1,54 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EtfScorer } from '../server/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');
});
+65
View File
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EtfScorer } from '../server/scorers/EtfScorer';
import type { EtfMetrics } from '../server/types';
const rules = {
gates: { maxExpenseRatio: 0.5 },
weights: { yield: 2, lowCost: 3 },
thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 },
};
// Helper to build minimal EtfMetrics fixtures (totalAssets/fiveYearReturn unused by scorer).
const etf = (partial: Partial<EtfMetrics>): EtfMetrics => ({
totalAssets: 0,
fiveYearReturn: 0,
volume: 0,
yield: 0,
expenseRatio: 0,
...partial,
});
test('rejects ETF with expense ratio above gate', () => {
const result = EtfScorer.score(etf({ 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(etf({ 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(etf({ 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(etf({ 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(etf({ 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(
etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 10 }),
rulesWithReturn,
);
const poor = EtfScorer.score(
etf({ 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');
});
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
// 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) {
function stripFences(raw: string): string {
return raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
@@ -1,9 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MarketRegime } from '../server/market/MarketRegime.js';
import { SECTOR, ASSET_TYPE } from '../server/config/constants.js';
import { MarketRegime } from '../server/services/MarketRegime';
import { SECTOR, ASSET_TYPE } from '../server/config/constants';
import type { Benchmarks, RateRegime } from '../server/types';
const regime = (benchmarks, extra = {}) => new MarketRegime({ benchmarks, ...extra });
const regime = (benchmarks: Partial<Benchmarks>, extra: { rateRegime?: RateRegime } = {}) =>
new MarketRegime({ benchmarks: benchmarks as Benchmarks, ...extra });
test('stock inflated P/E = marketPE × 1.5', () => {
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
@@ -1,50 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
import { SIGNAL } from '../server/config/constants.js';
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
import { SIGNAL } from '../server/config/constants';
import type { PortfolioHolding } from '../server/types';
const advisor = new PortfolioAdvisor();
// Cast to any to access private methods — tests exercise internal behaviour directly.
const advisor = new PortfolioAdvisor() as any;
// Minimal holding shape used by _position and _advice (only costBasis/shares matter).
const holding = (costBasis: number, shares: number): PortfolioHolding => ({
ticker: 'TEST',
source: 'Test',
type: 'stock',
costBasis,
shares,
});
test('_position: computes gain/loss correctly', () => {
const pos = advisor._position({ costBasis: 100, shares: 10 }, 150);
const pos = advisor._position(holding(100, 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);
const pos = advisor._position(holding(100, 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);
const { action } = advisor._advice(SIGNAL.STRONG_BUY, holding(100, 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);
const { action } = advisor._advice(SIGNAL.AVOID, holding(150, 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);
const { action } = advisor._advice(SIGNAL.AVOID, holding(100, 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);
const { action } = advisor._advice(SIGNAL.SPECULATION, holding(100, 10), 125);
assert.equal(action, '🟠 Reduce Position');
});
test('_cryptoAdvice: no price → No price data', () => {
const { action } = advisor._cryptoAdvice({ costBasis: 100, shares: 1 }, null);
const { action } = advisor._cryptoAdvice(holding(100, 1), null);
assert.equal(action, '⚪ No price data');
});
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
const { action } = advisor._cryptoAdvice({ costBasis: 10000, shares: 1 }, 25000);
const { action } = advisor._cryptoAdvice(holding(10000, 1), 25000);
assert.equal(action, '🟠 Consider taking profits');
});
@@ -58,7 +69,7 @@ test('advise: BRK-B screener result matches BRK.B holding', async () => {
fundamental: { label: '🟢 BUY (High Conviction)' },
};
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
const holding = {
const holding: PortfolioHolding = {
ticker: 'BRK.B',
shares: 1,
costBasis: 400,
@@ -79,7 +90,7 @@ test('advise: BRK.B screener result matches BRK-B holding', async () => {
fundamental: { label: '🟢 BUY (High Conviction)' },
};
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
const holding = {
const holding: PortfolioHolding = {
ticker: 'BRK-B',
shares: 1,
costBasis: 400,
@@ -1,17 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RuleMerger } from '../server/screener/RuleMerger.js';
import { SCORE_MODE } from '../server/config/constants.js';
import { RuleMerger } from '../server/services/RuleMerger';
import { SCORE_MODE } from '../server/config/constants';
import type { MarketContext } from '../server/types';
const ctx = {
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
};
} as Partial<MarketContext>;
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
const rules = RuleMerger.getRulesForAsset(
'STOCK',
{ sector: 'GENERAL' },
ctx,
ctx as MarketContext,
SCORE_MODE.FUNDAMENTAL,
);
assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x
@@ -22,7 +23,7 @@ test('INFLATED mode loosens P/E gate from live SPY data', () => {
const rules = RuleMerger.getRulesForAsset(
'STOCK',
{ sector: 'GENERAL' },
ctx,
ctx as MarketContext,
SCORE_MODE.INFLATED,
);
assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37
@@ -33,7 +34,7 @@ test('INFLATED tech P/E gate uses XLK benchmark', () => {
const rules = RuleMerger.getRulesForAsset(
'STOCK',
{ sector: 'TECHNOLOGY' },
ctx,
ctx as MarketContext,
SCORE_MODE.INFLATED,
);
assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42
@@ -43,7 +44,7 @@ test('Sector override applied before inflated overrides', () => {
const rules = RuleMerger.getRulesForAsset(
'STOCK',
{ sector: 'REIT' },
ctx,
ctx as MarketContext,
SCORE_MODE.FUNDAMENTAL,
);
assert.equal(rules.gates.maxPERatio, 9999);
@@ -55,12 +56,15 @@ test('SECTOR_OVERRIDE is deleted from returned rules', () => {
const rules = RuleMerger.getRulesForAsset(
'STOCK',
{ sector: 'GENERAL' },
ctx,
ctx as MarketContext,
SCORE_MODE.FUNDAMENTAL,
);
) as unknown as Record<string, unknown>;
assert.equal(rules.SECTOR_OVERRIDE, undefined);
});
test('throws for unknown asset type', () => {
assert.throws(() => RuleMerger.getRulesForAsset('CRYPTO', {}, ctx), /No rules configured/);
assert.throws(
() => RuleMerger.getRulesForAsset('CRYPTO' as never, {}, ctx as MarketContext),
/No rules configured/,
);
});
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CREDIT_RATING_SCALE, ScoringRules } from '../server/config/ScoringConfig.js';
import { CREDIT_RATING_SCALE, ScoringRules } from '../server/config/ScoringConfig';
test('CREDIT_RATING_SCALE covers full spectrum', () => {
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
@@ -17,23 +17,23 @@ test('STOCK base gates are fundamental (Graham-style)', () => {
});
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);
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);
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);
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)', () => {
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { StockScorer } from '../server/screener/scorers/StockScorer.js';
import { StockScorer } from '../server/scorers/StockScorer';
import type { StockMetrics } from '../server/types';
const baseRules = {
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
@@ -21,7 +22,49 @@ const baseRules = {
},
};
const pass = {
// Minimal fixture — tests exercise specific fields; unused metrics are null.
const nullMetrics: Omit<
StockMetrics,
| 'sector'
| 'capCategory'
| 'growthCategory'
| 'currentPrice'
| 'peRatio'
| 'pegRatio'
| 'debtToEquity'
| 'quickRatio'
| 'returnOnEquity'
| 'operatingMargin'
| 'netProfitMargin'
| 'revenueGrowth'
| 'fcfYield'
> = {
priceToBook: null,
grossMargin: null,
earningsGrowth: null,
pFFO: null,
dividendYield: null,
beta: null,
week52High: null,
week52Low: null,
week52Change: null,
week52FromHigh: null,
week52FromLow: null,
marketCap: null,
analystRating: null,
analystTargetPrice: null,
analystUpside: null,
numberOfAnalysts: null,
dcfIntrinsicValue: null,
dcfMarginOfSafety: null,
};
const pass: StockMetrics = {
...nullMetrics,
sector: 'GENERAL',
capCategory: 'Large Cap',
growthCategory: 'Growth',
currentPrice: 150,
peRatio: 15,
pegRatio: 1.2,
debtToEquity: 1.0,
@@ -63,8 +106,8 @@ test('high-conviction BUY on strong metrics', () => {
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);
assert(result.audit.breakdown!.roe != null);
assert(result.audit.breakdown!.margin != null);
});
test('beta > 1.5 surfaces as risk flag', () => {
+1 -1
View File
@@ -9,6 +9,6 @@
"allowImportingTsExtensions": true,
"resolveJsonModule": true
},
"include": ["server/**/*", "bin/**/*"],
"include": ["server/**/*", "bin/**/*", "tests/**/*", "scripts/**/*"],
"exclude": ["node_modules", "ui"]
}
+13 -130
View File
@@ -57,7 +57,20 @@
<div class="sb-list">
{#each a.relatedTickers ?? [] as rt}
<div class="sb-item">
<div class="sb-ticker-row">
<span class="sb-name ticker">{rt.ticker}</span>
<div class="sb-chips">
{#if rt.bias}
<span class="sb-chip sb-bias" data-bias={rt.bias}>{rt.bias}</span>
{/if}
{#if rt.horizon}
<span class="sb-chip sb-horizon">{rt.horizon}</span>
{/if}
{#if rt.sensitivity}
<span class="sb-chip sb-sensitivity" title="Sensitivity {rt.sensitivity}/5">S{rt.sensitivity}</span>
{/if}
</div>
</div>
<span class="sb-reason">{rt.reason}</span>
</div>
{/each}
@@ -67,133 +80,3 @@
</aside>
{/if}
<style>
.sidebar-backdrop {
position: fixed;
inset: 0;
background: #00000055;
z-index: 100;
}
.sidebar {
position: fixed;
top: 0; right: 0; bottom: 0;
width: 380px;
background: var(--bg-surface);
border-left: 1px solid var(--blue-surface);
z-index: 101;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px var(--space-xl);
border-bottom: 1px solid var(--border);
background: var(--blue-badge);
flex-shrink: 0;
}
.sidebar-title {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--fs-md);
font-weight: 700;
color: var(--text-secondary);
}
.sidebar-type {
font-size: var(--fs-xs);
font-weight: 700;
letter-spacing: 0.06em;
background: var(--blue-surface);
color: var(--blue-muted);
padding: 2px 8px;
border-radius: var(--radius-pill);
}
.sidebar-close {
background: none;
border: none;
color: var(--text-dimmer);
font-size: 14px;
padding: 4px 8px;
cursor: pointer;
border-radius: var(--radius-xs);
&:hover { color: var(--text-muted); background: var(--bg-card); }
}
.sidebar-body {
flex: 1;
overflow-y: auto;
padding: var(--space-xl);
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: var(--red);
background: var(--red-bg);
border-radius: var(--radius-md);
padding: 12px var(--space-lg);
font-size: var(--fs-md);
}
.sb-sentiment-row { display: flex; align-items: center; gap: 8px; }
.sb-summary {
font-size: var(--fs-md);
color: var(--text-muted);
line-height: 1.6;
border-left: 3px solid var(--blue-surface);
padding-left: 12px;
margin: 0;
}
.sb-sub {
font-size: var(--fs-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dimmer);
margin: 0;
}
.sb-list { display: flex; flex-direction: column; gap: 8px; }
.sb-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 12px;
background: var(--bg-elevated);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.sb-name {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.sb-reason {
font-size: var(--fs-sm);
color: var(--text-dim);
line-height: 1.4;
}
</style>
+65 -8
View File
@@ -19,6 +19,14 @@
// Mode state is self-contained — each table independently tracks inflated vs fundamental
let mode = $state('inflated');
// Colour class for signed % values (52W Chg, From High, Upside, DCF Safety)
function signClass(val: string | number | null | undefined): string {
if (val == null) return '';
const n = typeof val === 'number' ? val : parseFloat(String(val));
if (isNaN(n)) return '';
return n > 0 ? 'pos' : n < 0 ? 'neg' : '';
}
</script>
<section class="section">
@@ -54,9 +62,27 @@
<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>
<!-- Classification -->
<th title="Market cap tier">Cap</th>
<th title="Growth / style classification">Style</th>
<!-- Valuation -->
<th>P/E</th>
<th>PEG</th>
<!-- Quality -->
<th title="Gross Margin %">GrossM%</th>
<th>ROE%</th>
<th>OpMgn%</th>
<th>FCF%</th>
<!-- Risk -->
<th>D/E</th>
<!-- 52-week movement -->
<th title="Total price return over last 52 weeks">52W Chg</th>
<th title="% below 52-week high">From High</th>
<!-- Expert signals -->
<th title="Wall Street analyst consensus">Analyst</th>
<th title="% upside to analyst target price">Upside</th>
<th title="DCF margin of safety — positive means undervalued">DCF Safety</th>
<!-- Risk flags -->
<th>Flags</th>
{:else if type === 'ETF'}
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
@@ -68,20 +94,34 @@
<tbody>
{#each sorted(rows) as r}
{@const m = r.asset.displayMetrics ?? {}}
{@const v = r[mode]}
{@const v = r[mode as 'inflated' | 'fundamental']}
<tr class="data-row" data-signal={sigOrd(r.signal)}>
<td class="ticker">{r.asset.ticker}</td>
<td class="num">{m.Price ?? '—'}</td>
<td><VerdictPill label={v.label} /></td>
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
{#if type === 'STOCK'}
<td><span class="tag sm">{m.Sector ?? '—'}</span></td>
<!-- Classification -->
<td><span class="tag sm cap-tag">{m['Cap Tier'] ?? '—'}</span></td>
<td><span class="tag sm style-tag">{m['Style'] ?? '—'}</span></td>
<!-- Valuation -->
<td class="num">{m['P/E'] ?? '—'}</td>
<td class="num">{m['PEG'] ?? '—'}</td>
<!-- Quality -->
<td class="num">{m['GrossM%'] ?? '—'}</td>
<td class="num">{m['ROE%'] ?? '—'}</td>
<td class="num">{m['OpMgn%'] ?? '—'}</td>
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
<!-- Risk -->
<td class="num">{m['D/E'] ?? '—'}</td>
<!-- 52-week movement — green if up, red if down -->
<td class="num {signClass(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</td>
<td class="num {signClass(m['From High'])}">{m['From High'] ?? '—'}</td>
<!-- Expert signals -->
<td class="analyst-cell">{m['Analyst'] ?? '—'}</td>
<td class="num {signClass(m['Upside'])}">{m['Upside'] ?? '—'}</td>
<td class="num {signClass(m['DCF Safety'])}">{m['DCF Safety'] ?? '—'}</td>
<!-- Risk flags -->
<td class="flags">
{#each v.audit?.riskFlags ?? [] as flag}
<span class="flag">{flag}</span>
@@ -105,15 +145,32 @@
</section>
<style>
/* Score cell — truncates long gate summaries, full text via title tooltip */
/* Score cell — truncates long gate summaries, tooltip shows full text */
.score-cell {
color: var(--text-dim);
font-size: var(--fs-sm);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flags { display: flex; flex-direction: column; gap: 2px; }
.flag { color: var(--orange); font-size: var(--fs-sm); }
/* Classification tags */
.cap-tag { color: var(--blue-light, #93c5fd); border-color: var(--blue-dim, #1e3a5f); }
.style-tag { color: var(--text-muted); }
/* Signed % colouring */
.pos { color: var(--green); }
.neg { color: var(--red); }
/* Analyst label — not a number */
.analyst-cell {
font-size: var(--fs-sm);
color: var(--text-muted);
white-space: nowrap;
}
/* Risk flags column */
.flags { display: flex; flex-direction: column; gap: 2px; min-width: 160px; }
.flag { color: var(--orange); font-size: var(--fs-sm); white-space: nowrap; }
</style>
+2 -3
View File
@@ -8,8 +8,8 @@
{ label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' },
{ label: 'VIX', value: ctx.vixLevel?.toFixed(1) },
{ label: 'S&P', value: ctx.sp500Price?.toLocaleString() },
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE?.toFixed(1)) },
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE?.toFixed(1)) },
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE) },
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE) },
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' },
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' },
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime },
@@ -36,7 +36,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: 20px;
}
.ctx-chip {
+6 -127
View File
@@ -1,127 +1,6 @@
import type {
ScreenerResult,
MarketContext,
MarketCall,
CalendarEvent,
CatalystStory,
LLMAnalysis,
PortfolioHolding,
PortfolioAdvice,
} from '$lib/types.js';
const BASE = '/api';
// ── Screener ──────────────────────────────────────────────────────────────────
export async function screenTickers(tickers: string[]): Promise<ScreenerResult> {
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(): Promise<{ tickers: string[]; stories: CatalystStory[] }> {
const res = await fetch(`${BASE}/screen/catalysts`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function analyzeTickers(tickers: string[]): Promise<{ analysis: LLMAnalysis | null }> {
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();
}
// ── Finance / Portfolio ───────────────────────────────────────────────────────
export async function fetchPortfolio(): Promise<{
advice: PortfolioAdvice[];
holdings: PortfolioHolding[];
marketContext: MarketContext | null;
netWorth: number | null;
error?: string;
}> {
const res = await fetch(`${BASE}/finance/portfolio`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function addHolding(
holding: Omit<PortfolioHolding, never>,
): Promise<{ holdings: PortfolioHolding[] }> {
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: string): Promise<{ holdings: PortfolioHolding[] }> {
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(): Promise<MarketContext> {
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(): Promise<{ calls: MarketCall[] }> {
const res = await fetch(`${BASE}/calls`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCall(id: string): Promise<MarketCall & { current: ScreenerResult }> {
const res = await fetch(`${BASE}/calls/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createCall(payload: {
title: string;
quarter: string;
thesis: string;
tickers: string[];
date?: string;
}): Promise<MarketCall> {
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: string): Promise<{ ok: boolean }> {
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: string[] | null = null,
): Promise<{ events: CalendarEvent[] }> {
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();
}
// ── Backward-compat shim ──────────────────────────────────────────────────
// All API functions now live in $lib/api/*.ts domain modules.
// This file re-exports everything so existing import sites are unaffected.
// New code should import directly from the domain module:
// import { screenTickers } from '$lib/api/screener.js'
export * from './api/index.js';
+48
View File
@@ -0,0 +1,48 @@
import type { MarketCall, CalendarEvent, ScreenerResult } from '$lib/types.js';
const BASE = '/api';
export async function fetchCalls(): Promise<{ calls: MarketCall[] }> {
const res = await fetch(`${BASE}/calls`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchCall(id: string): Promise<MarketCall & { current: ScreenerResult }> {
const res = await fetch(`${BASE}/calls/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createCall(payload: {
title: string;
quarter: string;
thesis: string;
tickers: string[];
date?: string;
}): Promise<MarketCall> {
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: string): Promise<{ ok: boolean }> {
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: string[] | null = null,
): Promise<{ events: CalendarEvent[] }> {
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();
}
+41
View File
@@ -0,0 +1,41 @@
import type { MarketContext, PortfolioHolding, PortfolioAdvice } from '$lib/types.js';
const BASE = '/api';
export async function fetchPortfolio(): Promise<{
advice: PortfolioAdvice[];
holdings: PortfolioHolding[];
marketContext: MarketContext | null;
netWorth: number | null;
error?: string;
}> {
const res = await fetch(`${BASE}/finance/portfolio`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function addHolding(
holding: PortfolioHolding,
): Promise<{ holdings: PortfolioHolding[] }> {
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: string): Promise<{ holdings: PortfolioHolding[] }> {
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(): Promise<MarketContext> {
const res = await fetch(`${BASE}/finance/market-context`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
+7
View File
@@ -0,0 +1,7 @@
// ── API module barrel ─────────────────────────────────────────────────────
// Drop-in replacement for the old $lib/api.ts flat file.
// Existing imports from '$lib/api.js' continue to work via api.ts re-export.
export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js';
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
+32
View File
@@ -0,0 +1,32 @@
import type { ScreenerResult } from '$lib/types.js';
import type { LLMAnalysis, CatalystStory } from '$lib/types.js';
const BASE = '/api';
export async function screenTickers(tickers: string[]): Promise<ScreenerResult> {
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(): Promise<{ tickers: string[]; stories: CatalystStory[] }> {
const res = await fetch(`${BASE}/screen/catalysts`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function analyzeTickers(
tickers: string[],
): Promise<{ analysis: LLMAnalysis | null; reason?: string | null }> {
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();
}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { CalendarEvent } from '$lib/types.js';
let { events }: { events: CalendarEvent[] } = $props();
type EventType = 'earnings' | 'exdividend' | 'dividend';
const eventIcon = (t: EventType): string => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[t] ?? '📅';
const eventColor = (t: EventType): string =>
({ earnings: '#60a5fa', exdividend: '#facc15', dividend: '#4ade80' })[t] ?? '#94a3b8';
const fmtMoney = (n: number | null | undefined): string | null => n == null ? null :
n >= 1e9 ? '$' + (n / 1e9).toFixed(1) + 'B' :
n >= 1e6 ? '$' + (n / 1e6).toFixed(1) + 'M' : '$' + n.toFixed(2);
const upcoming = $derived(events.filter(e => !e.isPast).slice(0, 20));
const past = $derived(events.filter(e => e.isPast).slice(0, 10));
</script>
{#if 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}
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
interface TickerSnapshot {
price: number | null;
signal: string | null;
}
interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
let {
call,
onDelete,
}: {
call: MarketCall;
onDelete: (id: string) => void;
} = $props();
const signalColor = (s: string | null | undefined): string => {
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';
};
</script>
<section class="section call-card">
<div class="section-header">
<div class="call-card-meta">
<a href="/calls/{call.id}" class="call-card-title">{call.title}</a>
<div class="call-card-badges">
<span class="tag">{call.quarter}</span>
<span class="call-date-badge">{call.date}</span>
<span class="count">{call.tickers.length} tickers</span>
</div>
</div>
<button class="btn-call-delete" onclick={() => onDelete(call.id)}>✕</button>
</div>
<div class="call-card-body">
<p class="call-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="call-view-link">View performance →</a>
{/if}
</div>
</section>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import Spinner from '$lib/Spinner.svelte';
interface FormData {
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string;
}
let {
saving = false,
error = null,
onSubmit,
onCancel,
}: {
saving?: boolean;
error?: string | null;
onSubmit: (data: FormData) => void;
onCancel: () => void;
} = $props();
function currentQuarter(): string {
const d = new Date();
return `Q${Math.ceil((d.getMonth() + 1) / 3)} ${d.getFullYear()}`;
}
let form = $state<FormData>({
title: '',
quarter: currentQuarter(),
date: new Date().toISOString().slice(0, 10),
thesis: '',
tickers: '',
});
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
onSubmit({ ...form });
}
</script>
<section class="section form-section">
<div class="section-header"><h2>New Market Call</h2></div>
<form class="call-form" onsubmit={handleSubmit}>
<div class="call-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="call-hint">Comma or space separated. Current prices will be snapshot automatically.</span>
</label>
{#if error}
<div class="form-error-block">{error}</div>
{/if}
<div class="call-form-actions">
<button type="submit" class="btn-primary" disabled={saving}>
{#if saving}<Spinner size="sm" /><span>Snapshotting prices…</span>
{:else}Save Call{/if}
</button>
<button type="button" class="btn-ghost" onclick={onCancel}>Cancel</button>
</div>
</form>
</section>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { fmt, fmtShort } from '$lib/utils.js';
import type { PersonalFinance } from '$lib/types.js';
let { pf }: { pf: PersonalFinance } = $props();
</script>
<div class="pnl-grid">
<div class="pnl-card"><div class="pnl-label">Net Worth</div>
<div class="pnl-value {pf.netWorth >= 0 ? 'green' : 'red'}">{fmtShort(pf.netWorth)}</div></div>
<div class="pnl-card"><div class="pnl-label">Total Assets</div>
<div class="pnl-value">{fmtShort(pf.totalAssets)}</div></div>
<div class="pnl-card"><div class="pnl-label">Liabilities</div>
<div class="pnl-value red">{fmtShort(pf.totalLiabilities)}</div></div>
<div class="pnl-card"><div class="pnl-label">Cash ({pf.cashPct}%)</div>
<div class="pnl-value">{fmtShort(pf.totalCash)}</div></div>
<div class="pnl-card"><div class="pnl-label">Investments ({pf.investPct}%)</div>
<div class="pnl-value">{fmtShort(pf.totalInvestments)}</div></div>
{#if pf.savingsRate != null}
<div class="pnl-card"><div class="pnl-label">Savings Rate</div>
<div class="pnl-value {parseFloat(pf.savingsRate) >= 20 ? 'green' : 'yellow'}">{pf.savingsRate}%</div></div>
{/if}
<div class="pnl-card"><div class="pnl-label">Monthly Income</div>
<div class="pnl-value">{fmtShort(pf.totalIncome)}</div></div>
<div class="pnl-card"><div class="pnl-label">Monthly Spend</div>
<div class="pnl-value">{fmtShort(pf.totalSpend)}</div></div>
</div>
<div class="accounts-two-col">
<section class="accounts-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="accounts-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="spend-bar-bg"><div class="spend-bar-fill" style="width:{Math.min(c.pct,100)}%"></div></div>
</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import type { HoldingFormData } from '$lib/types.js';
let {
saving = false,
error = null as string | null,
onSubmit,
onClose,
}: {
saving?: boolean;
error?: string | null;
onSubmit: (data: HoldingFormData) => void;
onClose: () => void;
} = $props();
let form = $state({ ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' });
function handleSubmit() {
const ticker = form.ticker.trim().toUpperCase();
const shares = parseFloat(form.shares);
const costBasis = parseFloat(form.costBasis) || 0;
if (!ticker || !shares || shares <= 0) return;
onSubmit({
ticker,
shares,
costBasis,
type: form.type as HoldingFormData['type'],
source: form.source,
});
form = { ticker: '', shares: '', costBasis: '', type: 'stock', source: 'Robinhood' };
}
</script>
<div class="add-form">
<div class="add-form-title">Add Holding</div>
<div class="add-form-row">
<div class="field">
<label for="form-ticker">Ticker</label>
<input id="form-ticker" bind:value={form.ticker} placeholder="AAPL" maxlength="10" style="text-transform:uppercase" />
</div>
<div class="field">
<label for="form-shares">Shares</label>
<input id="form-shares" bind:value={form.shares} placeholder="10" type="number" min="0" step="any" />
</div>
<div class="field">
<label for="form-cost">Cost Basis / share</label>
<input id="form-cost" bind:value={form.costBasis} placeholder="150.00" type="number" min="0" step="any" />
</div>
<div class="field">
<label for="form-type">Type</label>
<select id="form-type" 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 for="form-source">Source</label>
<input id="form-source" bind:value={form.source} placeholder="Robinhood" />
</div>
<button class="btn-form-save" onclick={handleSubmit} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
<button class="btn-form-cancel" onclick={onClose}>✕</button>
</div>
{#if error}
<div class="form-error">{error}</div>
{/if}
</div>
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import SignalBadge from '$lib/SignalBadge.svelte';
import { sigOrd, fmt, fmtShort, glClass, advClass } from '$lib/utils.js';
import type { AdviceRow } from '$lib/types.js';
export interface UpdateData {
shares: number;
costBasis: number;
type: string;
source: string;
}
let {
rows,
onUpdate,
onDelete,
}: {
rows: AdviceRow[];
onUpdate: (ticker: string, data: UpdateData) => void;
onDelete: (ticker: string) => void;
} = $props();
// ── Sort ──────────────────────────────────────────────────────────
let sortCol = $state('ticker');
let sortDir = $state(1);
function toggleSort(col: string) {
if (sortCol === col) sortDir = sortDir === 1 ? -1 : 1;
else { sortCol = col; sortDir = 1; }
}
const sortIcon = (col: string) => sortCol !== col ? '⇅' : sortDir === 1 ? '↑' : '↓';
const sorted = $derived.by(() => [...rows].sort((a, b) => {
let av: string | number, bv: string | number;
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') || 0; bv = parseFloat(b.currentPrice ?? '0') || 0; break;
case 'value': av = parseFloat(a.marketValue ?? '0') || 0; bv = parseFloat(b.marketValue ?? '0') || 0; break;
case 'gl': av = parseFloat(a.gainLossPct ?? '0') || 0; bv = parseFloat(b.gainLossPct ?? '0') || 0; break;
case 'signal': av = sigOrd(a.signal); bv = sigOrd(b.signal); break;
default: return 0;
}
return av < bv ? -sortDir : av > bv ? sortDir : 0;
}));
// ── Totals ────────────────────────────────────────────────────────
const totalValue = $derived(rows.reduce((s, a) => s + (parseFloat(a.marketValue ?? '0') || 0), 0));
const totalCost = $derived(rows.reduce((s, a) => s + (a.costBasis ?? 0) * a.shares, 0));
const totalGL = $derived(totalValue - totalCost);
// ── Inline edit ───────────────────────────────────────────────────
interface InlineEdit { ticker: string; shares: string; costBasis: string; type: string; source: string }
let editing: InlineEdit | null = $state(null);
let saving = $state(false);
function startEdit(a: AdviceRow) {
editing = {
ticker: a.ticker,
shares: String(a.shares),
costBasis: String(a.costBasis ?? 0),
type: a.type ?? 'stock',
source: a.source ?? 'Robinhood',
};
}
async function saveEdit() {
if (!editing) return;
saving = true;
onUpdate(editing.ticker, {
shares: parseFloat(editing.shares),
costBasis: parseFloat(editing.costBasis) || 0,
type: editing.type,
source: editing.source,
});
editing = null;
saving = false;
}
</script>
<!-- P&L Summary -->
<div class="pnl-grid">
<div class="pnl-card">
<div class="pnl-label-row">
<span class="pnl-label">Total Value</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Current market value of all holdings. Shares × live price from Yahoo Finance.</span>
</span>
</div>
<div class="pnl-value">{fmtShort(totalValue)}</div>
</div>
<div class="pnl-card">
<div class="pnl-label-row">
<span class="pnl-label">Total Cost</span>
<span class="stip-wrap">
<span class="stip-anchor">?</span>
<span class="stip-box">Total amount invested — sum of cost basis × shares across all positions.</span>
</span>
</div>
<div class="pnl-value">{fmtShort(totalCost)}</div>
</div>
<div class="pnl-card">
<div class="pnl-label-row">
<span class="pnl-label">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.</span>
</span>
</div>
<div class="pnl-value {totalGL >= 0 ? 'green' : 'red'}">{fmtShort(totalGL)}</div>
</div>
</div>
<!-- Holdings table -->
<section class="advice-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 sorted as a}
{@const isEditing = editing?.ticker === a.ticker}
<tr class:editing={isEditing}>
<td class="ticker">{a.ticker}</td>
<td>
{#if isEditing && editing}
<select class="inline-select" bind:value={editing.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 && editing}
<input class="inline-input" bind:value={editing.shares} type="number" min="0" step="any" />
{:else}
{a.shares}
{/if}
</td>
<td class="num">
{#if isEditing && editing}
<input class="inline-input" bind:value={editing.costBasis} type="number" min="0" step="any" />
{:else}
{fmt(a.costBasis)}
{/if}
</td>
<td class="num">{fmt(a.currentPrice != null ? parseFloat(a.currentPrice) : null)}</td>
<td class="num">{fmt(a.marketValue != null ? parseFloat(a.marketValue) : null)}</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="advice-row-actions">
{#if isEditing}
<button class="btn-save-inline" onclick={saveEdit} disabled={saving}>{saving ? '…' : '✓'}</button>
<button class="btn-cancel-inline" onclick={() => editing = null}>✕</button>
{:else}
<button class="btn-row-edit" onclick={() => startEdit(a)} title="Edit"></button>
<button class="btn-row-delete" onclick={() => onDelete(a.ticker)} title="Remove"></button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</section>
+144
View File
@@ -0,0 +1,144 @@
import { addHolding, removeHolding } from '$lib/api.js';
import type { MarketContext, AdviceRow, PersonalFinance, HoldingFormData } from '$lib/types.js';
interface PortfolioData {
advice: AdviceRow[];
marketContext: MarketContext | null;
personalFinance: PersonalFinance | null;
}
class PortfolioStore {
// ── State ──────────────────────────────────────────────────────────
data = $state<PortfolioData | null>(null);
loading = $state(true);
refreshing = $state(false);
loadError = $state<string | null>(null);
formOpen = $state(false);
saving = $state(false);
formError = $state<string | null>(null);
// ── Fetch ──────────────────────────────────────────────────────────
fetch(showFullSpinner = false): void {
if (showFullSpinner) this.loading = true;
else this.refreshing = true;
this.loadError = null;
window
.fetch('/api/finance/portfolio')
.then((res) =>
res.ok
? res.json()
: res.text().then((t) => {
throw new Error(t);
}),
)
.then((json: PortfolioData) => {
this.data = json;
})
.catch((e: Error) => {
this.loadError = e.message;
})
.finally(() => {
this.loading = false;
this.refreshing = false;
});
}
// ── Add holding ────────────────────────────────────────────────────
async add(formData: HoldingFormData): Promise<void> {
this.formError = null;
this.saving = true;
try {
await addHolding(formData);
// Optimistic: insert placeholder row immediately
const exists = this.data?.advice?.find((a) => a.ticker === formData.ticker);
if (this.data?.advice && !exists) {
this.data = {
...this.data,
advice: [
...this.data.advice,
{
ticker: formData.ticker,
shares: formData.shares,
costBasis: formData.costBasis,
type: formData.type,
source: formData.source,
currentPrice: null,
marketValue: null,
gainLossPct: null,
signal: null,
advice: '⏳ Fetching…',
reason: 'Screener data loading in background.',
},
],
};
}
this.formOpen = false;
this.fetch(false);
} catch (e) {
this.formError = (e as Error).message;
} finally {
this.saving = false;
}
}
// ── Update holding ─────────────────────────────────────────────────
async update(
ticker: string,
updated: { shares: number; costBasis: number; type: string; source: string },
): Promise<void> {
try {
await addHolding({ ticker, ...updated, type: updated.type as HoldingFormData['type'] });
if (this.data?.advice) {
this.data = {
...this.data,
advice: this.data.advice.map((a) =>
a.ticker === ticker
? {
...a,
...updated,
marketValue: String(updated.shares * (parseFloat(a.currentPrice ?? '0') || 0)),
gainLossPct: a.currentPrice
? (
((parseFloat(a.currentPrice) - updated.costBasis) / updated.costBasis) *
100
).toFixed(1)
: null,
}
: a,
),
};
}
this.fetch(false);
} catch (e) {
this.loadError = (e as Error).message;
}
}
// ── Delete holding ─────────────────────────────────────────────────
async remove(ticker: string): Promise<void> {
if (!confirm(`Remove ${ticker} from your portfolio?`)) return;
if (this.data?.advice) {
this.data = { ...this.data, advice: this.data.advice.filter((a) => a.ticker !== ticker) };
}
try {
await removeHolding(ticker);
this.fetch(false);
} catch (e) {
this.loadError = (e as Error).message;
}
}
// ── Form helpers ───────────────────────────────────────────────────
openForm(): void {
this.formOpen = true;
this.formError = null;
}
closeForm(): void {
this.formOpen = false;
this.formError = null;
}
}
export const portfolioStore = new PortfolioStore();
export type { PortfolioData };
@@ -0,0 +1,91 @@
import { fetchCatalysts, screenTickers, analyzeTickers } from '$lib/api.js';
import { sorted } from '$lib/utils.js';
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
class ScreenerStore {
// ── State ──────────────────────────────────────────────────────────
input = $state('');
results = $state<ScreenerResult | null>(null);
screenedAt = $state('');
loading = $state(false);
loadingCats = $state(false);
error = $state<string | null>(null);
sidebar = $state<SidebarState>({
open: false,
loading: false,
analysis: null,
type: null,
error: null,
});
// ── Derived ────────────────────────────────────────────────────────
ctx = $derived(this.results?.marketContext ?? null);
allAssets = $derived(
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
);
// ── Actions ────────────────────────────────────────────────────────
async screen(): Promise<void> {
this.error = null;
this.loading = true;
try {
const tickers = this.input
.split(/[\s,]+/)
.map((t) => t.trim().toUpperCase())
.filter(Boolean);
this.results = await screenTickers(tickers);
this.screenedAt = new Date().toLocaleTimeString();
} catch (e) {
this.error = (e as Error).message;
} finally {
this.loading = false;
}
}
async reloadCatalysts(): Promise<void> {
this.loadingCats = true;
this.error = null;
try {
const cat = await fetchCatalysts();
this.input = cat.tickers.join(', ');
this.results = await screenTickers(cat.tickers);
this.screenedAt = new Date().toLocaleTimeString();
} catch (e) {
this.error = (e as Error).message;
} finally {
this.loadingCats = false;
}
}
async runTabAnalysis(type: AssetType): Promise<void> {
const tickers = (this.results?.[type] ?? []).map((r) => r.asset.ticker);
if (!tickers.length) return;
this.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;
this.sidebar = {
open: true,
loading: false,
analysis: res.analysis,
type,
error: res.analysis ? null : (reason ?? 'Analysis failed — check server logs for details.'),
};
} catch (e) {
this.sidebar = {
open: true,
loading: false,
analysis: null,
type,
error: (e as Error).message,
};
}
}
closeSidebar(): void {
this.sidebar = { ...this.sidebar, open: false };
}
}
export const screenerStore = new ScreenerStore();
+126 -95
View File
@@ -1,88 +1,100 @@
// ── Shared UI types ───────────────────────────────────────────────────────
// Mirror of the server's domain types, used across Svelte components.
// ── UI type layer ─────────────────────────────────────────────────────────
// Shared domain types are imported from the server's canonical model files
// via the $types alias (→ server/types/). Only UI-specific types live here.
//
// All consumers should import from '$lib/types.js' as before — nothing changes
// at the call site.
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
// ── Re-export shared domain types ────────────────────────────────────────
export type {
Signal,
AssetType,
ScoreMode,
ScoreResult,
AssetResult,
ScreenerResult,
} from '$types/asset.model.js';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export type {
RateRegime,
VolatilityRegime,
Benchmarks,
MarketContext,
} from '$types/market.model.js';
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export type { HoldingType, PortfolioHolding, PortfolioAdvice } from '$types/portfolio.model.js';
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: 'HIGH' | 'NORMAL' | 'LOW';
volatilityRegime: 'HIGH' | 'NORMAL' | 'LOW';
benchmarks: Benchmarks;
}
export type { TickerSnapshot, MarketCall } from '$types/calls.model.js';
export interface ScoreResult {
label: string;
score: number;
scoreSummary: string;
audit: {
riskFlags?: string[];
[key: string]: unknown;
};
}
export type { LLMAnalysis, CatalystStory, CalendarEvent } from '$types/finance.model.js';
// ── UI-only types (not on the server) ────────────────────────────────────
import type { AssetType } from '$types/asset.model.js';
import type { LLMAnalysis } from '$types/finance.model.js';
/** Detailed display metrics rendered per asset row in the screener table. */
export interface AssetDisplayMetrics {
// ── Common ──────────────────────────────────────────────────────────
Price?: string;
// ── Stock: classification ────────────────────────────────────────────
Sector?: string;
'Cap Tier'?: string; // Mega Cap / Large Cap / Mid Cap / Small Cap / Micro Cap
Style?: string; // High Growth / Growth / Stable / Value / Turnaround / Declining
// ── Stock: valuation ─────────────────────────────────────────────────
'P/E'?: string;
PEG?: string;
'P/B'?: string;
// ── Stock: quality ───────────────────────────────────────────────────
'GrossM%'?: string; // gross margin — key for tech/software moat
'ROE%'?: string;
'OpMgn%'?: string;
'NetMgn%'?: string;
'FCF Yld%'?: string;
'Div%'?: string;
// ── Stock: risk ───────────────────────────────────────────────────────
'D/E'?: string;
Quick?: string;
Beta?: string;
// ── Stock: 52-week movement ───────────────────────────────────────────
'52W Pos'?: string; // % position within the 52-week range
'52W Chg'?: string; // total price return over last 52 weeks (signed %)
'From High'?: string; // % below 52-week high (negative = drawdown)
'From Low'?: string; // % above 52-week low (positive = recovery)
// ── Stock: analyst consensus ──────────────────────────────────────────
Analyst?: string; // Strong Buy / Buy / Hold / Sell / Strong Sell
'# Analysts'?: string;
Target?: string; // analyst consensus price target
Upside?: string; // % upside to analyst target (signed %)
// ── Stock: DCF intrinsic value ────────────────────────────────────────
'DCF Value'?: string; // intrinsic value per share
'DCF Safety'?: string; // margin of safety % (positive = undervalued)
// ── Stock: REIT-specific ──────────────────────────────────────────────
'P/FFO'?: string;
// ── ETF ───────────────────────────────────────────────────────────────
'Exp Ratio%'?: string;
'Yield%'?: string;
AUM?: string;
'5Y Return%'?: string;
// ── Bond ──────────────────────────────────────────────────────────────
'YTM%'?: string;
Duration?: string;
Rating?: string;
[key: string]: string | null | undefined;
}
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: AssetDisplayMetrics;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: MarketContext;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: Array<{ name: string; reason: string }>;
relatedTickers: Array<{ ticker: string; reason: string }>;
}
/** State object for the LLM analysis slide-over sidebar. */
export interface SidebarState {
open: boolean;
loading: boolean;
@@ -91,49 +103,68 @@ export interface SidebarState {
error: string | null;
}
export interface PortfolioHolding {
/** Transient state for inline row editing in the portfolio table. */
export interface InlineEdit {
ticker: string;
shares: string;
costBasis: string;
type: string;
source: string;
}
// ── Portfolio component types ─────────────────────────────────────────────
import type { Signal } from '$types/asset.model.js';
/** A single row in the portfolio advice table. */
export interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: string | null;
marketValue: string | null;
gainLossPct: string | null;
signal: Signal | null;
advice: string;
reason: string;
}
/** Form data for adding or updating a holding. */
export interface HoldingFormData {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: 'stock' | 'etf' | 'bond' | 'crypto';
source: string;
}
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
interface SimpleFINAccount {
name: string;
type: string;
org: string;
balance: number;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
interface CategoryBreakdown {
category: string;
amount: number;
pct: number;
}
export interface CalendarEvent {
ticker: string;
type: 'earnings' | 'dividend';
date: string;
[key: string]: unknown;
}
export interface CatalystStory {
title: string;
link: string;
publisher: string;
publishedAt: string;
relatedTickers: string[];
}
export interface PortfolioAdvice {
ticker: string;
action: 'hold' | 'sell' | 'add' | 'watch';
reason: string;
signal: Signal | null;
currentPrice: number | null;
gainLossPct: number | null;
/** Personal finance summary from SimpleFIN. */
export interface PersonalFinance {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
totalCash: number;
totalInvestments: number;
totalIncome: number;
totalSpend: number;
cashPct: number;
investPct: number;
savingsRate: string | null;
accounts: SimpleFINAccount[];
categoryBreakdown: CategoryBreakdown[];
}
+46 -93
View File
@@ -1,80 +1,26 @@
<script lang="ts">
import { screenTickers, analyzeTickers } from '$lib/api.js';
import { sigOrd, sorted, verdictShort, vClass } from '$lib/utils.js';
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
import VerdictPill from '$lib/VerdictPill.svelte';
import MarketContextStrip from '$lib/MarketContextStrip.svelte';
import AssetTable from '$lib/AssetTable.svelte';
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
interface PageData { results: ScreenerResult; catalystInput: string }
let { data }: { data: PageData } = $props();
const s = screenerStore;
let input: string = $state(data.catalystInput);
let results: ScreenerResult = $state(data.results);
let screenedAt: string = $state(new Date().toLocaleTimeString());
let loading: boolean = $state(false);
let loadingCats: boolean = $state(false);
let error: string | null = $state(null);
let searchOpen: boolean = $state(false);
let { data: _data } = $props();
// ── LLM Analysis sidebar ────────────────────────────────────────────────
let sidebar: SidebarState = $state({ open: false, loading: false, analysis: null, type: null, error: null });
// Pure UI state — not shared, kept local
let searchOpen = $state(false);
async function runTabAnalysis(type: AssetType): Promise<void> {
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 as Error).message };
}
}
// ── Manual ticker search ─────────────────────────────────────────────────
async function screen(): Promise<void> {
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 as Error).message;
} finally {
loading = false;
}
}
// ── Re-fetch today's catalysts ───────────────────────────────────────────
async function reloadCatalysts(): Promise<void> {
const { fetchCatalysts } = await import('$lib/api.js');
loadingCats = true;
error = null;
try {
const cat = await fetchCatalysts();
input = cat.tickers.join(', ');
loading = true;
results = await screenTickers(cat.tickers);
screenedAt = new Date().toLocaleTimeString();
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
loadingCats = false;
}
}
const ctx = $derived(results?.marketContext ?? null);
const allAssets = $derived(results
? sorted([...results.STOCK, ...results.ETF, ...results.BOND])
: []);
// Boot — fetch catalysts + screen on mount
let _booted = false;
$effect(() => {
if (_booted) return;
_booted = true;
s.reloadCatalysts();
});
</script>
<div class="page">
@@ -82,8 +28,8 @@
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
<div class="toolbar">
<div class="toolbar-top">
<button onclick={reloadCatalysts} disabled={loading || loadingCats} class="btn-catalyst">
{#if loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
<button onclick={() => s.reloadCatalysts()} disabled={s.loading || s.loadingCats} class="btn-catalyst">
{#if s.loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
</button>
<button
onclick={() => searchOpen = !searchOpen}
@@ -92,43 +38,45 @@
>
🔍 {searchOpen ? 'Hide search' : 'Search tickers'}
</button>
{#if screenedAt}
<span class="screened-at">Last screened {screenedAt}</span>
{#if s.screenedAt}
<span class="screened-at">Last screened {s.screenedAt}</span>
{/if}
</div>
{#if searchOpen}
<div class="search-row">
<input
bind:value={input}
bind:value={s.input}
placeholder="AAPL, MSFT, VOO …"
onkeydown={e => e.key === 'Enter' && screen()}
onkeydown={e => e.key === 'Enter' && s.screen()}
/>
<button onclick={screen} disabled={loading || loadingCats} class="btn-screen">
{#if loading}<Spinner size="sm" />{:else}Screen{/if}
<button onclick={() => s.screen()} disabled={s.loading || s.loadingCats} class="btn-screen">
{#if s.loading}<Spinner size="sm" />{:else}Screen{/if}
</button>
</div>
{/if}
{#if s.ctx}
<MarketContextStrip ctx={s.ctx} />
{/if}
</div>
{#if error}
<div class="error-banner">{error}</div>
{#if s.error}
<div class="error-banner">{s.error}</div>
{/if}
{#if loading || loadingCats}
{#if s.loading || s.loadingCats}
<div class="loading-area">
<Spinner size="lg" label={loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
<Spinner size="lg" label={s.loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
</div>
{/if}
{#if ctx}
<MarketContextStrip {ctx} />
{#if s.results && !s.loading && !s.loadingCats}
<!-- ── Signal Summary ───────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2>Signal Summary</h2>
<span class="count">{allAssets.length} assets</span>
<span class="count">{s.allAssets.length} assets</span>
</div>
<div class="table-wrap">
<table>
@@ -139,16 +87,21 @@
<th>Signal</th>
<th>Mkt-Adjusted</th>
<th>Fundamental</th>
<th title="Market cap tier (stocks only)">Cap</th>
<th title="Growth / style classification (stocks only)">Style</th>
</tr>
</thead>
<tbody>
{#each allAssets as r}
{#each s.allAssets as r}
{@const dm = r.asset.displayMetrics ?? {}}
<tr>
<td class="ticker">{r.asset.ticker}</td>
<td><span class="tag">{r.asset.type}</span></td>
<td><SignalBadge signal={r.signal} /></td>
<td><VerdictPill label={r.inflated.label} /></td>
<td><VerdictPill label={r.fundamental.label} /></td>
<td class="dim-cell">{dm['Cap Tier'] ?? '—'}</td>
<td class="dim-cell">{dm['Style'] ?? '—'}</td>
</tr>
{/each}
</tbody>
@@ -158,22 +111,22 @@
<!-- ── Per-type detail tables ────────────────────────────────────── -->
{#each (['STOCK', 'ETF', 'BOND'] as const) as type}
{#if results[type]?.length}
{#if s.results[type]?.length}
<AssetTable
{type}
rows={results[type]}
analyzeLoading={sidebar.loading && sidebar.type === type}
onAnalyze={() => runTabAnalysis(type)}
rows={s.results[type]}
analyzeLoading={s.sidebar.loading && s.sidebar.type === type}
onAnalyze={() => s.runTabAnalysis(type)}
/>
{/if}
{/each}
<!-- ── Failed tickers ────────────────────────────────────────────── -->
{#if results.ERROR?.length}
{#if s.results.ERROR?.length}
<section class="section">
<h2>Failed <span class="count">{results.ERROR.length}</span></h2>
<h2>Failed <span class="count">{s.results.ERROR.length}</span></h2>
<div class="error-list">
{#each results.ERROR as e}
{#each s.results.ERROR as e}
<div class="error-item"><span class="ticker">{e.ticker}</span> {e.message}</div>
{/each}
</div>
@@ -182,12 +135,11 @@
{/if}
</div>
<AnalysisSidebar {sidebar} onClose={() => sidebar = { ...sidebar, open: false }} />
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} />
<style>
.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; }
@@ -225,7 +177,8 @@
color: var(--text-dimmer);
}
/* ── Error list ──────────────────────────────────────────────────── */
.dim-cell { font-size: var(--fs-sm); color: var(--text-dim); white-space: nowrap; }
.error-list { padding: 12px var(--space-xl); display: flex; flex-direction: column; gap: 6px; }
.error-item { color: var(--text-dim); font-size: 12px; }
.error-item :global(.ticker) { color: var(--red); font-weight: 700; margin-right: 8px; }
+5 -12
View File
@@ -1,14 +1,7 @@
import { fetchCatalysts, screenTickers } from '$lib/api.js';
import type { PageLoad } from './$types.js';
// Client-only — the API lives at localhost:3000, not accessible during SSR
export const ssr = false;
export const load: PageLoad = async () => {
const cat = await fetchCatalysts();
const results = await screenTickers(cat.tickers);
return {
results,
catalystInput: cat.tickers.join(', '),
};
};
// Return nothing — data loading happens client-side in the page component
// so the spinner fires on initial boot (hard refresh) too.
export function load() {
return {};
}
+16 -314
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { createCall, deleteCall } from '$lib/api.js';
import SignalBadge from '$lib/SignalBadge.svelte';
import Spinner from '$lib/Spinner.svelte';
import { invalidateAll } from '$app/navigation';
import CallForm from '$lib/calls/CallForm.svelte';
import CallCard from '$lib/calls/CallCard.svelte';
import CalendarSection from '$lib/calls/CalendarSection.svelte';
import type { CalendarEvent } from '$lib/types.js';
interface MarketCall {
id: string;
@@ -16,35 +18,19 @@
interface PageData {
calls: MarketCall[];
events: unknown[];
events: CalendarEvent[];
error?: string;
}
let { data }: { data: PageData } = $props();
// New call form state
let showForm: boolean = $state(false);
let saving: boolean = $state(false);
let formError: string|null = $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() {
async function submit(form: {
title: string; quarter: string; date: string; thesis: string; tickers: string;
}): Promise<void> {
formError = null;
saving = true;
try {
@@ -56,8 +42,7 @@
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
await invalidateAll();
} catch (e) {
formError = (e as Error).message;
} finally {
@@ -70,316 +55,33 @@
await deleteCall(id);
await invalidateAll();
}
const signalColor = (s: string | null | undefined): string => {
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';
};
type EventType = 'earnings' | 'exdividend' | 'dividend';
const eventIcon = (type: EventType): string => ({ earnings: '📊', exdividend: '💰', dividend: '💵' })[type] ?? '📅';
const eventColor = (type: EventType): string => ({ 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 class="calls-page">
<div class="calls-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}>
<button class="btn-primary" onclick={() => { showForm = !showForm; formError = null; }}>
{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>
<CallForm {saving} error={formError} onSubmit={submit} onCancel={() => showForm = false} />
{/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}
<CalendarSection events={data.events ?? []} />
<!-- ── 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>
<div class="calls-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>
<CallCard {call} onDelete={remove} />
{/each}
{/if}
</div>
<style>
/* ── Page ── unique to this route ──────────────────────────────── */
.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: var(--fs-2xl); font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.subtitle { font-size: 12px; color: var(--text-dimmer); }
/* btn-delete — calls-specific icon button */
.btn-delete { background: transparent; color: var(--text-dimmer); padding: 4px 8px; font-size: 14px; }
.btn-delete:hover { color: var(--red); }
/* ── 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: var(--fs-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
input, textarea {
background: var(--bg-card);
border: 1px solid var(--border-input);
border-radius: var(--radius-md);
color: var(--text-secondary);
padding: 9px 12px;
font-size: var(--fs-md);
outline: none;
transition: border-color var(--transition);
font-family: inherit;
}
input:focus, textarea:focus { border-color: var(--blue); }
textarea { resize: vertical; }
.hint { font-size: var(--fs-sm); color: var(--text-dimmer); }
.form-error {
color: var(--red);
font-size: 12px;
background: var(--red-bg);
padding: 8px 12px;
border-radius: var(--radius-sm);
}
/* ── 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: var(--text-primary); text-decoration: none; }
.call-title:hover { color: var(--blue-muted); }
.call-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.date-badge { font-size: var(--fs-sm); color: var(--text-dimmer); }
.call-body { padding: var(--space-xl); display: flex; flex-direction: column; gap: 16px; }
.thesis {
font-size: var(--fs-md);
color: var(--text-muted);
line-height: 1.6;
border-left: 3px solid var(--blue-surface);
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: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 3px;
text-decoration: none;
transition: border-color var(--transition);
}
.snap-card:hover { border-color: var(--text-faint); }
.snap-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); }
.snap-price { font-size: var(--fs-sm); color: var(--text-dim); font-variant-numeric: tabular-nums; }
.snap-signal { font-size: var(--fs-xs); font-weight: 600; }
.view-link { font-size: 12px; color: var(--blue-muted); text-decoration: none; }
.view-link:hover { text-decoration: underline; }
.empty { color: var(--text-dimmer); font-size: var(--fs-md); padding: 40px 0; text-align: center; }
/* ── Calendar ───────────────────────────────────────────────────── */
.cal-grid { padding: 8px var(--space-xl) 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: var(--radius-sm);
transition: background 0.1s;
}
.cal-event:hover { background: var(--bg-elevated); }
.cal-event.past { opacity: 0.45; }
.cal-date { font-size: var(--fs-sm); font-variant-numeric: tabular-nums; color: var(--text-dimmer); padding-top: 1px; white-space: nowrap; }
.cal-content { display: flex; flex-direction: column; gap: 2px; }
.cal-ticker { font-size: 12px; font-weight: 700; color: var(--text-primary); }
.cal-type { font-size: var(--fs-sm); font-weight: 600; }
.cal-detail { font-weight: 400; color: var(--text-dim); }
.past-type { color: var(--text-dimmer) !important; }
.cal-est { font-size: var(--fs-xs); color: var(--text-dimmer); }
.cal-divider {
font-size: var(--fs-xs);
color: var(--text-faint);
text-align: center;
padding: 8px 0 4px;
letter-spacing: 0.06em;
}
</style>

Some files were not shown because too many files have changed in this diff Show More