From 76a4d914c69d6485a1d7633bc4c2c8183aea326a Mon Sep 17 00:00:00 2001 From: saikiranvella Date: Sat, 6 Jun 2026 21:49:31 -0400 Subject: [PATCH] fix bruno collection --- PHASES.md | 884 ++++++++++++++++++ README.md | 286 +++++- api_collections/market-screener.bruno.yaml | 606 ++++++++++++ .../market-screener.postman_collection.json | 0 server/app.ts | 18 +- .../domains/portfolio/finance.controller.ts | 71 -- server/domains/portfolio/index.ts | 1 - server/domains/screener/ScreenerEngine.ts | 2 + server/domains/screener/analyze.controller.ts | 22 +- server/domains/screener/scorers/BondScorer.ts | 11 +- server/domains/screener/scorers/EtfScorer.ts | 17 +- .../shared/adapters/SimpleFINClient.ts | 2 + .../domains/shared/scoring/ScoringConfig.ts | 21 + tests/.fuse_hidden0000000d00000001 | 279 ++++++ tests/app.test.ts | 92 ++ tests/bond-scorer.test.ts | 247 +++++ tests/calls-controller.test.ts | 300 ++++++ tests/etf-scorer.test.ts | 270 ++++++ tests/helpers/mockDb.ts | 42 + tests/portfolio-advisor.test.ts | 278 ++++++ tests/portfolio-controller.test.ts | 305 ++++++ tests/screener-controller.test.ts | 196 ++++ tests/screener-engine.test.ts | 196 ++++ tests/setup.js | 30 + tests/stock-scorer.test.ts | 279 ++++++ 25 files changed, 4361 insertions(+), 94 deletions(-) create mode 100644 PHASES.md create mode 100644 api_collections/market-screener.bruno.yaml rename market-screener.postman_collection.json => api_collections/market-screener.postman_collection.json (100%) delete mode 100644 server/domains/portfolio/finance.controller.ts create mode 100644 tests/.fuse_hidden0000000d00000001 create mode 100644 tests/app.test.ts create mode 100644 tests/bond-scorer.test.ts create mode 100644 tests/calls-controller.test.ts create mode 100644 tests/etf-scorer.test.ts create mode 100644 tests/helpers/mockDb.ts create mode 100644 tests/portfolio-advisor.test.ts create mode 100644 tests/portfolio-controller.test.ts create mode 100644 tests/screener-controller.test.ts create mode 100644 tests/screener-engine.test.ts create mode 100644 tests/setup.js create mode 100644 tests/stock-scorer.test.ts diff --git a/PHASES.md b/PHASES.md new file mode 100644 index 0000000..64c9f4e --- /dev/null +++ b/PHASES.md @@ -0,0 +1,884 @@ +# PHASES.md + +Complete roadmap for market-screener evolution from Phase 9 through Phase 16+. + +## Phase 9 — Subdomain Restructure: Server Layer Organization + +**Goal:** Reorganize `server/` from flat layer-based structure to domain-driven structure. + +**Timeline:** 3 weeks. + +### 9a — Create shared infrastructure layer + +Create `server/domains/shared/` hierarchy: + +``` +server/domains/shared/ + ├── entities/ (models + their types together) + │ ├── Asset.ts + │ ├── Stock.ts + │ ├── Etf.ts + │ ├── Bond.ts + │ └── index.ts + ├── adapters/ (external API wrappers, renamed from "clients") + │ ├── YahooFinanceAdapter.ts + │ ├── AnthropicAdapter.ts + │ ├── SimpleFINAdapter.ts + │ └── index.ts + ├── services/ (cross-domain services) + │ ├── BenchmarkProvider.ts + │ ├── CatalystAnalyst.ts + │ ├── LLMAnalyst.ts + │ └── index.ts + ├── scoring/ (rules + regime management) + │ ├── ScoringConfig.ts + │ ├── GateValidator.ts + │ ├── MarketRegime.ts + │ └── index.ts + ├── persistence/ (SQLite stores) + │ ├── MarketCallStore.ts + │ ├── PortfolioStore.ts + │ └── index.ts + ├── types/ (all domain types) + │ ├── asset.model.ts + │ ├── finance.model.ts + │ ├── market.model.ts + │ ├── [...other models] + │ └── index.ts + ├── config/ + │ └── constants.ts + ├── utils/ + │ ├── logger.ts + │ ├── Chunker.ts + │ └── index.ts + ├── db/ + │ └── index.ts + ├── schemas.ts + └── index.ts +``` + +### 9b — Extract screener domain + +``` +server/domains/screener/ + ├── ScreenerController.ts + ├── ScreenerEngine.ts + ├── PersonalFinanceAnalyzer.ts + ├── scorers/ + │ ├── StockScorer.ts + │ ├── EtfScorer.ts + │ ├── BondScorer.ts + │ └── index.ts + ├── transform/ + │ ├── DataMapper.ts + │ ├── RuleMerger.ts + │ └── index.ts + └── index.ts +``` + +### 9c — Extract portfolio domain + +``` +server/domains/portfolio/ + ├── PortfolioController.ts + ├── PortfolioAdvisor.ts + ├── persistence/ + │ └── PortfolioStore.ts + └── index.ts +``` + +### 9d — Extract calls domain + +``` +server/domains/calls/ + ├── CallsController.ts + ├── CalendarService.ts + ├── persistence/ + │ └── MarketCallStore.ts + └── index.ts +``` + +### 9e — Extract finance domain + +``` +server/domains/finance/ + ├── FinanceController.ts + └── index.ts +``` + +### 9f — Clean up old directories + +Remove: `server/controllers/`, `server/services/`, `server/repositories/`, `server/clients/`, `server/models/`, `server/scorers/`, `server/config/`, `server/types/`, `server/utils/` + +### 9g — Update documentation in CLAUDE.md + +Update "Server layer map" section with new domain structure. + +### 9h — Smoke test all routes + +Create integration smoke test verifying all major routes work after restructure. + +--- + +## Phase 10 — UI Component Restructure & Clarity + +**Goal:** Mirror Phase 9 server restructure at UI layer. Organize components by domain. + +**Timeline:** 1 week. + +### 10a — Create component hierarchy + +``` +ui/src/lib/components/ + ├── shared/ + │ ├── Spinner.svelte + │ ├── VerdictPill.svelte + │ ├── SignalBadge.svelte + │ └── index.ts + ├── screener/ + │ ├── AssetTable.svelte + │ ├── AnalysisSidebar.svelte + │ └── index.ts + ├── portfolio/ + │ ├── AddHoldingForm.svelte + │ ├── AdviceTable.svelte + │ └── index.ts + └── calls/ + ├── CallForm.svelte + ├── CallCard.svelte + └── index.ts +``` + +### 10b — Split utils and types + +``` +lib/utils/ + ├── formatting.ts + ├── sorting.ts + ├── verdicts.ts + └── index.ts + +lib/types/ + ├── ui.types.ts + ├── portfolio.types.ts + └── index.ts +``` + +### 10c — Update all imports in routes + stores + +### 10d — Extract reusable layout components + +### 10e — UI Phase 10 complete + +--- + +## Phase 10.5 — Professional-Grade Screener UI (Institutional Research Tool) + +**Goal:** Build professional screener interface showing complete investment research capabilities. + +**Timeline:** 4-6 weeks (after Phase 10). + +### 10.5a — Three-Layer Layout + +``` +Sidebar (280px) | Main Table (flex) | Tearsheet Panel (420px) +────────────────┼──────────────────┼────────────────────── +Advanced │ Compact table │ Forensic detail +filters │ 10 columns only │ Full metrics +(left) │ │ Peer comparison + │ Scannable │ Decision framework +Quick presets │ minimal │ Risk breakdown + │ │ (right side-panel) +``` + +### 10.5b — Sidebar: Advanced Filtering + +- Preset buttons: All, Strong Buy, Buy, Hold, Avoid +- Custom filters: P/E Range, ROE Min, Dip %, D/E Max +- Quick presets: "Value Trap Screen", "Growth at Fair Price", "Dip Opportunity" + +### 10.5c — Main Table: Minimal, Scannable + +10 columns: Ticker | Price | Verdict | Score | P/E | ROE | 52W | DCF | Flags | Menu + +- Sortable, sticky header +- Monospace numbers (professional) +- Color-coded metrics +- Click row → opens tearsheet + +### 10.5d — Tearsheet Panel: Professional Research + +Right-side slide-in (420px) with sections: + +1. Core Metrics (4-grid, color-coded cards) +2. Valuation Context (comparison table) +3. Decision Framework (gate-by-gate breakdown) +4. Risk Breakdown (ranked, quantified) +5. Threshold Sensitivity (what-if scenarios) +6. Peer Comparison +7. CTA Row (Add to Watchlist, Decision Log) + +### 10.5e — Decision Logging & Backtest + +- Save thesis + entry date/price +- Track 30/60/90 day outcomes +- Simple review modal ("did thesis play out?") +- Backtest dashboard (win rate by signal type) + +### 10.5f — Implementation (Phased) + +- Week 1-2: Core UI (sidebar, table, tearsheet basic) +- Week 2-3: Tearsheet sections (all 7 sections) +- Week 3-4: Interactivity (sorting, filters, animation) +- Week 4-5: Decision logging +- Week 5-6: Backtest dashboard (optional) + +--- + +## Phase 10.6 — Portfolio Integration: Market Analysis → Action + +**Goal:** Connect screener signals + market context to portfolio decisions. + +### 10.6a — Market-Aware Position Sizing + +Auto-calculate recommended position size based on: +- Stock verdict +- Market regime +- Sector momentum +- Portfolio allocation + +Display: "Recommended: 2-4% of portfolio" or "$2,000-$4,000" + +### 10.6b — Portfolio Dashboard: Integrated View + +Single screen showing: +1. Holdings + P&L +2. Allocation vs Target +3. Market Context +4. Screener Signals +5. Recommended Action + +### 10.6c — Screener-Portfolio Bridge + +Add "Your Holdings" column in screener showing: +- "You own 2% | +$1,000 gain" +- Verdict changes +- Thesis change alerts + +### 10.6d — Thesis Journal (Simplified) + +When adding position: +1. Why I'm buying (pick ONE reason) +2. What I'll watch (pick 1-2 metrics) +3. Review date (auto 30 days) + +### 10.6e — Rebalancing Advisor + +Monitor allocation vs target. When screener verdict changes on existing holding, suggest action. + +--- + +## Phase 10.7 — Newbie UX: Progressive Disclosure + +**Goal:** Professional tool with newbie-friendly interface. Same power, different experience. + +### 10.7a — Screener Entry: Strategy-Based + +Instead of filters, ask: "What are you looking for?" + +Options: +- ○ Solid companies at good prices (Balanced) +- ○ Hot stocks with momentum (Momentum) +- ○ Beaten-down bargains (Value) +- ○ Let me customize filters (Advanced) + +### 10.7b — Table View: Plain Language Explanations + +Minimal table: Ticker | Price | Verdict | Why? ℹ️ + +Clicking "ℹ️" shows plain-language explanation with reasons, scores, and what it means. + +### 10.7c — Buy Decision Helper + +Calculate recommended position size automatically. Show: +- Star rating (intuitive) +- Concrete dollars (not abstract %) +- Clear "safe" path highlighted + +### 10.7d — Portfolio Status View (Not Analysis) + +Show status + guidance, not complex metrics: +- Visual breakdown (bars) +- What it means +- Concrete actions (sell, buy, do nothing) + +### 10.7e — Market Context: Status Light + Impact + +Use traffic light system: +- 🟢 Good / ⚠️ Mixed / 🔴 Bad +- Plain explanation of why +- Impact on YOUR portfolio + +### 10.7f — Thesis Logging: Simple Checklist + +Pick ONE reason + 1-2 metrics to watch. Built-in review schedule. + +### 10.7g — After Buying: 30-Day Check-In + +Auto-reminder after 30 days showing: +- How metrics moved vs prediction +- Thesis status (working / shaken / broken) +- Next action + +### 10.7h — Newbie Mode vs Pro Mode (Toggle) + +**Newbie Mode:** Simplified screener, plain language, auto position sizing, status lights, guided workflows + +**Pro Mode:** Full filter control, all metrics, raw data, advanced analysis, complete transparency + +--- + +## Phase 10.8 — Earnings Calendar: Context, Not Destination + +**Goal:** Integrate earnings data contextually, NOT as standalone tab. + +### 10.8a — Earnings in Screener Tearsheet (Primary) + +``` +UPCOMING EVENTS: +├── Earnings: July 30, 2026 (18 days away) +│ ├── EPS estimate: $6.50 +│ ├── Historical beat rate: 65% +│ ├── Avg price move on earnings: +3% (beat), -2% (miss) +│ └── Timing decision: "Buy now before earnings?" or "Wait?" +│ +├── Ex-dividend: June 15 (6 days away) +│ └── Dividend: $0.24/share +│ +└── Analyst call: Post-earnings July 30 +``` + +### 10.8b — Earnings in Portfolio (Secondary) + +Portfolio holdings view shows upcoming events for YOUR positions with thesis-specific tracking. + +### 10.8c — Earnings Discovery Widget (Optional, Tertiary) + +Light calendar feature in screener header (NOT main nav): + +``` +📅 25 earnings this week in your screened results + └── [View by day] [View by verdict] +``` + +### 10.8d — What NOT to Build + +❌ **Standalone "Calendar" nav tab** — creates bloat, out-of-context data, redundant. + +### 10.8e — Earnings in Thesis Journal + +Earnings become key tracking metric when user logs thesis. + +### 10.8f — Design Note: Revisit Earnings Display Format + +**⚠️ DESIGN REVIEW NEEDED:** + +Consider consistency across three locations, visual hierarchy differences, and mobile responsiveness before finalizing visual design. + +--- + +## Phase 10.9 — Strong Buys: Professional Dip Opportunity Monitor + +**Goal:** Flag quality stocks when they drop 5%+ from 52W high, with market analysis of why. + +### 10.9a — Data Structure + +| Field | Source | Purpose | +|-------|--------|---------| +| Ticker | Yahoo Finance | Stock identifier | +| Current Price | Yahoo Finance daily fetch | Entry price today | +| 52W High | Yahoo Finance | Reference for dip % | +| Dip % | Calculated | Triggers display if ≥5% | +| Screener Verdict | ScreenerEngine | Quality ranking | +| Dip Reason | Market Analysis | Macro vs company issue | +| Market Context | Daily fetched | Why dropped? Temporary? | +| Your Play | LLM analysis | Buy dip or wait? | +| Recommended Action | Position sizing | "Add 2-4% to portfolio" | + +### 10.9b — Fetching Mechanism (Daily) + +1. Get "Too Big to Fail" universe (~150 stocks: mega-cap + large-cap + watchlist) +2. Fetch prices + 52W high (one Yahoo batch call) +3. Filter dips ≥5% from 52W high +4. Run screener on dipped stocks +5. Analyze why dipped (macro vs company) +6. Combine + cache (TTL 24 hours) +7. API serves from cache + +### 10.9c — UI: Tabular Display of Dip Opportunities + +| Ticker | Price | Dip % | Verdict | Why It Dipped | Your Play | Action | +|--------|-------|-------|---------|---------------|-----------|--------| +| AAPL | $189.50 | -9.76% | Strong Buy (8.2) | Fed rates high (macro, not company) | Buy dip. iPhone intact. | [+2-4%] | +| JPM | $215.30 | -7.2% | Strong Buy (7.8) | Sector rotation (capital away) | Defensive play. Undervalued. | [+3%] | + +- Sortable by: Dip %, Verdict, Your Play +- Click row → full tearsheet +- Daily refresh +- Threshold configurable: 5% (default) → 10% → 15% + +### 10.9d — Configuration (User Control) + +``` +Settings > Strong Buys Monitor: + + Stock Universe: + ☑ Mega-cap (10) + ☑ Large-cap (50) + ☑ My Watchlist (custom) + + Dip Threshold: + ○ 5% (Aggressive) + ○ 10% (Balanced) + ○ 15% (Conservative) + + Update Frequency: + ○ Daily morning (9:30 AM) + ● Daily EOD (4:00 PM) +``` + +### 10.9e — Design Note: Revisit Tabular Format + +**⚠️ DESIGN REVIEW NEEDED:** + +Consider: +1. **Card-based alternative** (cleaner, easier scan) vs current **compact table** +2. **Hybrid approach** (desktop table + mobile cards) + +Recommendation: Implement Phase 10.9a, gather user feedback, adjust design. + +--- + +## Phase 10.5j — Comprehensive Free Data Stack (Zero Cost, Zero Redundancy) + +**Philosophy:** Professional-grade screener using only FREE sources. No $99-$200/mo subscriptions. Each source has ONE clear job (no duplication). + +### Data Sources + +| Source | Cost | Job | Why | +|--------|------|-----|-----| +| Yahoo Finance (YahooFinanceClient) | $0 | Core metrics (P/E, ROE, FCF, D/E, analyst ratings) | Already integrated. No alternatives needed. | +| yfinance | $0 | Per-ticker enrichment (news, earnings dates, dividends) | Wraps Yahoo, optimized for news extraction. | +| Finnhub FREE | $0 | Earnings calendar + estimates only | Reliable future events (3-month lookahead). | +| Alpha Vantage FREE | $0 | Market context + sentiment (macro-focused) | Sector trends, Fed decisions, keyword search. | +| API Ninjas FREE | $0 | Earnings backup only (redundancy layer) | Fallback if Finnhub hits rate limits. | +| Your LLM (Claude) | ~$50/mo | Intelligence layer (turns data into insights) | Sentiment analysis, decision framework, thesis validation. | + +**Total:** ~$50/mo (just LLM), vs $300-400/mo for Bloomberg/FactSet. + +### Data Flow in Tearsheet + +1. User screens stocks → ScreenerEngine uses YahooFinanceClient +2. Metrics cached in memory/state (no extra calls) +3. User clicks row → Tearsheet opens +4. Fetch per-ticker enrichment on-demand (yfinance, Finnhub, Alpha Vantage — parallel) +5. Process with LLM (if enabled) for sentiment + decision framework +6. Display complete tearsheet + +### Integration Timeline + +- **Week 1:** Add yfinance news enrichment +- **Week 2:** Add Finnhub earnings calendar +- **Week 3:** Add Alpha Vantage market context +- **Week 4:** Add API Ninjas as backup +- **Week 5:** Wire everything into tearsheet +- **Week 6:** Add LLM enrichment (optional) + +### Why This Approach + +✅ **Zero Cost:** $0/month (all sources FREE) +✅ **Zero Redundancy:** Each source has ONE job, no overlap +✅ **Professional Grade:** Layered sources like institutional traders use +✅ **Reliability:** Redundancy where it matters (earnings calendar via Finnhub + API Ninjas backup) +✅ **Intelligent:** Your LLM adds 10x value without additional data cost + +### Rate Limits & Sustainability + +- Yahoo Finance: No official limits (proven in production) +- yfinance: No limits (wraps Yahoo) +- Finnhub FREE: 60 calls/minute (sufficient for 250 stocks) +- Alpha Vantage FREE: 5 calls/minute (one daily call, easily manageable) +- API Ninjas: 100 calls/month (backup only, minimal usage) + +--- + +## Phase 11 — Day Trading: Authentication & Authorization + +**Goal:** Add multi-user support with JWT auth, role-based access control, and portfolio isolation. + +**Timeline:** 2-3 weeks. + +### Why Auth is First + +Can't test multi-user portfolios, public + private access, Discord notifications with user context, or trade journal attribution without auth. + +### 11a — Create auth domain + +``` +server/domains/auth/ + ├── AuthController.ts + ├── AuthService.ts + ├── JWTStrategy.ts + ├── RBACGuard.ts + ├── persistence/ + │ └── UserStore.ts + └── types/ + └── auth.model.ts +``` + +### 11b — Database schema changes + +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT DEFAULT 'viewer' CHECK (role IN ('trader', 'viewer', 'admin')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME +); + +ALTER TABLE holdings ADD COLUMN user_id TEXT NOT NULL REFERENCES users(id); +ALTER TABLE market_calls ADD COLUMN created_by TEXT REFERENCES users(id); +``` + +### 11c — Middleware + route protection + +Apply RBACGuard to protected routes. JWT secret from env var. + +### 11d — UI auth layer + +Add `routes/auth/login/` and `routes/auth/register/`. + +Create `lib/stores/auth.store.svelte.ts` for currentUser, JWT, login/logout. + +--- + +## Phase 12 — Day Trading: News Webhooks + +**Goal:** Ingest real-time market news via Polygon.io webhooks. + +**Timeline:** 2-3 weeks. + +### Why Webhooks Come Second + +News feeds everything downstream: Safe Buys monitor, LLM analysis, price dips. + +### 12a — Create news domain + +``` +server/domains/news/ + ├── NewsController.ts + ├── WebhookHandler.ts + ├── NewsStore.ts + ├── NewsQueue.ts (BullMQ worker) + ├── persistence/ + │ └── NewsArticleStore.ts + └── types/ + └── news.model.ts +``` + +### 12b — Database schema + +```sql +CREATE TABLE news_articles ( + id TEXT PRIMARY KEY, + ticker TEXT NOT NULL, + headline TEXT NOT NULL, + body TEXT, + source TEXT, + url TEXT, + sentiment TEXT, + published_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_news_ticker_date ON news_articles(ticker, published_at DESC); +``` + +### 12c — Set up Polygon.io webhook + +1. Subscribe to Polygon news API (~$200/mo) +2. Register webhook: `https://yourapp.com/webhooks/news` +3. Validate signature (Polygon sends HMAC) +4. Queue article for async processing + +### 12d — Async processing with BullMQ + +Queue processes articles: +1. Store in DB +2. Trigger LLM analysis if key tickers mentioned +3. Notify subscribers (Discord, etc) + +--- + +## Phase 13 — Day Trading: Prompt Caching & LLM Optimization + +**Goal:** Reduce LLM costs by 90% using Anthropic prompt caching. + +**Timeline:** 2-3 weeks. + +### 13a — Create llm domain + +``` +server/domains/llm/ + ├── LLMRouter.ts + ├── PromptCache.ts + ├── LLMAnalyst.ts (refactored) + ├── persistence/ + │ ├── AnalysisStore.ts + │ └── CacheStore.ts + └── types/ + └── llm.model.ts +``` + +### 13b — Database schema + +```sql +CREATE TABLE llm_analysis ( + id TEXT PRIMARY KEY, + ticker TEXT NOT NULL, + analysis_result TEXT NOT NULL, + model_used TEXT DEFAULT 'claude-opus', + tokens_used INTEGER, + cache_hit BOOLEAN DEFAULT false, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +``` + +### 13c — Implement Anthropic prompt caching + +Add `cache_control: { type: 'ephemeral' }` to system prompt message block. + +Use `anthropic-beta: prompt-caching-2024-07-31` header. + +### 13d — LLM Router for cost optimization + +Route to cheaper models (Sonnet) when cost-sensitive. Fallback to OpenAI if rate-limited. + +--- + +## Phase 14 — Day Trading: Safe Buys Monitor with Discord Alerts + +**Goal:** Monitor safe-buy stocks in real-time, detect 5%+ dips, notify via Discord. + +**Timeline:** 3-4 weeks. + +### 14a — Create trading domain + +``` +server/domains/trading/ + ├── TradingController.ts + ├── DipDetector.ts + ├── PriceMonitor.ts + ├── DiscordNotifier.ts + ├── persistence/ + │ ├── PriceSnapshotStore.ts + │ └── TradeSignalStore.ts + └── types/ + └── trading.model.ts +``` + +### 14b — Database schema + +```sql +CREATE TABLE price_snapshots ( + id TEXT PRIMARY KEY, + ticker TEXT NOT NULL, + price REAL NOT NULL, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + source TEXT, + dip_detected BOOLEAN DEFAULT false +); + +CREATE TABLE trading_signals ( + id TEXT PRIMARY KEY, + ticker TEXT NOT NULL, + signal_type TEXT CHECK (signal_type IN ('strong_buy', 'dip', 'warning')), + entry_price REAL, + detected_at DATETIME DEFAULT CURRENT_TIMESTAMP, + notified BOOLEAN DEFAULT false, + outcome TEXT +); +``` + +### 14c — Real-time price polling + +Check watched tickers every 5 seconds. Filter dips ≥5%. Process via DipDetector. + +### 14d — Discord notifications + +Send rich embeds with: +- 🔴 5% Dip Detected: TICKER +- Price fell from $X to $Y (-%Z) +- LLM sentiment + recommendation +- Risks + +--- + +## Phase 15 — Day Trading: Trade Journal & Performance Tracking + +**Goal:** Log every decision, track outcomes, measure strategy performance. + +**Timeline:** 1-2 weeks. + +### 15a — Database schema + +```sql +CREATE TABLE trade_journal ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + ticker TEXT NOT NULL, + signal TEXT, + entry_price REAL NOT NULL, + entry_date DATETIME DEFAULT CURRENT_TIMESTAMP, + exit_price REAL, + exit_date DATETIME, + outcome TEXT CHECK (outcome IN ('win', 'loss', 'pending')), + pnl REAL, + reason TEXT, + notes TEXT +); + +CREATE INDEX idx_journal_user ON trade_journal(user_id, entry_date DESC); +``` + +### 15b — Trade stats dashboard + +Compute daily aggregates: +- Total trades, wins, losses +- Win rate, total P&L +- Average win/loss, best/worst signal + +### 15c — UI: Trade Stats Dashboard + +Display stats + trade history with filtering. + +--- + +## Phase 16 — Multi-LLM Support (Optional) + +**Goal:** Support Claude, OpenAI, optionally Llama for cost optimization. + +**Timeline:** 2-3 weeks (do after Phase 14). + +### Minimal implementation + +```typescript +const MODELS = { + 'claude-opus': { cost: 0.015, speed: 'slow', quality: 'best' }, + 'claude-sonnet': { cost: 0.003, speed: 'fast', quality: 'good' }, + 'gpt-4': { cost: 0.03, speed: 'medium', quality: 'excellent' }, +}; + +async analyze(ticker: string, preferredModel?: string) { + const model = preferredModel || 'claude-sonnet'; + return await routers[model].analyze(ticker); +} +``` + +--- + +## Production Readiness Checklist + +**Before going live:** + +- [ ] Environment variables locked down (.env.production, no secrets in code) +- [ ] Database: Migrate SQLite → Postgres if expect >10 concurrent users +- [ ] Job Queue: Set up BullMQ with Redis +- [ ] Logging: Add structured logging (Winston, Pino) to track LLM calls + costs +- [ ] Rate Limiting: Enabled on all public endpoints (@fastify/rate-limit) +- [ ] Discord Webhook: Test alerts with real market data +- [ ] Auth: JWT secret rotated, session timeout 1h +- [ ] SSL/TLS: HTTPS enforced +- [ ] Monitoring: Alerts for job backlog, API latency, cache hit rate, webhook failures +- [ ] Alpaca price feed staleness: Should be <5s + +**Cost estimation (steady state):** + +| Service | Cost | Notes | +|---------|------|-------| +| Polygon.io (real-time news + quotes) | $200 | Required for webhooks | +| Anthropic Claude API (w/ prompt caching) | $50–100 | Most cached; 90% cost reduction | +| OpenAI API (fallback, optional) | $50 | Only if GPT-4 fallback added | +| Alpaca/Interactive Brokers | $30–100 | Depends on which feed | +| BullMQ (Redis queue, if scaled) | $0–30 | Free if self-hosted | +| **Total** | **~$330–450/month** | Scales well (no per-user seat cost) | + +--- + +## Final Architecture Summary + +| Layer | Tech | Status | +|-------|------|--------| +| **Auth** | JWT + RBAC | Phase 11 (weeks 1-2) | +| **Data** | SQLite → Postgres if 1000+ users | Phase 11 | +| **News** | Polygon.io webhooks | Phase 12 (weeks 3-4) | +| **LLM** | Anthropic + OpenAI w/ prompt caching | Phase 13-14 (weeks 5-6) | +| **Trading** | Real-time price monitoring + Discord | Phase 14 (weeks 7-10) | +| **Tracking** | Trade journal + stats | Phase 15 (weeks 11-12) | +| **UI** | Svelte 5 + Phase 10 structure | Phase 10 (weeks 1-5 parallel) | + +**Total time to "trading ready":** 12-16 weeks solo, 8 weeks with 1-2 junior devs. + +**Go-live target:** Q3 2026 (July–September). + +--- + +## Postgres Migration Path (When Needed) + +If you grow to 10+ active traders: + +1. Create Postgres RDS instance (AWS: ~$15/mo, db.t3.micro) +2. Update connection string to point to Postgres +3. Run schema dump SQLite → Postgres +4. Test on staging first +5. Blue-green deploy: run both DBs in parallel for 1 day, switch, keep SQLite as backup + +**Time:** 2–4 hours. No code changes needed. + +--- + +## Frequently Asked Questions + +**Q: How many traders can this system handle?** + +A: +- **10–50 traders:** Single instance. Costs ~$450/mo. +- **50–500 traders:** Add Postgres + Redis queue. Costs ~$1000/mo. +- **500+ traders:** Add Kubernetes + load balancing. Costs ~$5000+/mo. + +**Q: What if Polygon.io goes down?** + +A: Have fallback plan: +1. Switch to Finnhub webhooks (similar API, different provider) +2. Or fall back to polling (5s instead of real-time, less expensive) +3. Add circuit breaker: if Polygon fails for >5 min, automatically switch + +**Q: Can I trade with real money?** + +A: Yes, but: +1. Start with **paper trading** (Alpaca's paper account, no real money) +2. Test for 2+ weeks on real market conditions +3. Once you hit 55%+ win rate on paper, go live with small position sizes +4. Scale up gradually (1% → 5% → 10%) +5. Always have manual kill-switch + +**Q: Should I use local LLM training?** + +A: Not yet. Only consider if: +- You have 6+ months of clean trade data +- Your LLM bill is >$1000/mo +- You have $20K+ to spend on GPU infrastructure + +For now, optimize prompts instead. Good prompt beats fine-tuned model. diff --git a/README.md b/README.md index 6679835..2cd1058 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A personal stock screener and portfolio tracker. Scores stocks, ETFs, and bonds - [Running Tests](#running-tests) - [Project Structure](#project-structure) - [User Guide](#user-guide) +- [API Testing with Bruno](#api-testing-with-bruno) --- @@ -19,8 +20,16 @@ A personal stock screener and portfolio tracker. Scores stocks, ETFs, and bonds ### Prerequisites -- Node.js 20+ -- npm 10+ +- **Node.js 20+** (v22 recommended) +- **npm 10+** + +**Check your versions:** +```bash +node --version # Should output v20.x.x or higher +npm --version # Should output 10.x.x or higher +``` + +**Not on Node 20+?** See [NODE_VERSION_FIX.md](./NODE_VERSION_FIX.md) for upgrade instructions. ### Install @@ -322,3 +331,276 @@ A filtered view showing only tickers with a **✅ Strong Buy** signal across bot ### API rate limits `/api/screen`, `/api/screen/catalysts`, and `/api/analyze` are capped at **10 requests per minute** per IP. All other routes allow 60 per minute. + +--- + +## API Testing with Bruno + +### What is Bruno? + +[Bruno](https://www.usebruno.com/) is a lightweight, open-source API client that's Git-friendly and perfect for testing REST APIs. It stores collections as plain text files instead of JSON blobs, making them easy to version control and collaborate on. + +### Installing Bruno + +#### macOS (via Homebrew) +```bash +brew install bruno +``` + +#### macOS (Direct Download) +1. Visit [usebruno.com/downloads](https://www.usebruno.com/downloads) +2. Download the macOS version +3. Drag `Bruno.app` to Applications folder + +#### Windows +1. Visit [usebruno.com/downloads](https://www.usebruno.com/downloads) +2. Download the Windows installer (.exe) +3. Run the installer and follow the prompts +4. Or via Chocolatey: `choco install bruno` + +#### Linux (Ubuntu/Debian) +```bash +# Add Bruno repository +curl -1sLf 'https://dl.usebruno.com/install.sh' | sudo bash + +# Install +sudo apt-get install bruno +``` + +#### Linux (Fedora/RHEL) +```bash +curl -1sLf 'https://dl.usebruno.com/install.sh' | sudo bash +sudo dnf install bruno +``` + +### Installing Bruno CLI (brucli) + +For running tests from the command line without the GUI: + +#### macOS +```bash +brew install brucli +``` + +#### Windows +```bash +choco install bruno-cli +``` + +#### Linux +```bash +curl -1sLf 'https://dl.usebruno.com/install.sh' | sudo bash +``` + +### Importing the API Collection + +#### Method 1: Import via Bruno GUI (Easiest) + +1. **Open Bruno** +2. **File → Import Collection** +3. **Select** `api_collections/market-screener.postman_collection.json` +4. **Choose location** where to save the converted collection (e.g., `api_collections/market-screener`) +5. **Click Import** — Bruno automatically converts and structures the collection + +#### Method 2: Import via Bruno CLI + +```bash +# Navigate to the project root +cd market-screener + +# Import the Postman collection +bru import api_collections/market-screener.postman_collection.json -o api_collections/market-screener + +# Output: Collection imported to api_collections/market-screener/ +``` + +#### Method 3: Convert Postman to Bruno Format (Manual) + +If you prefer to manually convert the collection: + +```bash +# Install conversion dependencies (if needed) +pip install requests + +# Run the conversion script +python3 api_collections/convert_postman_to_bruno.py \ + api_collections/market-screener.postman_collection.json \ + api_collections/market-screener +``` + +### Running Tests + +#### Via Bruno GUI + +1. **Open the imported collection** in Bruno +2. **Set the `baseUrl` variable** (default: `http://localhost:3000`) +3. **Click the Play button** to run all tests +4. **View results** for each request in the UI + +#### Via Bruno CLI (brucli) + +```bash +# Navigate to the collection directory +cd api_collections/market-screener + +# Run all tests in the collection +bru run + +# Run with specific environment +bru run --env local + +# Run with output format +bru run --output json > test-results.json + +# Run specific test file +bru run "Screener/Screen - Mixed.bru" +``` + +### Collection Structure + +After import, you'll have: + +``` +api_collections/market-screener/ +├── bruno.json # Collection metadata +├── Health/ +│ └── Health Check.bru +├── Screener/ +│ ├── Screen - Mixed.bru +│ ├── Screen - Tech Stocks.bru +│ ├── Screen - REIT.bru +│ ├── Validation empty tickers.bru +│ ├── Validation 50 plus tickers.bru +│ └── Get Catalysts.bru +├── Market Context/ +│ └── Get Market Context.bru +├── Portfolio/ +│ ├── Add Holding AAPL.bru +│ ├── Add Holding VOO.bru +│ ├── Add Holding BTC-USD.bru +│ ├── Add Holding Validation.bru +│ ├── Get Portfolio.bru +│ ├── Remove Holding AAPL.bru +│ └── Remove Holding Non-existent.bru +├── Market Calls/ +│ ├── List Calls.bru +│ ├── Create Market Call.bru +│ ├── Get Call by ID.bru +│ ├── Get Call Non-existent.bru +│ ├── Get Earnings Calendar.bru +│ ├── Get Calendar Specific Tickers.bru +│ ├── Create Call Validation.bru +│ ├── Delete Call.bru +│ └── Delete Call Already Deleted.bru +└── LLM Analysis/ + ├── Analyze Tickers.bru + └── Analyze Validation.bru +``` + +### Configuration + +#### Setting Variables + +Variables are stored in `bruno.json` and can be overridden per request: + +**Default variables:** +- `baseUrl`: `http://localhost:3000` +- `callId`: (auto-populated by Create Market Call request) + +To change variables in the GUI: +1. Right-click collection → **Settings** +2. Click **Variables** tab +3. Edit `baseUrl` or other variables +4. Click **Save** + +#### Environment Files + +Create a `.env.bruno` file in the collection directory for local overrides: + +```env +baseUrl=http://localhost:3000 +apiKey=your-secret-key +``` + +### Common Workflows + +#### 1. Test the full API flow + +```bash +cd api_collections/market-screener +bru run +``` + +#### 2. Test just the Screener endpoints + +```bash +cd api_collections/market-screener +bru run "Screener" +``` + +#### 3. Test and save results + +```bash +cd api_collections/market-screener +bru run --output json > test-results-$(date +%Y%m%d).json +``` + +#### 4. Continuous testing (while developing) + +```bash +# Terminal 1: Run the API server +npm run dev + +# Terminal 2: Watch and run tests every 5 seconds +cd api_collections/market-screener +watch -n 5 'bru run' +``` + +### Troubleshooting + +#### "You can run only at the root of a collection" error + +Make sure you're in the correct directory: + +```bash +# ❌ Wrong — project root +cd market-screener +bru run + +# ✅ Correct — collection root +cd api_collections/market-screener +bru run +``` + +#### Variables not found + +Verify variable names in `bruno.json`: + +```bash +# Check variables +cat api_collections/market-screener/bruno.json | grep -A 10 "vars" +``` + +#### Tests failing with "undefined" errors + +Common causes: +- Variable name mismatch (case-sensitive) +- Server not running on the expected port +- Port conflict (try `lsof -i :3000` to check) + +### Postman vs Bruno + +| Feature | Postman | Bruno | +|---------|---------|-------| +| **Download Size** | ~380MB | ~50MB | +| **Collection Format** | Single JSON blob | Plain text `.bru` files | +| **Git-Friendly** | ❌ Binary | ✅ Text-based, diffable | +| **API Response** | UI-only | CLI + GUI | +| **Cost** | Free tier + paid | ✅ Completely free | +| **IDE Integration** | None | Can edit `.bru` files directly | + +### References + +- **Bruno Docs**: [docs.usebruno.com](https://docs.usebruno.com) +- **Bruno GitHub**: [github.com/usebruno/bruno](https://github.com/usebruno/bruno) +- **Postman Collection**: `api_collections/market-screener.postman_collection.json` diff --git a/api_collections/market-screener.bruno.yaml b/api_collections/market-screener.bruno.yaml new file mode 100644 index 0000000..fb6273e --- /dev/null +++ b/api_collections/market-screener.bruno.yaml @@ -0,0 +1,606 @@ +openapi: 3.0.0 +info: + title: market-screener.bruno + version: 1.0.0 +paths: + /api/analyze: + post: + summary: 'Analyze — Validation: empty tickers (expect 400)' + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_llm_analysis_analyze_validation-_empty_tickers_expect_400_bru + description: 'Schema validation: minItems: 1. Expect 400.' + tags: + - LLM Analysis + responses: + '200': + description: '' + parameters: + - name: Content-Type + in: header + description: '' + required: true + schema: + type: string + example: application/json + requestBody: + $ref: '#/components/requestBodies/analyze_validation_empty_tickers_expect_400' + /health: + get: + summary: Health Check + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_health_health_check_bru + description: 'Confirms the server is running. Expects { status: ''ok'' }.' + tags: + - Health + responses: + '200': + description: '' + /api/finance/market-context: + get: + summary: Get Market Context + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_context_get_market_context_bru + 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. + tags: + - Market Context + responses: + '200': + description: '' + /api/calls: + post: + summary: Create Market Call + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_calls_create_market_call_bru + description: >- + Creates a market thesis call. Snapshots current prices + screener + signals at creation time for future comparison. + + + The test script saves the returned ID to the {{callId}} collection + variable for use in subsequent requests. + tags: + - Market Calls + responses: + '200': + description: '' + parameters: + - name: Content-Type + in: header + description: '' + required: true + schema: + type: string + example: application/json + requestBody: + $ref: '#/components/requestBodies/create_market_call' + get: + summary: List Calls (empty or existing) + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_calls_list_calls_empty_or_existing_bru + description: >- + Returns all market calls sorted newest first. Returns { calls: [] } if + none exist yet. + tags: + - Market Calls + responses: + '200': + description: '' + /api/calls/{{callId}}: + delete: + summary: Delete Call + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_calls_delete_call_bru + description: >- + Deletes the call created earlier. Returns { ok: true }. Requires + {{callId}} to be set. + tags: + - Market Calls + responses: + '200': + description: '' + get: + summary: Get Call by ID (with current re-screen) + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_call_by_id_with_current_re-screen_bru + description: >- + Fetches the call and re-screens all tickers to show how signal/price has + changed since creation. + + + Returns: original call fields + `current` map of ticker → { price, + signal, inflatedVerdict, fundamentalVerdict, pe, roe, fcf }. + + + Depends on {{callId}} being set by the Create Market Call request. + tags: + - Market Calls + responses: + '200': + description: '' + /api/calls/00000000-0000-0000-0000-000000000000: + get: + summary: Get Call — Non-existent ID (expect 404) + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_call_non-existent_id_expect_404_bru + description: A UUID that doesn't exist. Expect 404. + tags: + - Market Calls + responses: + '200': + description: '' + /api/calls/calendar: + get: + summary: Get Earnings Calendar (call tickers) + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_earnings_calendar_call_tickers_bru + description: >- + Returns upcoming earnings dates and dividend events for all tickers + across all saved calls. + + + Optional query param ?tickers=AAPL,MSFT to restrict to specific tickers. + tags: + - Market Calls + responses: + '200': + description: '' + /api/calls/calendar?tickers=AAPL,MSFT: + get: + summary: Get Earnings Calendar — Specific Tickers + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_market_calls_get_earnings_calendar_specific_tickers_bru + description: Calendar for specific tickers regardless of saved calls. + tags: + - Market Calls + responses: + '200': + description: '' + parameters: + - name: tickers + in: query + description: '' + required: true + schema: + type: string + example: AAPL,MSFT + /api/finance/holdings: + post: + summary: 'Add Holding — Validation: missing shares (expect 400)' + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_portfolio_add_holding_validation-_missing_shares_expect_400_bru + description: 'Schema validation: shares is required. Expect 400.' + tags: + - Portfolio + responses: + '200': + description: '' + parameters: + - name: Content-Type + in: header + description: '' + required: true + schema: + type: string + example: application/json + requestBody: + $ref: >- + #/components/requestBodies/add_holding_validation_missing_shares_expect_400 + /api/finance/portfolio: + get: + summary: Get Portfolio + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_portfolio_get_portfolio_bru + description: >- + Screens all non-crypto holdings via Yahoo Finance, then cross-references + with signals to produce buy/hold/sell advice. + + + Each row has: ticker, signal, advice, reason, currentPrice, marketValue, + gainLossPct. + + Also returns marketContext. + + + Note: first call after server start may be slow (benchmark cache cold). + tags: + - Portfolio + responses: + '200': + description: '' + /api/finance/holdings/AAPL: + delete: + summary: Remove Holding — AAPL + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_portfolio_remove_holding_aapl_bru + description: 'Removes the AAPL holding from portfolio.json. Expect { ok: true }.' + tags: + - Portfolio + responses: + '200': + description: '' + /api/finance/holdings/ZZZZZZ: + delete: + summary: Remove Holding — Non-existent (expect 404) + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_portfolio_remove_holding_non-existent_expect_404_bru + description: Ticker does not exist in portfolio. Expect 404. + tags: + - Portfolio + responses: + '200': + description: '' + /api/screen/catalysts: + get: + summary: Get Catalysts + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_screener_get_catalysts_bru + 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. + tags: + - Screener + responses: + '200': + description: '' + /api/screen: + post: + summary: 'Screen — Validation: over 50 tickers (expect 400)' + operationId: >- + users_kanna_documents_bruno_market_screener_api_-_1_screener_screen_validation-_over_50_tickers_expect_400_bru + description: 'Schema validation: maxItems: 50. 51 tickers should return 400.' + tags: + - Screener + responses: + '200': + description: '' + parameters: + - name: Content-Type + in: header + description: '' + required: true + schema: + type: string + example: application/json + requestBody: + $ref: >- + #/components/requestBodies/screen_validation_over_50_tickers_expect_400 +servers: + - url: http://localhost:3000 + description: Base Server +components: + schemas: + analyze_tickers: + type: object + properties: + tickers: + type: array + items: + type: string + example: + tickers: + - NVDA + - AMD + - INTC + analyze_validation_empty_tickers_expect_400: + type: object + properties: + tickers: + type: array + items: + type: string + example: + tickers: [] + create_call_validation_short_thesis_expect_400: + type: object + properties: + title: + type: string + quarter: + type: string + thesis: + type: string + tickers: + type: array + items: + type: string + example: + title: Test + quarter: Q1 + thesis: short + tickers: + - AAPL + create_market_call: + type: object + properties: + title: + type: string + quarter: + type: string + thesis: + type: string + tickers: + type: array + items: + type: string + example: + title: AI Infrastructure Supercycle + quarter: Q3 2025 + 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. + tickers: + - NVDA + - MSFT + - AMD + add_holding_aapl: + type: object + properties: + ticker: + type: string + shares: + type: integer + costBasis: + type: integer + type: + type: string + source: + type: string + example: + ticker: AAPL + shares: 10 + costBasis: 150 + type: stock + source: Robinhood + add_holding_btc-usd_crypto_no_scoring: + type: object + properties: + ticker: + type: string + shares: + type: number + costBasis: + type: integer + type: + type: string + source: + type: string + example: + ticker: BTC-USD + shares: 0.1 + costBasis: 50000 + type: crypto + source: Coinbase + add_holding_voo_etf: + type: object + properties: + ticker: + type: string + shares: + type: integer + costBasis: + type: integer + type: + type: string + source: + type: string + example: + ticker: VOO + shares: 5 + costBasis: 420 + type: etf + source: Vanguard + add_holding_validation_missing_shares_expect_400: + type: object + properties: + ticker: + type: string + example: + ticker: MSFT + screen_mixed_stock_etf_bond: + type: object + properties: + tickers: + type: array + items: + type: string + example: + tickers: + - AAPL + - MSFT + - GOOGL + - VOO + - AGG + screen_reit_tests_p_ffo_scoring_path: + type: object + properties: + tickers: + type: array + items: + type: string + example: + tickers: + - O + - VICI + - PLD + screen_tech_stocks_tests_technology_sector_override: + type: object + properties: + tickers: + type: array + items: + type: string + example: + tickers: + - NVDA + - META + - AMZN + - TSLA + screen_validation_empty_tickers_expect_400: + type: object + properties: + tickers: + type: array + items: + type: string + example: + tickers: [] + screen_validation_over_50_tickers_expect_400: + type: object + properties: + tickers: + type: array + items: + type: string + example: + 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 + requestBodies: + analyze_tickers: + content: + application/json: + schema: + $ref: '#/components/schemas/analyze_tickers' + description: '' + required: true + analyze_validation_empty_tickers_expect_400: + content: + application/json: + schema: + $ref: '#/components/schemas/analyze_validation_empty_tickers_expect_400' + description: '' + required: true + create_call_validation_short_thesis_expect_400: + content: + application/json: + schema: + $ref: >- + #/components/schemas/create_call_validation_short_thesis_expect_400 + description: '' + required: true + create_market_call: + content: + application/json: + schema: + $ref: '#/components/schemas/create_market_call' + description: '' + required: true + add_holding_aapl: + content: + application/json: + schema: + $ref: '#/components/schemas/add_holding_aapl' + description: '' + required: true + add_holding_btc-usd_crypto_no_scoring: + content: + application/json: + schema: + $ref: '#/components/schemas/add_holding_btc-usd_crypto_no_scoring' + description: '' + required: true + add_holding_voo_etf: + content: + application/json: + schema: + $ref: '#/components/schemas/add_holding_voo_etf' + description: '' + required: true + add_holding_validation_missing_shares_expect_400: + content: + application/json: + schema: + $ref: >- + #/components/schemas/add_holding_validation_missing_shares_expect_400 + description: '' + required: true + screen_mixed_stock_etf_bond: + content: + application/json: + schema: + $ref: '#/components/schemas/screen_mixed_stock_etf_bond' + description: '' + required: true + screen_reit_tests_p_ffo_scoring_path: + content: + application/json: + schema: + $ref: '#/components/schemas/screen_reit_tests_p_ffo_scoring_path' + description: '' + required: true + screen_tech_stocks_tests_technology_sector_override: + content: + application/json: + schema: + $ref: >- + #/components/schemas/screen_tech_stocks_tests_technology_sector_override + description: '' + required: true + screen_validation_empty_tickers_expect_400: + content: + application/json: + schema: + $ref: '#/components/schemas/screen_validation_empty_tickers_expect_400' + description: '' + required: true + screen_validation_over_50_tickers_expect_400: + content: + application/json: + schema: + $ref: '#/components/schemas/screen_validation_over_50_tickers_expect_400' + description: '' + required: true + securitySchemes: {} diff --git a/market-screener.postman_collection.json b/api_collections/market-screener.postman_collection.json similarity index 100% rename from market-screener.postman_collection.json rename to api_collections/market-screener.postman_collection.json diff --git a/server/app.ts b/server/app.ts index 7e7f073..a26b806 100644 --- a/server/app.ts +++ b/server/app.ts @@ -4,7 +4,8 @@ import rateLimit from '@fastify/rate-limit'; // Domain imports import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains/screener'; -import { FinanceController, PortfolioAdvisor } from './domains/portfolio'; +import { FinanceController } from './domains/finance'; +import { PortfolioAdvisor } from './domains/portfolio'; import { CallsController, CalendarService } from './domains/calls'; // Shared infrastructure @@ -23,6 +24,7 @@ import { interface BuildAppOptions { logger?: boolean; + db?: DatabaseConnection; } // ── Adding a new domain ─────────────────────────────────────────────── @@ -31,7 +33,7 @@ interface BuildAppOptions { // 3. Create barrel: server/domains//index.ts // 4. Import from domain and register controller below // ─────────────────────────────────────────────────────────────────────────── -export async function buildApp({ logger = true }: BuildAppOptions = {}) { +export async function buildApp({ logger = true, db: injectedDb }: BuildAppOptions = {}) { const app = Fastify({ logger }); await app.register(cors, { @@ -58,10 +60,14 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) { }); } - // Database setup - const rawDb = createDb(); - const audit = new QueryAudit(); - const db = new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 }); + // Database setup — use injected db (for tests) or create real one + const db = + injectedDb ?? + (() => { + const rawDb = createDb(); + const audit = new QueryAudit(); + return new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 }); + })(); // Services and clients const yahoo = new YahooFinanceClient(); diff --git a/server/domains/portfolio/finance.controller.ts b/server/domains/portfolio/finance.controller.ts deleted file mode 100644 index 2a582ac..0000000 --- a/server/domains/portfolio/finance.controller.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared'; -import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener'; -import { PortfolioAdvisor } from './PortfolioAdvisor'; -import type { PortfolioHolding } from '../../domains/shared'; -import { holdingSchema } from '../../domains/shared/types/schemas'; - -export class FinanceController { - constructor( - private readonly engine: ScreenerEngine, - private readonly repo: PortfolioRepository, - private readonly advisor: PortfolioAdvisor, - ) {} - - 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) => h.ticker.toUpperCase()); - - const results = - screenable.length > 0 - ? await this.engine.screenTickers(screenable) - : { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any }; - - const advice = await this.advisor.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(); - } -} diff --git a/server/domains/portfolio/index.ts b/server/domains/portfolio/index.ts index 9700041..a47d7cb 100644 --- a/server/domains/portfolio/index.ts +++ b/server/domains/portfolio/index.ts @@ -1,3 +1,2 @@ // Portfolio domain — holdings management and advice -export { FinanceController } from './finance.controller'; export { PortfolioAdvisor } from './PortfolioAdvisor'; diff --git a/server/domains/screener/ScreenerEngine.ts b/server/domains/screener/ScreenerEngine.ts index ae4668d..85b998a 100644 --- a/server/domains/screener/ScreenerEngine.ts +++ b/server/domains/screener/ScreenerEngine.ts @@ -44,7 +44,9 @@ export class ScreenerEngine { // eslint-disable-next-line no-console this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg), + // eslint-disable-next-line no-console log: (...args: unknown[]) => console.log(...args), + // eslint-disable-next-line no-console warn: (...args: unknown[]) => console.warn(...args), }; } diff --git a/server/domains/screener/analyze.controller.ts b/server/domains/screener/analyze.controller.ts index b11e87f..66d0af8 100644 --- a/server/domains/screener/analyze.controller.ts +++ b/server/domains/screener/analyze.controller.ts @@ -4,15 +4,10 @@ import { CatalystCache, CatalystAnalyst } from '../../domains/shared'; import { analyzeSchema } from '../../domains/shared/types/schemas'; export class AnalyzeController { - private readonly catalystAnalyst: CatalystAnalyst; - constructor( private readonly catalystCache: CatalystCache, private readonly llm: LLMAnalyst, - ) { - // Create a fresh instance for per-ticker story fetching (not cached) - this.catalystAnalyst = new CatalystAnalyst(); - } + ) {} register(app: FastifyInstance): void { app.post( @@ -27,13 +22,22 @@ export class AnalyzeController { 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 requestedTickers = (req.body as { tickers: string[] }).tickers.map((t) => + t.toUpperCase(), + ); + + // Use cached catalyst data (refreshed every 15 minutes) + const { stories: allStories } = await this.catalystCache.get(); + + // Filter stories to only those matching requested tickers + const stories = allStories.filter((story) => + story.tickers.some((t) => requestedTickers.includes(t)), + ); - const stories = await this.catalystAnalyst.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); + const analysis = await this.llm.analyze(stories, requestedTickers, tickerFrequency); return { analysis }; } } diff --git a/server/domains/screener/scorers/BondScorer.ts b/server/domains/screener/scorers/BondScorer.ts index 34a0db4..91eb200 100644 --- a/server/domains/screener/scorers/BondScorer.ts +++ b/server/domains/screener/scorers/BondScorer.ts @@ -21,9 +21,14 @@ export class BondScorer { if (metrics.creditRatingNumeric < gates.minCreditRating) { return { - label: '🔴 Avoid', - scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`, - audit: { passedGates: false }, + label: '🔴 REJECT', + scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`, + audit: { + passedGates: false, + failures: [ + `creditRating: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`, + ], + }, }; } diff --git a/server/domains/screener/scorers/EtfScorer.ts b/server/domains/screener/scorers/EtfScorer.ts index 6efb305..db1654b 100644 --- a/server/domains/screener/scorers/EtfScorer.ts +++ b/server/domains/screener/scorers/EtfScorer.ts @@ -17,11 +17,24 @@ export class EtfScorer { fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0, }; + const failures: string[] = []; if (metrics.expenseRatio > gates.maxExpenseRatio) { + failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`); + } + if ( + thresholds.minFiveYearReturn != null && + metrics.fiveYearReturn < thresholds.minFiveYearReturn + ) { + failures.push(`5-year return: ${metrics.fiveYearReturn}% < ${thresholds.minFiveYearReturn}%`); + } + if (thresholds.minVolume != null && metrics.volume < thresholds.minVolume) { + failures.push(`Volume: ${metrics.volume} < ${thresholds.minVolume}`); + } + if (failures.length > 0) { return { label: '🔴 REJECT', - scoreSummary: 'Gate failed: High Expense Ratio', - audit: { passedGates: false }, + scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`, + audit: { passedGates: false, failures }, }; } diff --git a/server/domains/shared/adapters/SimpleFINClient.ts b/server/domains/shared/adapters/SimpleFINClient.ts index eb0fb0d..1eb2aa7 100644 --- a/server/domains/shared/adapters/SimpleFINClient.ts +++ b/server/domains/shared/adapters/SimpleFINClient.ts @@ -13,7 +13,9 @@ export class SimpleFINClient { // eslint-disable-next-line no-console this.logger = logger ?? { write: (msg) => process.stdout.write(msg), + // eslint-disable-next-line no-console log: (...args) => console.log(...args), + // eslint-disable-next-line no-console warn: (...args) => console.warn(...args), }; this.onAccessUrlClaimed = onAccessUrlClaimed ?? null; diff --git a/server/domains/shared/scoring/ScoringConfig.ts b/server/domains/shared/scoring/ScoringConfig.ts index 87b4ef6..78e1647 100644 --- a/server/domains/shared/scoring/ScoringConfig.ts +++ b/server/domains/shared/scoring/ScoringConfig.ts @@ -193,3 +193,24 @@ export const ScoringRules: ScoringRulesShape = { thresholds: { minSpread: 1.5, maxDuration: 7 }, }, }; + +// Alias used by tests — shape: ScoringConfig.base.gates.STOCK etc. +export const ScoringConfig = { + base: { + gates: { + STOCK: ScoringRules.STOCK.gates, + ETF: ScoringRules.ETF.gates, + BOND: ScoringRules.BOND.gates, + }, + weights: { + STOCK: ScoringRules.STOCK.weights, + ETF: ScoringRules.ETF.weights, + BOND: ScoringRules.BOND.weights, + }, + thresholds: { + STOCK: ScoringRules.STOCK.thresholds, + ETF: ScoringRules.ETF.thresholds, + BOND: ScoringRules.BOND.thresholds, + }, + }, +}; diff --git a/tests/.fuse_hidden0000000d00000001 b/tests/.fuse_hidden0000000d00000001 new file mode 100644 index 0000000..7a66854 --- /dev/null +++ b/tests/.fuse_hidden0000000d00000001 @@ -0,0 +1,279 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { StockScorer } from '../server/domains/screener/scorers/StockScorer.js'; +import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js'; +import type { StockMetrics } from '../server/domains/shared/types/models.model.js'; + +const DEFAULT_RULES = { + gates: ScoringConfig.base.gates.STOCK, + weights: ScoringConfig.base.weights.STOCK, + thresholds: ScoringConfig.base.thresholds.STOCK, +}; + +test('StockScorer', async (t) => { + await t.test('rejects stock with high debt-to-equity ratio', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.0, + debtToEquity: 2.5, // Exceeds 1.5 gate + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('D/E')); + }); + + await t.test('rejects stock with low quick ratio', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.0, + debtToEquity: 1.0, + quickRatio: 0.5, // Below 0.8 gate + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('Quick')); + }); + + await t.test('rejects stock with high P/E ratio', () => { + const metrics: StockMetrics = { + peRatio: 25, // Exceeds 15 gate + pegRatio: 1.0, + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('P/E')); + }); + + await t.test('rejects stock with high PEG ratio', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.5, // Exceeds 1.0 gate + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('PEG')); + }); + + await t.test('scores high-quality stock positively', () => { + const metrics: StockMetrics = { + peRatio: 12, // Below gate + pegRatio: 0.8, // Below gate + debtToEquity: 0.5, + quickRatio: 1.2, + returnOnEquity: 25, // High ROE + operatingMargin: 20, + netProfitMargin: 15, + revenueGrowth: 10, + fcfYield: 5, + priceToBook: 2.0, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + // Should have positive score + assert.ok(result.audit?.passedGates); + }); + + await t.test('handles null/undefined metrics gracefully', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.0, + debtToEquity: null, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: null, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + // Should not crash, null values are skipped in gate checks + assert.ok(result); + }); + + await t.test('passes all quality gates for strong stock', () => { + const metrics: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 30, + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.ok(result.audit?.passedGates); + assert.notEqual(result.label, '🔴 REJECT'); + }); + + await t.test('includes audit trail of gate checks', () => { + const metrics: StockMetrics = { + peRatio: 25, + pegRatio: 1.0, + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.ok(result.audit); + if (!result.audit?.passedGates) { + assert.ok(Array.isArray(result.audit?.failures)); + } + }); + + await t.test('scores ROE as primary quality factor', () => { + const metricsLowRoe: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 5, // Low ROE + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const metricsHighRoe: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 40, // High ROE + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const resultLow = StockScorer.score(metricsLowRoe, DEFAULT_RULES); + const resultHigh = StockScorer.score(metricsHighRoe, DEFAULT_RULES); + + // Both should pass gates, but high ROE should score better + assert.ok(resultLow.audit?.passedGates); + assert.ok(resultHigh.audit?.passedGates); + }); + + await t.test('rejects stock with multiple gate failures', () => { + const metrics: StockMetrics = { + peRatio: 25, // High + pegRatio: 1.5, // High + debtToEquity: 2.5, // High + quickRatio: 0.5, // Low + returnOnEquity: 5, + operatingMargin: 5, + netProfitMargin: 2, + revenueGrowth: -5, + fcfYield: -1, + priceToBook: 0.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + // Should have multiple failures + if (!result.audit?.passedGates && result.audit?.failures) { + assert.ok(result.audit.failures.length > 1); + } + }); + + await t.test('handles edge case of zero metrics', () => { + const metrics: StockMetrics = { + peRatio: 0, + pegRatio: 0, + debtToEquity: 0, + quickRatio: 0, + returnOnEquity: 0, + operatingMargin: 0, + netProfitMargin: 0, + revenueGrowth: 0, + fcfYield: 0, + priceToBook: 0, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + // Should handle gracefully (zero is falsy, treated as null) + assert.ok(result); + }); + + await t.test('scores based on configured thresholds', () => { + const metricsLowMargin: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 20, + operatingMargin: 5, // Below medium threshold + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const metricsHighMargin: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 20, + operatingMargin: 25, // Above high threshold + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const resultLow = StockScorer.score(metricsLowMargin, DEFAULT_RULES); + const resultHigh = StockScorer.score(metricsHighMargin, DEFAULT_RULES); + + // Both should pass gates + assert.ok(resultLow.audit?.passedGates); + assert.ok(resultHigh.audit?.passedGates); + }); +}); diff --git a/tests/app.test.ts b/tests/app.test.ts new file mode 100644 index 0000000..74cb12e --- /dev/null +++ b/tests/app.test.ts @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildApp } from '../server/app.js'; +import { MockDatabaseConnection } from './helpers/mockDb.js'; + +// Inject mock DB so tests don't require the native better-sqlite3 binary +const mockDb = new MockDatabaseConnection() as never; + +test('App Bootstrap', async (t) => { + await t.test('builds successfully without logger', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + assert.ok(app); + assert.ok(app.server); + }); + + await t.test('health check endpoint returns 200', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + const response = await app.inject({ + method: 'GET', + url: '/health', + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.equal(body.status, 'ok'); + }); + + await t.test('POST /api/screen requires valid schema', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + const response = await app.inject({ + method: 'POST', + url: '/api/screen', + payload: { tickers: [] }, // Empty array fails minItems: 1 + }); + // Empty array is invalid per schema (minItems: 1) + assert.equal(response.statusCode, 400); + }); + + await t.test('POST /api/screen rejects invalid payload', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + const response = await app.inject({ + method: 'POST', + url: '/api/screen', + payload: { invalid: 'data' }, + }); + assert.equal(response.statusCode, 400); + }); + + await t.test('GET /api/screen/catalysts returns results', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + const response = await app.inject({ + method: 'GET', + url: '/api/screen/catalysts', + }); + assert.equal(response.statusCode, 200); + const body = JSON.parse(response.body); + assert.ok('tickers' in body); + assert.ok('stories' in body); + }); + + await t.test('CORS is enabled for configured origin', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + const response = await app.inject({ + method: 'GET', + url: '/health', + headers: { + origin: 'http://localhost:5173', + }, + }); + assert.ok(response.headers['access-control-allow-origin']); + }); + + await t.test('API key auth is optional (disabled by default)', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + const response = await app.inject({ + method: 'GET', + url: '/health', + }); + // Should work without API key when not configured + assert.equal(response.statusCode, 200); + }); + + await t.test('OPTIONS requests bypass auth check', async () => { + const app = await buildApp({ logger: false, db: mockDb }); + const response = await app.inject({ + method: 'OPTIONS', + url: '/api/screen', + headers: { origin: 'http://localhost:5173' }, + }); + // OPTIONS preflight should not be blocked by auth (any non-401/403 is fine) + assert.ok(response.statusCode !== 401 && response.statusCode !== 403); + }); +}); diff --git a/tests/bond-scorer.test.ts b/tests/bond-scorer.test.ts new file mode 100644 index 0000000..5f08084 --- /dev/null +++ b/tests/bond-scorer.test.ts @@ -0,0 +1,247 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BondScorer } from '../server/domains/screener/scorers/BondScorer.js'; +import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js'; +import type { BondMetrics } from '../server/domains/shared/types/models.model.js'; + +const DEFAULT_RULES = { + gates: ScoringConfig.base.gates.BOND, + weights: ScoringConfig.base.weights.BOND, + thresholds: ScoringConfig.base.thresholds.BOND, +}; + +test('BondScorer', async (t) => { + await t.test('rejects bond with low credit rating', () => { + const metrics: BondMetrics = { + ytm: 5.5, + duration: 5, + creditRating: 'BB', // Below BBB (gate is BBB = 7) + creditRatingNumeric: 5, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('Credit')); + }); + + await t.test('accepts bond with BBB credit rating', () => { + const metrics: BondMetrics = { + ytm: 5.5, + duration: 5, + creditRating: 'BBB', + creditRatingNumeric: 7, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + }); + + await t.test('accepts bond with A credit rating', () => { + const metrics: BondMetrics = { + ytm: 5.5, + duration: 5, + creditRating: 'A', + creditRatingNumeric: 9, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + }); + + await t.test('scores high-yield bond positively', () => { + const metrics: BondMetrics = { + ytm: 7.5, // High yield + duration: 5, + creditRating: 'A', + creditRatingNumeric: 9, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + assert.ok(result.audit?.passedGates); + }); + + await t.test('penalizes long-duration bond', () => { + const metricsShort: BondMetrics = { + ytm: 5.5, + duration: 3, // Short + creditRating: 'A', + creditRatingNumeric: 9, + }; + + const metricsLong: BondMetrics = { + ytm: 5.5, + duration: 10, // Long + creditRating: 'A', + creditRatingNumeric: 9, + }; + + const resultShort = BondScorer.score(metricsShort, DEFAULT_RULES); + const resultLong = BondScorer.score(metricsLong, DEFAULT_RULES); + + // Both should pass gates + assert.ok(resultShort.audit?.passedGates); + assert.ok(resultLong.audit?.passedGates); + }); + + await t.test('handles null/undefined metrics gracefully', () => { + const metrics: BondMetrics = { + ytm: null, + duration: 5, + creditRating: null, + creditRatingNumeric: null, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + // Should not crash + assert.ok(result); + }); + + await t.test('includes audit trail of gate checks', () => { + const metrics: BondMetrics = { + ytm: 5.5, + duration: 5, + creditRating: 'BB', + creditRatingNumeric: 5, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.ok(result.audit); + if (!result.audit?.passedGates) { + assert.ok(Array.isArray(result.audit?.failures)); + } + }); + + await t.test('accepts high-quality bond (AAA rated)', () => { + const metrics: BondMetrics = { + ytm: 4.0, // Lower yield (less risk) + duration: 7, + creditRating: 'AAA', + creditRatingNumeric: 10, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.ok(result.audit?.passedGates); + }); + + await t.test('scores spread relative to risk-free rate', () => { + const metricsWideSpread: BondMetrics = { + ytm: 7.5, // Wide spread from ~4.5% risk-free + duration: 5, + creditRating: 'BBB', + creditRatingNumeric: 7, + }; + + const metricsTightSpread: BondMetrics = { + ytm: 4.8, // Tight spread + duration: 5, + creditRating: 'BBB', + creditRatingNumeric: 7, + }; + + const resultWide = BondScorer.score(metricsWideSpread, DEFAULT_RULES); + const resultTight = BondScorer.score(metricsTightSpread, DEFAULT_RULES); + + // Both should pass gates + assert.ok(resultWide.audit?.passedGates); + assert.ok(resultTight.audit?.passedGates); + }); + + await t.test('rejects bond with very long duration', () => { + const metrics: BondMetrics = { + ytm: 5.5, + duration: 20, // Much longer than default gate + creditRating: 'A', + creditRatingNumeric: 9, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + // May fail duration gate if threshold is enforced + assert.ok(result); + }); + + await t.test('handles extreme yield scenarios', () => { + const metricsVeryHigh: BondMetrics = { + ytm: 15.0, // Very high (distressed) + duration: 5, + creditRating: 'B', + creditRatingNumeric: 4, + }; + + const metricsVeryLow: BondMetrics = { + ytm: 2.0, // Very low (low risk) + duration: 5, + creditRating: 'AAA', + creditRatingNumeric: 10, + }; + + const resultHigh = BondScorer.score(metricsVeryHigh, DEFAULT_RULES); + const resultLow = BondScorer.score(metricsVeryLow, DEFAULT_RULES); + + // High yield bond likely fails credit gate + assert.ok(resultHigh); + // Low yield AAA bond should pass + assert.ok(resultLow.audit?.passedGates); + }); + + await t.test('scores based on credit rating thresholds', () => { + const metricsJustAboveGate: BondMetrics = { + ytm: 5.5, + duration: 5, + creditRating: 'BBB', + creditRatingNumeric: 7, // Exactly at gate + }; + + const metricsWellAboveGate: BondMetrics = { + ytm: 5.5, + duration: 5, + creditRating: 'AA', + creditRatingNumeric: 9, // Well above gate + }; + + const resultAt = BondScorer.score(metricsJustAboveGate, DEFAULT_RULES); + const resultAbove = BondScorer.score(metricsWellAboveGate, DEFAULT_RULES); + + // Both should pass + assert.ok(resultAt.audit?.passedGates); + assert.ok(resultAbove.audit?.passedGates); + }); + + await t.test('handles negative YTM (unlikely but possible)', () => { + const metrics: BondMetrics = { + ytm: -0.5, // Negative yield (Swiss bonds in past) + duration: 2, + creditRating: 'AAA', + creditRatingNumeric: 10, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + // Should handle gracefully + assert.ok(result); + }); + + await t.test('investment-grade bond scores well', () => { + const metrics: BondMetrics = { + ytm: 5.0, + duration: 5, + creditRating: 'A', + creditRatingNumeric: 8, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + assert.ok(result.audit?.passedGates); + }); + + await t.test('speculative-grade bond rejected', () => { + const metrics: BondMetrics = { + ytm: 8.5, + duration: 5, + creditRating: 'CCC', + creditRatingNumeric: 3, + }; + + const result = BondScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + }); +}); diff --git a/tests/calls-controller.test.ts b/tests/calls-controller.test.ts new file mode 100644 index 0000000..9c83700 --- /dev/null +++ b/tests/calls-controller.test.ts @@ -0,0 +1,300 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CallsController } from '../server/domains/calls/calls.controller.js'; +import { CalendarService } from '../server/domains/calls/CalendarService.js'; +import type { MarketCall } from '../server/domains/shared/types/calls.model.js'; + +class MockMarketCallRepository { + private calls: (MarketCall & { id: string })[] = [ + { + id: '1', + title: 'AAPL Post-Earnings', + quarter: 'Q2 2024', + thesis: 'Strong iPhone sales cycle', + tickers: ['AAPL'], + date: new Date('2024-05-01'), + snapshots: [{ ticker: 'AAPL', price: 180, date: new Date('2024-05-01') }], + }, + ]; + + async list(): Promise<(MarketCall & { id: string })[]> { + return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime()); + } + + async get(id: string): Promise<(MarketCall & { id: string }) | null> { + return this.calls.find((c) => c.id === id) || null; + } + + async create(call: MarketCall): Promise { + const id = String(this.calls.length + 1); + const newCall = { id, ...call }; + this.calls.push(newCall); + return newCall; + } + + async delete(id: string) { + const index = this.calls.findIndex((c) => c.id === id); + if (index >= 0) { + this.calls.splice(index, 1); + return true; + } + return false; + } +} + +class MockScreenerEngine { + async screenTickers(tickers: string[]) { + return { + STOCK: tickers.map((ticker) => ({ + asset: { + ticker, + type: 'STOCK', + currentPrice: 100, + metrics: {}, + }, + signal: 'STRONG_BUY', + })), + ETF: [], + BOND: [], + ERROR: [], + marketContext: { + sp500Price: 5500, + riskFreeRate: 4.5, + vixLevel: 12.5, + rateRegime: 'NORMAL' as const, + volatilityRegime: 'LOW' as const, + benchmarks: { + marketPE: 20, + techPE: 28, + reitYield: 3.5, + igSpread: 1.2, + }, + }, + }; + } +} + +class MockYahooClient { + async fetchCalendarEvents() { + return [ + { + ticker: 'AAPL', + date: new Date('2024-05-15'), + type: 'earnings', + epsEstimate: 1.52, + epsActual: null, + }, + ]; + } +} + +test('CallsController', async (t) => { + await t.test('registers GET /api/calls endpoint', async () => { + const repository = new MockMarketCallRepository() as any; + const engine = new MockScreenerEngine() as any; + const calendar = new CalendarService(new MockYahooClient() as any); + const controller = new CallsController(repository, engine, calendar); + + let callsEndpointRegistered = false; + const mockApp = { + get: (path: string) => { + if (path === '/api/calls') callsEndpointRegistered = true; + }, + post: () => {}, + delete: () => {}, + }; + + controller.register(mockApp as any); + assert.ok(callsEndpointRegistered); + }); + + await t.test('registers POST /api/calls endpoint', async () => { + const repository = new MockMarketCallRepository() as any; + const engine = new MockScreenerEngine() as any; + const calendar = new CalendarService(new MockYahooClient() as any); + const controller = new CallsController(repository, engine, calendar); + + let createEndpointRegistered = false; + const mockApp = { + get: () => {}, + post: (path: string) => { + if (path === '/api/calls') createEndpointRegistered = true; + }, + delete: () => {}, + }; + + controller.register(mockApp as any); + assert.ok(createEndpointRegistered); + }); + + await t.test('registers DELETE /api/calls/:id endpoint', async () => { + const repository = new MockMarketCallRepository() as any; + const engine = new MockScreenerEngine() as any; + const calendar = new CalendarService(new MockYahooClient() as any); + const controller = new CallsController(repository, engine, calendar); + + let deleteEndpointRegistered = false; + const mockApp = { + get: () => {}, + post: () => {}, + delete: (path: string) => { + if (path === '/api/calls/:id') deleteEndpointRegistered = true; + }, + }; + + controller.register(mockApp as any); + assert.ok(deleteEndpointRegistered); + }); + + await t.test('lists all market calls', async () => { + const repository = new MockMarketCallRepository() as any; + + const calls = await repository.list(); + assert.ok(Array.isArray(calls)); + assert.equal(calls.length, 1); + assert.equal(calls[0].ticker || calls[0].title, 'AAPL Post-Earnings' || 'AAPL'); + }); + + await t.test('returns calls sorted by date (newest first)', async () => { + class MultiCallRepository { + private calls = [ + { + id: '1', + title: 'Old Call', + quarter: 'Q1 2024', + thesis: 'Old thesis', + tickers: ['AAPL'], + date: new Date('2024-01-01'), + snapshots: [], + }, + { + id: '2', + title: 'New Call', + quarter: 'Q2 2024', + thesis: 'New thesis', + tickers: ['MSFT'], + date: new Date('2024-05-01'), + snapshots: [], + }, + ]; + + async list() { + return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime()); + } + + async get(id: string) { + return this.calls.find((c) => c.id === id) || null; + } + + async create(call: any) { + return call; + } + + async delete(_id: string) { + return true; + } + } + + const repository = new MultiCallRepository() as any; + + const calls = await repository.list(); + assert.equal(calls[0].title, 'New Call'); + assert.equal(calls[1].title, 'Old Call'); + }); + + await t.test('creates new market call', async () => { + const repository = new MockMarketCallRepository() as any; + + const newCall: MarketCall = { + title: 'MSFT Q3 2024', + quarter: 'Q3 2024', + thesis: 'Cloud growth acceleration', + tickers: ['MSFT'], + date: new Date('2024-07-01'), + snapshots: [], + }; + + const created = await repository.create(newCall); + assert.ok(created.id); + assert.equal(created.title, 'MSFT Q3 2024'); + }); + + await t.test('retrieves single market call by id', async () => { + const repository = new MockMarketCallRepository() as any; + + const call = await repository.get('1'); + assert.ok(call); + assert.equal(call.id, '1'); + assert.equal(call.title, 'AAPL Post-Earnings'); + }); + + await t.test('deletes market call', async () => { + const repository = new MockMarketCallRepository() as any; + + const deleted = await repository.delete('1'); + assert.ok(deleted); + + const call = await repository.get('1'); + assert.equal(call, null); + }); + + await t.test('returns 404 for non-existent call', async () => { + const repository = new MockMarketCallRepository() as any; + + const call = await repository.get('999'); + assert.equal(call, null); + }); + + await t.test('screens tickers in call', async () => { + const repository = new MockMarketCallRepository() as any; + const engine = new MockScreenerEngine() as any; + + const call = await repository.get('1'); + if (call) { + const results = await engine.screenTickers(call.tickers); + assert.ok(results); + assert.ok(results.STOCK || results.ERROR); + } + }); + + await t.test('handles multiple tickers in call', async () => { + const repository = new MockMarketCallRepository() as any; + const engine = new MockScreenerEngine() as any; + + const newCall: MarketCall = { + title: 'Tech Quartet', + quarter: 'Q3 2024', + thesis: 'All tech leaders', + tickers: ['AAPL', 'MSFT', 'NVDA', 'GOOG'], + date: new Date('2024-07-01'), + snapshots: [], + }; + + const created = await repository.create(newCall); + const results = await engine.screenTickers(created.tickers); + + assert.ok(created.tickers.length === 4); + assert.ok(results); + // Should have screened all 4 tickers + }); + + await t.test('gets calendar events for call tickers', async () => { + const repository = new MockMarketCallRepository() as any; + const calendar = new CalendarService(new MockYahooClient() as any); + + const call = await repository.get('1'); + if (call) { + const result = await calendar.getEvents(call.tickers); + assert.ok(Array.isArray(result.events)); + assert.ok(Array.isArray(result.tickers)); + } + }); + + await t.test('call includes snapshots of entry prices', async () => { + const repository = new MockMarketCallRepository() as any; + + const call = await repository.get('1'); + assert.ok(call); + assert.ok(Array.isArray(call.snapshots)); + }); +}); diff --git a/tests/etf-scorer.test.ts b/tests/etf-scorer.test.ts new file mode 100644 index 0000000..894ec79 --- /dev/null +++ b/tests/etf-scorer.test.ts @@ -0,0 +1,270 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EtfScorer } from '../server/domains/screener/scorers/EtfScorer.js'; +import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js'; +import type { EtfMetrics } from '../server/domains/shared/types/models.model.js'; + +const DEFAULT_RULES = { + gates: ScoringConfig.base.gates.ETF, + weights: ScoringConfig.base.weights.ETF, + thresholds: ScoringConfig.base.thresholds.ETF, +}; + +test('EtfScorer', async (t) => { + await t.test('rejects ETF with high expense ratio', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.8, // Exceeds 0.2% gate + yield: 2.5, + volume: 5000000, + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('Expense ratio')); + }); + + await t.test('accepts ETF with low expense ratio', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.05, // Well below 0.2% gate + yield: 2.5, + volume: 5000000, + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + }); + + await t.test('rejects ETF with zero expense ratio (data issue)', () => { + const metrics: EtfMetrics = { + expenseRatio: 0, // Zero treated as missing/invalid + yield: 2.5, + volume: 5000000, + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + // Zero is treated as null/missing data + assert.ok(result); + }); + + await t.test('rejects ETF with low 5-year return', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 5000000, + fiveYearReturn: 5, // Below 8% gate + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('5-year return')); + }); + + await t.test('accepts ETF with strong 5-year return', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 5000000, + fiveYearReturn: 12, // Above 8% gate + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + }); + + await t.test('rejects ETF with insufficient volume', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 500000, // Below 1M gate + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('Volume')); + }); + + await t.test('accepts ETF with strong volume', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 10000000, // Well above 1M gate + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + }); + + await t.test('scores high-quality ETF positively', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.05, // Low + yield: 3.0, // Decent + volume: 20000000, // High + fiveYearReturn: 15, // Strong + totalAssets: 50e9, // Large + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + assert.ok(result.audit?.passedGates); + }); + + await t.test('handles null/undefined metrics gracefully', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.1, + yield: null, + volume: 5000000, + fiveYearReturn: 10, + totalAssets: null, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + // Should not crash, null values are skipped + assert.ok(result); + }); + + await t.test('includes audit trail of gate checks', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.8, + yield: 2.5, + volume: 5000000, + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.ok(result.audit); + if (!result.audit?.passedGates) { + assert.ok(Array.isArray(result.audit?.failures)); + } + }); + + await t.test('scores yield as secondary factor', () => { + const metricsLowYield: EtfMetrics = { + expenseRatio: 0.1, + yield: 0.5, // Low yield + volume: 5000000, + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const metricsHighYield: EtfMetrics = { + expenseRatio: 0.1, + yield: 4.0, // High yield + volume: 5000000, + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const resultLow = EtfScorer.score(metricsLowYield, DEFAULT_RULES); + const resultHigh = EtfScorer.score(metricsHighYield, DEFAULT_RULES); + + // Both should pass gates + assert.ok(resultLow.audit?.passedGates); + assert.ok(resultHigh.audit?.passedGates); + }); + + await t.test('rejects ETF with multiple gate failures', () => { + const metrics: EtfMetrics = { + expenseRatio: 1.0, // High + yield: 0.5, + volume: 100000, // Low + fiveYearReturn: 3, // Low + totalAssets: 100e6, // Small + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + // Should have multiple failures + if (!result.audit?.passedGates && result.audit?.failures) { + assert.ok(result.audit.failures.length > 1); + } + }); + + await t.test('calculates score based on thresholds', () => { + const metricsLowReturn: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 5000000, + fiveYearReturn: 8.5, // Just above gate + totalAssets: 5e9, + }; + + const metricsHighReturn: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 5000000, + fiveYearReturn: 15, // Well above gate + totalAssets: 5e9, + }; + + const resultLow = EtfScorer.score(metricsLowReturn, DEFAULT_RULES); + const resultHigh = EtfScorer.score(metricsHighReturn, DEFAULT_RULES); + + // Both should pass + assert.ok(resultLow.audit?.passedGates); + assert.ok(resultHigh.audit?.passedGates); + }); + + await t.test('penalizes low-volume ETF', () => { + const metricsLowVolume: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 2000000, // Low but above gate + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const metricsHighVolume: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 50000000, // High volume + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const resultLow = EtfScorer.score(metricsLowVolume, DEFAULT_RULES); + const resultHigh = EtfScorer.score(metricsHighVolume, DEFAULT_RULES); + + // Both pass gates, but high volume should score higher + assert.ok(resultLow.audit?.passedGates); + assert.ok(resultHigh.audit?.passedGates); + }); + + await t.test('handles extremely high expense ratio', () => { + const metrics: EtfMetrics = { + expenseRatio: 5.0, // 5%! + yield: 2.5, + volume: 5000000, + fiveYearReturn: 10, + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + }); + + await t.test('handles negative 5-year return', () => { + const metrics: EtfMetrics = { + expenseRatio: 0.1, + yield: 2.5, + volume: 5000000, + fiveYearReturn: -5, // Negative return + totalAssets: 5e9, + }; + + const result = EtfScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + }); +}); diff --git a/tests/helpers/mockDb.ts b/tests/helpers/mockDb.ts new file mode 100644 index 0000000..4d095cf --- /dev/null +++ b/tests/helpers/mockDb.ts @@ -0,0 +1,42 @@ +/** + * MockDatabaseConnection — in-memory stub for tests. + * + * Substitutes for DatabaseConnection when better-sqlite3 is unavailable + * (e.g. native binary built for wrong platform). + * All mutation methods are no-ops; read methods return empty results. + */ + +import { QueryBuilder } from '../../server/domains/shared/utils/QueryBuilder.js'; +import { QueryAudit } from '../../server/domains/shared/db/QueryAudit.js'; + +export class MockDatabaseConnection { + private audit = new QueryAudit(); + + all>(_qb: QueryBuilder): T[] { + return []; + } + + get>(_qb: QueryBuilder): T | null { + return null; + } + + run(_qb: QueryBuilder): number { + return 0; + } + + transaction(fn: () => T): T { + return fn(); + } + + raw(): never { + throw new Error('MockDatabaseConnection: raw() not available in tests'); + } + + getAudit(): QueryAudit { + return this.audit; + } + + clearStatementCache(): void {} + + printAudit(): void {} +} diff --git a/tests/portfolio-advisor.test.ts b/tests/portfolio-advisor.test.ts new file mode 100644 index 0000000..a374ffd --- /dev/null +++ b/tests/portfolio-advisor.test.ts @@ -0,0 +1,278 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PortfolioAdvisor } from '../server/domains/portfolio/PortfolioAdvisor.js'; +import { SIGNAL } from '../server/domains/shared/config/constants.js'; +import type { + PortfolioHolding, + AdviceRow, +} from '../server/domains/shared/types/portfolio.model.js'; +import type { ScreenerResult } from '../server/domains/shared/types/asset.model.js'; + +class MockYahooClient { + async fetchSummary(ticker: string) { + const prices: Record = { + AAPL: 189.5, + MSFT: 425.3, + 'BRK-B': 385.0, + }; + + return { + price: { + regularMarketPrice: prices[ticker] || 100, + marketCap: 1e12, + }, + summaryDetail: { + fiftyTwoWeekHigh: (prices[ticker] || 100) * 1.1, + fiftyTwoWeekLow: (prices[ticker] || 100) * 0.9, + }, + quoteType: { quoteType: 'EQUITY' }, + defaultKeyStatistics: { trailingPE: 20 }, + financialData: { + returnOnEquity: 0.2, + operatingMargins: 0.15, + grossMargins: 0.4, + freeCashflow: 50e9, + totalRevenue: 200e9, + debtToEquity: 50, + currentRatio: 1.2, + }, + incomeStatementHistoryQuarterly: { + incomeStatementHistory: [{ commonStockSharesOutstanding: 2.6e9, netIncome: 25e9 }], + }, + }; + } + + normalise(ticker: string): string { + return ticker.replace(/\./g, '-'); + } +} + +// Helper: build a minimal ScreenerResult with one STOCK result +function makeScreenerResult(ticker: string, signal: string, price: number): ScreenerResult { + return { + STOCK: [ + { + signal, + fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + asset: { + ticker, + currentPrice: price, + type: 'STOCK', + getDisplayMetrics: () => ({}), + } as any, + displayMetrics: {}, + } as any, + ], + ETF: [], + BOND: [], + ERROR: [], + marketContext: null as any, + }; +} + +test('PortfolioAdvisor', async (t) => { + await t.test('analyzes portfolio with single holding', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + ]; + const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5); + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + assert.equal(advice.length, 1); + }); + + await t.test('calculates position gain/loss correctly', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + ]; + const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5); + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + const row = advice[0] as AdviceRow; + // gain = 10 * (189.5 - 150) = $395 + assert.ok(row.gainLossPct !== null); + }); + + await t.test('handles empty portfolio', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const screened: ScreenerResult = { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: null as any, + }; + const advice = await advisor.advise([], screened); + assert.ok(Array.isArray(advice)); + assert.equal(advice.length, 0); + }); + + await t.test('normalizes BRK.B to BRK-B', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'BRK.B', shares: 5, costBasis: 350, source: 'manual', type: 'stock' }, + ]; + const screened = makeScreenerResult('BRK-B', SIGNAL.NEUTRAL, 385.0); + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + assert.equal(advice.length, 1); + }); + + await t.test('analyzes multiple holdings', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + { ticker: 'MSFT', shares: 5, costBasis: 400, source: 'manual', type: 'stock' }, + ]; + const screened: ScreenerResult = { + STOCK: [ + { + signal: SIGNAL.STRONG_BUY, + fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + asset: { + ticker: 'AAPL', + currentPrice: 189.5, + type: 'STOCK', + getDisplayMetrics: () => ({}), + } as any, + displayMetrics: {}, + } as any, + { + signal: SIGNAL.BUY, + fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + asset: { + ticker: 'MSFT', + currentPrice: 425.3, + type: 'STOCK', + getDisplayMetrics: () => ({}), + } as any, + displayMetrics: {}, + } as any, + ], + ETF: [], + BOND: [], + ERROR: [], + marketContext: null as any, + }; + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + assert.equal(advice.length, 2); + }); + + await t.test('maps signal to advice recommendation', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + ]; + + for (const signal of [SIGNAL.STRONG_BUY, SIGNAL.NEUTRAL, SIGNAL.AVOID]) { + const screened = makeScreenerResult('AAPL', signal, 189.5); + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + } + }); + + await t.test('handles fractional shares', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10.5, costBasis: 150, source: 'manual', type: 'stock' }, + ]; + const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5); + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + assert.equal(advice.length, 1); + }); + + await t.test('returns advice rows with ticker information', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + ]; + const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5); + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + const row = advice[0] as AdviceRow; + assert.ok(row.ticker); + }); + + await t.test('handles missing current price gracefully', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'UNKNOWN', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + ]; + const screened: ScreenerResult = { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: null as any, + }; + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + assert.equal(advice.length, 1); + assert.equal(advice[0].currentPrice, null); + }); + + await t.test('calculates total portfolio value', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + { ticker: 'MSFT', shares: 5, costBasis: 400, source: 'manual', type: 'stock' }, + ]; + const screened: ScreenerResult = { + STOCK: [ + { + signal: SIGNAL.STRONG_BUY, + fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + asset: { + ticker: 'AAPL', + currentPrice: 189.5, + type: 'STOCK', + getDisplayMetrics: () => ({}), + } as any, + displayMetrics: {}, + } as any, + { + signal: SIGNAL.BUY, + fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, + asset: { + ticker: 'MSFT', + currentPrice: 425.3, + type: 'STOCK', + getDisplayMetrics: () => ({}), + } as any, + displayMetrics: {}, + } as any, + ], + ETF: [], + BOND: [], + ERROR: [], + marketContext: null as any, + }; + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + assert.equal(advice.length, 2); + // AAPL: 10 * 189.5 = 1895, MSFT: 5 * 425.3 = 2126.5 + const totalValue = advice.reduce((sum, r) => sum + parseFloat(r.marketValue ?? '0'), 0); + assert.ok(totalValue > 0); + }); + + await t.test('signals match in advice rows', async () => { + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const holdings: PortfolioHolding[] = [ + { ticker: 'AAPL', shares: 10, costBasis: 150, source: 'manual', type: 'stock' }, + ]; + const screened = makeScreenerResult('AAPL', SIGNAL.STRONG_BUY, 189.5); + const advice = await advisor.advise(holdings, screened); + assert.ok(Array.isArray(advice)); + const row = advice[0] as AdviceRow; + assert.equal(row.signal, SIGNAL.STRONG_BUY); + }); +}); diff --git a/tests/portfolio-controller.test.ts b/tests/portfolio-controller.test.ts new file mode 100644 index 0000000..1eeb10f --- /dev/null +++ b/tests/portfolio-controller.test.ts @@ -0,0 +1,305 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { FinanceController } from '../server/domains/finance/finance.controller.js'; +import { PortfolioAdvisor } from '../server/domains/portfolio/PortfolioAdvisor.js'; +import type { PortfolioHolding } from '../server/domains/shared/types/portfolio.model.js'; + +class MockScreenerEngine { + async screenTickers() { + return { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: { + sp500Price: 5500, + riskFreeRate: 4.5, + vixLevel: 12.5, + rateRegime: 'NORMAL' as const, + volatilityRegime: 'LOW' as const, + benchmarks: { + marketPE: 20, + techPE: 28, + reitYield: 3.5, + igSpread: 1.2, + }, + }, + }; + } +} + +class MockPortfolioRepository { + async getHoldings() { + return [ + { + id: '1', + ticker: 'AAPL', + shares: 10, + costBasis: 150, + type: 'stock' as const, + }, + ]; + } + + async addHolding(holding: PortfolioHolding) { + return { id: '1', ...holding }; + } + + async deleteHolding(_id: string) { + return true; + } +} + +class MockYahooClient { + async fetchSummary() { + return { + price: { regularMarketPrice: 189.5 }, + summaryDetail: { fiftyTwoWeekHigh: 199, fiftyTwoWeekLow: 164 }, + quoteType: { quoteType: 'EQUITY' }, + defaultKeyStatistics: { trailingPE: 28.5 }, + financialData: { + returnOnEquity: 95.2 / 100, + operatingMargins: 30.7 / 100, + grossMargins: 46.2 / 100, + freeCashflow: 110e9, + totalRevenue: 383.3e9, + debtToEquity: 75.56, + currentRatio: 0.95, + }, + incomeStatementHistoryQuarterly: { + incomeStatementHistory: [ + { + commonStockSharesOutstanding: 15.6e9, + netIncome: 25e9, + }, + ], + }, + }; + } +} + +test('FinanceController', async (t) => { + await t.test('registers portfolio endpoint', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const controller = new FinanceController(engine, repository, advisor); + + let portfolioEndpointRegistered = false; + const mockApp = { + get: (path: string) => { + if (path === '/api/finance/portfolio') portfolioEndpointRegistered = true; + }, + post: () => {}, + delete: () => {}, + }; + + controller.register(mockApp as any); + assert.ok(portfolioEndpointRegistered); + }); + + await t.test('registers market context endpoint', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const controller = new FinanceController(engine, repository, advisor); + + let contextEndpointRegistered = false; + const mockApp = { + get: (path: string) => { + if (path === '/api/finance/market-context') contextEndpointRegistered = true; + }, + post: () => {}, + delete: () => {}, + }; + + controller.register(mockApp as any); + assert.ok(contextEndpointRegistered); + }); + + await t.test('registers holdings endpoints', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const controller = new FinanceController(engine, repository, advisor); + + let addHoldingRegistered = false; + let deleteHoldingRegistered = false; + + const mockApp = { + get: () => {}, + post: (path: string) => { + if (path === '/api/finance/holdings') addHoldingRegistered = true; + }, + delete: (path: string) => { + if (path === '/api/finance/holdings/:ticker') deleteHoldingRegistered = true; + }, + }; + + controller.register(mockApp as any); + assert.ok(addHoldingRegistered); + assert.ok(deleteHoldingRegistered); + }); + + await t.test('returns portfolio with holdings', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const holdings = await repository.getHoldings(); + assert.ok(Array.isArray(holdings)); + assert.equal(holdings.length, 1); + assert.equal(holdings[0].ticker, 'AAPL'); + }); + + await t.test('returns market context data', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const results = await engine.screenTickers([]); + assert.ok(results.marketContext); + assert.ok(results.marketContext.sp500Price); + assert.ok(results.marketContext.benchmarks); + }); + + await t.test('adds holding to portfolio', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const newHolding: PortfolioHolding = { + ticker: 'MSFT', + shares: 5, + costBasis: 400, + source: 'manual', + type: 'stock', + }; + + const added = await repository.addHolding(newHolding); + assert.ok(added); + assert.equal(added.ticker, 'MSFT'); + }); + + await t.test('deletes holding from portfolio', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const deleted = await repository.deleteHolding('1'); + assert.ok(deleted); + }); + + await t.test('handles empty portfolio gracefully', async () => { + class EmptyRepository { + async getHoldings() { + return []; + } + + async addHolding(holding: PortfolioHolding) { + return { id: '1', ...holding }; + } + + async deleteHolding(_id: string) { + return true; + } + } + + const engine = new MockScreenerEngine() as any; + const repository = new EmptyRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const holdings = await repository.getHoldings(); + assert.equal(holdings.length, 0); + }); + + await t.test('analyzes portfolio advice', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const holdings = await repository.getHoldings(); + const advice = await advisor.advise(holdings, { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: null as any, + }); + assert.ok(advice); + }); + + await t.test('includes holdings in portfolio response', async () => { + const engine = new MockScreenerEngine() as any; + const repository = new MockPortfolioRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const holdings = await repository.getHoldings(); + const advice = await advisor.advise(holdings, { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: null as any, + }); + + assert.ok(holdings); + assert.ok(advice); + }); + + await t.test('handles multiple holdings in portfolio', async () => { + class MultiHoldingRepository { + async getHoldings() { + return [ + { + id: '1', + ticker: 'AAPL', + shares: 10, + costBasis: 150, + type: 'stock' as const, + }, + { + id: '2', + ticker: 'MSFT', + shares: 5, + costBasis: 400, + type: 'stock' as const, + }, + { + id: '3', + ticker: 'VOO', + shares: 20, + costBasis: 350, + type: 'etf' as const, + }, + ]; + } + + async addHolding(holding: PortfolioHolding) { + return { id: '4', ...holding }; + } + + async deleteHolding(_id: string) { + return true; + } + } + + const engine = new MockScreenerEngine() as any; + const repository = new MultiHoldingRepository() as any; + const advisor = new PortfolioAdvisor(new MockYahooClient() as any); + const _controller = new FinanceController(engine, repository, advisor); + + const holdings = await repository.getHoldings(); + assert.equal(holdings.length, 3); + assert.ok(holdings.some((h: any) => h.ticker === 'AAPL')); + assert.ok(holdings.some((h: any) => h.ticker === 'MSFT')); + assert.ok(holdings.some((h: any) => h.type === 'etf')); + }); +}); diff --git a/tests/screener-controller.test.ts b/tests/screener-controller.test.ts new file mode 100644 index 0000000..03bbcc5 --- /dev/null +++ b/tests/screener-controller.test.ts @@ -0,0 +1,196 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ScreenerController } from '../server/domains/screener/screener.controller.js'; +import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js'; + +import type { + LiveAssetResult, + MarketContext, + Stock, +} from '../server/domains/shared/types/index.js'; +import { ASSET_TYPE, SIGNAL } from '../server/domains/shared/config/constants.js'; + +// Mock implementations +class MockScreenerEngine extends ScreenerEngine { + async screenTickers(tickers: string[]) { + const mockContext: MarketContext = { + sp500Price: 5500, + riskFreeRate: 4.5, + vixLevel: 12.5, + rateRegime: 'NORMAL', + volatilityRegime: 'LOW', + benchmarks: { + marketPE: 20, + techPE: 28, + reitYield: 3.5, + igSpread: 1.2, + }, + }; + + // Return mock results for tested tickers + const mockStock = { + ticker: 'AAPL', + type: ASSET_TYPE.STOCK, + currentPrice: 189.5, + metrics: { + peRatio: 28.5, + returnOnEquity: 95.2, + freeCashFlow: 100000000, + debtToEquity: 0.75, + }, + getDisplayMetrics: () => ({ + peRatio: 28.5, + returnOnEquity: 95.2, + freeCashFlow: 100000000, + }), + } as unknown as Stock; + + const mockResult: LiveAssetResult = { + asset: mockStock, + fundamentalScore: { label: '✓ BUY', scoreSummary: 'Quality gate PASS' }, + inflatedScore: { label: '✓ BUY', scoreSummary: 'Market adjusted gate PASS' }, + signal: SIGNAL.STRONG_BUY, + }; + + return { + STOCK: tickers.length > 0 ? [mockResult] : [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: mockContext, + }; + } +} + +class MockCatalystCache { + async get() { + return { + tickers: ['AAPL', 'MSFT', 'NVDA'], + stories: [ + { + headline: 'Apple beats Q2 earnings', + summary: 'Strong iPhone sales boost revenue', + sentiment: 'positive', + relatedTickers: ['AAPL'], + }, + ], + }; + } +} + +test('ScreenerController', async (t) => { + await t.test('screen() processes valid ticker list', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const cache = new MockCatalystCache() as any; + const controller = new ScreenerController(engine, cache); + + // Create a mock FastifyInstance with only the methods we need + const mockApp = { + post: () => {}, + get: () => {}, + }; + + // Register should succeed + controller.register(mockApp as any); + assert.ok(true); + }); + + await t.test('screen() serializes assets correctly', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const cache = new MockCatalystCache() as any; + const _controller = new ScreenerController(engine, cache); + + // Test private serialization method indirectly through results + const results = await engine.screenTickers(['AAPL']); + assert.equal(results.STOCK.length, 1); + assert.ok(results.STOCK[0].asset.ticker === 'AAPL'); + }); + + await t.test('screen() handles empty ticker list', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const cache = new MockCatalystCache() as any; + const _controller = new ScreenerController(engine, cache); + + const results = await engine.screenTickers([]); + assert.equal(results.STOCK.length, 0); + assert.equal(results.ETF.length, 0); + assert.equal(results.BOND.length, 0); + }); + + await t.test('catalysts() returns cached results', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const cache = new MockCatalystCache() as any; + const _controller = new ScreenerController(engine, cache); + + const catalysts = await cache.get(); + assert.ok(Array.isArray(catalysts.tickers)); + assert.ok(Array.isArray(catalysts.stories)); + assert.equal(catalysts.tickers.length, 3); + }); + + await t.test('catalysts() includes headlines and sentiment', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const cache = new MockCatalystCache() as any; + const _controller = new ScreenerController(engine, cache); + + const catalysts = await cache.get(); + const story = catalysts.stories[0]; + assert.ok(story.headline); + assert.ok(story.sentiment); + assert.ok(story.relatedTickers); + }); + + await t.test('controller registers POST /api/screen endpoint', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const cache = new MockCatalystCache() as any; + const controller = new ScreenerController(engine, cache); + + let screenEndpointRegistered = false; + const mockApp = { + post: (path: string) => { + if (path === '/api/screen') screenEndpointRegistered = true; + }, + get: () => {}, + }; + + controller.register(mockApp as any); + assert.ok(screenEndpointRegistered); + }); + + await t.test('controller registers GET /api/screen/catalysts endpoint', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const cache = new MockCatalystCache() as any; + const controller = new ScreenerController(engine, cache); + + let catalystEndpointRegistered = false; + const mockApp = { + post: () => {}, + get: (path: string) => { + if (path === '/api/screen/catalysts') catalystEndpointRegistered = true; + }, + }; + + controller.register(mockApp as any); + assert.ok(catalystEndpointRegistered); + }); + + await t.test('screen() includes market context in response', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const results = await engine.screenTickers(['AAPL']); + + assert.ok(results.marketContext); + assert.ok(results.marketContext.sp500Price); + assert.ok(results.marketContext.benchmarks); + }); + + await t.test('screen() includes verdict signal in results', async () => { + const engine = new MockScreenerEngine(null as any, null as any); + const results = await engine.screenTickers(['AAPL']); + + assert.equal(results.STOCK.length, 1); + const result = results.STOCK[0]; + assert.ok(result.signal); + assert.ok(result.fundamentalScore); + assert.ok(result.inflatedScore); + }); +}); diff --git a/tests/screener-engine.test.ts b/tests/screener-engine.test.ts new file mode 100644 index 0000000..9f47b5e --- /dev/null +++ b/tests/screener-engine.test.ts @@ -0,0 +1,196 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js'; +import { BenchmarkProvider } from '../server/domains/shared/services/BenchmarkProvider.js'; +import { noopLogger } from '../server/domains/shared/utils/logger.js'; +import type { MarketContext } from '../server/domains/shared/types/market.model.js'; + +// Mock Yahoo Finance Client +class MockYahooClient { + async fetchSummary(ticker: string) { + if (ticker === 'INVALID') { + throw new Error('Not found'); + } + + return { + price: { + regularMarketPrice: ticker === 'AAPL' ? 189.5 : 425.3, + marketCap: ticker === 'AAPL' ? 2.8e12 : 1.6e12, + }, + summaryDetail: { + fiftyTwoWeekHigh: ticker === 'AAPL' ? 199.62 : 468.5, + fiftyTwoWeekLow: ticker === 'AAPL' ? 164.08 : 380.2, + }, + quoteType: { quoteType: 'EQUITY' }, + defaultKeyStatistics: { + trailingPE: ticker === 'AAPL' ? 28.5 : 32.1, + }, + financialData: { + returnOnEquity: (ticker === 'AAPL' ? 95.2 : 48.5) / 100, + operatingMargins: (ticker === 'AAPL' ? 30.7 : 27.8) / 100, + grossMargins: (ticker === 'AAPL' ? 46.2 : 45.5) / 100, + freeCashflow: ticker === 'AAPL' ? 110e9 : 60e9, + totalRevenue: ticker === 'AAPL' ? 383.3e9 : 215.1e9, + debtToEquity: ticker === 'AAPL' ? 75.56 : 55.2, + currentRatio: ticker === 'AAPL' ? 0.95 : 1.2, + }, + incomeStatementHistoryQuarterly: { + incomeStatementHistory: [ + { + commonStockSharesOutstanding: ticker === 'AAPL' ? 15.6e9 : 9.3e9, + netIncome: ticker === 'AAPL' ? 25e9 : 20e9, + }, + ], + }, + }; + } + + async fetchCalendarEvents() { + return []; + } + + async search() { + return { quotes: [] }; + } +} + +class MockBenchmarkProvider extends BenchmarkProvider { + async getMarketContext(): Promise { + return { + sp500Price: 5500, + riskFreeRate: 4.5, + vixLevel: 12.5, + rateRegime: 'NORMAL', + volatilityRegime: 'LOW', + benchmarks: { + marketPE: 20, + techPE: 28, + reitYield: 3.5, + igSpread: 1.2, + }, + }; + } +} + +test('ScreenerEngine', async (t) => { + await t.test('screenTickers() processes valid ticker', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenTickers(['AAPL']); + assert.ok(results); + assert.ok('STOCK' in results); + assert.ok('ETF' in results); + assert.ok('BOND' in results); + assert.ok('ERROR' in results); + }); + + await t.test('screenTickers() handles error gracefully', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenTickers(['INVALID']); + assert.equal(results.ERROR.length, 1); + assert.equal(results.ERROR[0].ticker, 'INVALID'); + assert.ok(results.ERROR[0].message); + }); + + await t.test('screenTickers() normalizes ticker to uppercase', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenTickers(['aapl']); + // Should process without error + assert.ok(results); + }); + + await t.test('screenTickers() batches requests with delay', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const startTime = Date.now(); + await engine.screenTickers(['AAPL', 'MSFT']); + const endTime = Date.now(); + + // Should have delay between batches (1000ms per batch) + assert.ok(endTime - startTime >= 0); // At minimum, some time should pass + }); + + await t.test('screenTickers() returns market context', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenTickers(['AAPL']); + assert.ok(results.marketContext); + assert.equal(results.marketContext.sp500Price, 5500); + assert.equal(results.marketContext.rateRegime, 'NORMAL'); + }); + + await t.test('screenTickers() handles empty list', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenTickers([]); + assert.equal(results.STOCK.length, 0); + assert.equal(results.ETF.length, 0); + assert.equal(results.BOND.length, 0); + assert.equal(results.ERROR.length, 0); + }); + + await t.test('screenTickers() processes multiple tickers', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenTickers(['AAPL', 'MSFT']); + const totalResults = + results.STOCK.length + results.ETF.length + results.BOND.length + results.ERROR.length; + assert.equal(totalResults, 2); + }); + + await t.test('screenWithProgress() works without logger', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenWithProgress(['AAPL']); + assert.ok(results); + assert.ok('marketContext' in results); + }); + + await t.test('screenTickers() processes large ticker list correctly', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + // Create array of 10 tickers (batch size is 5, so should need 2 batches) + const tickers = Array(10) + .fill(0) + .map((_, i) => (i % 2 === 0 ? 'AAPL' : 'MSFT')); + const results = await engine.screenTickers(tickers); + + const totalResults = + results.STOCK.length + results.ETF.length + results.BOND.length + results.ERROR.length; + assert.equal(totalResults, 10); + }); + + await t.test('screenTickers() includes scoring details', async () => { + const client = new MockYahooClient(); + const benchmark = new MockBenchmarkProvider(null as any); + const engine = new ScreenerEngine(client as any, benchmark, { logger: noopLogger }); + + const results = await engine.screenTickers(['AAPL']); + if (results.STOCK.length > 0) { + const result = results.STOCK[0]; + assert.ok(result.signal); + assert.ok(result.fundamental); + assert.ok(result.inflated); + } + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..c202294 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,30 @@ +/** + * Test Setup — Handle platform-specific issues gracefully + * + * This file runs before tests to handle: + * - Platform mismatches (macOS binaries in Linux environment) + * - Native module loading failures (better-sqlite3, esbuild) + * - Environment-specific test skipping + */ + +const canLoadNativeModules = () => { + try { + require('better-sqlite3'); + return true; + } catch (err) { + if (err.code === 'ERR_MODULE_NOT_FOUND' || err.message.includes('wrong platform')) { + return false; + } + throw err; + } +}; + +export const canRunDatabaseTests = canLoadNativeModules(); + +// Set environment variable for test suite +if (!canRunDatabaseTests) { + process.env.SKIP_DATABASE_TESTS = 'true'; + console.warn('⚠️ Native modules not available (platform mismatch detected)'); + console.warn(' Skipping database-dependent tests'); + console.warn(' Run tests on macOS or rebuild with: npm ci'); +} diff --git a/tests/stock-scorer.test.ts b/tests/stock-scorer.test.ts new file mode 100644 index 0000000..22994d1 --- /dev/null +++ b/tests/stock-scorer.test.ts @@ -0,0 +1,279 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { StockScorer } from '../server/domains/screener/scorers/StockScorer.js'; +import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js'; +import type { StockMetrics } from '../server/domains/shared/types/models.model.js'; + +const DEFAULT_RULES = { + gates: ScoringConfig.base.gates.STOCK, + weights: ScoringConfig.base.weights.STOCK, + thresholds: ScoringConfig.base.thresholds.STOCK, +}; + +test('StockScorer', async (t) => { + await t.test('rejects stock with high debt-to-equity ratio', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.0, + debtToEquity: 2.5, // Exceeds 1.5 gate + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('D/E')); + }); + + await t.test('rejects stock with low quick ratio', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.0, + debtToEquity: 1.0, + quickRatio: 0.5, // Below 0.8 gate + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('Quick')); + }); + + await t.test('rejects stock with high P/E ratio', () => { + const metrics: StockMetrics = { + peRatio: 25, // Exceeds 15 gate + pegRatio: 1.0, + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('P/E')); + }); + + await t.test('rejects stock with high PEG ratio', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.5, // Exceeds 1.0 gate + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + assert.ok(result.scoreSummary.includes('PEG')); + }); + + await t.test('scores high-quality stock positively', () => { + const metrics: StockMetrics = { + peRatio: 12, // Below gate + pegRatio: 0.8, // Below gate + debtToEquity: 0.5, + quickRatio: 1.2, + returnOnEquity: 25, // High ROE + operatingMargin: 20, + netProfitMargin: 15, + revenueGrowth: 10, + fcfYield: 5, + priceToBook: 2.0, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.notEqual(result.label, '🔴 REJECT'); + // Should have positive score + assert.ok(result.audit?.passedGates); + }); + + await t.test('handles null/undefined metrics gracefully', () => { + const metrics: StockMetrics = { + peRatio: 15, + pegRatio: 1.0, + debtToEquity: null, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: null, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + // Should not crash, null values are skipped in gate checks + assert.ok(result); + }); + + await t.test('passes all quality gates for strong stock', () => { + const metrics: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 30, + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.ok(result.audit?.passedGates); + assert.notEqual(result.label, '🔴 REJECT'); + }); + + await t.test('includes audit trail of gate checks', () => { + const metrics: StockMetrics = { + peRatio: 25, + pegRatio: 1.0, + debtToEquity: 1.0, + quickRatio: 1.0, + returnOnEquity: 20, + operatingMargin: 15, + netProfitMargin: 10, + revenueGrowth: 5, + fcfYield: 3, + priceToBook: 1.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.ok(result.audit); + if (!result.audit?.passedGates) { + assert.ok(Array.isArray(result.audit?.failures)); + } + }); + + await t.test('scores ROE as primary quality factor', () => { + const metricsLowRoe: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 5, // Low ROE + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const metricsHighRoe: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 40, // High ROE + operatingMargin: 25, + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const resultLow = StockScorer.score(metricsLowRoe, DEFAULT_RULES); + const resultHigh = StockScorer.score(metricsHighRoe, DEFAULT_RULES); + + // Both should pass gates, but high ROE should score better + assert.ok(resultLow.audit?.passedGates); + assert.ok(resultHigh.audit?.passedGates); + }); + + await t.test('rejects stock with multiple gate failures', () => { + const metrics: StockMetrics = { + peRatio: 25, // High + pegRatio: 1.5, // High + debtToEquity: 2.5, // High + quickRatio: 0.5, // Low + returnOnEquity: 5, + operatingMargin: 5, + netProfitMargin: 2, + revenueGrowth: -5, + fcfYield: -1, + priceToBook: 0.5, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + assert.equal(result.label, '🔴 REJECT'); + // Should have multiple failures + if (!result.audit?.passedGates && result.audit?.failures) { + assert.ok(result.audit.failures.length > 1); + } + }); + + await t.test('handles edge case of zero metrics', () => { + const metrics: StockMetrics = { + peRatio: 0, + pegRatio: 0, + debtToEquity: 0, + quickRatio: 0, + returnOnEquity: 0, + operatingMargin: 0, + netProfitMargin: 0, + revenueGrowth: 0, + fcfYield: 0, + priceToBook: 0, + } as any; + + const result = StockScorer.score(metrics, DEFAULT_RULES); + // Should handle gracefully (zero is falsy, treated as null) + assert.ok(result); + }); + + await t.test('scores based on configured thresholds', () => { + const metricsLowMargin: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 20, + operatingMargin: 5, // Below medium threshold + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const metricsHighMargin: StockMetrics = { + peRatio: 10, + pegRatio: 0.9, + debtToEquity: 0.3, + quickRatio: 1.5, + returnOnEquity: 20, + operatingMargin: 25, // Above high threshold + netProfitMargin: 18, + revenueGrowth: 8, + fcfYield: 6, + priceToBook: 3.0, + } as any; + + const resultLow = StockScorer.score(metricsLowMargin, DEFAULT_RULES); + const resultHigh = StockScorer.score(metricsHighMargin, DEFAULT_RULES); + + // Both should pass gates + assert.ok(resultLow.audit?.passedGates); + assert.ok(resultHigh.audit?.passedGates); + }); +});