fix bruno collection
This commit is contained in:
@@ -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.
|
||||||
@@ -12,6 +12,7 @@ A personal stock screener and portfolio tracker. Scores stocks, ETFs, and bonds
|
|||||||
- [Running Tests](#running-tests)
|
- [Running Tests](#running-tests)
|
||||||
- [Project Structure](#project-structure)
|
- [Project Structure](#project-structure)
|
||||||
- [User Guide](#user-guide)
|
- [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
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 20+
|
- **Node.js 20+** (v22 recommended)
|
||||||
- npm 10+
|
- **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
|
### Install
|
||||||
|
|
||||||
@@ -322,3 +331,276 @@ A filtered view showing only tickers with a **✅ Strong Buy** signal across bot
|
|||||||
### API rate limits
|
### 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/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`
|
||||||
|
|||||||
@@ -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: {}
|
||||||
+10
-4
@@ -4,7 +4,8 @@ import rateLimit from '@fastify/rate-limit';
|
|||||||
|
|
||||||
// Domain imports
|
// Domain imports
|
||||||
import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains/screener';
|
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';
|
import { CallsController, CalendarService } from './domains/calls';
|
||||||
|
|
||||||
// Shared infrastructure
|
// Shared infrastructure
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
|
|
||||||
interface BuildAppOptions {
|
interface BuildAppOptions {
|
||||||
logger?: boolean;
|
logger?: boolean;
|
||||||
|
db?: DatabaseConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Adding a new domain ───────────────────────────────────────────────
|
// ── Adding a new domain ───────────────────────────────────────────────
|
||||||
@@ -31,7 +33,7 @@ interface BuildAppOptions {
|
|||||||
// 3. Create barrel: server/domains/<domain>/index.ts
|
// 3. Create barrel: server/domains/<domain>/index.ts
|
||||||
// 4. Import from domain and register controller below
|
// 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 });
|
const app = Fastify({ logger });
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
@@ -58,10 +60,14 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database setup
|
// Database setup — use injected db (for tests) or create real one
|
||||||
|
const db =
|
||||||
|
injectedDb ??
|
||||||
|
(() => {
|
||||||
const rawDb = createDb();
|
const rawDb = createDb();
|
||||||
const audit = new QueryAudit();
|
const audit = new QueryAudit();
|
||||||
const db = new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 });
|
return new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 });
|
||||||
|
})();
|
||||||
|
|
||||||
// Services and clients
|
// Services and clients
|
||||||
const yahoo = new YahooFinanceClient();
|
const yahoo = new YahooFinanceClient();
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
// Portfolio domain — holdings management and advice
|
// Portfolio domain — holdings management and advice
|
||||||
export { FinanceController } from './finance.controller';
|
|
||||||
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||||
|
|||||||
@@ -44,7 +44,9 @@ export class ScreenerEngine {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
this.logger = logger ?? {
|
this.logger = logger ?? {
|
||||||
write: (msg: string) => process.stdout.write(msg),
|
write: (msg: string) => process.stdout.write(msg),
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
log: (...args: unknown[]) => console.log(...args),
|
log: (...args: unknown[]) => console.log(...args),
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
warn: (...args: unknown[]) => console.warn(...args),
|
warn: (...args: unknown[]) => console.warn(...args),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,10 @@ import { CatalystCache, CatalystAnalyst } from '../../domains/shared';
|
|||||||
import { analyzeSchema } from '../../domains/shared/types/schemas';
|
import { analyzeSchema } from '../../domains/shared/types/schemas';
|
||||||
|
|
||||||
export class AnalyzeController {
|
export class AnalyzeController {
|
||||||
private readonly catalystAnalyst: CatalystAnalyst;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalystCache: CatalystCache,
|
private readonly catalystCache: CatalystCache,
|
||||||
private readonly llm: LLMAnalyst,
|
private readonly llm: LLMAnalyst,
|
||||||
) {
|
) {}
|
||||||
// Create a fresh instance for per-ticker story fetching (not cached)
|
|
||||||
this.catalystAnalyst = new CatalystAnalyst();
|
|
||||||
}
|
|
||||||
|
|
||||||
register(app: FastifyInstance): void {
|
register(app: FastifyInstance): void {
|
||||||
app.post(
|
app.post(
|
||||||
@@ -27,13 +22,22 @@ export class AnalyzeController {
|
|||||||
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
|
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' });
|
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
|
||||||
|
|
||||||
const { tickerFrequency } = CatalystAnalyst.rankTickers(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 };
|
return { analysis };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ export class BondScorer {
|
|||||||
|
|
||||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||||
return {
|
return {
|
||||||
label: '🔴 Avoid',
|
label: '🔴 REJECT',
|
||||||
scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||||
audit: { passedGates: false },
|
audit: {
|
||||||
|
passedGates: false,
|
||||||
|
failures: [
|
||||||
|
`creditRating: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,24 @@ export class EtfScorer {
|
|||||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const failures: string[] = [];
|
||||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
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 {
|
return {
|
||||||
label: '🔴 REJECT',
|
label: '🔴 REJECT',
|
||||||
scoreSummary: 'Gate failed: High Expense Ratio',
|
scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`,
|
||||||
audit: { passedGates: false },
|
audit: { passedGates: false, failures },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export class SimpleFINClient {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
this.logger = logger ?? {
|
this.logger = logger ?? {
|
||||||
write: (msg) => process.stdout.write(msg),
|
write: (msg) => process.stdout.write(msg),
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
warn: (...args) => console.warn(...args),
|
warn: (...args) => console.warn(...args),
|
||||||
};
|
};
|
||||||
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
||||||
|
|||||||
@@ -193,3 +193,24 @@ export const ScoringRules: ScoringRulesShape = {
|
|||||||
thresholds: { minSpread: 1.5, maxDuration: 7 },
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MarketCall & { id: string }> {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T = Record<string, unknown>>(_qb: QueryBuilder): T[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T = Record<string, unknown>>(_qb: QueryBuilder): T | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
run(_qb: QueryBuilder): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction<T>(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 {}
|
||||||
|
}
|
||||||
@@ -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<string, number> = {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MarketContext> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user