phase-7: code restructure
This commit is contained in:
committed by
saikiranvella
parent
c160e65bd6
commit
357b0c0f6e
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -45,10 +45,9 @@ npm run ui:install # install UI dependencies (ui/ sub
|
|||||||
|
|
||||||
```
|
```
|
||||||
bin/
|
bin/
|
||||||
screen.js ← CLI screener entry point
|
screen.ts ← CLI screener entry point
|
||||||
finance.js ← CLI personal finance entry point
|
finance.ts ← CLI personal finance entry point
|
||||||
import-portfolio.js ← broker CSV importer
|
server.ts ← Fastify API server entry point (imports buildApp from server/app.ts)
|
||||||
server.js ← Fastify API server entry point
|
|
||||||
|
|
||||||
scripts/
|
scripts/
|
||||||
summary-reporter.js ← custom node:test reporter (silent on pass, summary line at end)
|
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)
|
catalyst-analysis.md ← daily catalyst analysis playbook (LLM prompt + workflow)
|
||||||
|
|
||||||
server/
|
server/
|
||||||
config/
|
app.ts ← Fastify app factory (buildApp). Registers CORS + all controllers.
|
||||||
ScoringConfig.js ← CREDIT_RATING_SCALE + ScoringRules (single source of truth)
|
NOTE: lives at server/app.ts, NOT inside server/controllers/.
|
||||||
constants.js ← SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME, SIGNAL_ORDER
|
|
||||||
|
|
||||||
market/ ← Yahoo Finance data layer
|
controllers/ ← HTTP only: parse request, call service, return response
|
||||||
YahooClient.js ← wraps yahoo-finance2 v3, retry + backoff
|
screener.controller.ts ← POST /api/screen, GET /api/screen/catalysts
|
||||||
BenchmarkProvider.js ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext
|
finance.controller.ts ← GET /api/finance/portfolio, POST|DELETE /api/finance/holdings
|
||||||
MarketRegime.js ← derives INFLATED gate overrides from live benchmarks + rate regime
|
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
|
services/ ← business logic, no HTTP or I/O concerns
|
||||||
ScreenerEngine.js ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data),
|
ScreenerEngine.ts ← orchestrates: fetch → score × 2. Methods: screenTickers() (pure data),
|
||||||
screenWithProgress() (CLI with stdout). Accepts { logger } option.
|
screenWithProgress() (CLI with stdout). Accepts { logger } option.
|
||||||
DataMapper.js ← normalises Yahoo payload → flat asset data object
|
DataMapper.ts ← normalises Yahoo payload → flat asset data object.
|
||||||
NOTE: uses trailingPE (not forwardPE). Preserves negative FCF.
|
Computes: DCF intrinsic value, analyst upside, 52W movement fields,
|
||||||
Infers bond duration from category string. Maps ETF volume.
|
grossMargin, marketCap. Uses trailingPE. Preserves negative FCF.
|
||||||
RuleMerger.js ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
|
RuleMerger.ts ← merges base rules + sector overrides + MarketRegime (INFLATED mode)
|
||||||
Chunker.js ← splits ticker list into batches
|
BenchmarkProvider.ts ← fetches ^GSPC, ^TNX, ^VIX, SPY, XLK, XLRE, LQD → marketContext.
|
||||||
assets/
|
In-memory cache: 1 hr TTL. Resets on server restart.
|
||||||
Asset.js ← abstract base: ticker, currentPrice, type, formatting helpers
|
MarketRegime.ts ← derives INFLATED gate overrides from live benchmarks + rate regime
|
||||||
Stock.js ← metrics + _mapToStandardSector (8 sectors detected)
|
CatalystAnalyst.ts ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }.
|
||||||
Etf.js ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets
|
LLMAnalyst.ts ← uses AnthropicClient to analyze headlines → summary, sentiment,
|
||||||
Bond.js ← metrics: ytm, duration, creditRating, creditRatingNumeric
|
affectedIndustries, relatedTickers. Returns null if API key not set.
|
||||||
scorers/
|
PersonalFinanceAnalyzer.ts ← net worth, cash vs investments, spending by category
|
||||||
StockScorer.js ← gate checks + weighted registry (ROE, opMargin, margin, peg, rev, fcf)
|
PortfolioAdvisor.ts ← cross-references holdings with screener signals → hold/sell/add advice
|
||||||
EtfScorer.js ← expense gate + registry (cost, yield, volume, fiveYearReturn)
|
index.ts ← barrel re-export (import services from here, not individual files)
|
||||||
BondScorer.js ← credit gate + spread/duration scoring
|
|
||||||
|
|
||||||
analyst/
|
repositories/ ← data persistence only (JSON file read/write)
|
||||||
CatalystAnalyst.js ← fetches Yahoo Finance news, extracts relatedTickers. Accepts { logger }.
|
MarketCallRepository.ts ← persists market thesis entries to market-calls.json.
|
||||||
LLMAnalyst.js ← uses Claude Haiku (ANTHROPIC_API_KEY) to analyze headlines → summary,
|
CRUD: list/get/create/delete.
|
||||||
sentiment (BULLISH/NEUTRAL/BEARISH), affectedIndustries, relatedTickers.
|
PortfolioRepository.ts ← read/write portfolio.json. Methods: read, upsert, remove.
|
||||||
Returns null gracefully if API key is not set. Accepts { logger }.
|
|
||||||
|
|
||||||
calls/
|
clients/ ← external API connectors, one class per third-party system
|
||||||
MarketCallStore.js ← persists quarterly market thesis entries to market-calls.json.
|
YahooFinanceClient.ts ← wraps yahoo-finance2 v3, retry + backoff. Methods: fetchSummary,
|
||||||
Each call stores: title, quarter, date, thesis, tickers[], snapshot{}
|
fetchCalendarEvents, search. Typed via YahooFinanceLib interface.
|
||||||
(price + signal per ticker at creation time). CRUD: list/get/create/delete.
|
SimpleFINClient.ts ← claims setup token → access URL, fetches /accounts via Basic Auth.
|
||||||
|
AnthropicClient.ts ← wraps Anthropic SDK. complete(system, user) → raw text response.
|
||||||
|
|
||||||
finance/
|
models/ ← domain entity classes with metrics + display logic
|
||||||
clients/
|
Asset.ts ← abstract base: ticker, currentPrice, type, formatting helpers
|
||||||
SimpleFINClient.js ← claims setup token → access URL, fetches /accounts via Basic Auth header
|
Stock.ts ← metrics + _mapToStandardSector (8 sectors) + _classifyMarketCap
|
||||||
(NOT embedded credentials in URL). Accepts { logger, onAccessUrlClaimed }.
|
(Mega/Large/Mid/Small/Micro) + _classifyGrowth (style classification).
|
||||||
PersonalFinanceAnalyzer.js ← net worth, cash vs investments, spending by category
|
Holds: valuation, quality, risk, 52W movement, analyst consensus, DCF.
|
||||||
PortfolioAdvisor.js ← cross-references holdings with screener signals → hold/sell/add advice
|
Etf.ts ← metrics: expenseRatio, yield, volume, fiveYearReturn, totalAssets
|
||||||
|
Bond.ts ← metrics: ytm, duration, creditRating, creditRatingNumeric
|
||||||
|
|
||||||
reporters/
|
scorers/ ← stateless pure scoring functions, no I/O
|
||||||
HtmlReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
|
StockScorer.ts ← gate checks + weighted registry:
|
||||||
FinanceReporter.js ← render() → HTML string (server), generate() → writes file (CLI)
|
core: ROE, opMargin, margin, peg, revenue, fcf
|
||||||
|
expert: analyst consensus (inverted Yahoo 1–5 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/
|
reporters/ ← HTML rendering, no business logic
|
||||||
app.js ← Fastify app factory (buildApp). Registers CORS + routes.
|
HtmlReporter.ts ← render() → HTML string (server), generate() → writes file (CLI)
|
||||||
routes/
|
FinanceReporter.ts ← render() → HTML string (server), generate() → writes file (CLI)
|
||||||
screener.js ← POST /api/screen, GET /api/screen/catalysts
|
|
||||||
Serializes asset.getDisplayMetrics() before JSON response.
|
config/
|
||||||
finance.js ← GET /api/finance/portfolio, GET /api/finance/market-context
|
ScoringConfig.ts ← CREDIT_RATING_SCALE + ScoringRules (single source of truth for all
|
||||||
calls.js ← CRUD for market calls + GET /api/calls/calendar (earnings/dividend events)
|
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)
|
ui/ ← SvelteKit dashboard (lives inside this repo, not a separate repo)
|
||||||
src/
|
src/
|
||||||
styles/ ← global SCSS design-token system (Phase 4)
|
styles/ ← global SCSS design-token system
|
||||||
app.scss ← root file — @use all partials
|
app.scss ← root file — @use all partials
|
||||||
_tokens.scss ← CSS custom properties generated from SCSS maps ($bg, $text, $blues, $signals…)
|
_tokens.scss ← CSS custom properties generated from SCSS maps ($bg, $text, $blues, $signals…)
|
||||||
_reset.scss ← box-sizing reset + body base
|
_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
|
_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)
|
_badges.scss ← .verdict-pill, .sentiment-pill, .text-* helpers (SCSS @each maps)
|
||||||
lib/
|
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…
|
utils.ts ← shared pure functions: sigOrd, sorted, verdictShort, vClass, fmtPE, fmt…
|
||||||
MarketContext.svelte ← collapsible card-grid context (used in portfolio + safe-buys)
|
MarketContext.svelte ← collapsible card-grid context (used in portfolio + safe-buys)
|
||||||
MarketContextStrip.svelte ← horizontal chip strip (used in screener — Phase 5)
|
MarketContextStrip.svelte ← horizontal chip strip (used in screener)
|
||||||
AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table (Phase 5)
|
AssetTable.svelte ← STOCK/ETF/BOND section: mode tabs + Analyze + table
|
||||||
AnalysisSidebar.svelte ← LLM analysis slide-over panel (Phase 5)
|
AnalysisSidebar.svelte ← LLM analysis slide-over panel
|
||||||
VerdictPill.svelte ← verdict-pill span; props: label (Phase 5)
|
VerdictPill.svelte ← verdict-pill span; props: label
|
||||||
SignalBadge.svelte ← signal emoji + label badge
|
SignalBadge.svelte ← signal emoji + label badge
|
||||||
Spinner.svelte ← sm: dot-pulse | md/lg: chart-line animation
|
Spinner.svelte ← sm: dot-pulse | md/lg: chart-line animation
|
||||||
routes/
|
routes/
|
||||||
+page.js ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount (Phase 5)
|
+page.ts ← SvelteKit load (ssr:false) — fetches catalysts + screens on mount
|
||||||
+page.svelte ← main screener UI (~230 lines after Phase 5 decomposition)
|
+page.svelte ← main screener UI
|
||||||
+layout.svelte ← shell, nav, nav-progress bar, nav-overlay with Spinner
|
+layout.svelte ← shell, nav, nav-progress bar, nav-overlay with Spinner
|
||||||
calls/ ← market calls list + detail views
|
calls/ ← market calls list + detail views
|
||||||
portfolio/ ← portfolio advice view
|
portfolio/ ← portfolio advice view
|
||||||
safe-buys/ ← filtered strong-buy 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
|
portfolio.json ← user's holdings: ticker, shares, costBasis, source, type
|
||||||
.env ← SIMPLEFIN_ACCESS_URL or SIMPLEFIN_SETUP_TOKEN, ANTHROPIC_API_KEY
|
.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)
|
DataMapper — normalises raw Yahoo payload → flat data object with type (STOCK/ETF/BOND)
|
||||||
uses trailingPE as primary; preserves negative FCF yield; infers bond duration
|
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()
|
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)
|
RuleMerger × 2 — FUNDAMENTAL mode: ScoringConfig as-is (Graham-style)
|
||||||
INFLATED mode: sector override + MarketRegime live gate overrides
|
INFLATED mode: sector override + MarketRegime live gate overrides
|
||||||
@@ -182,7 +219,7 @@ ScreenerEngine — derives Signal from comparing both verdicts
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/health` | Health check |
|
| 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/screen/catalysts` | Yahoo news → `{ tickers, stories }` |
|
||||||
| GET | `/api/finance/portfolio` | Portfolio advice + optional SimpleFIN data |
|
| GET | `/api/finance/portfolio` | Portfolio advice + optional SimpleFIN data |
|
||||||
| GET | `/api/finance/market-context` | Live benchmark data only |
|
| 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`).
|
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
|
## Scoring Modes
|
||||||
@@ -221,7 +260,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
|
|||||||
|
|
||||||
## ScoringConfig Key Values
|
## 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):**
|
**STOCK base gates (Fundamental mode):**
|
||||||
- `maxPERatio: 15` — Graham's actual rule (trailing P/E)
|
- `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
|
- `maxDebtToEquity: 1.5` — most distress starts above 2x
|
||||||
- `minQuickRatio: 0.8` — below this signals real liquidity stress
|
- `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 1–5 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 overrides** (structural — apply in both modes):
|
||||||
|
|
||||||
| Sector | Key difference |
|
| Sector | Key difference |
|
||||||
@@ -256,7 +301,7 @@ CORS is configured for `CLIENT_ORIGIN` env var (default `http://localhost:5173`)
|
|||||||
|
|
||||||
## MarketRegime (INFLATED overrides)
|
## 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) |
|
| 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 | LQD−TNX × 0.80 | LQD−TNX × 0.90 |
|
| Bond minSpread | LQD−TNX × 0.80 | LQD−TNX × 0.90 |
|
||||||
| ETF maxExpenseRatio | 0.75% | 0.75% |
|
| ETF maxExpenseRatio | 0.75% | 0.75% |
|
||||||
|
|
||||||
|
Rate regime thresholds: `< 2%` = LOW, `2–5%` = 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; 0–20% → +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 5–15% |
|
||||||
|
| 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
|
## Sector Detection
|
||||||
@@ -292,8 +407,14 @@ GENERAL → fallback
|
|||||||
|
|
||||||
- **peRatio**: prefers `trailingPE` (audited) over `forwardPE` (analyst estimate, ~10-15% optimistic)
|
- **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
|
- **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.
|
- **grossMargin**: `financialData.grossMargins * 100` — exposed in display; not yet a scoring factor
|
||||||
- **ETF volume**: `summaryDetail.averageVolume` — was missing before, causing the `-2` liquidity penalty on every ETF
|
- **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.
|
- Missing metrics use `null` (not `0`) in `_sanitize`. Gate checks skip `null` rather than auto-failing.
|
||||||
- `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it.
|
- `pegRatio` falls back to `trailingPE / earningsGrowth` when Yahoo doesn't provide it.
|
||||||
- `quickRatio` falls back to `currentRatio` when missing.
|
- DCF, analyst, and 52W scoring factors are all optional — they activate only when the underlying data is non-null.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Logger Injection Pattern
|
## 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
|
// CLI (default) — writes to stdout
|
||||||
new ScreenerEngine()
|
new ScreenerEngine()
|
||||||
|
|
||||||
// Server — fully silent
|
// Server — fully silent
|
||||||
new ScreenerEngine({ logger: { write: () => {}, log: () => {}, warn: () => {} } })
|
new ScreenerEngine({ logger: noopLogger })
|
||||||
```
|
```
|
||||||
|
|
||||||
Affected: `ScreenerEngine`, `BenchmarkProvider`, `CatalystAnalyst`, `SimpleFINClient`, `LLMAnalyst`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Reporter Pattern
|
## Reporter Pattern
|
||||||
|
|
||||||
Both reporters have two methods:
|
Both reporters have two methods:
|
||||||
|
|
||||||
```js
|
```ts
|
||||||
reporter.render(...) // → HTML string (use in server route responses)
|
reporter.render(...) // → HTML string (use in server route responses)
|
||||||
reporter.generate(...) // → writes file to disk, returns path (use in CLI)
|
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
|
BondScorer.test.js ← credit gate, spread/duration scoring, unit handling
|
||||||
DataMapper.test.js ← type detection, PEG computation, trailing PE preference,
|
DataMapper.test.js ← type detection, PEG computation, trailing PE preference,
|
||||||
negative FCF, ETF volume, bond duration inference
|
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
|
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.
|
**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):**
|
**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
|
- `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
|
- API controllers (`server/controllers/`) — no integration tests; covered implicitly by manual testing only
|
||||||
- UI components — not tested at the unit level (Phases 4–5 are pure UI; no server logic changed)
|
- 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.
|
- Prefer adjusting `ScoringConfig` or `MarketRegime` over hardcoding numbers in scorers.
|
||||||
- BenchmarkProvider caches for 1 hour — restart the server to force a fresh fetch.
|
- `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 to entry points — they call into `server/`.
|
- All entry points live in `bin/`. Do not add logic there — they call into `services/` and controllers.
|
||||||
- `bin/server.js` starts Fastify; `server/server/` contains all route logic.
|
|
||||||
- **Never** call `process.exit()` inside `server/` — only `bin/` may do that.
|
- **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
|
## Architecture Roadmap
|
||||||
|
|
||||||
Planned improvements in priority order. Do not start a later phase before completing earlier ones.
|
### Phases 1–7 ✅ COMPLETE
|
||||||
|
|
||||||
### Phase 1 — Cleanup ✅ COMPLETE
|
All phases of the original roadmap are done. Summary of what was delivered:
|
||||||
All items completed. Additional features delivered alongside cleanup:
|
- Phase 1–4: 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 7a–7b: 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:**
|
**Pending UI work (Phase 7c–7e):**
|
||||||
- Deleted root-level `finance.js`, `import-portfolio.js`, `markdown.md`
|
- 7c: Decompose `portfolio/+page.svelte` (751 lines) into `AddHoldingForm`, `InlineEditRow`, `AdviceTable`, `AccountsTable`; decompose `calls/+page.svelte` (385 lines) into `CallForm`, `CallCard`, `CalendarSection`
|
||||||
- Deleted `server/server/routes/analyze.js` (orphaned route file)
|
- 7d: Add `ui/src/lib/stores/` layer — `screener.store.ts`, `portfolio.store.ts`
|
||||||
- Removed dead `analysis` state, `analysisOpen` state, and "🤖 AI Market Analysis" panel from `+page.svelte`
|
- 7e: Extract inline `<style>` blocks from portfolio, calls, and AnalysisSidebar into `_forms.scss`, `_sidebar.scss`, `_calls.scss`, `_portfolio.scss`
|
||||||
- Fixed `.gitignore` — `portfolio.json`, `market-calls.json`, `.env` are now excluded from git
|
|
||||||
|
|
||||||
**Features added during Phase 1:**
|
---
|
||||||
- `POST /api/analyze` — per-tab LLM analysis with sidebar (✦ Analyze button on each asset section)
|
|
||||||
- `POST /api/finance/holdings` + `DELETE /api/finance/holdings/:ticker` — add/edit/delete holdings via UI
|
|
||||||
- Portfolio page: inline row editing, optimistic UI updates, sortable columns, collapsible market context with tooltips, P&L summary card tooltips
|
|
||||||
- Holdings can be added/edited/deleted via the portfolio UI (manual entry replaces CSV importer)
|
|
||||||
- `BRK.B` dot-notation tickers now normalised to Yahoo Finance format (`BRK.B → BRK-B`)
|
|
||||||
- Market graph drawing-line animation replaces generic spinner (lg/md); dot-pulse for sm (buttons)
|
|
||||||
- Portfolio page loads client-side (`$effect`) to avoid blocking navigation
|
|
||||||
- Catalyst page auto-loads on mount; LLM analysis only runs on explicit ✦ Analyze click
|
|
||||||
|
|
||||||
**Pending (deferred to later):**
|
### Phase 8 — Server Hardening & Quality
|
||||||
- LLM Analysis button on portfolio page (analyse holdings against current news)
|
|
||||||
|
|
||||||
### Phase 2 — Extract Shared Utilities ✅ COMPLETE
|
Priority order. Complete earlier items before starting later ones.
|
||||||
|
|
||||||
**Done:**
|
#### 8a — Fix `as never` scorer dispatch in `ScreenerEngine`
|
||||||
- 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
|
|
||||||
|
|
||||||
### 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:**
|
#### 8b — Inject dependencies into `ScreenerEngine` and `PortfolioAdvisor`
|
||||||
- Renamed `src/` to `server/` — `src/server/` is now `server/server/`
|
|
||||||
- Updated all import paths in `bin/`, `tests/`, and `CLAUDE.md`
|
|
||||||
|
|
||||||
### 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
|
```ts
|
||||||
type Signal = '✅ Strong Buy' | '⚡ Momentum' | '🔄 Neutral' | '⚠️ Speculation' | '❌ Avoid'
|
export class ScreenerEngine {
|
||||||
type AssetType = 'STOCK' | 'ETF' | 'BOND'
|
constructor(
|
||||||
type ScoreMode = 'inflated' | 'fundamental'
|
private readonly client: YahooFinanceClient,
|
||||||
|
private readonly benchmarkProvider: BenchmarkProvider,
|
||||||
interface ScreenerResult { STOCK, ETF, BOND, ERROR, marketContext }
|
{ logger }: ScreenerEngineOptions = {},
|
||||||
interface MarketContext { sp500Price, riskFreeRate, vixLevel, rateRegime, benchmarks }
|
) {}
|
||||||
interface LLMAnalysis { summary, sentiment, affectedIndustries, relatedTickers }
|
}
|
||||||
interface MarketCall { id, title, quarter, date, thesis, tickers, snapshot }
|
|
||||||
interface PortfolioHolding { ticker, shares, costBasis, source, type }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
SvelteKit supports TypeScript natively — components just need `<script lang="ts">`.
|
Wire in `server/app.ts`. This unblocks proper service-layer unit tests.
|
||||||
|
|
||||||
### Not Planned
|
#### 8c — Controller integration tests
|
||||||
- **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.
|
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 2–4s 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
|
## Adding a New Asset Type
|
||||||
|
|
||||||
1. Create a subclass of `Asset` in `server/screener/assets/` with a flat `metrics` object and `getDisplayMetrics()`.
|
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 `ScoringConfig.js`.
|
2. Add a per-type entry (`gates` / `weights` / `thresholds`) to `ScoringRules` in `server/config/ScoringConfig.ts`.
|
||||||
3. Add inflated overrides in `MarketRegime.getInflatedOverrides()`.
|
3. Add inflated overrides in `server/services/MarketRegime.ts` → `getInflatedOverrides()`.
|
||||||
4. Create a Scorer in `server/screener/scorers/` exposing `score(metrics, rules, marketContext)`.
|
4. Create a Scorer in `server/scorers/` exposing `score(metrics, rules, marketContext)`.
|
||||||
5. Add a mapper in `DataMapper.js`.
|
5. Add a mapper branch in `server/services/DataMapper.ts`.
|
||||||
6. Wire into `ScreenerEngine`: add `case` in `_buildAsset`, entry in `SCORERS` map.
|
6. Wire into `server/services/ScreenerEngine.ts`: add `case` in `_buildAsset`, entry in `SCORERS` map.
|
||||||
7. Add the new type to `serializeAssets()` handling in `server/server/routes/screener.js`.
|
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
@@ -3,13 +3,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
import { existsSync, readFileSync } from 'fs';
|
||||||
import { SimpleFINClient, saveAccessUrlToEnv } from '../server/finance/clients/SimpleFINClient.js';
|
import { SimpleFINClient, saveAccessUrlToEnv } from '../server/clients/SimpleFINClient';
|
||||||
import { PersonalFinanceAnalyzer } from '../server/finance/PersonalFinanceAnalyzer.js';
|
import { FinanceReporter } from '../server/reporters/FinanceReporter';
|
||||||
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
|
import { PersonalFinanceAnalyzer } from '../server/services/PersonalFinanceAnalyzer';
|
||||||
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
|
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
|
||||||
import { FinanceReporter } from '../server/reporters/FinanceReporter.js';
|
import { ScreenerEngine } from '../server/services/ScreenerEngine';
|
||||||
import type { PortfolioHolding } from '../server/types.js';
|
import type { PortfolioHolding } from '../server/types';
|
||||||
|
|
||||||
const PORTFOLIO_PATH = './portfolio.json';
|
const PORTFOLIO_PATH = './portfolio.json';
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ async function main(): Promise<void> {
|
|||||||
const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv });
|
const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv });
|
||||||
await client.init();
|
await client.init();
|
||||||
const { accounts } = await client.getAccounts();
|
const { accounts } = await client.getAccounts();
|
||||||
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
|
personalFinance = new PersonalFinanceAnalyzer().analyze(accounts);
|
||||||
process.stdout.write(` ${accounts.length} accounts loaded\n`);
|
process.stdout.write(` ${accounts.length} accounts loaded\n`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
process.stdout.write(` skipped — ${(err as Error).message}\n`);
|
process.stdout.write(` skipped — ${(err as Error).message}\n`);
|
||||||
|
|||||||
+4
-4
@@ -12,9 +12,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { CatalystAnalyst } from '../server/analyst/CatalystAnalyst.js';
|
import { CatalystAnalyst } from '../server/services/CatalystAnalyst';
|
||||||
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
|
import { ScreenerEngine } from '../server/services/ScreenerEngine';
|
||||||
import { HtmlReporter } from '../server/reporters/HtmlReporter.js';
|
import { HtmlReporter } from '../server/reporters/HtmlReporter';
|
||||||
|
|
||||||
const DEFAULT_WATCHLIST: string[] = [
|
const DEFAULT_WATCHLIST: string[] = [
|
||||||
'PLTR',
|
'PLTR',
|
||||||
@@ -54,7 +54,7 @@ async function main(): Promise<void> {
|
|||||||
tickers = newsTickers;
|
tickers = newsTickers;
|
||||||
console.log("\n📰 Stories driving today's screen:");
|
console.log("\n📰 Stories driving today's screen:");
|
||||||
stories.slice(0, 5).forEach((s) => {
|
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(` • ${s.title}${tags ? ` [${tags}]` : ''}`);
|
||||||
});
|
});
|
||||||
console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`);
|
console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`);
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { buildApp } from '../server/server/app.js';
|
import { buildApp } from '../server/app';
|
||||||
|
|
||||||
const PORT = process.env.PORT ?? 3000;
|
const PORT = process.env.PORT ?? 3000;
|
||||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||||
|
|||||||
@@ -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));"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+3132
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -9,14 +9,17 @@
|
|||||||
"ui:install": "npm install --prefix ui --legacy-peer-deps",
|
"ui:install": "npm install --prefix ui --legacy-peer-deps",
|
||||||
"finance": "tsx bin/finance.ts",
|
"finance": "tsx bin/finance.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "tsx --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js",
|
"test": "tsx --test --test-reporter=./scripts/summary-reporter.ts tests/*.test.ts",
|
||||||
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.js",
|
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts",
|
||||||
"format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.js\"",
|
"lint": "eslint . --ext .ts,.js",
|
||||||
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.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"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,js}": [
|
"*.{ts,js}": [
|
||||||
|
"eslint --fix",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -29,7 +32,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"husky": "^9.0.0",
|
"husky": "^9.0.0",
|
||||||
"lint-staged": "^15.0.0",
|
"lint-staged": "^15.0.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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 2–3 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 (1–5 days) | MEDIUM (1–4 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 10–20%
|
||||||
|
- 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.
|
// Minimal test reporter: silent on pass, prints failures in full, ends with one summary line.
|
||||||
export default async function* summaryReporter(source) {
|
import type { TestEvent } from 'node:test/reporters';
|
||||||
const failures = [];
|
|
||||||
|
interface Failure {
|
||||||
|
name: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function* summaryReporter(
|
||||||
|
source: AsyncIterable<TestEvent>,
|
||||||
|
): AsyncGenerator<string> {
|
||||||
|
const failures: Failure[] = [];
|
||||||
let passed = 0,
|
let passed = 0,
|
||||||
failed = 0,
|
failed = 0,
|
||||||
totalMs = 0;
|
totalMs = 0;
|
||||||
|
|
||||||
for await (const event of source) {
|
for await (const event of source) {
|
||||||
// Skip file-level wrapper events (name ends in .js) — only count individual tests.
|
// Skip file-level wrapper events (name ends in .ts) — only count individual tests.
|
||||||
if (event.data?.name?.endsWith('.js')) continue;
|
if ((event.data as { name?: string })?.name?.endsWith('.ts')) continue;
|
||||||
|
|
||||||
if (event.type === 'test:pass') {
|
if (event.type === 'test:pass') {
|
||||||
passed++;
|
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') {
|
} else if (event.type === 'test:fail') {
|
||||||
failed++;
|
failed++;
|
||||||
totalMs += event.data.details?.duration_ms ?? 0;
|
totalMs += (event.data as { details?: { duration_ms?: number } }).details?.duration_ms ?? 0;
|
||||||
const err = event.data.details?.error;
|
const err = (
|
||||||
|
event.data as { details?: { error?: { cause?: { message?: string }; message?: string } } }
|
||||||
|
).details?.error;
|
||||||
failures.push({
|
failures.push({
|
||||||
name: event.data.name,
|
name: (event.data as { name?: string }).name ?? 'unknown',
|
||||||
reason: err?.cause?.message ?? err?.message ?? 'unknown',
|
reason: err?.cause?.message ?? err?.message ?? 'unknown',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 fs from 'fs';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import type { Logger } from '../../types.js';
|
import type { Logger, GetAccountsOptions, SimpleFINData, SimpleFINOptions } from '../types';
|
||||||
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SimpleFINClient {
|
export class SimpleFINClient {
|
||||||
private accessUrl: string | null;
|
private accessUrl: string | null;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private onAccessUrlClaimed: ((url: string) => Promise<void> | void) | null;
|
private onAccessUrlClaimed: ((_url: string) => Promise<void> | void) | null;
|
||||||
|
|
||||||
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
|
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
|
||||||
this.accessUrl = null;
|
this.accessUrl = null;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Sector } from './constants.js';
|
import type { ScoringRulesShape } from '../types';
|
||||||
|
|
||||||
// ── Credit rating scale (S&P convention) ─────────────────────────────────
|
// ── Credit rating scale (S&P convention) ─────────────────────────────────
|
||||||
// Bond.ts converts letter ratings to these numbers; BondScorer uses them for gate checks.
|
// 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 ────────────────────────────────────────────────────
|
// ── Scoring rule shape ────────────────────────────────────────────────────
|
||||||
|
// Structural shapes (GateSet/WeightSet/ThresholdSet/RuleBlock/StockRules/
|
||||||
interface GateSet extends Record<string, number> {}
|
// ScoringRulesShape) live in server/types/asset.model.ts.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Fundamental baseline — Graham / value-investing style.
|
// Fundamental baseline — Graham / value-investing style.
|
||||||
@@ -58,6 +40,8 @@ export const ScoringRules: ScoringRulesShape = {
|
|||||||
peg: 2, // valuation relative to growth
|
peg: 2, // valuation relative to growth
|
||||||
revenue: 2, // revenue growth
|
revenue: 2, // revenue growth
|
||||||
fcf: 3, // FCF is the most manipulation-resistant quality signal
|
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: {
|
thresholds: {
|
||||||
marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
|
marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
|
||||||
@@ -72,6 +56,12 @@ export const ScoringRules: ScoringRulesShape = {
|
|||||||
revMed: 5,
|
revMed: 5,
|
||||||
fcfHigh: 5,
|
fcfHigh: 5,
|
||||||
fcfMed: 2,
|
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, // 0–20% → fairly valued; negative → overvalued
|
||||||
},
|
},
|
||||||
|
|
||||||
SECTOR_OVERRIDE: {
|
SECTOR_OVERRIDE: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Signal, AssetType, RateRegime } from '../types.js';
|
import type { Signal, AssetType, RateRegime } from '../types';
|
||||||
|
|
||||||
export const SIGNAL = {
|
export const SIGNAL = {
|
||||||
STRONG_BUY: '✅ Strong Buy' as Signal,
|
STRONG_BUY: '✅ Strong Buy' as Signal,
|
||||||
@@ -6,14 +6,28 @@ export const SIGNAL = {
|
|||||||
SPECULATION: '⚠️ Speculation' as Signal,
|
SPECULATION: '⚠️ Speculation' as Signal,
|
||||||
NEUTRAL: '🔄 Neutral' as Signal,
|
NEUTRAL: '🔄 Neutral' as Signal,
|
||||||
AVOID: '❌ Avoid' as Signal,
|
AVOID: '❌ Avoid' as Signal,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const ASSET_TYPE = {
|
export const ASSET_TYPE = {
|
||||||
STOCK: 'STOCK' as AssetType,
|
STOCK: 'STOCK' as AssetType,
|
||||||
ETF: 'ETF' as AssetType,
|
ETF: 'ETF' as AssetType,
|
||||||
BOND: 'BOND' as AssetType,
|
BOND: 'BOND' as AssetType,
|
||||||
CRYPTO: 'crypto',
|
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 = {
|
export const SECTOR = {
|
||||||
TECHNOLOGY: 'TECHNOLOGY',
|
TECHNOLOGY: 'TECHNOLOGY',
|
||||||
@@ -38,7 +52,7 @@ export const REGIME = {
|
|||||||
LOW: 'LOW' as RateRegime,
|
LOW: 'LOW' as RateRegime,
|
||||||
NORMAL: 'NORMAL' as RateRegime,
|
NORMAL: 'NORMAL' as RateRegime,
|
||||||
HIGH: 'HIGH' as RateRegime,
|
HIGH: 'HIGH' as RateRegime,
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const YAHOO_MODULES: string[] = [
|
export const YAHOO_MODULES: string[] = [
|
||||||
'assetProfile',
|
'assetProfile',
|
||||||
@@ -55,3 +69,29 @@ export const SIGNAL_ORDER: Record<string, number> = {
|
|||||||
[SIGNAL.SPECULATION]: 3,
|
[SIGNAL.SPECULATION]: 3,
|
||||||
[SIGNAL.AVOID]: 4,
|
[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 5–15%
|
||||||
|
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];
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
import type { AssetType } from '../types';
|
||||||
|
import type { AssetData } from '../types/models.model';
|
||||||
interface AssetData {
|
|
||||||
ticker?: string;
|
|
||||||
currentPrice?: number;
|
|
||||||
type?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Asset {
|
export class Asset {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
import { CREDIT_RATING_SCALE } from '../../config/ScoringConfig.js';
|
import { CREDIT_RATING_SCALE } from '../config/ScoringConfig';
|
||||||
import { Asset } from './Asset.js';
|
import { Asset } from './Asset';
|
||||||
|
import type { BondData, BondMetrics } from '../types/models.model';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Bond extends Asset {
|
export class Bond extends Asset {
|
||||||
metrics: BondMetrics;
|
metrics: BondMetrics;
|
||||||
@@ -1,23 +1,5 @@
|
|||||||
import { Asset } from './Asset.js';
|
import { Asset } from './Asset';
|
||||||
|
import type { EtfData, EtfMetrics } from '../types/models.model';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Etf extends Asset {
|
export class Etf extends Asset {
|
||||||
metrics: EtfMetrics;
|
metrics: EtfMetrics;
|
||||||
@@ -1,49 +1,7 @@
|
|||||||
import { Asset } from './Asset.js';
|
import { Asset } from './Asset';
|
||||||
import type { Sector } from '../../config/constants.js';
|
import { CAP_CATEGORY, GROWTH_CATEGORY } from '../config/constants';
|
||||||
|
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
|
||||||
interface StockData {
|
import type { StockData, StockMetrics } from '../types/models.model';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Stock extends Asset {
|
export class Stock extends Asset {
|
||||||
sector: Sector;
|
sector: Sector;
|
||||||
@@ -55,9 +13,16 @@ export class Stock extends Asset {
|
|||||||
|
|
||||||
this.metrics = {
|
this.metrics = {
|
||||||
sector: this.sector,
|
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,
|
peRatio: data.peRatio ?? null,
|
||||||
pegRatio: data.pegRatio ?? null,
|
pegRatio: data.pegRatio ?? null,
|
||||||
priceToBook: data.priceToBook ?? null,
|
priceToBook: data.priceToBook ?? null,
|
||||||
|
grossMargin: data.grossMargin ?? null,
|
||||||
netProfitMargin: data.netProfitMargin ?? null,
|
netProfitMargin: data.netProfitMargin ?? null,
|
||||||
operatingMargin: data.operatingMargin ?? null,
|
operatingMargin: data.operatingMargin ?? null,
|
||||||
returnOnEquity: data.returnOnEquity ?? null,
|
returnOnEquity: data.returnOnEquity ?? null,
|
||||||
@@ -71,10 +36,51 @@ export class Stock extends Asset {
|
|||||||
beta: data.beta ?? null,
|
beta: data.beta ?? null,
|
||||||
week52High: data.week52High ?? null,
|
week52High: data.week52High ?? null,
|
||||||
week52Low: data.week52Low ?? 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,
|
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 {
|
_mapToStandardSector(data: StockData): Sector {
|
||||||
const profile = data.assetProfile ?? {};
|
const profile = data.assetProfile ?? {};
|
||||||
const industry = (profile.industry || '').toLowerCase();
|
const industry = (profile.industry || '').toLowerCase();
|
||||||
@@ -140,6 +146,8 @@ export class Stock extends Asset {
|
|||||||
getDisplayMetrics(): Record<string, string | null> {
|
getDisplayMetrics(): Record<string, string | null> {
|
||||||
const fmt = (v: number | null, dec = 1, suffix = '') =>
|
const fmt = (v: number | null, dec = 1, suffix = '') =>
|
||||||
v != null ? `${v.toFixed(dec)}${suffix}` : null;
|
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 m = this.metrics;
|
||||||
|
|
||||||
const w52pos =
|
const w52pos =
|
||||||
@@ -147,27 +155,68 @@ export class Stock extends Asset {
|
|||||||
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
|
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Analyst label: convert Yahoo's 1–5 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> = {
|
const display: Record<string, string | null> = {
|
||||||
Ticker: this.ticker,
|
Ticker: this.ticker,
|
||||||
Price: this.formatCurrency(this.currentPrice),
|
Price: this.formatCurrency(this.currentPrice),
|
||||||
Sector: this.sector,
|
Sector: this.sector,
|
||||||
|
'Cap Tier': m.capCategory,
|
||||||
|
Style: m.growthCategory,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Valuation
|
||||||
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
|
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
|
||||||
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
|
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
|
||||||
if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 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.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%');
|
||||||
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
|
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
|
||||||
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
|
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
|
||||||
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
|
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
|
||||||
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
|
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
|
||||||
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
|
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
|
||||||
|
|
||||||
|
// Risk
|
||||||
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
|
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
|
||||||
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
|
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
|
||||||
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
|
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
|
||||||
|
|
||||||
|
// 52-week movement
|
||||||
if (w52pos != null) display['52W Pos'] = w52pos;
|
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);
|
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;
|
return display;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import type { MarketContext } from '../types.js';
|
import type { MarketContext } from '../types';
|
||||||
|
|
||||||
export class FinanceReporter {
|
export class FinanceReporter {
|
||||||
render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string {
|
render(advice: unknown[], personalFinance: unknown, marketContext: MarketContext): string {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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
|
// Generates a self-contained HTML report saved to ./screener-report.html
|
||||||
// Console output shows only the signal summary — full breakdown lives here.
|
// 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
|
// 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'];
|
const base = ['Ticker', 'Price', 'Verdict', 'Score'];
|
||||||
if (type === 'STOCK') {
|
if (type === 'STOCK') {
|
||||||
const metricKeys = [
|
const metricKeys = [
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { randomUUID } from 'crypto';
|
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 {
|
private _load(): StoreData {
|
||||||
if (!existsSync(STORE_PATH)) return { calls: [] };
|
if (!existsSync(MarketCallRepository.STORE_PATH)) return { calls: [] };
|
||||||
try {
|
try {
|
||||||
return JSON.parse(readFileSync(STORE_PATH, 'utf8')) as StoreData;
|
return JSON.parse(readFileSync(MarketCallRepository.STORE_PATH, 'utf8')) as StoreData;
|
||||||
} catch {
|
} catch {
|
||||||
return { calls: [] };
|
return { calls: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _save(data: StoreData): void {
|
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 })[] {
|
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 { BondMetrics, MarketContext, ScoreResult, SanitizedBondMetrics } from '../types';
|
||||||
import type { MarketContext } from '../../types.js';
|
|
||||||
|
|
||||||
interface SanitizedBondMetrics {
|
export class BondScorer {
|
||||||
ytm: number;
|
static score(
|
||||||
duration: number;
|
|
||||||
creditRating: string;
|
|
||||||
creditRatingNumeric: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScoreOutput {
|
|
||||||
label: string;
|
|
||||||
scoreSummary: string;
|
|
||||||
audit: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BondScorer = {
|
|
||||||
score(
|
|
||||||
m: BondMetrics,
|
m: BondMetrics,
|
||||||
rules: {
|
rules: {
|
||||||
gates: Record<string, number>;
|
gates: Record<string, number>;
|
||||||
@@ -23,9 +9,9 @@ export const BondScorer = {
|
|||||||
thresholds: Record<string, number>;
|
thresholds: Record<string, number>;
|
||||||
},
|
},
|
||||||
context?: MarketContext | null,
|
context?: MarketContext | null,
|
||||||
): ScoreOutput {
|
): ScoreResult {
|
||||||
const { gates, weights, thresholds } = rules;
|
const { gates, weights, thresholds } = rules;
|
||||||
const metrics = this._sanitize(m);
|
const metrics = BondScorer._sanitize(m);
|
||||||
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
|
||||||
|
|
||||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||||
@@ -47,11 +33,11 @@ export const BondScorer = {
|
|||||||
return {
|
return {
|
||||||
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
|
||||||
scoreSummary: `Score: ${score}`,
|
scoreSummary: `Score: ${score}`,
|
||||||
audit: { breakdown },
|
audit: { passedGates: true, breakdown },
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
_sanitize(m: BondMetrics): SanitizedBondMetrics {
|
private static _sanitize(m: BondMetrics): SanitizedBondMetrics {
|
||||||
const pct = (v: unknown): number =>
|
const pct = (v: unknown): number =>
|
||||||
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
|
||||||
return {
|
return {
|
||||||
@@ -60,5 +46,5 @@ export const BondScorer = {
|
|||||||
creditRating: m.creditRating || 'BBB',
|
creditRating: m.creditRating || 'BBB',
|
||||||
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
creditRatingNumeric: m.creditRatingNumeric ?? 7,
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
};
|
}
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import type { EtfMetrics } from '../assets/Etf.js';
|
import type { EtfMetrics, ScoreResult } from '../types';
|
||||||
|
|
||||||
interface ScoreOutput {
|
export class EtfScorer {
|
||||||
label: string;
|
static score(
|
||||||
scoreSummary: string;
|
|
||||||
audit?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EtfScorer = {
|
|
||||||
score(
|
|
||||||
m: EtfMetrics,
|
m: EtfMetrics,
|
||||||
rules: {
|
rules: {
|
||||||
gates: Record<string, number>;
|
gates: Record<string, number>;
|
||||||
weights: Record<string, number>;
|
weights: Record<string, number>;
|
||||||
thresholds: Record<string, number>;
|
thresholds: Record<string, number>;
|
||||||
},
|
},
|
||||||
): ScoreOutput {
|
): ScoreResult {
|
||||||
const { gates, weights, thresholds } = rules;
|
const { gates, weights, thresholds } = rules;
|
||||||
const metrics = {
|
const metrics = {
|
||||||
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
|
||||||
@@ -24,7 +18,11 @@ export const EtfScorer = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
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> = {
|
const breakdown: Record<string, number> = {
|
||||||
@@ -46,5 +44,5 @@ export const EtfScorer = {
|
|||||||
scoreSummary: `Score: ${score}`,
|
scoreSummary: `Score: ${score}`,
|
||||||
audit: { passedGates: true, breakdown },
|
audit: { passedGates: true, breakdown },
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
};
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
);
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,39 +1,36 @@
|
|||||||
import { YahooClient } from './YahooClient.js';
|
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||||
import { REGIME } from '../config/constants.js';
|
import { REGIME } from '../config/constants';
|
||||||
import type { MarketContext, Logger } from '../types.js';
|
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,
|
sp500Price: 5000,
|
||||||
riskFreeRate: 4.5,
|
riskFreeRate: 4.5,
|
||||||
vixLevel: 20,
|
vixLevel: 20,
|
||||||
rateRegime: 'HIGH',
|
rateRegime: 'HIGH',
|
||||||
volatilityRegime: 'NORMAL',
|
volatilityRegime: 'NORMAL',
|
||||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const rateRegime = (rate: number): MarketContext['rateRegime'] =>
|
private static rateRegime(rate: number): MarketContext['rateRegime'] {
|
||||||
rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
|
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
|
||||||
|
}
|
||||||
|
|
||||||
const volRegime = (vix: number): MarketContext['volatilityRegime'] =>
|
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
|
||||||
vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
|
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
private static pe(summary: any): number | null {
|
||||||
const pe = (summary: any): number | null =>
|
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
|
||||||
summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
|
}
|
||||||
|
private client: YahooFinanceClient;
|
||||||
interface BenchmarkProviderOptions {
|
|
||||||
logger?: Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BenchmarkProvider {
|
|
||||||
private client: YahooClient;
|
|
||||||
private cache: { data: MarketContext | null; expiresAt: number };
|
private cache: { data: MarketContext | null; expiresAt: number };
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor({ logger }: BenchmarkProviderOptions = {}) {
|
constructor({ logger }: BenchmarkProviderOptions = {}) {
|
||||||
this.client = new YahooClient();
|
this.client = new YahooFinanceClient();
|
||||||
this.cache = { data: null, expiresAt: 0 };
|
this.cache = { data: null, expiresAt: 0 };
|
||||||
this.logger = logger ?? (console as unknown as Logger);
|
this.logger = logger ?? (console as unknown as Logger);
|
||||||
}
|
}
|
||||||
@@ -67,21 +64,21 @@ export class BenchmarkProvider {
|
|||||||
sp500Price,
|
sp500Price,
|
||||||
riskFreeRate,
|
riskFreeRate,
|
||||||
vixLevel,
|
vixLevel,
|
||||||
rateRegime: rateRegime(riskFreeRate),
|
rateRegime: BenchmarkProvider.rateRegime(riskFreeRate),
|
||||||
volatilityRegime: volRegime(vixLevel),
|
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
|
||||||
benchmarks: {
|
benchmarks: {
|
||||||
marketPE: pe(spy) ?? 22,
|
marketPE: BenchmarkProvider.pe(spy) ?? 22,
|
||||||
techPE: pe(xlk) ?? 30,
|
techPE: BenchmarkProvider.pe(xlk) ?? 30,
|
||||||
reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
|
reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
|
||||||
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
|
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;
|
return context;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
|
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
|
||||||
return this.cache.data ?? DEFAULTS;
|
return this.cache.data ?? BenchmarkProvider.DEFAULTS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { SECTOR, ASSET_TYPE, REGIME } from '../config/constants';
|
||||||
import type { MarketContext, AssetType } from '../types.js';
|
import type { MarketContext, AssetType, InflatedOverrides } from '../types';
|
||||||
|
|
||||||
interface InflatedOverrides {
|
|
||||||
gates: Record<string, number>;
|
|
||||||
thresholds: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MarketRegime {
|
export class MarketRegime {
|
||||||
private marketPE: number;
|
private marketPE: number;
|
||||||
+2
-33
@@ -1,38 +1,7 @@
|
|||||||
interface Transaction {
|
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../types';
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PersonalFinanceAnalyzer {
|
export class PersonalFinanceAnalyzer {
|
||||||
analyse(accounts: Account[]): FinanceAnalysis {
|
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
|
||||||
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
|
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
|
||||||
const liabilities = 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 { SIGNAL } from '../config/constants';
|
||||||
import { YahooClient } from '../market/YahooClient.js';
|
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||||
import type { PortfolioHolding, Signal, ScreenerResult, AssetResult } from '../types.js';
|
import type {
|
||||||
|
PortfolioHolding,
|
||||||
interface PositionCalc {
|
Signal,
|
||||||
totalCost: string;
|
ScreenerResult,
|
||||||
marketValue: string | null;
|
AssetResult,
|
||||||
gainLossPct: string | null;
|
AdviceRow,
|
||||||
}
|
PositionCalc,
|
||||||
|
AdviceOutput,
|
||||||
interface AdviceOutput {
|
} from '../types';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PortfolioAdvisor {
|
export class PortfolioAdvisor {
|
||||||
private client: YahooClient;
|
private client: YahooFinanceClient;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = new YahooClient();
|
this.client = new YahooFinanceClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
async advise(
|
async advise(
|
||||||
@@ -1,16 +1,10 @@
|
|||||||
import { ScoringRules } from '../config/ScoringConfig.js';
|
import { ScoringRules } from '../config/ScoringConfig';
|
||||||
import { MarketRegime } from '../market/MarketRegime.js';
|
import { MarketRegime } from './MarketRegime';
|
||||||
import { SCORE_MODE } from '../config/constants.js';
|
import { SCORE_MODE } from '../config/constants';
|
||||||
import type { AssetType, MarketContext } from '../types.js';
|
import type { AssetType, MarketContext, RuleSet } from '../types';
|
||||||
|
|
||||||
interface RuleSet {
|
export class RuleMerger {
|
||||||
gates: Record<string, number>;
|
static getRulesForAsset(
|
||||||
weights: Record<string, number>;
|
|
||||||
thresholds: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RuleMerger = {
|
|
||||||
getRulesForAsset(
|
|
||||||
type: AssetType,
|
type: AssetType,
|
||||||
metrics: { sector?: string },
|
metrics: { sector?: string },
|
||||||
marketContext: Partial<MarketContext> = {},
|
marketContext: Partial<MarketContext> = {},
|
||||||
@@ -45,5 +39,5 @@ export const RuleMerger = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
},
|
}
|
||||||
};
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,135 +1,4 @@
|
|||||||
// ── Shared domain types ───────────────────────────────────────────────────
|
// ── Barrel re-export ──────────────────────────────────────────────────────
|
||||||
// Single source of truth for all cross-cutting interfaces and type aliases.
|
// All types now live in server/types/*.model.ts — import from there directly
|
||||||
// Server classes import from here; UI imports from $lib/types.ts (mirrored subset).
|
// for clarity, or from here for convenience (existing imports still work).
|
||||||
|
export type * from './types/index';
|
||||||
// ── 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Logger interface ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
write: (msg: string) => void;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
warn: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
* Shared server-side logger utilities.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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.
|
// 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.
|
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
||||||
@@ -10,7 +11,8 @@ const rules = {
|
|||||||
weights: { yieldSpread: 3, duration: 2 },
|
weights: { yieldSpread: 3, duration: 2 },
|
||||||
thresholds: { minSpread: 1.0, maxDuration: 10 },
|
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', () => {
|
test('rejects bond below investment-grade floor', () => {
|
||||||
const result = BondScorer.score(
|
const result = BondScorer.score(
|
||||||
@@ -38,7 +40,7 @@ test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %
|
|||||||
rules,
|
rules,
|
||||||
ctx,
|
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', () => {
|
test('fails spread when yield barely above risk-free', () => {
|
||||||
@@ -48,7 +50,7 @@ test('fails spread when yield barely above risk-free', () => {
|
|||||||
rules,
|
rules,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
assert.equal(result.audit.breakdown.spread, -2);
|
assert.equal(result.audit.breakdown!.spread, -2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('penalises long duration', () => {
|
test('penalises long duration', () => {
|
||||||
@@ -57,5 +59,5 @@ test('penalises long duration', () => {
|
|||||||
rules,
|
rules,
|
||||||
ctx,
|
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 { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mapToStandardFormat } from '../server/screener/DataMapper.js';
|
import { DataMapper } from '../server/services/DataMapper';
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
||||||
@@ -27,13 +27,13 @@ const base = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('maps EQUITY quote type to STOCK', () => {
|
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.type, 'STOCK');
|
||||||
assert.equal(result.ticker, 'AAPL');
|
assert.equal(result.ticker, 'AAPL');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('computes PEG from trailingPE / earningsGrowth when Yahoo returns null', () => {
|
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%
|
const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12%
|
||||||
assert.equal(result.pegRatio, expected);
|
assert.equal(result.pegRatio, expected);
|
||||||
});
|
});
|
||||||
@@ -43,12 +43,12 @@ test('uses Yahoo pegRatio when available', () => {
|
|||||||
...base,
|
...base,
|
||||||
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
|
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('AAPL', summary);
|
const result = DataMapper.mapToStandardFormat('AAPL', summary);
|
||||||
assert.equal(result.pegRatio, 1.5);
|
assert.equal(result.pegRatio, 1.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('debtToEquity is divided by 100', () => {
|
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
|
assert.equal(result.debtToEquity, 1.5); // 150 / 100
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ test('maps ETF quoteType to ETF', () => {
|
|||||||
price: { ...base.price, quoteType: 'ETF' },
|
price: { ...base.price, quoteType: 'ETF' },
|
||||||
assetProfile: { category: 'Large Blend' },
|
assetProfile: { category: 'Large Blend' },
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('VOO', etfSummary);
|
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
||||||
assert.equal(result.type, 'ETF');
|
assert.equal(result.type, 'ETF');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,19 +68,19 @@ test('classifies bond ETF from category keyword', () => {
|
|||||||
price: { ...base.price, quoteType: 'ETF' },
|
price: { ...base.price, quoteType: 'ETF' },
|
||||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
assetProfile: { category: 'Intermediate-Term Bond' },
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('BND', bondSummary);
|
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
||||||
assert.equal(result.type, 'BOND');
|
assert.equal(result.type, 'BOND');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('FCF yield is computed when data available', () => {
|
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.notEqual(result.fcfYield, null);
|
||||||
assert(result.fcfYield > 0);
|
assert((result.fcfYield as number) > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('peRatio prefers trailingPE over forwardPE', () => {
|
test('peRatio prefers trailingPE over forwardPE', () => {
|
||||||
// trailingPE=30 in summaryDetail, forwardPE=28 in defaultKeyStatistics
|
// 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
|
assert.equal(result.peRatio, 30); // trailing should win
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,9 +89,9 @@ test('negative FCF yield is preserved, not nulled', () => {
|
|||||||
...base,
|
...base,
|
||||||
financialData: { ...base.financialData, freeCashflow: -2e9 },
|
financialData: { ...base.financialData, freeCashflow: -2e9 },
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('AAPL', negativeFcf);
|
const result = DataMapper.mapToStandardFormat('AAPL', negativeFcf);
|
||||||
assert.notEqual(result.fcfYield, null);
|
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', () => {
|
test('ETF maps volume from summaryDetail', () => {
|
||||||
@@ -107,7 +107,7 @@ test('ETF maps volume from summaryDetail', () => {
|
|||||||
},
|
},
|
||||||
defaultKeyStatistics: { fiveYearAverageReturn: 0.12 },
|
defaultKeyStatistics: { fiveYearAverageReturn: 0.12 },
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('VOO', etfSummary);
|
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
||||||
assert.equal(result.volume, 5000000);
|
assert.equal(result.volume, 5000000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ test('bond duration inferred from category — intermediate maps to 5y', () => {
|
|||||||
summaryDetail: { yield: 0.045 },
|
summaryDetail: { yield: 0.045 },
|
||||||
defaultKeyStatistics: {},
|
defaultKeyStatistics: {},
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('BND', bondSummary);
|
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
||||||
assert.equal(result.duration, 5);
|
assert.equal(result.duration, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ test('bond duration inferred from category — short-term maps to 2y', () => {
|
|||||||
summaryDetail: { yield: 0.05 },
|
summaryDetail: { yield: 0.05 },
|
||||||
defaultKeyStatistics: {},
|
defaultKeyStatistics: {},
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('SHY', bondSummary);
|
const result = DataMapper.mapToStandardFormat('SHY', bondSummary);
|
||||||
assert.equal(result.duration, 2);
|
assert.equal(result.duration, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ test('metrics are null (not 0) when data missing', () => {
|
|||||||
summaryDetail: {},
|
summaryDetail: {},
|
||||||
assetProfile: {},
|
assetProfile: {},
|
||||||
};
|
};
|
||||||
const result = mapToStandardFormat('X', sparse);
|
const result = DataMapper.mapToStandardFormat('X', sparse);
|
||||||
assert.equal(result.pegRatio, null);
|
assert.equal(result.pegRatio, null);
|
||||||
assert.equal(result.quickRatio, null);
|
assert.equal(result.quickRatio, null);
|
||||||
});
|
});
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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).
|
// we don't instantiate LLMAnalyst (requires Anthropic SDK + API key).
|
||||||
// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim()
|
// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim()
|
||||||
|
|
||||||
function stripFences(raw) {
|
function stripFences(raw: string): string {
|
||||||
return raw
|
return raw
|
||||||
.replace(/^```(?:json)?\s*/i, '')
|
.replace(/^```(?:json)?\s*/i, '')
|
||||||
.replace(/```\s*$/i, '')
|
.replace(/```\s*$/i, '')
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { MarketRegime } from '../server/market/MarketRegime.js';
|
import { MarketRegime } from '../server/services/MarketRegime';
|
||||||
import { SECTOR, ASSET_TYPE } from '../server/config/constants.js';
|
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', () => {
|
test('stock inflated P/E = marketPE × 1.5', () => {
|
||||||
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
||||||
@@ -1,50 +1,61 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
|
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
|
||||||
import { SIGNAL } from '../server/config/constants.js';
|
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', () => {
|
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.gainLossPct, '50.0');
|
||||||
assert.equal(pos.marketValue, '1500.00');
|
assert.equal(pos.marketValue, '1500.00');
|
||||||
assert.equal(pos.totalCost, '1000.00');
|
assert.equal(pos.totalCost, '1000.00');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_position: returns null gainLoss when price unavailable', () => {
|
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.gainLossPct, null);
|
||||||
assert.equal(pos.marketValue, null);
|
assert.equal(pos.marketValue, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Strong Buy → Hold & Add', () => {
|
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');
|
assert.equal(action, '🟢 Hold & Add');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
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)');
|
assert.equal(action, '🔴 Sell (Cut Loss)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Avoid + profit → Sell (Take Profits)', () => {
|
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)');
|
assert.equal(action, '🔴 Sell (Take Profits)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
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');
|
assert.equal(action, '🟠 Reduce Position');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_cryptoAdvice: no price → No price data', () => {
|
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');
|
assert.equal(action, '⚪ No price data');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
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');
|
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)' },
|
fundamental: { label: '🟢 BUY (High Conviction)' },
|
||||||
};
|
};
|
||||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
||||||
const holding = {
|
const holding: PortfolioHolding = {
|
||||||
ticker: 'BRK.B',
|
ticker: 'BRK.B',
|
||||||
shares: 1,
|
shares: 1,
|
||||||
costBasis: 400,
|
costBasis: 400,
|
||||||
@@ -79,7 +90,7 @@ test('advise: BRK.B screener result matches BRK-B holding', async () => {
|
|||||||
fundamental: { label: '🟢 BUY (High Conviction)' },
|
fundamental: { label: '🟢 BUY (High Conviction)' },
|
||||||
};
|
};
|
||||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
||||||
const holding = {
|
const holding: PortfolioHolding = {
|
||||||
ticker: 'BRK-B',
|
ticker: 'BRK-B',
|
||||||
shares: 1,
|
shares: 1,
|
||||||
costBasis: 400,
|
costBasis: 400,
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { RuleMerger } from '../server/screener/RuleMerger.js';
|
import { RuleMerger } from '../server/services/RuleMerger';
|
||||||
import { SCORE_MODE } from '../server/config/constants.js';
|
import { SCORE_MODE } from '../server/config/constants';
|
||||||
|
import type { MarketContext } from '../server/types';
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
||||||
};
|
} as Partial<MarketContext>;
|
||||||
|
|
||||||
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
|
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
|
||||||
const rules = RuleMerger.getRulesForAsset(
|
const rules = RuleMerger.getRulesForAsset(
|
||||||
'STOCK',
|
'STOCK',
|
||||||
{ sector: 'GENERAL' },
|
{ sector: 'GENERAL' },
|
||||||
ctx,
|
ctx as MarketContext,
|
||||||
SCORE_MODE.FUNDAMENTAL,
|
SCORE_MODE.FUNDAMENTAL,
|
||||||
);
|
);
|
||||||
assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x
|
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(
|
const rules = RuleMerger.getRulesForAsset(
|
||||||
'STOCK',
|
'STOCK',
|
||||||
{ sector: 'GENERAL' },
|
{ sector: 'GENERAL' },
|
||||||
ctx,
|
ctx as MarketContext,
|
||||||
SCORE_MODE.INFLATED,
|
SCORE_MODE.INFLATED,
|
||||||
);
|
);
|
||||||
assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37
|
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(
|
const rules = RuleMerger.getRulesForAsset(
|
||||||
'STOCK',
|
'STOCK',
|
||||||
{ sector: 'TECHNOLOGY' },
|
{ sector: 'TECHNOLOGY' },
|
||||||
ctx,
|
ctx as MarketContext,
|
||||||
SCORE_MODE.INFLATED,
|
SCORE_MODE.INFLATED,
|
||||||
);
|
);
|
||||||
assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42
|
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(
|
const rules = RuleMerger.getRulesForAsset(
|
||||||
'STOCK',
|
'STOCK',
|
||||||
{ sector: 'REIT' },
|
{ sector: 'REIT' },
|
||||||
ctx,
|
ctx as MarketContext,
|
||||||
SCORE_MODE.FUNDAMENTAL,
|
SCORE_MODE.FUNDAMENTAL,
|
||||||
);
|
);
|
||||||
assert.equal(rules.gates.maxPERatio, 9999);
|
assert.equal(rules.gates.maxPERatio, 9999);
|
||||||
@@ -55,12 +56,15 @@ test('SECTOR_OVERRIDE is deleted from returned rules', () => {
|
|||||||
const rules = RuleMerger.getRulesForAsset(
|
const rules = RuleMerger.getRulesForAsset(
|
||||||
'STOCK',
|
'STOCK',
|
||||||
{ sector: 'GENERAL' },
|
{ sector: 'GENERAL' },
|
||||||
ctx,
|
ctx as MarketContext,
|
||||||
SCORE_MODE.FUNDAMENTAL,
|
SCORE_MODE.FUNDAMENTAL,
|
||||||
);
|
) as unknown as Record<string, unknown>;
|
||||||
assert.equal(rules.SECTOR_OVERRIDE, undefined);
|
assert.equal(rules.SECTOR_OVERRIDE, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('throws for unknown asset type', () => {
|
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 { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
test('CREDIT_RATING_SCALE covers full spectrum', () => {
|
||||||
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
|
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', () => {
|
test('REIT sector override zeroes out irrelevant weights', () => {
|
||||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
||||||
assert.equal(reit.weights.margin, 0);
|
assert.equal(reit.weights!.margin, 0);
|
||||||
assert.equal(reit.weights.peg, 0);
|
assert.equal(reit.weights!.peg, 0);
|
||||||
assert.equal(reit.weights.revenue, 0);
|
assert.equal(reit.weights!.revenue, 0);
|
||||||
assert.equal(reit.weights.yield, 5);
|
assert.equal(reit.weights!.yield, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('REIT gates disable P/E and PEG', () => {
|
test('REIT gates disable P/E and PEG', () => {
|
||||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT;
|
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
||||||
assert.equal(reit.gates.maxPERatio, 9999);
|
assert.equal(reit.gates!.maxPERatio, 9999);
|
||||||
assert.equal(reit.gates.maxPegGate, 9999);
|
assert.equal(reit.gates!.maxPegGate, 9999);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('TECHNOLOGY gates are realistic for mega-cap', () => {
|
test('TECHNOLOGY gates are realistic for mega-cap', () => {
|
||||||
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY;
|
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY!;
|
||||||
assert.equal(tech.gates.maxDebtToEquity, 2.0);
|
assert.equal(tech.gates!.maxDebtToEquity, 2.0);
|
||||||
assert.equal(tech.gates.minQuickRatio, 0.8);
|
assert.equal(tech.gates!.minQuickRatio, 0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('BOND requires investment-grade floor (BBB = 7)', () => {
|
test('BOND requires investment-grade floor (BBB = 7)', () => {
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 = {
|
const baseRules = {
|
||||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
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,
|
peRatio: 15,
|
||||||
pegRatio: 1.2,
|
pegRatio: 1.2,
|
||||||
debtToEquity: 1.0,
|
debtToEquity: 1.0,
|
||||||
@@ -63,8 +106,8 @@ test('high-conviction BUY on strong metrics', () => {
|
|||||||
test('audit breakdown contains scored factors', () => {
|
test('audit breakdown contains scored factors', () => {
|
||||||
const result = StockScorer.score(pass, baseRules);
|
const result = StockScorer.score(pass, baseRules);
|
||||||
assert(result.audit.passedGates);
|
assert(result.audit.passedGates);
|
||||||
assert(result.audit.breakdown.roe != null);
|
assert(result.audit.breakdown!.roe != null);
|
||||||
assert(result.audit.breakdown.margin != null);
|
assert(result.audit.breakdown!.margin != null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('beta > 1.5 surfaces as risk flag', () => {
|
test('beta > 1.5 surfaces as risk flag', () => {
|
||||||
+1
-1
@@ -9,6 +9,6 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["server/**/*", "bin/**/*"],
|
"include": ["server/**/*", "bin/**/*", "tests/**/*", "scripts/**/*"],
|
||||||
"exclude": ["node_modules", "ui"]
|
"exclude": ["node_modules", "ui"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,20 @@
|
|||||||
<div class="sb-list">
|
<div class="sb-list">
|
||||||
{#each a.relatedTickers ?? [] as rt}
|
{#each a.relatedTickers ?? [] as rt}
|
||||||
<div class="sb-item">
|
<div class="sb-item">
|
||||||
|
<div class="sb-ticker-row">
|
||||||
<span class="sb-name ticker">{rt.ticker}</span>
|
<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>
|
<span class="sb-reason">{rt.reason}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -67,133 +80,3 @@
|
|||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/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>
|
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
|
|
||||||
// Mode state is self-contained — each table independently tracks inflated vs fundamental
|
// Mode state is self-contained — each table independently tracks inflated vs fundamental
|
||||||
let mode = $state('inflated');
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
@@ -54,9 +62,27 @@
|
|||||||
<th>Verdict</th>
|
<th>Verdict</th>
|
||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
{#if type === 'STOCK'}
|
{#if type === 'STOCK'}
|
||||||
<th>Sector</th>
|
<!-- Classification -->
|
||||||
<th>P/E</th><th>PEG</th><th>ROE%</th>
|
<th title="Market cap tier">Cap</th>
|
||||||
<th>OpMgn%</th><th>FCF%</th><th>D/E</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>
|
<th>Flags</th>
|
||||||
{:else if type === 'ETF'}
|
{:else if type === 'ETF'}
|
||||||
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
|
<th>Expense</th><th>Yield</th><th>AUM</th><th>5Y Ret</th>
|
||||||
@@ -68,20 +94,34 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each sorted(rows) as r}
|
{#each sorted(rows) as r}
|
||||||
{@const m = r.asset.displayMetrics ?? {}}
|
{@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)}>
|
<tr class="data-row" data-signal={sigOrd(r.signal)}>
|
||||||
<td class="ticker">{r.asset.ticker}</td>
|
<td class="ticker">{r.asset.ticker}</td>
|
||||||
<td class="num">{m.Price ?? '—'}</td>
|
<td class="num">{m.Price ?? '—'}</td>
|
||||||
<td><VerdictPill label={v.label} /></td>
|
<td><VerdictPill label={v.label} /></td>
|
||||||
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
|
<td class="score-cell" title={v.scoreSummary}>{v.scoreSummary}</td>
|
||||||
{#if type === 'STOCK'}
|
{#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['P/E'] ?? '—'}</td>
|
||||||
<td class="num">{m['PEG'] ?? '—'}</td>
|
<td class="num">{m['PEG'] ?? '—'}</td>
|
||||||
|
<!-- Quality -->
|
||||||
|
<td class="num">{m['GrossM%'] ?? '—'}</td>
|
||||||
<td class="num">{m['ROE%'] ?? '—'}</td>
|
<td class="num">{m['ROE%'] ?? '—'}</td>
|
||||||
<td class="num">{m['OpMgn%'] ?? '—'}</td>
|
<td class="num">{m['OpMgn%'] ?? '—'}</td>
|
||||||
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
|
<td class="num">{m['FCF Yld%'] ?? '—'}</td>
|
||||||
|
<!-- Risk -->
|
||||||
<td class="num">{m['D/E'] ?? '—'}</td>
|
<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">
|
<td class="flags">
|
||||||
{#each v.audit?.riskFlags ?? [] as flag}
|
{#each v.audit?.riskFlags ?? [] as flag}
|
||||||
<span class="flag">⚠ {flag}</span>
|
<span class="flag">⚠ {flag}</span>
|
||||||
@@ -105,15 +145,32 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Score cell — truncates long gate summaries, full text via title tooltip */
|
/* Score cell — truncates long gate summaries, tooltip shows full text */
|
||||||
.score-cell {
|
.score-cell {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flags { display: flex; flex-direction: column; gap: 2px; }
|
/* Classification tags */
|
||||||
.flag { color: var(--orange); font-size: var(--fs-sm); }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
{ label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' },
|
{ label: '10Y', value: ctx.riskFreeRate?.toFixed(2) + '%' },
|
||||||
{ label: 'VIX', value: ctx.vixLevel?.toFixed(1) },
|
{ label: 'VIX', value: ctx.vixLevel?.toFixed(1) },
|
||||||
{ label: 'S&P', value: ctx.sp500Price?.toLocaleString() },
|
{ label: 'S&P', value: ctx.sp500Price?.toLocaleString() },
|
||||||
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE?.toFixed(1)) },
|
{ label: 'S&P P/E', value: fmtPE(ctx.benchmarks?.marketPE) },
|
||||||
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE?.toFixed(1)) },
|
{ label: 'Tech P/E', value: fmtPE(ctx.benchmarks?.techPE) },
|
||||||
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' },
|
{ label: 'REIT Yld', value: ctx.benchmarks?.reitYield?.toFixed(2) + '%' },
|
||||||
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' },
|
{ label: 'IG Sprd', value: ctx.benchmarks?.igSpread?.toFixed(2) + '%' },
|
||||||
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime },
|
{ label: 'Rates', value: ctx.rateRegime, regime: ctx.rateRegime },
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-chip {
|
.ctx-chip {
|
||||||
|
|||||||
+6
-127
@@ -1,127 +1,6 @@
|
|||||||
import type {
|
// ── Backward-compat shim ──────────────────────────────────────────────────
|
||||||
ScreenerResult,
|
// All API functions now live in $lib/api/*.ts domain modules.
|
||||||
MarketContext,
|
// This file re-exports everything so existing import sites are unaffected.
|
||||||
MarketCall,
|
// New code should import directly from the domain module:
|
||||||
CalendarEvent,
|
// import { screenTickers } from '$lib/api/screener.js'
|
||||||
CatalystStory,
|
export * from './api/index.js';
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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
@@ -1,88 +1,100 @@
|
|||||||
// ── Shared UI types ───────────────────────────────────────────────────────
|
// ── UI type layer ─────────────────────────────────────────────────────────
|
||||||
// Mirror of the server's domain types, used across Svelte components.
|
// 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 =
|
// ── Re-export shared domain types ────────────────────────────────────────
|
||||||
| '✅ Strong Buy'
|
export type {
|
||||||
| '⚡ Momentum'
|
Signal,
|
||||||
| '⚠️ Speculation'
|
AssetType,
|
||||||
| '🔄 Neutral'
|
ScoreMode,
|
||||||
| '❌ Avoid';
|
ScoreResult,
|
||||||
|
AssetResult,
|
||||||
|
ScreenerResult,
|
||||||
|
} from '$types/asset.model.js';
|
||||||
|
|
||||||
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
|
export type {
|
||||||
export type ScoreMode = 'inflated' | 'fundamental';
|
RateRegime,
|
||||||
|
VolatilityRegime,
|
||||||
|
Benchmarks,
|
||||||
|
MarketContext,
|
||||||
|
} from '$types/market.model.js';
|
||||||
|
|
||||||
export interface Benchmarks {
|
export type { HoldingType, PortfolioHolding, PortfolioAdvice } from '$types/portfolio.model.js';
|
||||||
marketPE: number | null;
|
|
||||||
techPE: number | null;
|
|
||||||
reitYield: number | null;
|
|
||||||
igSpread: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarketContext {
|
export type { TickerSnapshot, MarketCall } from '$types/calls.model.js';
|
||||||
sp500Price: number | null;
|
|
||||||
riskFreeRate: number | null;
|
|
||||||
vixLevel: number | null;
|
|
||||||
rateRegime: 'HIGH' | 'NORMAL' | 'LOW';
|
|
||||||
volatilityRegime: 'HIGH' | 'NORMAL' | 'LOW';
|
|
||||||
benchmarks: Benchmarks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScoreResult {
|
export type { LLMAnalysis, CatalystStory, CalendarEvent } from '$types/finance.model.js';
|
||||||
label: string;
|
|
||||||
score: number;
|
|
||||||
scoreSummary: string;
|
|
||||||
audit: {
|
|
||||||
riskFlags?: string[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── 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 {
|
export interface AssetDisplayMetrics {
|
||||||
|
// ── Common ──────────────────────────────────────────────────────────
|
||||||
Price?: string;
|
Price?: string;
|
||||||
|
|
||||||
|
// ── Stock: classification ────────────────────────────────────────────
|
||||||
Sector?: string;
|
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;
|
'P/E'?: string;
|
||||||
PEG?: string;
|
PEG?: string;
|
||||||
|
'P/B'?: string;
|
||||||
|
|
||||||
|
// ── Stock: quality ───────────────────────────────────────────────────
|
||||||
|
'GrossM%'?: string; // gross margin — key for tech/software moat
|
||||||
'ROE%'?: string;
|
'ROE%'?: string;
|
||||||
'OpMgn%'?: string;
|
'OpMgn%'?: string;
|
||||||
|
'NetMgn%'?: string;
|
||||||
'FCF Yld%'?: string;
|
'FCF Yld%'?: string;
|
||||||
|
'Div%'?: string;
|
||||||
|
|
||||||
|
// ── Stock: risk ───────────────────────────────────────────────────────
|
||||||
'D/E'?: string;
|
'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;
|
'Exp Ratio%'?: string;
|
||||||
'Yield%'?: string;
|
'Yield%'?: string;
|
||||||
AUM?: string;
|
AUM?: string;
|
||||||
'5Y Return%'?: string;
|
'5Y Return%'?: string;
|
||||||
|
|
||||||
|
// ── Bond ──────────────────────────────────────────────────────────────
|
||||||
'YTM%'?: string;
|
'YTM%'?: string;
|
||||||
Duration?: string;
|
Duration?: string;
|
||||||
Rating?: string;
|
Rating?: string;
|
||||||
|
|
||||||
[key: string]: string | null | undefined;
|
[key: string]: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetResult {
|
/** State object for the LLM analysis slide-over sidebar. */
|
||||||
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 }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarState {
|
export interface SidebarState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -91,49 +103,68 @@ export interface SidebarState {
|
|||||||
error: string | null;
|
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;
|
ticker: string;
|
||||||
shares: number;
|
shares: number;
|
||||||
costBasis: number;
|
costBasis: number;
|
||||||
source: string;
|
|
||||||
type: 'stock' | 'etf' | 'bond' | 'crypto';
|
type: 'stock' | 'etf' | 'bond' | 'crypto';
|
||||||
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TickerSnapshot {
|
interface SimpleFINAccount {
|
||||||
price: number | null;
|
name: string;
|
||||||
signal: Signal | null;
|
type: string;
|
||||||
|
org: string;
|
||||||
|
balance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketCall {
|
interface CategoryBreakdown {
|
||||||
id: string;
|
category: string;
|
||||||
title: string;
|
amount: number;
|
||||||
quarter: string;
|
pct: number;
|
||||||
date: string;
|
|
||||||
thesis: string;
|
|
||||||
tickers: string[];
|
|
||||||
snapshot: Record<string, TickerSnapshot>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CalendarEvent {
|
/** Personal finance summary from SimpleFIN. */
|
||||||
ticker: string;
|
export interface PersonalFinance {
|
||||||
type: 'earnings' | 'dividend';
|
netWorth: number;
|
||||||
date: string;
|
totalAssets: number;
|
||||||
[key: string]: unknown;
|
totalLiabilities: number;
|
||||||
}
|
totalCash: number;
|
||||||
|
totalInvestments: number;
|
||||||
export interface CatalystStory {
|
totalIncome: number;
|
||||||
title: string;
|
totalSpend: number;
|
||||||
link: string;
|
cashPct: number;
|
||||||
publisher: string;
|
investPct: number;
|
||||||
publishedAt: string;
|
savingsRate: string | null;
|
||||||
relatedTickers: string[];
|
accounts: SimpleFINAccount[];
|
||||||
}
|
categoryBreakdown: CategoryBreakdown[];
|
||||||
|
|
||||||
export interface PortfolioAdvice {
|
|
||||||
ticker: string;
|
|
||||||
action: 'hold' | 'sell' | 'add' | 'watch';
|
|
||||||
reason: string;
|
|
||||||
signal: Signal | null;
|
|
||||||
currentPrice: number | null;
|
|
||||||
gainLossPct: number | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-93
@@ -1,80 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { screenTickers, analyzeTickers } from '$lib/api.js';
|
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||||
import { sigOrd, sorted, verdictShort, vClass } from '$lib/utils.js';
|
|
||||||
import SignalBadge from '$lib/SignalBadge.svelte';
|
import SignalBadge from '$lib/SignalBadge.svelte';
|
||||||
import Spinner from '$lib/Spinner.svelte';
|
import Spinner from '$lib/Spinner.svelte';
|
||||||
import VerdictPill from '$lib/VerdictPill.svelte';
|
import VerdictPill from '$lib/VerdictPill.svelte';
|
||||||
import MarketContextStrip from '$lib/MarketContextStrip.svelte';
|
import MarketContextStrip from '$lib/MarketContextStrip.svelte';
|
||||||
import AssetTable from '$lib/AssetTable.svelte';
|
import AssetTable from '$lib/AssetTable.svelte';
|
||||||
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
|
import AnalysisSidebar from '$lib/AnalysisSidebar.svelte';
|
||||||
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
|
|
||||||
|
|
||||||
interface PageData { results: ScreenerResult; catalystInput: string }
|
const s = screenerStore;
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
let input: string = $state(data.catalystInput);
|
let { data: _data } = $props();
|
||||||
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);
|
|
||||||
|
|
||||||
// ── LLM Analysis sidebar ────────────────────────────────────────────────
|
// Pure UI state — not shared, kept local
|
||||||
let sidebar: SidebarState = $state({ open: false, loading: false, analysis: null, type: null, error: null });
|
let searchOpen = $state(false);
|
||||||
|
|
||||||
async function runTabAnalysis(type: AssetType): Promise<void> {
|
// Boot — fetch catalysts + screen on mount
|
||||||
const tickers = (results?.[type] ?? []).map((r) => r.asset.ticker);
|
let _booted = false;
|
||||||
if (!tickers.length) return;
|
$effect(() => {
|
||||||
sidebar = { open: true, loading: true, analysis: null, type, error: null };
|
if (_booted) return;
|
||||||
try {
|
_booted = true;
|
||||||
const res = await analyzeTickers(tickers);
|
s.reloadCatalysts();
|
||||||
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])
|
|
||||||
: []);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
@@ -82,8 +28,8 @@
|
|||||||
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
|
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-top">
|
<div class="toolbar-top">
|
||||||
<button onclick={reloadCatalysts} disabled={loading || loadingCats} class="btn-catalyst">
|
<button onclick={() => s.reloadCatalysts()} disabled={s.loading || s.loadingCats} class="btn-catalyst">
|
||||||
{#if loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
|
{#if s.loadingCats}<Spinner size="sm" />{:else}📰 Today Market{/if}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => searchOpen = !searchOpen}
|
onclick={() => searchOpen = !searchOpen}
|
||||||
@@ -92,43 +38,45 @@
|
|||||||
>
|
>
|
||||||
🔍 {searchOpen ? 'Hide search' : 'Search tickers'}
|
🔍 {searchOpen ? 'Hide search' : 'Search tickers'}
|
||||||
</button>
|
</button>
|
||||||
{#if screenedAt}
|
{#if s.screenedAt}
|
||||||
<span class="screened-at">Last screened {screenedAt}</span>
|
<span class="screened-at">Last screened {s.screenedAt}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if searchOpen}
|
{#if searchOpen}
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<input
|
<input
|
||||||
bind:value={input}
|
bind:value={s.input}
|
||||||
placeholder="AAPL, MSFT, VOO …"
|
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">
|
<button onclick={() => s.screen()} disabled={s.loading || s.loadingCats} class="btn-screen">
|
||||||
{#if loading}<Spinner size="sm" />{:else}Screen{/if}
|
{#if s.loading}<Spinner size="sm" />{:else}Screen{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if s.ctx}
|
||||||
|
<MarketContextStrip ctx={s.ctx} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if s.error}
|
||||||
<div class="error-banner">⚠ {error}</div>
|
<div class="error-banner">⚠ {s.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if loading || loadingCats}
|
{#if s.loading || s.loadingCats}
|
||||||
<div class="loading-area">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ctx}
|
{#if s.results && !s.loading && !s.loadingCats}
|
||||||
<MarketContextStrip {ctx} />
|
|
||||||
|
|
||||||
<!-- ── Signal Summary ───────────────────────────────────────────── -->
|
<!-- ── Signal Summary ───────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Signal Summary</h2>
|
<h2>Signal Summary</h2>
|
||||||
<span class="count">{allAssets.length} assets</span>
|
<span class="count">{s.allAssets.length} assets</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -139,16 +87,21 @@
|
|||||||
<th>Signal</th>
|
<th>Signal</th>
|
||||||
<th>Mkt-Adjusted</th>
|
<th>Mkt-Adjusted</th>
|
||||||
<th>Fundamental</th>
|
<th>Fundamental</th>
|
||||||
|
<th title="Market cap tier (stocks only)">Cap</th>
|
||||||
|
<th title="Growth / style classification (stocks only)">Style</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each allAssets as r}
|
{#each s.allAssets as r}
|
||||||
|
{@const dm = r.asset.displayMetrics ?? {}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="ticker">{r.asset.ticker}</td>
|
<td class="ticker">{r.asset.ticker}</td>
|
||||||
<td><span class="tag">{r.asset.type}</span></td>
|
<td><span class="tag">{r.asset.type}</span></td>
|
||||||
<td><SignalBadge signal={r.signal} /></td>
|
<td><SignalBadge signal={r.signal} /></td>
|
||||||
<td><VerdictPill label={r.inflated.label} /></td>
|
<td><VerdictPill label={r.inflated.label} /></td>
|
||||||
<td><VerdictPill label={r.fundamental.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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -158,22 +111,22 @@
|
|||||||
|
|
||||||
<!-- ── Per-type detail tables ────────────────────────────────────── -->
|
<!-- ── Per-type detail tables ────────────────────────────────────── -->
|
||||||
{#each (['STOCK', 'ETF', 'BOND'] as const) as type}
|
{#each (['STOCK', 'ETF', 'BOND'] as const) as type}
|
||||||
{#if results[type]?.length}
|
{#if s.results[type]?.length}
|
||||||
<AssetTable
|
<AssetTable
|
||||||
{type}
|
{type}
|
||||||
rows={results[type]}
|
rows={s.results[type]}
|
||||||
analyzeLoading={sidebar.loading && sidebar.type === type}
|
analyzeLoading={s.sidebar.loading && s.sidebar.type === type}
|
||||||
onAnalyze={() => runTabAnalysis(type)}
|
onAnalyze={() => s.runTabAnalysis(type)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- ── Failed tickers ────────────────────────────────────────────── -->
|
<!-- ── Failed tickers ────────────────────────────────────────────── -->
|
||||||
{#if results.ERROR?.length}
|
{#if s.results.ERROR?.length}
|
||||||
<section class="section">
|
<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">
|
<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>
|
<div class="error-item"><span class="ticker">{e.ticker}</span> {e.message}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -182,12 +135,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnalysisSidebar {sidebar} onClose={() => sidebar = { ...sidebar, open: false }} />
|
<AnalysisSidebar sidebar={s.sidebar} onClose={() => s.closeSidebar()} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { max-width: 1400px; padding-bottom: 60px; }
|
.page { max-width: 1400px; padding-bottom: 60px; }
|
||||||
|
|
||||||
/* ── Toolbar ─────────────────────────────────────────────────────── */
|
|
||||||
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
|
.toolbar { margin-bottom: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
.toolbar-top { display: flex; align-items: center; gap: 8px; }
|
.toolbar-top { display: flex; align-items: center; gap: 8px; }
|
||||||
.search-row { display: flex; gap: 8px; align-items: center; }
|
.search-row { display: flex; gap: 8px; align-items: center; }
|
||||||
@@ -225,7 +177,8 @@
|
|||||||
color: var(--text-dimmer);
|
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-list { padding: 12px var(--space-xl); display: flex; flex-direction: column; gap: 6px; }
|
||||||
.error-item { color: var(--text-dim); font-size: 12px; }
|
.error-item { color: var(--text-dim); font-size: 12px; }
|
||||||
.error-item :global(.ticker) { color: var(--red); font-weight: 700; margin-right: 8px; }
|
.error-item :global(.ticker) { color: var(--red); font-weight: 700; margin-right: 8px; }
|
||||||
|
|||||||
+5
-12
@@ -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 ssr = false;
|
||||||
|
|
||||||
export const load: PageLoad = async () => {
|
// Return nothing — data loading happens client-side in the page component
|
||||||
const cat = await fetchCatalysts();
|
// so the spinner fires on initial boot (hard refresh) too.
|
||||||
const results = await screenTickers(cat.tickers);
|
export function load() {
|
||||||
return {
|
return {};
|
||||||
results,
|
}
|
||||||
catalystInput: cat.tickers.join(', '),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createCall, deleteCall } from '$lib/api.js';
|
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 { 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 {
|
interface MarketCall {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,35 +18,19 @@
|
|||||||
|
|
||||||
interface PageData {
|
interface PageData {
|
||||||
calls: MarketCall[];
|
calls: MarketCall[];
|
||||||
events: unknown[];
|
events: CalendarEvent[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// New call form state
|
|
||||||
let showForm: boolean = $state(false);
|
let showForm: boolean = $state(false);
|
||||||
let saving: boolean = $state(false);
|
let saving: boolean = $state(false);
|
||||||
let formError: string|null = $state(null);
|
let formError: string|null = $state(null);
|
||||||
let form = $state({
|
|
||||||
title: '',
|
|
||||||
quarter: currentQuarter(),
|
|
||||||
date: today(),
|
|
||||||
thesis: '',
|
|
||||||
tickers: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
function currentQuarter() {
|
async function submit(form: {
|
||||||
const d = new Date();
|
title: string; quarter: string; date: string; thesis: string; tickers: string;
|
||||||
const q = Math.ceil((d.getMonth() + 1) / 3);
|
}): Promise<void> {
|
||||||
return `Q${q} ${d.getFullYear()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function today() {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
formError = null;
|
formError = null;
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
@@ -56,8 +42,7 @@
|
|||||||
tickers: form.tickers.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean),
|
tickers: form.tickers.split(/[\s,]+/).map(t => t.trim().toUpperCase()).filter(Boolean),
|
||||||
});
|
});
|
||||||
showForm = false;
|
showForm = false;
|
||||||
form = { title: '', quarter: currentQuarter(), date: today(), thesis: '', tickers: '' };
|
await invalidateAll();
|
||||||
await invalidateAll(); // re-run load() to refresh the list
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
formError = (e as Error).message;
|
formError = (e as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -70,316 +55,33 @@
|
|||||||
await deleteCall(id);
|
await deleteCall(id);
|
||||||
await invalidateAll();
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="calls-page">
|
||||||
<div class="page-header">
|
<div class="calls-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Market Calls</h1>
|
<h1>Market Calls</h1>
|
||||||
<p class="subtitle">Quarterly investment theses tracked from the day you made the call</p>
|
<p class="subtitle">Quarterly investment theses tracked from the day you made the call</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-primary" onclick={() => showForm = !showForm}>
|
<button class="btn-primary" onclick={() => { showForm = !showForm; formError = null; }}>
|
||||||
{showForm ? 'Cancel' : '+ New Call'}
|
{showForm ? 'Cancel' : '+ New Call'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── New Call Form ──────────────────────────────────────────────── -->
|
|
||||||
{#if showForm}
|
{#if showForm}
|
||||||
<section class="section form-section">
|
<CallForm {saving} error={formError} onSubmit={submit} onCancel={() => showForm = false} />
|
||||||
<div class="section-header"><h2>New Market Call</h2></div>
|
|
||||||
<form class="call-form" onsubmit={e => { e.preventDefault(); submit(); }}>
|
|
||||||
<div class="form-row">
|
|
||||||
<label>
|
|
||||||
<span>Title</span>
|
|
||||||
<input bind:value={form.title} placeholder="Q3 2025 — Rate pivot & tech rotation" required />
|
|
||||||
</label>
|
|
||||||
<label class="narrow">
|
|
||||||
<span>Quarter</span>
|
|
||||||
<input bind:value={form.quarter} placeholder="Q3 2025" required />
|
|
||||||
</label>
|
|
||||||
<label class="narrow">
|
|
||||||
<span>Date</span>
|
|
||||||
<input type="date" bind:value={form.date} required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Thesis</span>
|
|
||||||
<textarea
|
|
||||||
bind:value={form.thesis}
|
|
||||||
rows="4"
|
|
||||||
placeholder="Describe the macro thesis behind this call — why these tickers, why now..."
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Tickers to track</span>
|
|
||||||
<input
|
|
||||||
bind:value={form.tickers}
|
|
||||||
placeholder="AAPL, MSFT, TLT, GLD …"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<span class="hint">Comma or space separated. Current prices will be snapshot automatically.</span>
|
|
||||||
</label>
|
|
||||||
{#if formError}
|
|
||||||
<div class="form-error">⚠ {formError}</div>
|
|
||||||
{/if}
|
|
||||||
<button type="submit" class="btn-primary" disabled={saving}>
|
|
||||||
{#if saving}
|
|
||||||
<Spinner size="sm" />
|
|
||||||
<span>Snapshotting prices…</span>
|
|
||||||
{:else}
|
|
||||||
Save Call
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Calendar ──────────────────────────────────────────────────── -->
|
<CalendarSection events={data.events ?? []} />
|
||||||
{#if (data.events ?? []).length > 0}
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>📅 Upcoming Events</h2>
|
|
||||||
<span class="count">{upcoming.length} upcoming</span>
|
|
||||||
{#if past.length > 0}
|
|
||||||
<span class="count" style="margin-left:4px">{past.length} recent</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="cal-grid">
|
|
||||||
{#each upcoming as ev}
|
|
||||||
<div class="cal-event">
|
|
||||||
<div class="cal-date">{ev.date}</div>
|
|
||||||
<div class="cal-content">
|
|
||||||
<span class="cal-ticker">{ev.ticker}</span>
|
|
||||||
<span class="cal-type" style="color:{eventColor(ev.type)}">
|
|
||||||
{eventIcon(ev.type)} {ev.label}
|
|
||||||
{#if ev.detail}<span class="cal-detail">· {ev.detail}</span>{/if}
|
|
||||||
</span>
|
|
||||||
{#if ev.epsEstimate != null}
|
|
||||||
<span class="cal-est">EPS est. ${ev.epsEstimate?.toFixed(2)} · Rev est. {fmtMoney(ev.revEstimate)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#if past.length > 0}
|
|
||||||
<div class="cal-divider">— Past —</div>
|
|
||||||
{#each past as ev}
|
|
||||||
<div class="cal-event past">
|
|
||||||
<div class="cal-date">{ev.date}</div>
|
|
||||||
<div class="cal-content">
|
|
||||||
<span class="cal-ticker">{ev.ticker}</span>
|
|
||||||
<span class="cal-type past-type">
|
|
||||||
{eventIcon(ev.type)} {ev.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- ── Calls List ────────────────────────────────────────────────── -->
|
|
||||||
{#if data.error}
|
{#if data.error}
|
||||||
<div class="error-banner">⚠ {data.error}</div>
|
<div class="error-banner">⚠ {data.error}</div>
|
||||||
{:else if data.calls.length === 0}
|
{: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}
|
{:else}
|
||||||
{#each data.calls as call}
|
{#each data.calls as call}
|
||||||
<section class="section call-card">
|
<CallCard {call} onDelete={remove} />
|
||||||
<div class="section-header">
|
|
||||||
<div class="call-meta">
|
|
||||||
<a href="/calls/{call.id}" class="call-title">{call.title}</a>
|
|
||||||
<div class="call-badges">
|
|
||||||
<span class="tag">{call.quarter}</span>
|
|
||||||
<span class="date-badge">{call.date}</span>
|
|
||||||
<span class="count">{call.tickers.length} tickers</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn-delete" onclick={() => remove(call.id)}>✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="call-body">
|
|
||||||
<p class="thesis">{call.thesis}</p>
|
|
||||||
|
|
||||||
{#if Object.keys(call.snapshot ?? {}).length}
|
|
||||||
<div class="snapshot-grid">
|
|
||||||
{#each call.tickers as ticker}
|
|
||||||
{@const snap = call.snapshot[ticker]}
|
|
||||||
{#if snap}
|
|
||||||
<a href="/calls/{call.id}" class="snap-card">
|
|
||||||
<div class="snap-ticker">{ticker}</div>
|
|
||||||
<div class="snap-price">${snap.price?.toFixed(2) ?? '—'}</div>
|
|
||||||
<div class="snap-signal" style="color:{signalColor(snap.signal)}">
|
|
||||||
{snap.signal?.replace(/[✅⚡⚠️🔄❌]/u, '').trim() ?? '—'}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<a href="/calls/{call.id}" class="view-link">View performance → </a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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
Reference in New Issue
Block a user