Compare commits

..

2 Commits

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

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

code-format

code fixes
2026-06-03 01:36:21 -04:00
267 changed files with 6205 additions and 34206 deletions
+2 -13
View File
@@ -2,21 +2,10 @@
# #
# FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org # FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org
# (Settings → Connect an app → copy the token) # (Settings → Connect an app → copy the token)
## Get your key at: https://console.anthropic.com #
ANTHROPIC_API_KEY= SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3Jn...
# do not give below details if simplefin is not setup.
SIMPLEFIN_SETUP_TOKEN=
# #
# AFTER FIRST RUN: the Access URL is written here automatically. # AFTER FIRST RUN: the Access URL is written here automatically.
# Remove SIMPLEFIN_SETUP_TOKEN once this appears. # Remove SIMPLEFIN_SETUP_TOKEN once this appears.
# #
# SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin # SIMPLEFIN_ACCESS_URL=https://user:token@beta-bridge.simplefin.org/simplefin
# ── Docker / Production ───────────────────────────────────────────────────────
# Bearer token for all API routes (optional — leave blank to disable)
API_KEY=
# The public origin of your UI, used by Fastify for CORS
# Set to your domain when behind nginx (e.g. https://screener.example.com)
CLIENT_ORIGIN=http://localhost
-68
View File
@@ -1,68 +0,0 @@
{
"env": {
"node": true,
"es2020": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript"
],
"ignorePatterns": [
"node_modules",
"dist",
"ui"
],
"rules": {
"no-var": "error",
"prefer-const": "error",
"prefer-arrow-callback": "warn",
"no-console": [
"warn",
{
"allow": [
"warn",
"error"
]
}
],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"@typescript-eslint/no-explicit-any": "off",
"no-undef": "off",
"import/order": "off",
"import/no-unresolved": "off"
},
"overrides": [
{
"files": [
"bin/**/*.ts",
"tests/**/*.ts"
],
"rules": {
"no-console": "off"
}
},
{
"files": [
"server/types/**/*.ts",
"server/schemas/**/*.ts"
],
"rules": {
"no-unused-vars": "off"
}
}
]
}
+3 -12
View File
@@ -4,11 +4,6 @@ ui/node_modules
# Sensitive data — never commit # Sensitive data — never commit
portfolio.json portfolio.json
market-calls.json market-calls.json
portfolio.json.migrated
market-calls.json.migrated
market-screener.db
market-screener.db-shm
market-screener.db-wal
.env .env
.env.* .env.*
@@ -16,10 +11,6 @@ market-screener.db-wal
ui/.svelte-kit ui/.svelte-kit
ui/build ui/build
# Runtime cache # Reports
.benchmark-cache.json screener-report.html
finance-report.html
# Documentation (except CLAUDE.md)
*.md
!PHASES.md
!CLAUDE.md
Regular → Executable
+1 -1
View File
@@ -1,2 +1,2 @@
# Lint and auto-fix staged files only (fast)
npx lint-staged npx lint-staged
npm test
Regular → Executable
-1
View File
@@ -1,2 +1 @@
# Run full test suite before push
npm test npm test
+180 -3009
View File
File diff suppressed because it is too large Load Diff
-49
View File
@@ -1,49 +0,0 @@
# ── Stage 1: Build the SvelteKit UI ──────────────────────────────────────────
FROM node:22-alpine AS ui-builder
WORKDIR /app
COPY ui/package*.json ./ui/
RUN cd ui && npm ci --legacy-peer-deps
# UI source + shared server types (needed for $types alias)
COPY ui/ ./ui/
COPY server/ ./server/
WORKDIR /app/ui
ENV NODE_ENV=production
RUN npm run build
# ── Stage 2: Runtime (API + compiled UI) ─────────────────────────────────────
FROM node:22-alpine
WORKDIR /app
# API dependencies (tsx needed at runtime for ESM TypeScript)
COPY package*.json ./
RUN npm ci
# API source
COPY bin/ ./bin/
COPY server/ ./server/
COPY tsconfig*.json ./
# Pre-built UI from stage 1
COPY --from=ui-builder /app/ui/build ./ui/build
COPY --from=ui-builder /app/ui/package*.json ./ui/
RUN cd ui && npm ci --omit=dev --legacy-peer-deps
# SQLite volume mount point
RUN mkdir -p /app/data
ENV NODE_ENV=production
ENV DB_PATH=/app/data/market-screener.db
ENV PORT=3000
ENV UI_PORT=3001
EXPOSE 3000 3001
# Run both processes; if either dies the container exits
CMD ["npx", "concurrently", \
"--kill-others", \
"--names", "api,ui", \
"tsx bin/server.ts", \
"node ui/build/index.js"]
-19
View File
@@ -1,19 +0,0 @@
FROM node:22-alpine
WORKDIR /app
# Install all deps (tsx is needed at runtime for ESM + TypeScript)
COPY package*.json ./
RUN npm ci
# Copy source
COPY bin/ ./bin/
COPY server/ ./server/
COPY tsconfig*.json ./
# SQLite database lives here — mount a volume at /app/data in compose
RUN mkdir -p /app/data
ENV DB_PATH=/app/data/market-screener.db
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npx", "tsx", "bin/server.ts"]
-31
View File
@@ -1,31 +0,0 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copy UI package files and install
COPY ui/package*.json ./ui/
RUN cd ui && npm ci --legacy-peer-deps
# Copy UI source + shared server types (needed for $types alias resolution)
COPY ui/ ./ui/
COPY server/ ./server/
WORKDIR /app/ui
# adapter-auto picks adapter-node when NODE_ENV=production in a container
ENV NODE_ENV=production
RUN npm run build
# --- Runtime stage ---
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/ui/build ./build
COPY --from=builder /app/ui/package*.json ./
RUN npm ci --omit=dev --legacy-peer-deps
EXPOSE 3001
ENV PORT=3001
ENV HOST=0.0.0.0
CMD ["node", "build"]
-989
View File
@@ -1,989 +0,0 @@
# PHASES.md
---
## 📍 Roadmap Status & Realignment — June 2026
Cross-reference: **PRODUCT.md** (P0P3 priorities) and **FREE-DATA-STACK.md** ($0 data architecture). CLAUDE.md "Status Update" has the full shipped list.
### Done ahead of schedule (was "future", now shipped)
| Originally planned as | What actually shipped | Status |
|---|---|---|
| Phase 12 (news webhooks, ~$200/mo) | **Free-tier news pipeline**: EDGAR + PR-wire pollers → filter/dedupe/classify → SQLite; in-server scheduler + cron runner; `/api/news/*` | ✅ Free version shipped. Paid webhook spine = drop-in upgrade (same queue) |
| Phase 14 (real-time monitor + Discord) | **Daily change digest**: snapshot diff + catalyst join → Discord (forum-aware). EOD, not intraday | ✅ EOD version shipped. Real-time price feed still future |
| Phase 10.9 (dip opportunity monitor) | **💎 Quality dips filter**: quality-gate PASS + 10%+ off 52W high, in the STOCK table | ✅ v1 shipped. Dedicated daily monitor + dip attribution still future |
| Phase 10.5d tearsheet (partial) | **Ticker modal**: profile, 1D5Y chart w/ crosshair, analyst target bar, news | ✅ Covers chart/profile/targets/news. Peer comparison + what-ifs pending |
| 10.5e backtest (foundation) | **Signal snapshot ledger** + `/api/screen/history/:ticker` | ✅ Data accumulating; dashboard pending |
| (unplanned) | Market Pulse band, sector drill-down panel, advice layer, turnaround watch, data sentinel, verdict tiers, regime hysteresis | ✅ |
### Still at Phase-7 state (not touched this sprint)
**Portfolio**, **Market Calls**, **Safe Buys** pages — work as before, none of the new
intelligence (advice layer, snapshots, news) is wired into them yet.
### Realigned order of future work
1. **Finish Phase 10.5** — P/E+ROE+52W columns, P/E/ROE range filters, peer-comparison + what-if sections in the ticker modal (items listed in CLAUDE.md)
2. **Phase 10.6 — Portfolio integration** ← biggest gap now: wire signals/advice/snapshots/news into the Portfolio page ("you own this, verdict changed, here's why")
3. **Safe Buys upgrade (10.9 v2)** — rebuild the Safe Buys page on quality-dips + snapshot history + news attribution
4. **10.8a — earnings dates in the ticker modal** (Finnhub free tier, per FREE-DATA-STACK §1.5)
5. **10.5e — decision log + backtest dashboard** (once the ledger has ~3 months of data)
6. **Phase 11 — auth** (already partially present: JWT login/watchlist exist) → then paid upgrades: **Phase 12** webhook spine, **Phase 13** prompt caching, **Phase 14** real-time monitor
---
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
> **June 2026:** v1 SHIPPED as the 💎 Quality dips filter (quality-gate PASS + 10%+ off 52W high). Remaining: dedicated daily monitor, dip-reason attribution, configurable universe/thresholds.
**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
> **June 2026:** Free-tier equivalent SHIPPED (`server/domains/news/` — EDGAR + PR-wire pollers, same queue design). This phase now = adding the paid Polygon/Finnhub real-time spine as another producer. See FREE-DATA-STACK.md.
**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
> **June 2026:** EOD version SHIPPED (`server/domains/digest/` — daily signal-flip digest with catalysts → Discord). This phase now = real-time price feed + intraday dip 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) | $50100 | Most cached; 90% cost reduction |
| OpenAI API (fallback, optional) | $50 | Only if GPT-4 fallback added |
| Alpaca/Interactive Brokers | $30100 | Depends on which feed |
| BullMQ (Redis queue, if scaled) | $030 | Free if self-hosted |
| **Total** | **~$330450/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 (JulySeptember).
---
## 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:** 24 hours. No code changes needed.
---
## Frequently Asked Questions
**Q: How many traders can this system handle?**
A:
- **1050 traders:** Single instance. Costs ~$450/mo.
- **50500 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.
---
## Future Enhancements (Unscheduled)
### FE-1 — Pinned Stocks Watchlist
**Concept:** User can pin any stock from the screener table. Pinned stocks appear in a persistent sidebar or dedicated panel showing:
- Minimal summary: ticker, current price, signal badge, score
- Price-since-pin sparkline — a small inline chart showing how price moved from the day the stock was pinned to today
- Quick unpin button
**Data requirements:**
- Store `{ ticker, pinnedAt, pinnedPrice }` in SQLite (`pinned_stocks` table)
- Fetch daily OHLC history from Yahoo Finance for the period `pinnedAt → now` to power the sparkline
- API: `GET /api/pins` (list), `POST /api/pins` (add), `DELETE /api/pins/:ticker` (remove), `GET /api/pins/:ticker/history` (OHLC since pin)
**UI notes:**
- Pin button (📌) appears on hover of each summary row in the screener table
- Pinned panel can live in a collapsible drawer at the bottom, or a fixed right sidebar
- Sparkline: use a lightweight SVG path (no charting library needed); green if price above pin price, red if below
- On click of the sparkline, open a larger chart modal (Phase FE-1b — can use TradingView widget or Chart.js)
**Why deferred:** Requires persistent per-user state (needs Phase 11 auth to be meaningful across sessions). Build after Phase 11.
---
### FE-2 — Column Header Tooltips ("Why does this matter?")
**Concept:** Clicking a column header in the screener summary row opens a small popover explaining:
- What the metric measures
- What a good vs bad value looks like
- How the screener uses it in scoring
This turns the table into a learning tool — users understand *why* P/E or ROE matters, not just what the number is.
**Suggested content per column:**
| Header | What to explain |
|--------|----------------|
| **Score** | Weighted sum of all factor scores. >6 = quality, <4 = weak. Gates must pass first — score only fires if gates are cleared. |
| **Signal** | Compares two scoring lenses (Mkt-Adjusted vs Graham). Strong Buy = passes both. Momentum = passes inflated only. |
| **P/E** | Price-to-earnings. Lower = cheaper relative to earnings. Gate: <15x (Graham) or <SPY×1.5 (market-adjusted). >30x warrants scrutiny unless high growth. |
| **PEG** | P/E ÷ growth rate. Normalises valuation for growth. <1.0 = paying less than growth justifies. Lynch's standard. |
| **ROE%** | Return on equity — how efficiently the company uses shareholder money. >15% is healthy; >30% is exceptional. Weighted 3× in scoring. |
| **OpMgn%** | Operating margin — profit per dollar of revenue before interest and tax. Measures business efficiency. |
| **FCF%** | Free cash flow yield — cash the business actually generates relative to price. Negative = cash-burning; gate fails. |
| **D/E** | Debt-to-equity. Measures leverage. Gate: <1.5× (general), <2.0× (tech). Higher than 2× raises distress risk. |
| **52W Chg** | Total price return over last 52 weeks. Positive momentum is healthy; >+50% may signal overextension. |
| **From High** | % below the 52-week high. -5% to -15% is a typical dip zone; ≤-30% triggers a risk flag. |
| **Analyst** | Yahoo consensus (1=Strong Buy, 5=Strong Sell). Requires ≥3 analysts to fire. ≤2.0 adds points; >4.0 subtracts. |
| **DCF Safety** | Margin of safety from a two-stage DCF model. Positive = stock appears undervalued vs intrinsic value. Only fires when FCF > 0. |
| **Cap** | Market cap tier: Mega (>$200B), Large ($10B+), Mid ($2B+), Small ($300M+), Micro (<$300M). Smaller = higher risk + volatility. |
| **Style** | Growth classification from revenue + earnings growth rate. High Growth = ≥15% revenue growth. Value = low growth + ≥3% yield. |
**Implementation approach:**
- Add `data-tip` attribute to each `<th>` in `AssetTable.svelte`
- On click, show a positioned `<div class="col-tip">` anchored to the header
- Dismiss on outside click or Escape
- No library needed — pure Svelte `$state` + CSS positioning
- Mobile: tip opens as a bottom sheet modal
**Why not `title` attribute?** `title` tooltips are unstyled, non-interactive, and don't work on touch. A custom popover lets you format the content properly and include a "Good range" callout.
**Why deferred:** Nice-to-have educational feature. Build after the core screener UI (Phase 10.5) is stable.
+171 -595
View File
@@ -1,649 +1,225 @@
# Market Screener # Market Screener
A personal stock screener and portfolio tracker. Scores stocks, ETFs, and bonds under two lenses — **Market-Adjusted** (what's acceptable in today's market) and **Fundamental** (strict Graham value-investing) — then compares them to produce an actionable signal. Comes with a live SvelteKit dashboard. A Node.js stock screener and personal finance assistant. Screens stocks, ETFs, and bonds using live Yahoo Finance data and scores each asset under two lenses — **Market-Adjusted** (what's acceptable in today's market) and **Fundamental** (strict Graham value-investing) — then compares both to give you an honest signal.
Comes with a **Fastify API server** and a companion **SvelteKit dashboard** (`../market-screener-ui`) for an interactive UI, or use it as a CLI to generate HTML reports.
--- ---
## Table of Contents ## Quick Start
- [Developer Setup](#developer-setup)
- [Environment Variables](#environment-variables)
- [Commands](#commands)
- [Running Tests](#running-tests)
- [Project Structure](#project-structure)
- [User Guide](#user-guide)
- [API Testing with Bruno](#api-testing-with-bruno)
---
## Developer Setup
### Prerequisites
- **Node.js 20+** (v22 recommended)
- **npm 10+**
**Check your versions:**
```bash
node --version # Should output v20.x.x or higher
npm --version # Should output 10.x.x or higher
```
**Not on Node 20+?** See [NODE_VERSION_FIX.md](./NODE_VERSION_FIX.md) for upgrade instructions.
### Install
```bash ```bash
# Install server dependencies # API + Dashboard (recommended)
npm install npm install
cd ../market-screener-ui && npm install && cd ../market_screener
npm run dev # starts API on :3000 + UI on :5173
# open http://localhost:5173
# Install UI dependencies (first time only) # CLI only
npm run ui:install npm start # screen today's news catalyst tickers → screener-report.html
```
### Start
```bash
npm run dev
```
This starts both the API server on **port 3000** and the SvelteKit UI on **port 5173** concurrently. Open [http://localhost:5173](http://localhost:5173).
To run the API server alone:
```bash
npm run server
```
---
## Environment Variables
Create a `.env` file in the project root. None are required to run the app — it works with Yahoo Finance data out of the box. Optional keys unlock additional features.
### `ANTHROPIC_API_KEY` — LLM news analysis *(optional)*
Powers the **Analyze** button on each screener section. Without this key the button is disabled.
1. Go to [console.anthropic.com](https://console.anthropic.com)
2. Create an API key under **API Keys**
3. Add to `.env`:
```env
ANTHROPIC_API_KEY=sk-ant-...
```
### `SIMPLEFIN_SETUP_TOKEN` — Live bank/brokerage balances *(optional)*
Powers the personal finance section of the Portfolio page (net worth, account balances, spending breakdown).
1. Go to [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org) and create a Setup Token
2. Add to `.env`:
```env
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
```
On first request the token is claimed automatically and the resulting Access URL is saved back to `.env` as `SIMPLEFIN_ACCESS_URL`. Subsequent restarts use the Access URL directly.
### `API_KEY` — Bearer token auth *(optional)*
When set, every API route requires `Authorization: Bearer <key>`. Useful when the server is exposed to a network. `/health` and OPTIONS preflight are exempt.
```env
API_KEY=your-secret-key
```
### `CLIENT_ORIGIN` — CORS allowed origin *(optional)*
Defaults to `http://localhost:5173`. Change if the UI is served from a different origin.
```env
CLIENT_ORIGIN=https://yourdomain.com
```
### `EDGAR_USER_AGENT` — SEC filings poller *(recommended)*
The news pipeline polls SEC EDGAR for 8-K / SC 13D / S-4 / DEFM14A filings.
The SEC requires a descriptive User-Agent with contact info:
```env
EDGAR_USER_AGENT=market-screener/1.0 you@example.com
```
### `DISCORD_WEBHOOK_URL` — Daily digest alerts *(optional)*
The daily change digest (`npm run digest:daily`) posts signal flips + their
news catalysts to Discord. Create: channel → Settings → Integrations →
Webhooks → New Webhook → copy URL. Paste it RAW (no quotes, no escaping).
Forum channels are supported (each digest becomes a dated post).
Test with `npm run discord:test`.
```env
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
```
### `NEWS_PRWIRE_FEEDS` — Override press-release RSS feeds *(optional)*
Comma-separated RSS URLs. Defaults to GlobeNewswire + PR Newswire. Only
needed if a default feed goes stale or you want to add one.
### `NEWS_POLL` — Disable in-server news polling *(optional)*
Set `NEWS_POLL=off` if you prefer running `npm run news:poll` from cron
instead of polling inside the server (EDGAR 10 min, PR-wire 15 min).
### Complete `.env` example
```env
ANTHROPIC_API_KEY=sk-ant-...
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
API_KEY=optional-secret
CLIENT_ORIGIN=http://localhost:5173
EDGAR_USER_AGENT=market-screener/1.0 you@example.com
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
``` ```
--- ---
## Commands ## Commands
| Command | Description | | Command | What it does |
|---|---| |---|---|
| `npm run dev` | Start API (port 3000) + UI (port 5173) together | | `npm run dev` | Start API server (port 3000) + SvelteKit UI (port 5173) together |
| `npm run server` | Start API server only | | `npm run server` | Start API server only |
| `npm run ui:install` | Install UI dependencies (first time / after `git pull`) | | `npm start` | CLI: fetch today's market news, extract tickers, screen them |
| `npm test` | Run all unit + integration tests | | `npm start -- watch` | CLI: screen the default watchlist |
| `npm run test:watch` | Watch mode — re-run on file changes | | `npm start -- AAPL MSFT VOO` | CLI: screen specific tickers |
| `npm run typecheck` | TypeScript type check without emitting | | `npm run finance` | CLI: portfolio advice + SimpleFIN → `finance-report.html` |
| `npm run import-portfolio -- file.csv` | Import Robinhood/Vanguard/Fidelity CSV into `portfolio.json` |
| `npm test` | Run all 61 unit tests |
| `npm run test:watch` | Re-run tests on file changes |
| `npm run format` | Format all source files with Prettier | | `npm run format` | Format all source files with Prettier |
| `npm run format:check` | Check formatting without writing (used in CI) |
| `npm run lint` | Run ESLint on all TypeScript files |
| `npm run lint:fix` | Auto-fix ESLint issues |
| `npm run screen:daily` | Screen watchlist + holdings, write signal snapshots (cron at market close) |
| `npm run news:poll` | One-shot news poll: EDGAR + PR wires → news DB (cron alternative) |
| `npm run digest:daily` | Daily change digest: signal flips + catalysts → terminal/Discord (run after screen:daily) |
| `npm run discord:test` | Send a fake digest to verify the Discord webhook |
**Recommended cron (weekdays, market close):**
```
30 16 * * 1-5 cd /path/to/market_screener && npm run screen:daily && npm run digest:daily
```
--- ---
## Running Tests ## How the Screener Works
```bash Every asset is scored **twice** under different rule sets:
npm test
### Market-Adjusted mode
Gates derived from live Yahoo Finance benchmarks — reflects what is acceptable in today's market:
| Gate | Formula |
|---|---|
| Stock P/E | S&P 500 P/E (via SPY) × 1.5× (or × 1.2× in HIGH rate regime) |
| Tech P/E | XLK sector P/E × 1.3× |
| REIT min yield | XLRE dividend yield × 0.85× |
| Bond min spread | LQD TNX spread × 0.80× |
### Fundamental mode
Strict Graham/value-investing gates — reflects genuine value regardless of market conditions:
| Gate | Value | Rationale |
|---|---|---|
| Stock P/E | < 15× | Graham's actual rule (trailing earnings) |
| Stock PEG | < 1.0 | Lynch standard: PEG > 1.0 = paying full price |
| D/E ratio | < 1.5× | Distress typically starts above 2× |
| Quick ratio | > 0.8 | Below 0.8 = real liquidity stress |
| Bond spread | > 1.5% | Must clear risk-free rate meaningfully |
| Bond duration | < 7 years | Rate sensitivity management |
### Signals
| Signal | Meaning |
|---|---|
| ✅ Strong Buy | Passes both lenses — genuinely good value |
| ⚡ Momentum | Passes market-adjusted, holds fundamentally |
| ⚠️ Speculation | Passes market-adjusted, fails fundamental — priced for perfection |
| 🔄 Neutral | Hold territory in one or both lenses |
| ❌ Avoid | Fails both |
---
## Sector Overrides
Sector-specific rules apply in both modes (not just inflated):
| Sector | Key adjustments |
|---|---|
| **Technology** | P/E up to 35×, D/E up to 2.0 (buybacks), FCF weight raised |
| **REIT** | P/E/PEG disabled, scored on dividend yield + P/FFO proxy |
| **Financial** | D/E disabled, scored on ROE (≥12%) + P/B (< 1.5×) |
| **Energy** | FCF primary signal (weight 4), dividend yield scored |
| **Healthcare** | Revenue growth primary, P/E up to 25× (R&D burn) |
| **Communication** | FCF primary (META/GOOGL platforms), P/E up to 25× |
| **Consumer Staples** | Margin/ROE focus, low revenue growth expectations (25%) |
| **Consumer Discretionary** | Revenue growth primary, P/E up to 25× |
---
## API Server
```
GET /health → { status: "ok" }
POST /api/screen → screen tickers
body: { tickers: string[] }
GET /api/screen/catalysts → Yahoo news → { tickers, stories }
GET /api/finance/portfolio → portfolio advice + net worth
GET /api/finance/market-context → live benchmark data
``` ```
Uses Node's built-in `node:test` runner — no external framework. **114 test cases** across 9 files cover: Set `CLIENT_ORIGIN` env var to allow a different CORS origin (default: `http://localhost:5173`).
| Test File | Tests | Coverage | ---
|-----------|-------|----------|
| `app.test.ts` | 9 | App bootstrap, CORS, health endpoints |
| `screener-controller.test.ts` | 10 | `/api/screen` endpoints |
| `screener-engine.test.ts` | 11 | Screening orchestration logic |
| `stock-scorer.test.ts` | 13 | Stock valuation gates |
| `etf-scorer.test.ts` | 17 | ETF fund gates |
| `bond-scorer.test.ts` | 16 | Bond credit analysis |
| `portfolio-advisor.test.ts` | 12 | Portfolio advice logic |
| `portfolio-controller.test.ts` | 12 | Portfolio endpoints |
| `calls-controller.test.ts` | 14 | Market calls endpoints |
### Pre-Commit & Pre-Push Hooks ## Personal Finance
On `git commit`, the **pre-commit hook** automatically: Edit `portfolio.json` with your holdings (or import from a broker CSV):
1. **Formats** all files with Prettier ```json
2. **Lints & fixes** staged files with ESLint {
3. **Runs tests** to catch errors early "holdings": [
{ "ticker": "AAPL", "shares": 10, "costBasis": 150.00, "source": "Robinhood", "type": "stock" },
{ "ticker": "VOO", "shares": 8, "costBasis": 380.00, "source": "Vanguard", "type": "etf" },
{ "ticker": "BTC-USD", "shares": 0.25, "costBasis": 45000, "source": "Coinbase", "type": "crypto" }
]
}
```
On `git push`, the **pre-push hook** runs tests again for safety. `npm run finance` (CLI) or `GET /api/finance/portfolio` (API) screens your holdings and cross-references the screener signal with your gain/loss position to give hold/sell/add advice.
### SimpleFIN (optional — live bank/brokerage balances)
1. Get your setup token from [beta-bridge.simplefin.org](https://beta-bridge.simplefin.org)
2. Add to `.env`: `SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...`
3. On first run the Access URL is claimed and saved to `.env` automatically
### Importing broker holdings
```bash
npm run import-portfolio -- ~/Downloads/robinhood_holdings.csv
npm run import-portfolio -- ~/Downloads/vanguard_holdings.csv
```
Broker is auto-detected from CSV headers. Multiple imports merge into `portfolio.json`.
--- ---
## Project Structure ## Project Structure
**Phase 9: Domain-Driven Architecture** (completed)
``` ```
bin/ bin/
server.ts API server entry point screen.js CLI screener entry point
finance.js CLI personal finance entry point
import-portfolio.js Broker CSV importer
server.js Fastify API server entry point
server/ scripts/
app.ts Fastify app factory — wires DI, rate limiting, auth hook summary-reporter.js Custom node:test reporter (silent pass, shows failures + summary)
domains/ Domain-driven structure (shared, screener, portfolio, calls, finance)
shared/ Infrastructure & cross-domain utilities
adapters/ YahooFinanceClient, AnthropicClient, SimpleFINClient
services/ BenchmarkProvider, CatalystAnalyst, LLMAnalyst
entities/ Asset, Stock, Etf, Bond
persistence/ MarketCallRepository, PortfolioRepository
config/ ScoringConfig (gates/weights), constants
scoring/ MarketRegime, scoring overrides
types/ TypeScript interfaces (one file per domain)
screener/ Stock/ETF/Bond filtering & scoring
ScreenerEngine.ts Orchestrates: fetch → score × 2 (fundamental + inflated)
scorers/ StockScorer, EtfScorer, BondScorer
transform/ DataMapper, RuleMerger
portfolio/ Holdings management & investment advice
PortfolioAdvisor.ts Cross-references holdings with screener signals
calls/ Market call tracking & earnings calendar
CalendarService.ts Earnings calendar logic
finance/ Portfolio metrics & reporting
ui/ src/
src/ config/
routes/ SvelteKit pages: /, /portfolio, /calls, /safe-buys ScoringConfig.js All gates, weights, thresholds (single source of truth)
lib/ constants.js Shared enums: SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME
components/ Shared UI components organized by domain
stores/ Svelte 5 reactive stores
api/ Fetch wrappers for each API domain
styles/ Global SCSS design tokens and partials
tests/ Unit + integration tests (9 files, 114 test cases) market/
Controllers, services, scorers fully covered YahooClient.js Wraps yahoo-finance2 v3 with retry + backoff
BenchmarkProvider.js Fetches live benchmarks → marketContext (1-hour cache)
MarketRegime.js Derives inflated gate overrides from live data + rate regime
portfolio.json Your holdings (gitignored — create manually or via the UI) screener/
market-calls.json Persisted market thesis calls (gitignored) ScreenerEngine.js Orchestrates fetch → score × 2.
.benchmark-cache.json Benchmark data cache — survives server restart (gitignored) screenTickers() → pure data (server/CLI)
screenWithProgress() → with stdout progress (CLI only)
DataMapper.js Normalises Yahoo payload → flat asset objects
Uses trailingPE (not forwardPE). Preserves negative FCF.
RuleMerger.js Merges base rules + sector overrides + MarketRegime
assets/ Stock, Etf, Bond data containers
scorers/ StockScorer, EtfScorer, BondScorer (stateless)
analyst/
CatalystAnalyst.js Extracts tickers from Yahoo Finance news headlines
finance/
clients/
SimpleFINClient.js Auth + account fetching via Basic Auth header
PersonalFinanceAnalyzer.js Net worth, cash vs investments, spending
PortfolioAdvisor.js Hold/sell/add advice per holding
PortfolioImporter.js Parses Robinhood/Vanguard/Fidelity CSV
reporters/
HtmlReporter.js render() → string | generate() → file (CLI)
FinanceReporter.js render() → string | generate() → file (CLI)
server/
app.js Fastify app factory (buildApp)
routes/
screener.js POST /api/screen, GET /api/screen/catalysts
finance.js GET /api/finance/portfolio, GET /api/finance/market-context
``` ```
See **[CLAUDE.md](./CLAUDE.md)** for detailed architecture and **[PHASES.md](./PHASES.md)** for the complete roadmap.
--- ---
## User Guide ## Environment Variables
### Screener tab ```bash
# .env
SIMPLEFIN_ACCESS_URL=https://user:pass@beta-bridge.simplefin.org/simplefin
# or on first run:
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
The main view. On load it automatically fetches today's financial news, extracts the most-mentioned tickers, and screens them. # Optional server config
PORT=3000
#### Market context strip HOST=0.0.0.0
CLIENT_ORIGIN=http://localhost:5173 # CORS allowed origin for SvelteKit UI
The row of chips at the top shows live benchmark data fetched from Yahoo Finance: ```
| Chip | Meaning |
|---|---|
| 10Y | 10-year Treasury yield — the risk-free rate |
| VIX | Volatility index — market fear gauge |
| S&P | S&P 500 index price |
| S&P P/E | Trailing P/E of the S&P 500 (via SPY) |
| Tech P/E | Trailing P/E of the tech sector (via XLK) |
| REIT Yld | REIT dividend yield (via XLRE) |
| IG Sprd | Investment-grade bond spread above risk-free (LQD TNX) |
| Rates | Rate regime: LOW / NORMAL / HIGH (based on 10Y yield) |
| Vol | Volatility regime: LOW / NORMAL / HIGH (based on VIX) |
The rate regime affects how strict the Market-Adjusted gates are — in a HIGH rate environment the P/E multiplier compresses and bond spreads tighten.
#### Signal Summary table
Quick overview of all screened tickers with their signal, market-adjusted verdict, fundamental verdict, market cap tier, and growth style.
#### Per-asset detail tables
Expand each section (STOCK / ETF / BOND) for full metrics: P/E, PEG, ROE, margins, FCF yield, D/E, analyst consensus, DCF intrinsic value, 52-week movement, and more.
#### Analyze button
Runs an Anthropic LLM over the latest Yahoo Finance news for assets in that section. Returns a sentiment summary, affected industries, and related tickers to watch. Requires `ANTHROPIC_API_KEY`.
#### Search tickers
Click **Search tickers** to screen any custom list — type tickers comma or space separated and press Enter or click Screen.
#### Signals explained
| Signal | What it means |
|---|---|
| ✅ Strong Buy | Passes both Market-Adjusted AND Fundamental gates — genuine value at current prices |
| ⚡ Momentum | Passes Market-Adjusted, holds fundamentally — good in the current market but not a bargain |
| ⚠️ Speculation | Passes Market-Adjusted, fails Fundamental — priced for perfection, high risk |
| 🔄 Neutral | Borderline in one or both lenses — hold, no clear edge |
| ❌ Avoid | Fails both lenses |
#### How scoring works
Every asset is scored twice:
**Market-Adjusted** gates move with the market. The stock P/E gate = SPY trailing P/E × 1.5 (compresses to × 1.2 in a HIGH rate regime). Tech P/E = XLK P/E × 1.3. This reflects what the market is currently willing to pay.
**Fundamental** gates are fixed Graham/value-investing standards that never change:
| Gate | Threshold | Rationale |
|---|---|---|
| Stock P/E | < 15× | Graham's actual rule |
| Stock PEG | < 1.0 | Lynch: PEG > 1.0 = paying full price |
| D/E ratio | < 1.5× | Distress typically starts above 2× |
| Quick ratio | > 0.8 | Below 0.8 = real liquidity stress |
Sector overrides apply in both modes — e.g. tech stocks allow P/E up to 35× and D/E up to 2.0, REITs are scored on yield rather than P/E.
--- ---
### Portfolio tab ## Testing
Track your holdings and get hold/sell/add advice cross-referenced with screener signals. 61 unit tests, no external test framework:
**Adding holdings** — click **+ Add Holding** and fill in ticker, shares, cost basis, asset type, and source broker. Holdings are saved to `portfolio.json` on disk.
**Inline editing** — click the ✎ pencil icon on any row to edit shares, cost basis, type, or source directly in the table.
**Advice column** — each holding is screened live and the signal is combined with your gain/loss position:
| Situation | Advice |
|---|---|
| ✅ Strong Buy signal | Hold & Add |
| ⚡ Momentum + > 30% gain | Consider partial profit-taking |
| ⚠️ Speculation + > 20% gain | Reduce position |
| ❌ Avoid signal + in profit | Sell (Take Profits) |
| ❌ Avoid signal + at a loss | Sell (Cut Loss) |
| Crypto | Hold / Review position (no fundamental scoring) |
**Personal finance section** *(requires SimpleFIN)* — when configured, the page also shows net worth, total assets vs liabilities, cash vs investments ratio, monthly income/spend, account balances, and a spending breakdown by category for the last 30 days.
---
### Market Calls tab
Record and track quarterly investment theses from the day you make the call.
**Creating a call** — click ** New Call** and fill in:
- **Title** — e.g. "Q3 2025 — Rate pivot & tech rotation"
- **Quarter** — the quarter this thesis applies to
- **Thesis** — the macro reasoning behind the call (min 10 characters)
- **Tickers** — the assets you're watching for this thesis
When saved, the current price and signal for each ticker are snapshotted automatically.
**Viewing performance** — click any call card to see the current price and signal for each ticker alongside the original snapshot, so you can measure how the thesis played out.
**Calendar** — shows upcoming earnings dates, ex-dividend dates, and dividend payment dates for all tickers across your active calls.
---
### Safe Buys tab
A filtered view showing only tickers with a **✅ Strong Buy** signal across both lenses. A quick watchlist of assets passing the strictest criteria in the current market.
---
### API rate limits
`/api/screen`, `/api/screen/catalysts`, and `/api/analyze` are capped at **10 requests per minute** per IP. All other routes allow 60 per minute.
---
## API Testing with Bruno
### What is Bruno?
[Bruno](https://www.usebruno.com/) is a lightweight, open-source API client that's Git-friendly and perfect for testing REST APIs. It stores collections as plain text files instead of JSON blobs, making them easy to version control and collaborate on.
### Installing Bruno
#### macOS (via Homebrew)
```bash
brew install bruno
```
#### macOS (Direct Download)
1. Visit [usebruno.com/downloads](https://www.usebruno.com/downloads)
2. Download the macOS version
3. Drag `Bruno.app` to Applications folder
#### Windows
1. Visit [usebruno.com/downloads](https://www.usebruno.com/downloads)
2. Download the Windows installer (.exe)
3. Run the installer and follow the prompts
4. Or via Chocolatey: `choco install bruno`
#### Linux (Ubuntu/Debian)
```bash
# Add Bruno repository
curl -1sLf 'https://dl.usebruno.com/install.sh' | sudo bash
# Install
sudo apt-get install bruno
```
#### Linux (Fedora/RHEL)
```bash
curl -1sLf 'https://dl.usebruno.com/install.sh' | sudo bash
sudo dnf install bruno
```
### Installing Bruno CLI (brucli)
For running tests from the command line without the GUI:
#### macOS
```bash
brew install brucli
```
#### Windows
```bash
choco install bruno-cli
```
#### Linux
```bash
curl -1sLf 'https://dl.usebruno.com/install.sh' | sudo bash
```
### Importing the API Collection
#### Method 1: Import via Bruno GUI (Easiest)
1. **Open Bruno**
2. **File → Import Collection**
3. **Select** `api_collections/market-screener.postman_collection.json`
4. **Choose location** where to save the converted collection (e.g., `api_collections/market-screener`)
5. **Click Import** — Bruno automatically converts and structures the collection
#### Method 2: Import via Bruno CLI
```bash ```bash
# Navigate to the project root npm test # summary output: "✅ 61 tests: 61 passed (0.02s)"
cd market-screener npm run test:watch # verbose spec output for development
# 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) Pre-commit: Prettier (auto-format staged files) + full test suite.
Pre-push: full test suite.
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`
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
/**
* Daily change digest (PRODUCT.md P1.1) — diff today's signal snapshots
* against the previous ones, join with stored news catalysts, and post to
* Discord (DISCORD_WEBHOOK_URL) or print to the terminal.
*
* RUN ORDER MATTERS — screen first, digest second:
* 30 16 * * 1-5 cd /path/to/app && npm run screen:daily && npm run digest:daily
*
* Usage:
* npm run digest:daily # today
* npm run digest:daily -- 2026-06-09 # specific day
*/
import 'dotenv/config';
import {
createDb,
DatabaseConnection,
QueryAudit,
SignalSnapshotRepository,
} from '../server/domains/shared';
import { NewsRepository } from '../server/domains/news';
import { DigestService, DiscordNotifier } from '../server/domains/digest';
const db = new DatabaseConnection(createDb(process.env.DB_PATH ?? './market-screener.db'), {
audit: new QueryAudit(),
logSlowQueries: 100,
});
const consoleLogger = {
log: (...args: unknown[]) => console.log(...args), // eslint-disable-line no-console
warn: (...args: unknown[]) => console.warn(...args),
write: (msg: string) => process.stdout.write(msg),
};
const dateArg = process.argv[2];
const date =
dateArg && /^\d{4}-\d{2}-\d{2}$/.test(dateArg) ? dateArg : new Date().toISOString().slice(0, 10);
const digest = new DigestService(new SignalSnapshotRepository(db), new NewsRepository(db));
const report = digest.build(date);
/* eslint-disable no-console */
console.log(`\n📊 Daily Signal Digest — ${report.date}`);
console.log(`Tickers snapshotted: ${report.snapshotCount}`);
if (report.snapshotCount === 0) {
console.log('\nNo snapshots for this date. Run `npm run screen:daily` first.');
process.exit(0);
}
if (report.changes.length === 0) {
console.log('No signal changes since the previous snapshots. Calm day.');
} else {
console.log(`\nSignal changes (${report.changes.length}):`);
for (const c of report.changes) {
const delta =
c.scoreDelta != null ? ` (score ${c.scoreDelta > 0 ? '+' : ''}${c.scoreDelta})` : '';
console.log(`\n ${c.ticker}: ${c.previousSignal}${c.newSignal}${delta}`);
if (c.catalysts.length === 0) {
console.log(' no catalyst found — moved on fundamentals/market data');
}
for (const s of c.catalysts.slice(0, 3)) {
console.log(` [${s.catalyst ?? 'news'}] ${s.headline}`);
}
}
}
if (report.maStories.length > 0) {
console.log(`\n🔱 M&A activity (${report.maStories.length}):`);
for (const s of report.maStories.slice(0, 5)) console.log(`${s.headline}`);
}
if (report.newTickers.length > 0) {
console.log(`\nFirst-time snapshots (no baseline yet): ${report.newTickers.join(', ')}`);
}
const notifier = new DiscordNotifier(consoleLogger);
if (notifier.enabled) {
const sent = await notifier.send(report);
console.log(sent ? '\nPosted to Discord ✓' : '\nDiscord post skipped/failed');
} else {
console.log('\n(Set DISCORD_WEBHOOK_URL in .env to receive this as a Discord message.)');
}
/* eslint-enable no-console */
process.exit(0);
-92
View File
@@ -1,92 +0,0 @@
/**
* Daily screening job — keeps the signal snapshot ledger (PRODUCT.md P0.1)
* accumulating even when nobody opens the UI.
*
* Universe = union of all users' watchlist tickers + all non-crypto holdings,
* or an explicit list passed on the command line.
*
* Usage:
* npm run screen:daily # watchlist + holdings universe
* npm run screen:daily -- AAPL MSFT # explicit tickers
*
* Schedule for market close, e.g. crontab (4:30pm ET weekdays):
* 30 16 * * 1-5 cd /path/to/market_screener && npm run screen:daily
*/
import 'dotenv/config';
import {
YahooFinanceClient,
BenchmarkProvider,
SignalSnapshotRepository,
createDb,
DatabaseConnection,
QueryAudit,
} from '../server/domains/shared';
import { QueryBuilder } from '../server/domains/shared/utils/QueryBuilder';
import { ScreenerEngine } from '../server/domains/screener';
import type { AssetResult } from '../server/domains/shared';
function universeFromDb(db: DatabaseConnection): string[] {
const watchlist = db
.all<{ ticker: string }>(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_WATCHLIST_TICKERS'))
.map((r) => r.ticker);
const holdings = db
.all<{ ticker: string }>(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_HOLDING_TICKERS'))
.map((r) => r.ticker);
return [...new Set([...watchlist, ...holdings])].sort();
}
const db = new DatabaseConnection(createDb(process.env.DB_PATH ?? './market-screener.db'), {
audit: new QueryAudit(),
logSlowQueries: 100,
});
const cliTickers = process.argv.slice(2).map((t) => t.toUpperCase());
const tickers = cliTickers.length > 0 ? cliTickers : universeFromDb(db);
if (tickers.length === 0) {
console.log('No tickers to screen — watchlist and holdings are empty.');
console.log('Pass tickers explicitly: npm run screen:daily -- AAPL MSFT');
process.exit(0);
}
console.log(`Screening ${tickers.length} tickers: ${tickers.join(', ')}`);
const yahoo = new YahooFinanceClient();
const benchmark = new BenchmarkProvider(yahoo);
const engine = new ScreenerEngine(yahoo, benchmark);
const snapshots = new SignalSnapshotRepository(db);
try {
const results = await engine.screenWithProgress(tickers);
const rateRegime = results.marketContext?.rateRegime ?? null;
const assets = [...results.STOCK, ...results.ETF, ...results.BOND] as AssetResult[];
const written = snapshots.recordBatch(
assets.map((r) => ({
ticker: r.asset.ticker,
assetType: r.asset.type,
price: r.asset.currentPrice ?? null,
signal: r.signal,
fundamental: r.fundamental,
inflated: r.inflated,
rateRegime,
})),
);
const bySignal = new Map<string, number>();
for (const a of assets) bySignal.set(a.signal, (bySignal.get(a.signal) ?? 0) + 1);
console.log(`\nSnapshots written: ${written}`);
for (const [signal, count] of [...bySignal.entries()].sort()) {
console.log(` ${signal}: ${count}`);
}
if (results.ERROR.length > 0) {
console.log(`Errors (${results.ERROR.length}):`);
for (const e of results.ERROR) console.log(` ${e.ticker}: ${e.message}`);
}
process.exit(0);
} catch (err) {
console.error('Daily screen failed:', (err as Error).message);
process.exit(1);
}
+84
View File
@@ -0,0 +1,84 @@
/**
* bin/finance.js — Personal Finance CLI
*
* Fetches your accounts from SimpleFIN, screens your portfolio holdings,
* and saves a finance-report.html with:
* 1. Net worth + account overview (SimpleFIN)
* 2. Portfolio hold/sell/add advice (screener + crypto prices)
* 3. Spending breakdown (SimpleFIN)
*
* Usage:
* npm run finance
*/
import 'dotenv/config';
import { readFileSync, existsSync } from 'fs';
import { SimpleFINClient, saveAccessUrlToEnv } from '../src/finance/clients/SimpleFINClient.js';
import { PersonalFinanceAnalyzer } from '../src/finance/PersonalFinanceAnalyzer.js';
import { PortfolioAdvisor } from '../src/finance/PortfolioAdvisor.js';
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js';
import { FinanceReporter } from '../src/reporters/FinanceReporter.js';
const PORTFOLIO_PATH = './portfolio.json';
async function main() {
// ── 1. Load portfolio
if (!existsSync(PORTFOLIO_PATH)) {
throw new Error('portfolio.json not found — edit it with your holdings and re-run.');
}
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
const byType = holdings.reduce((acc, h) => {
const t = h.type ?? 'stock';
acc[t] = (acc[t] ?? 0) + 1;
return acc;
}, {});
console.log(
`📋 Portfolio: ${holdings.length} positions — ${Object.entries(byType)
.map(([t, n]) => `${n} ${t}`)
.join(', ')}\n`,
);
// ── 2. SimpleFIN accounts (optional)
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) {
try {
process.stdout.write('💰 Fetching SimpleFIN accounts...');
const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv });
await client.init();
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
process.stdout.write(` ${accounts.length} accounts loaded\n`);
} catch (err) {
process.stdout.write(` skipped — ${err.message}\n`);
}
} else {
console.log(' Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n');
}
// ── 3. Screen stocks & ETFs
const screenableTickers = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => h.ticker.toUpperCase());
let results = { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} };
if (screenableTickers.length > 0) {
process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`);
results = await new ScreenerEngine().screenTickers(screenableTickers);
process.stdout.write(' done\n');
}
// ── 4. Portfolio advice + crypto prices
process.stdout.write('💡 Generating portfolio advice...');
const advice = await new PortfolioAdvisor().advise(holdings, results);
process.stdout.write(' done\n');
// ── 5. Report
const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext);
console.log(`\n✅ Finance report: ${reportPath}\n`);
}
main().catch((err) => {
console.error('Failed:', err.message);
process.exit(1);
});
-67
View File
@@ -1,67 +0,0 @@
/**
* One-shot news poll — for cron users who don't run the server 24/7.
* Fetches EDGAR + PR-wire feeds once, runs the pipeline, runs retention,
* prints stats, exits.
*
* Usage:
* npm run news:poll
*
* Crontab example (every 15 min, market hours, weekdays):
* *\/15 9-16 * * 1-5 cd /path/to/market_screener && npm run news:poll
*
* If the server runs continuously, its built-in scheduler covers this —
* set NEWS_POLL=off on the server if you prefer cron-driven polling.
*/
import 'dotenv/config';
import { createDb, DatabaseConnection, QueryAudit, noopLogger } from '../server/domains/shared';
import {
NewsRepository,
NewsPipeline,
UniverseProvider,
NewsScheduler,
EdgarPoller,
PrWirePoller,
} from '../server/domains/news';
const db = new DatabaseConnection(createDb(process.env.DB_PATH ?? './market-screener.db'), {
audit: new QueryAudit(),
logSlowQueries: 100,
});
const consoleLogger = {
log: (...args: unknown[]) => console.log(...args), // eslint-disable-line no-console
warn: (...args: unknown[]) => console.warn(...args),
write: (msg: string) => process.stdout.write(msg),
};
const universe = new UniverseProvider(db);
const pipeline = new NewsPipeline(new NewsRepository(db));
const scheduler = new NewsScheduler(
pipeline,
universe,
new EdgarPoller(noopLogger),
new PrWirePoller(noopLogger),
consoleLogger,
);
const size = universe.getUniverse().size;
if (size === 0) {
console.log('Universe is empty (no watchlist, holdings, or recent screens) — nothing to poll.'); // eslint-disable-line no-console
process.exit(0);
}
console.log(`Polling news for a ${size}-ticker universe…`); // eslint-disable-line no-console
try {
const { edgar, prwire } = await scheduler.runOnce();
const retention = pipeline.runRetention();
/* eslint-disable no-console */
console.log('\nEDGAR :', JSON.stringify(edgar));
console.log('PR-wire:', JSON.stringify(prwire));
console.log('Retention:', JSON.stringify(retention));
/* eslint-enable no-console */
process.exit(0);
} catch (err) {
console.error('News poll failed:', (err as Error).message);
process.exit(1);
}
+83
View File
@@ -0,0 +1,83 @@
/**
* bin/screen.js — Market Screener CLI
*
* Fetches today's catalyst tickers from Yahoo Finance news,
* screens them under both Market-Adjusted and Fundamental lenses,
* and saves a full HTML report.
*
* Usage:
* npm start → Yahoo news → catalyst tickers → screen
* npm start -- watch → default watchlist
* npm start -- AAPL MSFT VOO → specific tickers
*/
import 'dotenv/config';
import { CatalystAnalyst } from '../src/analyst/CatalystAnalyst.js';
import { ScreenerEngine } from '../src/screener/ScreenerEngine.js';
import { HtmlReporter } from '../src/reporters/HtmlReporter.js';
const DEFAULT_WATCHLIST = [
// Stocks
'PLTR',
'AAPL',
'MSFT',
'TSLA',
'O',
// ETFs
'VOO',
'QQQ',
// Bonds
'BND',
'LQD',
'TLT',
'IEF',
'SHY',
'GOVT',
'AGG',
'MUB',
];
async function main() {
const args = process.argv.slice(2);
let tickers = [];
if (args.length > 0 && args[0] !== 'watch') {
tickers = args.map((t) => t.toUpperCase());
console.log(`📋 Screening: ${tickers.join(', ')}\n`);
} else if (args[0] === 'watch') {
tickers = DEFAULT_WATCHLIST;
console.log(`📋 Screening default watchlist (${tickers.length} tickers)\n`);
} else {
try {
const { tickers: newsTickers, stories } = await new CatalystAnalyst().run();
if (newsTickers.length === 0) {
console.warn("⚠ No tickers in today's news — using default watchlist\n");
tickers = DEFAULT_WATCHLIST;
} else {
tickers = newsTickers;
console.log("\n📰 Stories driving today's screen:");
stories.slice(0, 5).forEach((s) => {
const tags = s.relatedTickers.slice(0, 3).join(', ');
console.log(`${s.title}${tags ? ` [${tags}]` : ''}`);
});
console.log(`\n📋 Tickers: ${tickers.join(', ')}\n`);
}
} catch (err) {
console.warn(`⚠ Catalyst analysis failed (${err.message}) — using default watchlist\n`);
tickers = DEFAULT_WATCHLIST;
}
}
try {
const { STOCK, ETF, BOND, ERROR, marketContext } =
await new ScreenerEngine().screenWithProgress(tickers);
const reportPath = new HtmlReporter().generate({ STOCK, ETF, BOND, ERROR }, marketContext);
console.log(`\n✅ Done — report saved to: ${reportPath}\n`);
} catch (err) {
console.error('Screener failed:', err.message);
process.exit(1);
}
}
main().catch(console.error);
+1 -1
View File
@@ -1,5 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import { buildApp } from '../server/app'; import { buildApp } from '../src/server/app.js';
const PORT = process.env.PORT ?? 3000; const PORT = process.env.PORT ?? 3000;
const HOST = process.env.HOST ?? '0.0.0.0'; const HOST = process.env.HOST ?? '0.0.0.0';
-68
View File
@@ -1,68 +0,0 @@
/**
* Discord webhook smoke test — sends a FAKE digest to DISCORD_WEBHOOK_URL
* so you can verify the integration without waiting for a real signal change.
*
* Usage:
* npm run discord:test
*/
import 'dotenv/config';
import { DiscordNotifier } from '../server/domains/digest/DiscordNotifier';
import type { DigestReport } from '../server/domains/shared/types';
/* eslint-disable no-console */
if (!process.env.DISCORD_WEBHOOK_URL) {
console.error('DISCORD_WEBHOOK_URL is not set in .env');
console.error('Discord → channel → Settings → Integrations → Webhooks → New Webhook → Copy URL');
process.exit(1);
}
const fakeReport: DigestReport = {
date: new Date().toISOString().slice(0, 10),
snapshotCount: 3,
newTickers: [],
changes: [
{
ticker: 'TEST',
previousSignal: '✅ Strong Buy',
newSignal: '🔄 Neutral',
previousDate: 'yesterday',
scoreDelta: -7,
price: 123.45,
catalysts: [
{
headline: '🔧 This is a TEST message from market-screener — webhook works!',
catalyst: 'regulatory',
source: 'edgar',
url: 'https://example.com',
publishedAt: new Date().toISOString(),
},
],
},
],
maStories: [
{
headline: '🔧 TEST: SC 13D filing example (M&A section renders like this)',
catalyst: 'ma',
source: 'edgar',
url: 'https://example.com',
publishedAt: new Date().toISOString(),
},
],
};
const logger = {
log: (...args: unknown[]) => console.log(...args),
warn: (...args: unknown[]) => console.warn(...args),
write: (msg: string) => process.stdout.write(msg),
};
const ok = await new DiscordNotifier(logger).send(fakeReport);
if (ok) {
console.log('✓ Test digest posted — check your Discord channel.');
process.exit(0);
} else {
console.error('✗ Post failed. Check the webhook URL (it may have been deleted/regenerated).');
process.exit(1);
}
/* eslint-enable no-console */
-25
View File
@@ -1,25 +0,0 @@
services:
app:
build: .
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:3001:3001"
environment:
NODE_ENV: production
DB_PATH: /app/data/market-screener.db
API_KEY: ${API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
SIMPLEFIN_ACCESS_URL: ${SIMPLEFIN_ACCESS_URL:-}
SIMPLEFIN_SETUP_TOKEN: ${SIMPLEFIN_SETUP_TOKEN:-}
CLIENT_ORIGIN: ${CLIENT_ORIGIN:-http://localhost}
volumes:
- db_data:/app/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
volumes:
db_data:
-211
View File
@@ -1,211 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personal Finance — 2026-06-03</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; font-size: 13px; }
h1 { font-size: 20px; font-weight: 600; }
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; margin-left: auto; }
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
.content { padding: 24px 32px; }
.section { margin-bottom: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
.card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
.card-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.card-value { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
.card-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
tbody tr { border-bottom: 1px solid #1a2233; }
tbody tr:hover { background: #1e293b; }
tbody td { padding: 10px 12px; vertical-align: middle; }
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
.green { color: #4ade80; }
.yellow { color: #facc15; }
.orange { color: #fb923c; }
.red { color: #f87171; }
.gray { color: #64748b; }
.advice-green { color: #4ade80; font-weight: 600; }
.advice-yellow { color: #facc15; font-weight: 600; }
.advice-orange { color: #fb923c; font-weight: 600; }
.advice-red { color: #f87171; font-weight: 600; }
.reason { color: #94a3b8; font-size: 11px; }
.bar-bg { background: #1e293b; border-radius: 4px; height: 8px; }
.bar-fill { background: #3b82f6; border-radius: 4px; height: 8px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
</style>
</head>
<body>
<div class="header">
<h1>💰 Personal Finance</h1>
<div class="pill">Date <span>2026-06-03</span></div>
</div>
<div class="content">
<div class="section">
<h2>Portfolio — Hold / Sell / Add Advice</h2>
<div class="grid" style="margin-bottom:16px">
<div class="card">
<div class="card-label">Total Value</div>
<div class="card-value ">$41,451</div>
</div>
<div class="card">
<div class="card-label">Total Cost</div>
<div class="card-value ">$25,180</div>
</div>
<div class="card">
<div class="card-label">Total G/L</div>
<div class="card-value green">$16,271</div>
<div class="card-sub">64.6%</div>
</div>
<div class="card">
<div class="card-label">S&P 500 P/E</div>
<div class="card-value ">28.5x</div>
<div class="card-sub">Live benchmark</div>
</div>
</div>
<h2 style="margin-bottom:10px">Stocks &amp; ETFs</h2>
<table>
<thead><tr>
<th>Ticker</th><th>Source</th><th>Type</th><th>Shares</th>
<th>Cost Basis</th><th>Current</th><th>Value</th>
<th>G/L</th><th>Signal</th><th>Advice</th><th>Reason</th>
</tr></thead>
<tbody><tr>
<td class="ticker">AAPL</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>10</td>
<td>$150.00</td>
<td>$315.20</td>
<td>$3,152.00</td>
<td class="green">110.1%</td>
<td class="gray" style="font-size:11px">⚠️ Speculation</td>
<td class="advice-orange">🟠 Reduce Position</td>
<td class="reason">In profit on a speculative position — take partial profits.</td>
</tr><tr>
<td class="ticker">PLTR</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>50</td>
<td>$18.50</td>
<td>$152.17</td>
<td>$7,608.50</td>
<td class="green">722.5%</td>
<td class="gray" style="font-size:11px">❌ Avoid</td>
<td class="advice-red">🔴 Sell (Take Profits)</td>
<td class="reason">Fails both analyses — you're in profit, take it.</td>
</tr><tr>
<td class="ticker">TSLA</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>3</td>
<td>$200.00</td>
<td>$423.74</td>
<td>$1,271.22</td>
<td class="green">111.9%</td>
<td class="gray" style="font-size:11px">❌ Avoid</td>
<td class="advice-red">🔴 Sell (Take Profits)</td>
<td class="reason">Fails both analyses — you're in profit, take it.</td>
</tr><tr>
<td class="ticker">MSFT</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>5</td>
<td>$300.00</td>
<td>$441.31</td>
<td>$2,206.55</td>
<td class="green">47.1%</td>
<td class="gray" style="font-size:11px">✅ Strong Buy</td>
<td class="advice-green">🟢 Hold & Add</td>
<td class="reason">Passes both analyses. Strong conviction.</td>
</tr><tr>
<td class="ticker">VOO</td>
<td><span style="background:#3b82f622;color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Vanguard</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">etf</span></td>
<td>8</td>
<td>$380.00</td>
<td>$698.26</td>
<td>$5,586.08</td>
<td class="green">83.8%</td>
<td class="gray" style="font-size:11px">⚡ Momentum</td>
<td class="advice-yellow">🟡 Hold</td>
<td class="reason">Up significantly on momentum — consider partial profit-taking.</td>
</tr><tr>
<td class="ticker">BND</td>
<td><span style="background:#3b82f622;color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Vanguard</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">etf</span></td>
<td>15</td>
<td>$75.00</td>
<td>$73.20</td>
<td>$1,098.00</td>
<td class="red">-2.4%</td>
<td class="gray" style="font-size:11px">🔄 Neutral</td>
<td class="advice-yellow">🟡 Hold</td>
<td class="reason">No clear edge. Review on any catalyst.</td>
</tr><tr>
<td class="ticker">O</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>20</td>
<td>$52.00</td>
<td>$59.91</td>
<td>$1,198.20</td>
<td class="green">15.2%</td>
<td class="gray" style="font-size:11px">✅ Strong Buy</td>
<td class="advice-green">🟢 Hold & Add</td>
<td class="reason">Passes both analyses. Strong conviction.</td>
</tr></tbody>
</table>
<h2 style="margin-top:24px;margin-bottom:10px">Crypto</h2>
<table>
<thead><tr>
<th>Ticker</th><th>Source</th><th>Shares</th>
<th>Cost Basis</th><th>Current</th><th>Value</th>
<th>G/L</th><th>Advice</th><th>Note</th>
</tr></thead>
<tbody><tr>
<td class="ticker">BTC-USD</td>
<td><span style="background:#8b5cf622;color:#8b5cf6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Coinbase</span></td>
<td>0.25</td>
<td>$45,000.00</td>
<td>$66,289.92</td>
<td>$16,572.48</td>
<td class="green">47.3%</td>
<td class="advice-yellow">🟡 Hold</td>
<td class="reason">Crypto — no fundamental analysis. Track price and manage risk manually.</td>
</tr><tr>
<td class="ticker">ETH-USD</td>
<td><span style="background:#8b5cf622;color:#8b5cf6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Coinbase</span></td>
<td>1.5</td>
<td>$2,800.00</td>
<td>$1,838.88</td>
<td>$2,758.32</td>
<td class="red">-34.3%</td>
<td class="advice-red">🔴 Review position</td>
<td class="reason">Crypto — no fundamental analysis. Track price and manage risk manually.</td>
</tr></tbody>
</table>
</div>
</div>
</body>
</html>
File diff suppressed because it is too large Load Diff
-631
View File
@@ -1,631 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>LLM Analysis — Redesign Prototype</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
button{font-family:inherit;cursor:pointer;}
:root{
--bg-base: #0a0e14;
--bg-surface: #111820;
--bg-elevated: #1a2332;
--bg-card: #141e2b;
--border: #1e2d3d;
--border-lt: #263447;
--text-1: #e2eaf4;
--text-2: #7a93ad;
--text-3: #3d5166;
--green: #34d17a;
--green-dim: #0d2e1a;
--green-mid: #1a4a2a;
--red: #f05a5a;
--red-dim: #2e0d0d;
--red-mid: #4a1a1a;
--amber: #f0b429;
--amber-dim: #2e2000;
--blue: #4da6ff;
--blue-dim: #0d2240;
--purple: #a78bfa;
--purple-dim: #1e1535;
--teal: #2dd4bf;
--teal-dim: #0d2e2a;
--font-ui: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--t: 0.18s ease;
}
body{
font-family: var(--font-ui);
background: #060a10;
color: var(--text-1);
font-size: 13px;
line-height: 1.5;
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 24px 16px;
gap: 24px;
}
/* side-by-side comparison */
.compare-label{
font-size: 11px; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--text-3);
text-align: center; margin-bottom: 10px;
}
/* ── PANEL SHELL ── */
.panel{
width: 380px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 92vh;
}
/* ── HEADER ── */
.panel-header{
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg-surface);
}
.panel-icon{ font-size: 18px; }
.panel-title{ font-size: 14px; font-weight: 700; flex: 1; }
.scope-chip{
padding: 3px 10px; border-radius: 20px;
font-size: 11px; font-weight: 600; letter-spacing: .04em;
background: var(--blue-dim); color: var(--blue);
border: 1px solid #1a3a5c;
}
.close-btn{
width: 26px; height: 26px; border-radius: 6px;
border: 1px solid var(--border);
background: none; color: var(--text-2);
font-size: 16px; display: flex; align-items: center; justify-content: center;
transition: all var(--t);
}
.close-btn:hover{ background: var(--bg-elevated); color: var(--text-1); }
/* ── SCROLLABLE BODY ── */
.panel-body{
flex: 1; overflow-y: auto;
padding: 0;
}
.panel-body::-webkit-scrollbar{ width: 3px; }
.panel-body::-webkit-scrollbar-thumb{ background: var(--border); border-radius: 2px; }
/* ── SENTIMENT HERO ── */
.sentiment-hero{
padding: 20px 16px 16px;
border-bottom: 1px solid var(--border);
}
.sent-top{
display: flex; align-items: center;
justify-content: space-between; margin-bottom: 14px;
}
.sent-badge{
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 16px; border-radius: 24px;
font-size: 13px; font-weight: 700; letter-spacing: .04em;
}
.sent-bullish { background: var(--green-dim); color: var(--green); border: 1px solid var(--green-mid); }
.sent-neutral { background: var(--blue-dim); color: var(--blue); border: 1px solid #1a3a5c; }
.sent-bearish { background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
.sent-mixed { background: var(--amber-dim); color: var(--amber); border: 1px solid #4a3000; }
.sent-meta{
display: flex; align-items: center; gap: 8px;
}
.sent-time{
font-size: 10px; font-family: var(--font-mono);
color: var(--text-3);
}
.sent-model{
font-size: 10px; padding: 2px 7px; border-radius: 4px;
background: var(--bg-elevated); color: var(--text-3);
font-family: var(--font-mono);
}
/* confidence bar */
.conf-row{
display: flex; align-items: center; gap: 10px;
margin-bottom: 14px;
}
.conf-label{
font-size: 10px; font-weight: 600; letter-spacing: .06em;
text-transform: uppercase; color: var(--text-3); width: 72px; flex-shrink: 0;
}
.conf-track{
flex: 1; height: 5px; background: var(--border);
border-radius: 3px; overflow: hidden;
}
.conf-fill{
height: 100%; border-radius: 3px;
background: linear-gradient(90deg, var(--blue) 0%, var(--teal) 100%);
transition: width .6s ease;
}
.conf-pct{
font-size: 11px; font-weight: 600;
font-family: var(--font-mono); color: var(--blue); width: 36px; text-align: right;
}
/* summary */
.summary-text{
font-size: 13px; line-height: 1.7;
color: var(--text-2);
}
.summary-text strong{ color: var(--text-1); font-weight: 600; }
/* ── SECTION ── */
.section{ padding: 16px 16px 0; }
.section:last-child{ padding-bottom: 16px; }
.section-header{
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.section-title{
font-size: 10px; font-weight: 700; letter-spacing: .1em;
text-transform: uppercase; color: var(--text-3);
}
.section-count{
font-size: 10px; font-family: var(--font-mono);
padding: 1px 6px; border-radius: 3px;
background: var(--bg-elevated); color: var(--text-3);
}
.section-divider{
flex: 1; height: 1px; background: var(--border);
}
/* ── INDUSTRY CARDS ── */
.industry-list{ display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.ind-card{
border-radius: 8px; padding: 11px 12px;
border: 1px solid var(--border);
background: var(--bg-card);
transition: border-color var(--t);
cursor: default;
}
.ind-card:hover{ border-color: var(--border-lt); }
.ind-card-top{
display: flex; align-items: flex-start;
justify-content: space-between; gap: 8px; margin-bottom: 6px;
}
.ind-name{
font-size: 12px; font-weight: 600; color: var(--text-1);
line-height: 1.4;
}
.impact-chip{
flex-shrink: 0;
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; border-radius: 4px;
font-size: 10px; font-weight: 700; letter-spacing: .05em;
font-family: var(--font-mono);
}
.imp-bear{ background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
.imp-bull{ background: var(--green-dim); color: var(--green); border: 1px solid var(--green-mid); }
.imp-neut{ background: var(--bg-elevated); color: var(--text-2); border: 1px solid var(--border); }
.ind-body{
font-size: 12px; line-height: 1.6; color: var(--text-2);
}
.ind-body strong{ color: var(--text-1); font-weight: 600; }
/* accent left border by impact */
.ind-card.bear{ border-left: 2px solid var(--red); }
.ind-card.bull{ border-left: 2px solid var(--green); }
.ind-card.neut{ border-left: 2px solid var(--border-lt); }
/* ── TICKER CARDS ── */
.ticker-list{ display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.tick-card{
border-radius: 8px; padding: 12px;
border: 1px solid var(--border);
background: var(--bg-card);
transition: border-color var(--t), background var(--t);
cursor: pointer;
}
.tick-card:hover{ border-color: var(--border-lt); background: var(--bg-elevated); }
.tick-top{
display: flex; align-items: center; gap: 8px; margin-bottom: 7px;
}
.tick-sym{
font-size: 15px; font-weight: 700;
font-family: var(--font-mono); letter-spacing: .03em;
color: var(--text-1);
}
.tick-name{
font-size: 11px; color: var(--text-2); flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.signal-chip{
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px; border-radius: 20px;
font-size: 10px; font-weight: 700; letter-spacing: .05em;
}
.sig-bear{ background: var(--red-dim); color: var(--red); border: 1px solid var(--red-mid); }
.sig-bull{ background: var(--green-dim); color: var(--green); border: 1px solid var(--green-mid); }
.sig-neut{ background: var(--bg-elevated); color: var(--text-2); border: 1px solid var(--border); }
.tick-meta{
display: flex; align-items: center; gap: 6px; margin-bottom: 8px;
}
.conf-chip{
font-size: 10px; font-weight: 600; font-family: var(--font-mono);
padding: 2px 8px; border-radius: 4px;
}
.conf-high { background: var(--green-dim); color: var(--green); }
.conf-med { background: var(--amber-dim); color: var(--amber); }
.conf-low { background: var(--bg-elevated); color: var(--text-3); }
.score-tier{
font-size: 10px; font-weight: 600; font-family: var(--font-mono);
color: var(--text-3); padding: 2px 7px; border-radius: 4px;
background: var(--bg-elevated); border: 1px solid var(--border);
}
.score-tip{
font-size: 10px; color: var(--text-3); cursor: help;
text-decoration: underline; text-decoration-style: dotted;
}
.tick-thesis{
font-size: 12px; line-height: 1.6; color: var(--text-2);
padding-top: 8px; border-top: 1px solid var(--border);
}
.tick-thesis strong{ color: var(--text-1); font-weight: 600; }
/* catalyst tag */
.catalyst-tag{
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; font-weight: 500;
color: var(--purple); background: var(--purple-dim);
padding: 2px 8px; border-radius: 4px;
border: 1px solid #2d2050; margin-top: 7px;
}
/* ── SCREENER PROMPT ── */
.screener-prompt{
margin: 0 16px 16px;
padding: 12px 14px;
background: var(--blue-dim);
border: 1px solid #1a3a5c;
border-radius: 8px;
display: flex; align-items: center; justify-content: space-between; gap: 10px;
}
.sp-text{
font-size: 12px; color: var(--blue); line-height: 1.5;
}
.sp-text strong{ font-weight: 600; }
.sp-btn{
flex-shrink: 0;
padding: 6px 14px; border-radius: 6px;
background: var(--blue); color: #000;
border: none; font-size: 11px; font-weight: 700;
letter-spacing: .04em; cursor: pointer;
transition: background var(--t);
white-space: nowrap;
}
.sp-btn:hover{ background: #7ec0ff; }
/* ── OLD PANEL STYLES (for comparison) ── */
.old-panel{
width: 380px;
background: #1a2030;
border: 1px solid #2a3a50;
border-radius: 12px;
overflow: hidden;
max-height: 92vh;
display: flex; flex-direction: column;
}
.old-header{
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid #2a3a50;
font-size: 14px; font-weight: 700;
}
.old-body{ flex: 1; overflow-y: auto; padding: 16px; }
.old-sentiment{
font-size: 11px; font-weight: 700; letter-spacing: .1em;
text-transform: uppercase; color: #5a7a9a; margin-bottom: 12px;
}
.old-quote{
border-left: 3px solid #3a5a7a;
padding: 4px 0 4px 14px; margin-bottom: 20px;
font-size: 14px; color: #8aaac0; line-height: 1.7;
}
.old-section{
font-size: 11px; font-weight: 700; letter-spacing: .1em;
text-transform: uppercase; color: #c8d8e8; margin-bottom: 12px;
}
.old-ind-card{
background: #1e2a3a; border: 1px solid #2a3a50;
border-radius: 8px; padding: 12px; margin-bottom: 8px;
}
.old-ind-title{ font-size: 13px; font-weight: 600; color: #6a9ac0; margin-bottom: 6px; }
.old-ind-body { font-size: 13px; color: #9ab0c0; line-height: 1.6; }
.old-ticker-card{
background: #1e2a3a; border: 1px solid #2a3a50;
border-radius: 8px; padding: 12px; margin-bottom: 8px;
}
.old-tick-top{
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
}
.old-tick-sym{ font-size: 16px; font-weight: 700; color: #e8f0f8; }
.old-bear{ background: #c0392b; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
.old-med { background: #1a3a5c; color: #4da6ff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 700; }
.old-s { background: #2a3a4a; color: #9ab0c0; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
.old-tick-body{ font-size: 13px; color: #9ab0c0; line-height: 1.6; }
</style>
</head>
<body>
<!-- ── BEFORE (current) ── -->
<div>
<div class="compare-label">❌ Before — Current Design</div>
<div class="old-panel">
<div class="old-header">
🤖 LLM Analysis
<span style="background:#1a3a5c;color:#4da6ff;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600">STOCKS</span>
<span style="margin-left:auto;color:#5a7a9a;font-size:18px;cursor:pointer">×</span>
</div>
<div class="old-body">
<div class="old-sentiment">NEUTRAL</div>
<div class="old-quote">
Tech sector faces a consolidation phase as Apple's underwhelming AI announcements weigh on mega-cap sentiment, while financial stocks and fintech platforms show relative strength; the market braces for inflation data and Fed decisions with elevated volatility across semiconductors and growth equities.
</div>
<div class="old-section">AFFECTED INDUSTRIES</div>
<div class="old-ind-card">
<div class="old-ind-title">Semiconductor Equipment &amp; Materials</div>
<div class="old-ind-body">AI disappointment from AAPL reduces near-term demand signals for chip manufacturers; capex guidance revisions possible as OEMs delay purchasing cycles.</div>
</div>
<div class="old-ind-card">
<div class="old-ind-title">Enterprise Software &amp; Cloud Infrastructure</div>
<div class="old-ind-body">Inflation data and Fed rate expectations influence SaaS margin profiles and customer IT budget allocation; higher rates pressure growth-at-all-costs valuations.</div>
</div>
<div class="old-ind-card">
<div class="old-ind-title">Consumer Discretionary &amp; Travel/Hospitality</div>
<div class="old-ind-body">Earnings misses at MTN signal consumer spending weakness; tariff concerns (Trump pivot) threaten cost structures for imported goods and leisure operators.</div>
</div>
<br/>
<div class="old-section">RELATED TICKERS TO WATCH</div>
<div class="old-ticker-card">
<div class="old-tick-top">
<span class="old-tick-sym">LRCX</span>
<span class="old-bear">BEAR</span>
<span class="old-med">MEDIUM</span>
<span class="old-s">S4</span>
</div>
<div class="old-tick-body">Semiconductor equipment supplier directly exposed to AI capex cycles; Apple AI letdown signals delayed fab tool orders and potential guidance misses.</div>
</div>
<div class="old-ticker-card">
<div class="old-tick-top">
<span class="old-tick-sym">ASML</span>
<span class="old-bear">BEAR</span>
<span class="old-med">MEDIUM</span>
<span class="old-s">S3</span>
</div>
<div class="old-tick-body">Upstream equipment vendor to chip makers; weakening AI demand narrative pressures customer capex visibility and order book confidence.</div>
</div>
</div>
</div>
</div>
<!-- ── AFTER (redesigned) ── -->
<div>
<div class="compare-label">✅ After — Redesigned</div>
<div class="panel">
<!-- header -->
<div class="panel-header">
<span class="panel-icon">🤖</span>
<span class="panel-title">LLM Analysis</span>
<span class="scope-chip">STOCKS</span>
<button class="close-btn">×</button>
</div>
<div class="panel-body">
<!-- ── SENTIMENT HERO ── -->
<div class="sentiment-hero">
<div class="sent-top">
<span class="sent-badge sent-neutral">
⊙ Neutral
</span>
<div class="sent-meta">
<span class="sent-time">2 min ago</span>
<span class="sent-model">claude-sonnet</span>
</div>
</div>
<!-- confidence bar -->
<div class="conf-row">
<span class="conf-label">Confidence</span>
<div class="conf-track">
<div class="conf-fill" style="width:72%"></div>
</div>
<span class="conf-pct">72%</span>
</div>
<div class="summary-text">
Tech sector faces a <strong>consolidation phase</strong> as Apple's underwhelming AI announcements weigh on mega-cap sentiment, while <strong>financial stocks and fintech</strong> show relative strength. Market braces for inflation data and Fed decisions — elevated volatility expected across semiconductors and growth equities.
</div>
</div>
<!-- ── AFFECTED INDUSTRIES ── -->
<div class="section">
<div class="section-header">
<span class="section-title">Affected Industries</span>
<span class="section-count">4</span>
<div class="section-divider"></div>
</div>
<div class="industry-list">
<div class="ind-card bear">
<div class="ind-card-top">
<span class="ind-name">Semiconductor Equipment &amp; Materials</span>
<span class="impact-chip imp-bear">▼ BEAR</span>
</div>
<div class="ind-body">
<strong>AAPL AI letdown</strong> reduces near-term demand signals for chip manufacturers. Capex guidance revisions possible as OEMs delay purchasing cycles.
</div>
</div>
<div class="ind-card bear">
<div class="ind-card-top">
<span class="ind-name">Enterprise Software &amp; Cloud Infrastructure</span>
<span class="impact-chip imp-bear">▼ BEAR</span>
</div>
<div class="ind-body">
<strong>Higher rates</strong> pressure SaaS margin profiles and customer IT budget allocation. Growth-at-all-costs valuations face multiple compression.
</div>
</div>
<div class="ind-card bear">
<div class="ind-card-top">
<span class="ind-name">Consumer Discretionary &amp; Travel</span>
<span class="impact-chip imp-bear">▼ BEAR</span>
</div>
<div class="ind-body">
<strong>MTN earnings miss</strong> signals consumer spending weakness. Tariff concerns threaten cost structures for imported goods and leisure operators.
</div>
</div>
<div class="ind-card bull">
<div class="ind-card-top">
<span class="ind-name">Private Credit &amp; Non-Bank Lending</span>
<span class="impact-chip imp-bull">▲ BULL</span>
</div>
<div class="ind-body">
Rising yields reflect well on BDC net interest margins. <strong>Fintech lenders like SOFI</strong> benefit from institutional inflows, though spread compression is a risk.
</div>
</div>
</div>
</div>
<!-- ── RELATED TICKERS ── -->
<div class="section">
<div class="section-header">
<span class="section-title">Tickers to Watch</span>
<span class="section-count">5</span>
<div class="section-divider"></div>
</div>
<div class="ticker-list">
<div class="tick-card">
<div class="tick-top">
<span class="tick-sym">LRCX</span>
<span class="tick-name">Lam Research Corp</span>
<span class="signal-chip sig-bear">▼ BEARISH</span>
</div>
<div class="tick-meta">
<span class="conf-chip conf-med">MED confidence</span>
<span class="score-tier" title="Screener score tier: S4 = score 4/20">Screener S4</span>
</div>
<div class="tick-thesis">
Semiconductor equipment supplier <strong>directly exposed to AI capex cycles</strong>. Apple AI letdown signals delayed fab tool orders and potential guidance misses.
</div>
<div class="catalyst-tag">⚡ Catalyst: AAPL AI capex cut</div>
</div>
<div class="tick-card">
<div class="tick-top">
<span class="tick-sym">ASML</span>
<span class="tick-name">ASML Holding NV</span>
<span class="signal-chip sig-bear">▼ BEARISH</span>
</div>
<div class="tick-meta">
<span class="conf-chip conf-med">MED confidence</span>
<span class="score-tier" title="Screener score tier: S3 = score 3/20">Screener S3</span>
</div>
<div class="tick-thesis">
Upstream equipment vendor. <strong>Weakening AI demand narrative</strong> pressures customer capex visibility and order book confidence near-term.
</div>
<div class="catalyst-tag">⚡ Catalyst: AI capex slowdown</div>
</div>
<div class="tick-card">
<div class="tick-top">
<span class="tick-sym">SOFI</span>
<span class="tick-name">SoFi Technologies</span>
<span class="signal-chip sig-bull">▲ BULLISH</span>
</div>
<div class="tick-meta">
<span class="conf-chip conf-med">MED confidence</span>
<span class="score-tier" title="Screener score tier: S6 = score 6/20">Screener S6</span>
</div>
<div class="tick-thesis">
Fintech lender benefiting from <strong>institutional inflows</strong> as yields rise. Watch for spread compression risk if credit conditions tighten further.
</div>
<div class="catalyst-tag">⚡ Catalyst: Rate environment tailwind</div>
</div>
<div class="tick-card">
<div class="tick-top">
<span class="tick-sym">MTN</span>
<span class="tick-name">Vail Resorts Inc</span>
<span class="signal-chip sig-bear">▼ BEARISH</span>
</div>
<div class="tick-meta">
<span class="conf-chip conf-high">HIGH confidence</span>
<span class="score-tier">Screener S2</span>
</div>
<div class="tick-thesis">
Recent <strong>earnings miss</strong> directly signals consumer discretionary softness. Tariff pressure compounds cost-side risks. Monitor forward guidance closely.
</div>
<div class="catalyst-tag">⚡ Catalyst: Earnings miss + tariff risk</div>
</div>
<div class="tick-card">
<div class="tick-top">
<span class="tick-sym">NVDA</span>
<span class="tick-name">NVIDIA Corp</span>
<span class="signal-chip sig-neut">⊙ WATCH</span>
</div>
<div class="tick-meta">
<span class="conf-chip conf-low">LOW confidence</span>
<span class="score-tier">Screener S13</span>
</div>
<div class="tick-thesis">
<strong>Dual exposure</strong>: benefits from AI capex but indirectly exposed if Apple's AI pullback signals broader industry caution. Monitor hyperscaler guidance.
</div>
<div class="catalyst-tag">⚡ Catalyst: Hyperscaler capex announcements</div>
</div>
</div>
</div>
<!-- ── SCREENER BRIDGE ── -->
<div class="screener-prompt">
<div class="sp-text">
<strong>Screen these tickers</strong> to see current signals, scores, and gate results.
</div>
<button class="sp-btn">Screen All →</button>
</div>
</div><!-- /panel-body -->
</div><!-- /panel -->
</div>
</body>
</html>
-86
View File
@@ -1,86 +0,0 @@
# market-screener.conf
# Drop this in /etc/nginx/sites-available/ and symlink to sites-enabled/
# Replace YOUR_DOMAIN with your actual domain or server IP.
upstream market_screener_ui {
server 127.0.0.1:3001;
}
upstream market_screener_api {
server 127.0.0.1:3000;
}
server {
listen 80;
server_name YOUR_DOMAIN;
# Redirect HTTP → HTTPS (uncomment once you have a cert)
# return 301 https://$host$request_uri;
# --- API routes ---
location /api/ {
proxy_pass http://market_screener_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
location /health {
proxy_pass http://market_screener_api;
}
# Polygon / other webhook paths hitting /webhooks/*
location /webhooks/ {
proxy_pass http://market_screener_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
# --- SvelteKit UI (everything else) ---
location / {
proxy_pass http://market_screener_ui;
proxy_http_version 1.1;
# Required for SvelteKit HMR in dev; harmless in prod
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
}
# --- HTTPS block (uncomment + fill in after running certbot) ---
# server {
# listen 443 ssl http2;
# server_name YOUR_DOMAIN;
#
# ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
#
# location /api/ {
# proxy_pass http://market_screener_api;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
#
# location / {
# proxy_pass http://market_screener_ui;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
+1 -4134
View File
File diff suppressed because it is too large Load Diff
+10 -29
View File
@@ -3,52 +3,33 @@
"version": "2.0.0", "version": "2.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"server": "tsx bin/server.ts", "start": "node bin/screen.js",
"dev": "concurrently -n api,ui -c cyan,magenta \"tsx bin/server.ts\" \"npm run dev --prefix ui\"", "server": "node bin/server.js",
"dev": "concurrently -n api,ui -c cyan,magenta \"node bin/server.js\" \"npm run dev --prefix ui\"",
"ui:install": "npm install --prefix ui --legacy-peer-deps", "ui:install": "npm install --prefix ui --legacy-peer-deps",
"typecheck": "tsc --noEmit", "finance": "node bin/finance.js",
"test": "tsx --test --test-reporter=spec tests/*.test.ts", "test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js",
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts", "test:watch": "node --test --watch --test-reporter=spec tests/*.test.js",
"lint": "eslint . --ext .ts,.js", "format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"",
"lint:fix": "eslint . --ext .ts,.js --fix", "format:check": "prettier --check \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"",
"screen:daily": "tsx bin/daily-screen.ts",
"news:poll": "tsx bin/poll-news.ts",
"digest:daily": "tsx bin/daily-digest.ts",
"discord:test": "tsx bin/test-discord.ts",
"format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
"prepare": "husky" "prepare": "husky"
}, },
"lint-staged": { "lint-staged": {
"{server,bin,tests}/**/*.{ts,js}": [ "*.js": [
"eslint --fix",
"prettier --write"
],
"ui/src/**/*.ts": [
"prettier --write" "prettier --write"
] ]
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.100.1", "@anthropic-ai/sdk": "^0.100.1",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "^10.2.1",
"better-sqlite3": "^11.10.0",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"yahoo-finance2": "^3.15.2" "yahoo-finance2": "^3.15.2"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"concurrently": "^10.0.3", "concurrently": "^10.0.3",
"eslint": "^8.0.0",
"eslint-plugin-import": "^2.32.0",
"husky": "^9.0.0", "husky": "^9.0.0",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",
"prettier": "^3.0.0", "prettier": "^3.0.0"
"tsx": "^4.0.0",
"typescript": "^5.0.0"
} }
} }
+1
View File
@@ -0,0 +1 @@
{ "holdings": [] }
-4
View File
@@ -1,4 +0,0 @@
// Central export point for all system prompts
// Add new prompts here as they're created
export { LLM_ANALYST_PROMPT } from './llm-analyst';
-45
View File
@@ -1,45 +0,0 @@
You are a professional equity analyst specialising in catalyst-driven trading.
You will be given today's market news headlines (with Yahoo-tagged tickers per story) and a ranked ticker frequency list showing how many stories mention each ticker.
Your job:
1. Write a 23 sentence market summary capturing the dominant theme and tone.
2. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH.
3. Identify up to 4 industries secondarily affected — not directly mentioned, but impacted via contagion, supply chain, regulation, or macro.
4. Suggest up to 6 tickers worth screening. For each one provide:
- **ticker** — must have ADV > 500k; exclude generic analyst upgrades with no valuation catalyst
- **reason** — one mechanistic sentence (revenue/cost/supply-chain logic, not sentiment)
- **bias** — BULL or BEAR
- **horizon** — SHORT (15 days) | MEDIUM (14 weeks) | LONG (1+ quarter)
- **sensitivity** — how exposed this ticker is to the catalyst:
- 5 = direct revenue impact > 20% of annual sales
- 4 = direct revenue impact 1020%
- 3 = indirect exposure via cost structure or supply chain
- 2 = sector correlation, limited direct exposure
- 1 = macro tailwind/headwind only
Constraints:
- Prioritise tickers that appear multiple times in the frequency list — repeated mentions signal broader market awareness.
- For BEAR picks: require at least one of — elevated short interest, negative earnings revision trend, or sector rotation evidence.
- Do not suggest tickers already in the "already identified" list unless the story adds a new directional angle.
- Prefer ripple-effect tickers (supply chain partners, direct competitors, sector peers) over the primary ticker already in the news — those are where the alpha is.
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
```json
{
"summary": "string",
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
"affectedIndustries": [
{ "name": "string", "reason": "string" }
],
"relatedTickers": [
{
"ticker": "string",
"reason": "string",
"bias": "BULL" | "BEAR",
"horizon": "SHORT" | "MEDIUM" | "LONG",
"sensitivity": 1 | 2 | 3 | 4 | 5
}
]
}
```
+37
View File
@@ -0,0 +1,37 @@
// Minimal test reporter: silent on pass, prints failures in full, ends with one summary line.
export default async function* summaryReporter(source) {
const failures = [];
let passed = 0,
failed = 0,
totalMs = 0;
for await (const event of source) {
// Skip file-level wrapper events (name ends in .js) — only count individual tests.
if (event.data?.name?.endsWith('.js')) continue;
if (event.type === 'test:pass') {
passed++;
totalMs += event.data.details?.duration_ms ?? 0;
} else if (event.type === 'test:fail') {
failed++;
totalMs += event.data.details?.duration_ms ?? 0;
const err = event.data.details?.error;
failures.push({
name: event.data.name,
reason: err?.cause?.message ?? err?.message ?? 'unknown',
});
}
}
if (failures.length) {
yield '\nFailed tests:\n';
for (const f of failures) yield `${f.name}\n ${f.reason}\n`;
yield '\n';
}
const status = failed === 0 ? '✅' : '❌';
const time = (totalMs / 1000).toFixed(2);
yield `${status} ${passed + failed} tests: ${passed} passed`;
if (failed) yield `, ${failed} failed`;
yield ` (${time}s)\n`;
}
-212
View File
@@ -1,212 +0,0 @@
import { randomBytes } from 'crypto';
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
// Domain imports
import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains/screener';
import { FinanceController } from './domains/finance';
import { PortfolioAdvisor } from './domains/portfolio';
import { CallsController, CalendarService } from './domains/calls';
import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth';
import type { TokenPayload } from './domains/auth';
import { WatchlistController, WatchlistRepository } from './domains/watchlist';
import {
NewsController,
NewsRepository,
NewsPipeline,
UniverseProvider,
NewsScheduler,
EdgarPoller,
PrWirePoller,
} from './domains/news';
import { DigestController, DigestService } from './domains/digest';
// Shared infrastructure
import {
YahooFinanceClient,
BenchmarkProvider,
CatalystCache,
LLMAnalyst,
MarketCallRepository,
PortfolioRepository,
SignalSnapshotRepository,
createDb,
DatabaseConnection,
QueryAudit,
noopLogger,
} from './domains/shared';
interface BuildAppOptions {
logger?: boolean;
db?: DatabaseConnection;
/** Inject a stub in tests to avoid live Yahoo news fetches. */
catalystCache?: CatalystCache;
}
// ── JWT auth helpers ─────────────────────────────────────────────────────────
/** Fastify hook that requires a valid JWT. Attaches payload to req.user. */
function makeAuthGuard(secret: string) {
return async (req: FastifyRequest, reply: FastifyReply) => {
const header = req.headers['authorization'] ?? '';
if (!header.startsWith('Bearer ')) {
return reply.code(401).send({ error: 'Missing token' });
}
try {
(req as FastifyRequest & { user: TokenPayload }).user = verifyJwt(header.slice(7), secret);
} catch {
return reply.code(401).send({ error: 'Invalid or expired token' });
}
};
}
/** Fastify hook that requires a specific role (must run after authGuard). */
function makeRoleGuard(required: 'trader' | 'admin') {
return async (req: FastifyRequest, reply: FastifyReply) => {
const user = (req as FastifyRequest & { user?: TokenPayload }).user;
if (!user) return reply.code(401).send({ error: 'Unauthorized' });
// admin passes every role check; trader passes trader check
const roleRank: Record<string, number> = { viewer: 0, trader: 1, admin: 2 };
if ((roleRank[user.role] ?? 0) < (roleRank[required] ?? 99)) {
return reply.code(403).send({ error: 'Forbidden' });
}
};
}
// ── Adding a new domain ───────────────────────────────────────────────
// 1. Create: server/domains/<domain>/ directory structure
// 2. Move controllers, services, types to the domain
// 3. Create barrel: server/domains/<domain>/index.ts
// 4. Import from domain and register controller below
// ───────────────────────────────────────────────────────────────────────────
export async function buildApp({
logger = true,
db: injectedDb,
catalystCache: injectedCache,
}: BuildAppOptions = {}) {
const app = Fastify({ logger });
await app.register(cors, {
origin: process.env.CLIENT_ORIGIN ?? 'http://localhost:5173',
});
// ── Rate limiting — applied globally, tightest on expensive routes ───────
await app.register(rateLimit, {
global: false, // opt-in per route via config.rateLimit
max: 60,
timeWindow: '1 minute',
});
// ── API key auth — only enforced when API_KEY env var is set ─────────────
const API_KEY = process.env.API_KEY;
if (API_KEY) {
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
// Skip auth for health check, OPTIONS preflight, and auth routes
if (req.url === '/health' || req.method === 'OPTIONS' || req.url.startsWith('/auth/')) return;
const header = req.headers['authorization'] ?? '';
if (header !== `Bearer ${API_KEY}`) {
return reply.code(401).send({ error: 'Unauthorized' });
}
});
}
// Database setup — use injected db (for tests) or create real one
const db =
injectedDb ??
(() => {
const rawDb = createDb(process.env.DB_PATH ?? './market-screener.db');
const audit = new QueryAudit();
return new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 });
})();
// ── JWT secret ────────────────────────────────────────────────────────────
const JWT_SECRET = process.env.JWT_SECRET ?? 'dev-secret-change-in-production';
const authGuard = makeAuthGuard(JWT_SECRET);
const traderGuard = makeRoleGuard('trader');
// Services and clients
const yahoo = new YahooFinanceClient();
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
const advisor = new PortfolioAdvisor(yahoo);
const calSvc = new CalendarService(yahoo);
const llm = new LLMAnalyst({ logger: noopLogger });
const catalystCache = injectedCache ?? new CatalystCache({ logger: noopLogger }); // Singleton, 15m cache
// Auth domain — generate a fresh invite code on every boot and print it
const INVITE_CODE = randomBytes(12).toString('hex'); // 24-char hex string
// Box width based on longest content line (no emoji inside — emoji width is terminal-dependent)
const line1 = ` Invite code for this session:`;
const line2 = ` ${INVITE_CODE}`;
const innerWidth = Math.max(line1.length, line2.length) + 2;
const hr = '─'.repeat(innerWidth);
const pad = (s: string) => `${s}${' '.repeat(innerWidth - 1 - s.length)}`;
// Never print the invite code when the logger is disabled (tests) — secrets
// don't belong in test output.
if (logger !== false) {
/* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */
console.log(`\n┌${hr}`);
console.log(pad(''));
console.log(pad(line1));
console.log(pad(line2));
console.log(pad(''));
console.log(`${hr}\n`);
/* eslint-enable no-console */
}
const userStore = new UserStore(db);
const authService = new AuthService(userStore, JWT_SECRET);
new AuthController(authService, INVITE_CODE).register(app);
// Register controllers
// Public routes (GET) remain open; write routes require JWT + trader role
const newsRepo = new NewsRepository(db);
new ScreenerController(
engine,
catalystCache,
new SignalSnapshotRepository(db),
yahoo,
newsRepo,
).register(app);
new FinanceController(engine, new PortfolioRepository(db), advisor, {
authGuard,
traderGuard,
}).register(app);
new CallsController(new MarketCallRepository(db), engine, calSvc, {
authGuard,
traderGuard,
}).register(app);
new AnalyzeController(catalystCache, llm).register(app);
new WatchlistController(new WatchlistRepository(db), { authGuard }).register(app);
// ── News domain (FREE-DATA-STACK) — pipeline + read API + polling ────────
new NewsController(newsRepo, yahoo).register(app);
// ── Digest domain (P1.1) — snapshot diff + catalyst join, on demand ──────
new DigestController(new DigestService(new SignalSnapshotRepository(db), newsRepo)).register(app);
// Polling runs inside the server unless NEWS_POLL=off (use bin/poll-news.ts
// from cron instead). Timers are unref'd and cleared on app.close().
if (process.env.NEWS_POLL !== 'off') {
const newsLogger = {
log: (...args: unknown[]) => app.log.info(args.map(String).join(' ')),
warn: (...args: unknown[]) => app.log.warn(args.map(String).join(' ')),
write: () => {},
};
const newsScheduler = new NewsScheduler(
new NewsPipeline(newsRepo),
new UniverseProvider(db),
new EdgarPoller(newsLogger),
new PrWirePoller(newsLogger),
newsLogger,
);
app.addHook('onReady', async () => newsScheduler.start());
app.addHook('onClose', async () => newsScheduler.stop());
}
app.get('/health', async () => ({ status: 'ok' }));
return app;
}
-146
View File
@@ -1,146 +0,0 @@
/**
* AuthController — HTTP layer for authentication.
*
* POST /auth/register — create account (requires invite code generated at boot)
* POST /auth/login — verify credentials, returns JWT
*/
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { AuthService } from './AuthService.js';
interface RegisterBody {
email: string;
password: string;
inviteCode: string;
role?: 'trader' | 'viewer';
}
interface LoginBody {
email: string;
password: string;
}
interface ForgotBody {
email: string;
}
interface ResetBody {
token: string;
password: string;
}
const registerSchema = {
body: {
type: 'object',
required: ['email', 'password', 'inviteCode'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 8 },
inviteCode: { type: 'string' },
role: { type: 'string', enum: ['trader', 'viewer'] },
},
},
};
const loginSchema = {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
},
},
};
const forgotSchema = {
body: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
};
const resetSchema = {
body: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string', minLength: 32 },
password: { type: 'string', minLength: 8 },
},
},
};
export class AuthController {
readonly #inviteCode: string;
constructor(
private readonly authService: AuthService,
inviteCode: string,
) {
this.#inviteCode = inviteCode;
}
register(app: FastifyInstance): void {
app.post('/auth/register', { schema: registerSchema }, this.#register.bind(this));
app.post('/auth/login', { schema: loginSchema }, this.#login.bind(this));
app.post('/auth/forgot-password', { schema: forgotSchema }, this.#forgot.bind(this));
app.post('/auth/reset-password', { schema: resetSchema }, this.#reset.bind(this));
}
async #register(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { email, password, inviteCode, role } = req.body as RegisterBody;
if (inviteCode !== this.#inviteCode) {
return reply.code(403).send({ error: 'Invalid invite code' });
}
try {
const result = this.authService.register(email, password, role ?? 'viewer');
reply.code(201).send(result);
} catch (err: unknown) {
const e = err as { message: string; statusCode?: number };
reply.code(e.statusCode ?? 500).send({ error: e.message });
}
}
async #login(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { email, password } = req.body as LoginBody;
try {
const result = this.authService.login(email, password);
reply.send(result);
} catch (err: unknown) {
const e = err as { message: string; statusCode?: number };
reply.code(e.statusCode ?? 500).send({ error: e.message });
}
}
async #forgot(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { email } = req.body as ForgotBody;
const origin = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173';
try {
this.authService.forgotPassword(email, origin);
} catch (err) {
// Log server-side but never expose details to client
console.error('[forgot-password] error:', err);
}
// Always return 200 — never reveal whether the email exists or any error occurred
reply.send({
message: 'If that email is registered, a reset link has been printed to the server console.',
});
}
async #reset(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const { token, password } = req.body as ResetBody;
try {
this.authService.resetPassword(token, password);
reply.send({ message: 'Password updated. You can now log in.' });
} catch (err: unknown) {
const e = err as { message: string; statusCode?: number };
reply.code(e.statusCode ?? 500).send({ error: e.message });
}
}
}
-148
View File
@@ -1,148 +0,0 @@
/**
* AuthService — authentication logic.
*
* JWT: hand-rolled HMAC-SHA256 (no external lib) using Node's built-in crypto.
* Passwords: scrypt KDF with random salt (Node crypto, OWASP-recommended).
*/
import { createHmac, randomBytes, scryptSync, timingSafeEqual, randomUUID } from 'crypto';
import type { UserStore } from './UserStore.js';
import type { AuthResponse, Role, TokenPayload, User } from './auth.model.js';
// ── JWT helpers ───────────────────────────────────────────────────────────────
function b64url(input: string | Buffer): string {
const buf = typeof input === 'string' ? Buffer.from(input) : input;
return buf.toString('base64url');
}
function signJwt(payload: TokenPayload, secret: string, expiresInSec = 60 * 60 * 8): string {
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const now = Math.floor(Date.now() / 1000);
const body = b64url(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSec }));
const sig = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
return `${header}.${body}.${sig}`;
}
export function verifyJwt(token: string, secret: string): TokenPayload {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid token format');
const [header, body, sig] = parts;
const expected = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
if (sig !== expected) throw new Error('Invalid token signature');
const payload: TokenPayload = JSON.parse(Buffer.from(body, 'base64url').toString());
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired');
return payload;
}
// ── Password helpers ──────────────────────────────────────────────────────────
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 };
function hashPassword(plain: string): string {
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
N: SCRYPT_PARAMS.N,
r: SCRYPT_PARAMS.r,
p: SCRYPT_PARAMS.p,
}).toString('hex');
return `${salt}:${hash}`;
}
function verifyPassword(plain: string, stored: string): boolean {
const [salt, hash] = stored.split(':');
if (!salt || !hash) return false;
const attempt = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
N: SCRYPT_PARAMS.N,
r: SCRYPT_PARAMS.r,
p: SCRYPT_PARAMS.p,
});
return timingSafeEqual(Buffer.from(hash, 'hex'), attempt);
}
// ── AuthService ───────────────────────────────────────────────────────────────
export class AuthService {
readonly #store: UserStore;
readonly #secret: string;
constructor(store: UserStore, secret: string) {
this.#store = store;
this.#secret = secret;
}
register(email: string, password: string, role: Role = 'viewer'): AuthResponse {
const existing = this.#store.findByEmail(email);
if (existing) throw Object.assign(new Error('Email already registered'), { statusCode: 409 });
const passwordHash = hashPassword(password);
const user = this.#store.create(email, passwordHash, role);
const token = this.#issueToken(user);
return { token, user };
}
login(email: string, password: string): AuthResponse {
const row = this.#store.findByEmail(email);
if (!row) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
const valid = verifyPassword(password, row.password_hash);
if (!valid) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
this.#store.touchLogin(row.id);
const user: User = {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.created_at,
lastLogin: row.last_login,
};
const token = this.#issueToken(user);
return { token, user };
}
verify(token: string): TokenPayload {
return verifyJwt(token, this.#secret);
}
/**
* Generate a password reset token and print the reset link to the console.
* Always returns success (no email enumeration).
*/
forgotPassword(email: string, appOrigin: string): void {
this.#store.purgeExpiredTokens();
const user = this.#store.findByEmail(email);
if (!user) return; // silent — don't reveal whether email exists
const token = randomUUID().replace(/-/g, ''); // 32-char hex
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
this.#store.createResetToken(user.id, token, expiresAt);
const link = `${appOrigin}/auth/reset-password?token=${token}`;
/* eslint-disable no-console -- no mailer yet: reset link must reach the operator's terminal */
console.log('\n🔐 Password reset requested for:', email);
console.log(' Link (expires in 1 hour):');
console.log(` ${link}\n`);
/* eslint-enable no-console */
}
/**
* Validate a reset token and update the user's password.
*/
resetPassword(token: string, newPassword: string): void {
const row = this.#store.findResetToken(token);
if (!row) throw Object.assign(new Error('Invalid or expired reset link'), { statusCode: 400 });
if (row.used) throw Object.assign(new Error('Reset link already used'), { statusCode: 400 });
if (new Date(row.expires_at) < new Date()) {
throw Object.assign(new Error('Reset link has expired'), { statusCode: 400 });
}
const passwordHash = hashPassword(newPassword);
this.#store.updatePassword(row.user_id, passwordHash);
this.#store.markTokenUsed(token);
}
#issueToken(user: User): string {
return signJwt({ sub: user.id, email: user.email, role: user.role }, this.#secret);
}
}
-68
View File
@@ -1,68 +0,0 @@
/**
* UserStore — persistence layer for the users table.
* All queries go through DatabaseConnection for audit + safety.
*/
import { randomUUID } from 'crypto';
import type { DatabaseConnection } from '../shared/db/DatabaseConnection.js';
import { USER_QUERIES, RESET_TOKEN_QUERIES } from '../shared/db/queries.constant.js';
import type { Role, User, UserRow } from './auth.model.js';
export class UserStore {
constructor(private readonly db: DatabaseConnection) {}
findByEmail(email: string): UserRow | undefined {
return this.db.rawGet<UserRow>(USER_QUERIES.SELECT_BY_EMAIL, [email]);
}
findById(id: string): User | undefined {
const row = this.db.rawGet<UserRow>(USER_QUERIES.SELECT_BY_ID, [id]);
if (!row) return undefined;
return this.#toUser(row);
}
create(email: string, passwordHash: string, role: Role = 'viewer'): User {
const id = randomUUID();
const createdAt = new Date().toISOString();
this.db.rawRun(USER_QUERIES.INSERT, [id, email, passwordHash, role, createdAt]);
return { id, email, role, createdAt, lastLogin: null };
}
touchLogin(id: string): void {
this.db.rawRun(USER_QUERIES.UPDATE_LAST_LOGIN, [new Date().toISOString(), id]);
}
updatePassword(id: string, passwordHash: string): void {
this.db.rawRun('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]);
}
// ── Password reset tokens ──────────────────────────────────────────────────
createResetToken(userId: string, token: string, expiresAt: string): void {
this.db.rawRun(RESET_TOKEN_QUERIES.INSERT, [token, userId, expiresAt]);
}
findResetToken(
token: string,
): { token: string; user_id: string; expires_at: string; used: number } | undefined {
return this.db.rawGet(RESET_TOKEN_QUERIES.FIND, [token]);
}
markTokenUsed(token: string): void {
this.db.rawRun(RESET_TOKEN_QUERIES.MARK_USED, [token]);
}
purgeExpiredTokens(): void {
this.db.rawRun(RESET_TOKEN_QUERIES.PURGE, [new Date().toISOString()]);
}
#toUser(row: UserRow): User {
return {
id: row.id,
email: row.email,
role: row.role,
createdAt: row.created_at,
lastLogin: row.last_login,
};
}
}
-36
View File
@@ -1,36 +0,0 @@
// ── Auth domain types ─────────────────────────────────────────────────────────
export type Role = 'trader' | 'viewer' | 'admin';
export interface User {
id: string;
email: string;
role: Role;
createdAt: string;
lastLogin: string | null;
}
/** Full user row including password hash — only used internally by UserStore/AuthService. */
export interface UserRow {
id: string;
email: string;
password_hash: string;
role: Role;
created_at: string;
last_login: string | null;
}
/** Payload embedded in the JWT. */
export interface TokenPayload {
sub: string; // user id
email: string;
role: Role;
iat?: number;
exp?: number;
}
/** Response body for successful login / register. */
export interface AuthResponse {
token: string;
user: User;
}
-4
View File
@@ -1,4 +0,0 @@
export { AuthController } from './AuthController.js';
export { AuthService, verifyJwt } from './AuthService.js';
export { UserStore } from './UserStore.js';
export type { User, UserRow, Role, TokenPayload, AuthResponse } from './auth.model.js';
-82
View File
@@ -1,82 +0,0 @@
import { YahooFinanceClient, chunkArray } from '../../domains/shared';
import type { CalendarEvent } from '../../domains/shared';
export class CalendarService {
constructor(private readonly yahoo: YahooFinanceClient) {}
async getEvents(tickers: string[]): Promise<{ events: CalendarEvent[]; tickers: string[] }> {
if (tickers.length === 0) return { events: [], tickers: [] };
const raw: Record<string, any> = {};
for (const batch of chunkArray(tickers, 5)) {
await Promise.all(
batch.map(async (ticker) => {
const cal = await this.yahoo.fetchCalendarEvents(ticker);
if (cal) raw[ticker] = cal;
}),
);
await new Promise<void>((r) => setTimeout(r, 500));
}
const now = Date.now();
const events = CalendarService.buildEvents(raw, now);
CalendarService.sortEvents(events);
return { events, tickers };
}
private static buildEvents(raw: Record<string, any>, now: number): CalendarEvent[] {
const events: CalendarEvent[] = [];
for (const [ticker, cal] of Object.entries(raw)) {
for (const dateVal of cal.earnings?.earningsDate ?? []) {
const d = new Date(dateVal as string);
events.push({
ticker,
type: 'earnings',
date: d.toISOString().slice(0, 10),
label: 'Earnings',
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
epsEstimate: cal.earnings.earningsAverage ?? null,
revEstimate: cal.earnings.revenueAverage ?? null,
isPast: d.getTime() < now,
});
}
if (cal.exDividendDate) {
const d = new Date(cal.exDividendDate);
events.push({
ticker,
type: 'exdividend',
date: d.toISOString().slice(0, 10),
label: 'Ex-Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
if (cal.dividendDate) {
const d = new Date(cal.dividendDate);
events.push({
ticker,
type: 'dividend',
date: d.toISOString().slice(0, 10),
label: 'Dividend',
detail: null,
isPast: d.getTime() < now,
});
}
}
return events;
}
private static sortEvents(events: CalendarEvent[]): void {
events.sort((a, b) => {
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
return a.isPast
? new Date(b.date).getTime() - new Date(a.date).getTime()
: new Date(a.date).getTime() - new Date(b.date).getTime();
});
}
}
-124
View File
@@ -1,124 +0,0 @@
import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import { MarketCallRepository } from '../../domains/shared';
import { CalendarService } from './CalendarService';
import { ScreenerEngine } from '../screener';
import type { SnapshotEntry } from '../../domains/shared';
import { callSchema } from '../../domains/shared/types/schemas';
interface CallsControllerOptions {
authGuard?: preHandlerHookHandler;
traderGuard?: preHandlerHookHandler;
}
export class CallsController {
readonly #guards: preHandlerHookHandler[];
constructor(
private readonly repo: MarketCallRepository,
private readonly engine: ScreenerEngine,
private readonly calendar: CalendarService,
options: CallsControllerOptions = {},
) {
this.#guards =
options.authGuard && options.traderGuard ? [options.authGuard, options.traderGuard] : [];
}
private static toSnapshot(r: any): SnapshotEntry | null {
if (!r) return null;
const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {};
return {
price: r.asset?.currentPrice ?? null,
signal: r.signal ?? null,
inflatedVerdict: r.inflated?.label ?? null,
fundamentalVerdict: r.fundamental?.label ?? null,
pe: m['P/E'] ?? null,
roe: m['ROE%'] ?? null,
fcf: m['FCF Yld%'] ?? null,
};
}
register(app: FastifyInstance): void {
app.get('/api/calls', this.list.bind(this));
app.get('/api/calls/calendar', this.handleCalendar.bind(this));
app.get('/api/calls/:id', this.get.bind(this));
app.post(
'/api/calls',
{ schema: callSchema, preHandler: this.#guards },
this.create.bind(this),
);
app.delete('/api/calls/:id', { preHandler: this.#guards }, this.remove.bind(this));
}
private async list() {
return { calls: this.repo.list() };
}
private async get(req: FastifyRequest, reply: FastifyReply) {
const call = this.repo.get((req.params as { id: string }).id);
if (!call) return reply.code(404).send({ error: 'Call not found' });
const current: Record<string, SnapshotEntry | null> = {};
if (call.tickers.length > 0) {
try {
const results = await this.engine.screenTickers(call.tickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
current[r.asset.ticker] = CallsController.toSnapshot(r);
}
} catch {
/* non-fatal */
}
}
return { ...call, current };
}
private async create(req: FastifyRequest, reply: FastifyReply) {
const { title, quarter, date, thesis, tickers } = req.body as {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
};
const upperTickers = tickers.map((t) => t.toUpperCase());
const snapshot: Record<string, SnapshotEntry | null> = {};
try {
const results = await this.engine.screenTickers(upperTickers);
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
snapshot[r.asset.ticker] = CallsController.toSnapshot(r);
}
} catch (err) {
req.log.warn(`Could not snapshot prices for market call: ${(err as Error).message}`);
}
const call = this.repo.create({
title,
quarter,
date,
thesis,
tickers: upperTickers,
snapshot: snapshot as any,
});
return reply.code(201).send(call);
}
private async remove(req: FastifyRequest, reply: FastifyReply) {
const deleted = this.repo.delete((req.params as { id: string }).id);
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
return { ok: true };
}
private async handleCalendar(req: FastifyRequest) {
let tickers: string[];
if ((req.query as any).tickers) {
tickers = String((req.query as any).tickers)
.split(',')
.map((t) => t.trim().toUpperCase())
.filter(Boolean);
} else {
tickers = [...new Set(this.repo.list().flatMap((c) => c.tickers))];
}
return this.calendar.getEvents(tickers);
}
}
-3
View File
@@ -1,3 +0,0 @@
// Calls domain — market call tracking and calendar
export { CallsController } from './calls.controller';
export { CalendarService } from './CalendarService';
-110
View File
@@ -1,110 +0,0 @@
import { SignalSnapshotRepository } from '../shared/persistence/SignalSnapshotRepository';
import { NewsRepository } from '../news/NewsRepository';
import { SIGNAL_ORDER } from '../shared/config/constants';
import type {
DigestCatalyst,
DigestChange,
DigestReport,
NewsArticleRow,
SignalSnapshotRow,
} from '../shared/types';
/**
* Daily change digest (PRODUCT.md P1.1) — the step that makes the snapshot
* ledger and the news pipeline actionable together.
*
* For each ticker snapshotted today, diff against its most recent previous
* snapshot. A signal flip alone is just information; a signal flip WITH a
* known catalyst attached is the highest-value alert the free stack can
* produce. M&A stories are always surfaced, change or no change.
*
* Run order matters: screen first (writes today's snapshots), digest second.
*/
export class DigestService {
/** How many days back to look for catalyst stories per ticker. */
private static readonly NEWS_LOOKBACK_DAYS = 2;
constructor(
private readonly snapshots: SignalSnapshotRepository,
private readonly news: NewsRepository,
) {}
build(date = new Date().toISOString().slice(0, 10)): DigestReport {
const today = this.snapshots.byDate(date);
const previous = new Map(this.snapshots.latestBefore(date).map((r) => [r.ticker, r]));
const newsSince = DigestService.daysBefore(date, DigestService.NEWS_LOOKBACK_DAYS);
const changes: DigestChange[] = [];
const newTickers: string[] = [];
const maStories = new Map<string, DigestCatalyst>(); // url → story, deduped
for (const snap of today) {
const prev = previous.get(snap.ticker);
const catalysts = this.news
.newsForTicker(snap.ticker, newsSince)
.map(DigestService.toCatalyst);
// Always collect M&A stories, even without a signal change
for (const c of catalysts) {
if (c.catalyst === 'ma') maStories.set(c.url, c);
}
if (!prev) {
newTickers.push(snap.ticker);
continue;
}
if (prev.signal === snap.signal) continue;
changes.push({
ticker: snap.ticker,
previousSignal: prev.signal,
newSignal: snap.signal,
previousDate: prev.snapshot_date,
scoreDelta: DigestService.scoreDelta(prev, snap),
price: snap.price,
catalysts,
});
}
// Strongest impact first: biggest move across the signal ordering
changes.sort((a, b) => DigestService.impact(b) - DigestService.impact(a));
return {
date,
changes,
newTickers,
maStories: [...maStories.values()],
snapshotCount: today.length,
};
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static toCatalyst(row: NewsArticleRow): DigestCatalyst {
return {
headline: row.headline,
catalyst: row.catalyst,
source: row.source,
url: row.url,
publishedAt: row.published_at,
};
}
private static scoreDelta(prev: SignalSnapshotRow, curr: SignalSnapshotRow): number | null {
if (prev.fundamental_score == null || curr.fundamental_score == null) return null;
return +(curr.fundamental_score - prev.fundamental_score).toFixed(1);
}
/** Distance moved across the signal ordering (Strong Buy=0 … Avoid=4). */
private static impact(change: DigestChange): number {
const ord = (s: string) => SIGNAL_ORDER[s] ?? 5;
return Math.abs(ord(change.newSignal) - ord(change.previousSignal));
}
/** YYYY-MM-DD `n` days before the given day. */
private static daysBefore(date: string, n: number): string {
const d = new Date(`${date}T00:00:00.000Z`);
d.setUTCDate(d.getUTCDate() - n);
return d.toISOString().slice(0, 10);
}
}
-128
View File
@@ -1,128 +0,0 @@
import type { DigestReport, Logger } from '../shared/types';
/**
* Posts the daily digest to a Discord webhook (DISCORD_WEBHOOK_URL in .env).
* When the env var is unset, send() is a no-op and the caller falls back to
* console output — the digest is still useful without Discord.
*
* Embed building is a pure static so it can be unit-tested without network.
*/
export class DiscordNotifier {
private static readonly MAX_FIELDS = 10; // Discord caps embeds at 25 fields; keep digests scannable
constructor(
private readonly logger: Logger,
private readonly webhookUrl = process.env.DISCORD_WEBHOOK_URL,
) {}
get enabled(): boolean {
return Boolean(this.webhookUrl);
}
async send(report: DigestReport): Promise<boolean> {
if (!this.webhookUrl) return false;
const payload = DiscordNotifier.buildPayload(report);
if (!payload) {
this.logger.log('Digest: nothing to report — Discord post skipped');
return false;
}
let res = await this.post(payload);
// Forum channels require a thread name (Discord error code 220001) —
// retry once, creating a post titled with the digest date.
if (res.status === 400 && (await DiscordNotifier.isForumError(res))) {
this.logger.log('Webhook targets a forum channel — retrying with thread_name');
res = await this.post({ ...payload, thread_name: `Signal Digest ${report.date}` });
}
if (!res.ok) {
const body = await res.text().catch(() => '');
this.logger.warn(
`Discord webhook failed: HTTP ${res.status}${body.slice(0, 200) || 'no response body'}`,
);
if (res.status === 401 || res.status === 404) {
this.logger.warn(
'Hint: the URL in .env must be the RAW webhook URL (no <>, no quotes, no HTML escaping), ' +
'ending in a ~68-char token. Re-copy it: Channel Settings → Integrations → Webhooks.',
);
}
return false;
}
return true;
}
private post(payload: object): Promise<Response> {
return fetch(this.webhookUrl as string, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
private static async isForumError(res: Response): Promise<boolean> {
try {
const body = (await res.clone().json()) as { code?: number };
return body.code === 220001;
} catch {
return false;
}
}
/** Returns null when there is nothing worth posting. */
static buildPayload(report: DigestReport): { embeds: unknown[] } | null {
if (report.changes.length === 0 && report.maStories.length === 0) return null;
const fields: Array<{ name: string; value: string; inline: boolean }> = [];
for (const c of report.changes.slice(0, DiscordNotifier.MAX_FIELDS)) {
const delta =
c.scoreDelta != null ? ` (score ${c.scoreDelta > 0 ? '+' : ''}${c.scoreDelta})` : '';
const catalystLine = c.catalysts.length
? c.catalysts
.slice(0, 2)
.map((s) => `• [${s.catalyst ?? 'news'}] ${DiscordNotifier.trim(s.headline, 80)}`)
.join('\n')
: '• no catalyst found — verdict moved on fundamentals/market data';
fields.push({
name: `${c.ticker}: ${c.previousSignal}${c.newSignal}${delta}`,
value: catalystLine,
inline: false,
});
}
if (report.changes.length > DiscordNotifier.MAX_FIELDS) {
fields.push({
name: `…and ${report.changes.length - DiscordNotifier.MAX_FIELDS} more changes`,
value: 'See GET /api/digest for the full report',
inline: false,
});
}
if (report.maStories.length > 0) {
fields.push({
name: `🔱 M&A activity (${report.maStories.length})`,
value: report.maStories
.slice(0, 5)
.map((s) => `${DiscordNotifier.trim(s.headline, 90)}`)
.join('\n'),
inline: false,
});
}
return {
embeds: [
{
title: `📊 Daily Signal Digest — ${report.date}`,
description: `${report.snapshotCount} tickers screened · ${report.changes.length} signal change(s)`,
color: report.changes.length > 0 ? 0xf0b429 : 0x4ade80, // amber if changes, green if calm
fields,
},
],
};
}
private static trim(s: string, max: number): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}`;
}
}
@@ -1,22 +0,0 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { DigestService } from './DigestService';
/**
* On-demand digest read (P1.1). The scheduled path is bin/daily-digest.ts;
* this endpoint lets the UI (or curl) build the same report any time.
*/
export class DigestController {
constructor(private readonly digest: DigestService) {}
register(app: FastifyInstance): void {
app.get('/api/digest', this.today.bind(this));
}
/** GET /api/digest?date=YYYY-MM-DD (defaults to today) */
private async today(req: FastifyRequest) {
const { date } = req.query as { date?: string };
const day =
date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : new Date().toISOString().slice(0, 10);
return this.digest.build(day);
}
}
-5
View File
@@ -1,5 +0,0 @@
// Digest domain — daily change detection (PRODUCT.md P1.1)
export { DigestService } from './DigestService';
export { DiscordNotifier } from './DiscordNotifier';
export { DigestController } from './digest.controller';
@@ -1,102 +0,0 @@
import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared/index.js';
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener/index.js';
import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor.js';
import type { PortfolioHolding } from '../../domains/shared/index.js';
import { holdingSchema } from '../../domains/shared/types/schemas.js';
import type { TokenPayload } from '../auth/index.js';
interface FinanceControllerOptions {
authGuard?: preHandlerHookHandler;
traderGuard?: preHandlerHookHandler;
}
type AuthRequest = FastifyRequest & { user?: TokenPayload };
function userId(req: FastifyRequest): string {
return (req as AuthRequest).user?.sub ?? '';
}
export class FinanceController {
// All portfolio routes only need a valid login — data is already user-scoped by user_id.
// No role restriction needed; any registered user can manage their own portfolio.
readonly #authGuards: preHandlerHookHandler[];
constructor(
private readonly engine: ScreenerEngine,
private readonly repo: PortfolioRepository,
private readonly advisor: PortfolioAdvisor,
options: FinanceControllerOptions = {},
) {
this.#authGuards = options.authGuard ? [options.authGuard] : [];
}
register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', { preHandler: this.#authGuards }, this.portfolio.bind(this));
app.post(
'/api/finance/holdings',
{
schema: holdingSchema,
preHandler: this.#authGuards,
},
this.addHolding.bind(this),
);
app.delete(
'/api/finance/holdings/:ticker',
{
preHandler: this.#authGuards,
},
this.removeHolding.bind(this),
);
app.get('/api/finance/market-context', this.marketContext.bind(this));
}
private async portfolio(req: FastifyRequest, _reply: FastifyReply) {
const uid = userId(req);
const { holdings } = this.repo.exists(uid) ? this.repo.read(uid) : { holdings: [] };
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 uid = userId(req);
const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const entry = this.repo.upsert({ ticker, shares, costBasis, type, source }, uid);
return reply.code(201).send(entry);
}
private async removeHolding(req: FastifyRequest, reply: FastifyReply) {
const uid = userId(req);
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
const removed = this.repo.remove(ticker, uid);
if (!removed) return reply.code(404).send({ error: 'Holding not found' });
return { ok: true };
}
private async marketContext() {
return this.engine.getMarketContext();
}
}
-2
View File
@@ -1,2 +0,0 @@
// Finance domain — portfolio metrics and reporting
export { FinanceController } from './finance.controller';
-165
View File
@@ -1,165 +0,0 @@
import { createHash } from 'crypto';
import { NewsRepository } from './NewsRepository';
import type { CatalystType, IngestStats, NormalizedStory } from '../shared/types';
/**
* Shared ingest pipeline (FREE-DATA-STACK §2) — every source flows through
* here: FILTER → DEDUPE → CLASSIFY → STORE. All drops happen BEFORE insert,
* cheapest check first, so the tables stay small by construction (§4).
*/
export class NewsPipeline {
/** §4.4 — max stories linked per ticker per day (filings exempt). */
private static readonly DAILY_CAP = 25;
/** §4.3 — syndicated-copy window for title dedupe. */
private static readonly TITLE_WINDOW_MS = 48 * 60 * 60 * 1000;
/** §4.2 — headlines with no decision value are never stored. */
private static readonly NOISE_PATTERNS: RegExp[] = [
/\b\d+\s+(?:best|top|hot)\s+stocks?\b/i,
/\bstocks?\s+to\s+(?:watch|buy|sell)\b/i,
/\bprice\s+target\s+(?:raised|lowered|reiterated|maintained)\b/i,
/\b(?:premarket|after-?hours?)\s+movers?\b/i,
/\bwhy\s+.{0,40}\s+stock\s+(?:jumped|popped|soared|plunged|tanked)\b/i,
/\bmotley\s+fool\b/i,
];
constructor(private readonly repo: NewsRepository) {}
/**
* Run a batch of normalized stories through the pipeline.
* `universe` is the tracked-ticker set from UniverseProvider.
*/
ingest(stories: NormalizedStory[], universe: Set<string>): IngestStats {
const stats: IngestStats = {
fetched: stories.length,
stored: 0,
droppedNoUniverseTicker: 0,
droppedNoise: 0,
droppedDuplicate: 0,
droppedCapped: 0,
};
for (const story of stories) {
this.ingestOne(story, universe, stats);
}
return stats;
}
private ingestOne(story: NormalizedStory, universe: Set<string>, stats: IngestStats): void {
const isFiling = story.source === 'edgar';
// 1. Universe filter — the big one (§4.1)
const tickers = [...new Set(story.tickers.map((t) => t.toUpperCase()))].filter((t) =>
universe.has(t),
);
if (tickers.length === 0) {
stats.droppedNoUniverseTicker++;
return;
}
// 2. Noise blocklist (§4.2) — filings are never noise
if (!isFiling && NewsPipeline.isNoise(story.headline)) {
stats.droppedNoise++;
return;
}
// 3. Dedupe (§4.3): url hash (storage-level PK) + recent title match
const urlHash = NewsPipeline.sha(story.url);
const titleHash = NewsPipeline.sha(NewsPipeline.normalizeTitle(story.headline));
const titleCutoff = new Date(Date.now() - NewsPipeline.TITLE_WINDOW_MS).toISOString();
if (this.repo.titleSeenSince(titleHash, titleCutoff)) {
stats.droppedDuplicate++;
return;
}
// 4. Per-ticker daily cap (§4.4) — filings keep priority past the cap
const day = story.publishedAt.slice(0, 10);
const eligible = isFiling
? tickers
: tickers.filter((t) => this.repo.countTickerDay(t, day) < NewsPipeline.DAILY_CAP);
if (eligible.length === 0) {
stats.droppedCapped++;
return;
}
// 5. Classify + store
const catalyst = story.catalystHint ?? NewsPipeline.classify(story.headline);
const inserted = this.repo.insertArticle({
urlHash,
titleHash,
tickers: eligible,
headline: story.headline.trim(),
body: story.body ?? null,
source: story.source,
catalyst,
url: story.url,
publishedAt: story.publishedAt,
});
if (!inserted) {
stats.droppedDuplicate++; // url_hash collision — already stored
return;
}
for (const ticker of eligible) {
this.repo.linkTicker(ticker, day, urlHash);
}
stats.stored++;
}
/** Retention jobs (§5) — call once daily. */
runRetention(now = new Date()): { bodiesPurged: number; rowsDeleted: number } {
const bodyCutoff = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString();
const rowCutoff = new Date(now.getTime() - 548 * 24 * 60 * 60 * 1000).toISOString(); // ~18mo
return {
bodiesPurged: this.repo.purgeBodiesBefore(bodyCutoff),
rowsDeleted: this.repo.deleteUnreferencedBefore(rowCutoff),
};
}
// ── Pure helpers (exposed for tests) ──────────────────────────────────────
static isNoise(headline: string): boolean {
return NewsPipeline.NOISE_PATTERNS.some((re) => re.test(headline));
}
/**
* Keyword catalyst classifier. Order matters: M&A beats earnings
* ("acquisition closes in Q2" is an M&A story).
*/
static classify(headline: string): CatalystType | null {
const h = headline.toLowerCase();
if (
/\b(acqui[sr]|merger|takeover|buyout|tender offer|business combination|to be acquired)/.test(
h,
)
)
return 'ma';
if (/\b(guidance|outlook|forecast|raises full[- ]year|lowers full[- ]year)/.test(h))
return 'guidance';
if (
/\b(earnings|results|eps|quarterly report|q[1-4] (?:20\d\d|results)|fiscal (?:year|q[1-4]))/.test(
h,
)
)
return 'earnings';
if (
/\b(sec |fda|doj|ftc|antitrust|investigation|subpoena|lawsuit|settl|recall|approval)/.test(h)
)
return 'regulatory';
if (/\b(fed |fomc|inflation|cpi|jobs report|rate (?:cut|hike)|treasury yield)/.test(h))
return 'macro';
return null;
}
static normalizeTitle(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9 ]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
private static sha(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
}
-76
View File
@@ -1,76 +0,0 @@
import { DatabaseConnection } from '../shared/db/index';
import { QueryBuilder } from '../shared/utils/QueryBuilder';
import type { NewsArticleRow } from '../shared/types';
/**
* Persistence for the free-tier news pipeline (FREE-DATA-STACK §3).
* Pure data access — all filtering/dedupe decisions live in NewsPipeline.
*/
export class NewsRepository {
constructor(private readonly db: DatabaseConnection) {}
/** Returns true if the row was inserted (false = duplicate url_hash). */
insertArticle(a: {
urlHash: string;
titleHash: string;
tickers: string[];
headline: string;
body: string | null;
source: string;
catalyst: string | null;
url: string;
publishedAt: string;
}): boolean {
const qb = new QueryBuilder('NEWS_QUERIES.INSERT_ARTICLE', [
a.urlHash,
a.titleHash,
JSON.stringify(a.tickers),
a.headline,
a.body,
a.source,
a.catalyst,
a.url,
a.publishedAt,
new Date().toISOString(),
]);
return this.db.run(qb) > 0;
}
titleSeenSince(titleHash: string, sinceIso: string): boolean {
const qb = new QueryBuilder('NEWS_QUERIES.TITLE_SEEN_SINCE', [titleHash, sinceIso]);
return this.db.get(qb) != null;
}
linkTicker(ticker: string, day: string, urlHash: string): void {
const qb = new QueryBuilder('NEWS_QUERIES.INSERT_CATALYST_LINK', [ticker, day, urlHash]);
this.db.run(qb);
}
countTickerDay(ticker: string, day: string): number {
const qb = new QueryBuilder('NEWS_QUERIES.COUNT_TICKER_DAY', [ticker, day]);
return this.db.get<{ n: number }>(qb)?.n ?? 0;
}
newsForTicker(ticker: string, sinceDay: string): NewsArticleRow[] {
const qb = new QueryBuilder('NEWS_QUERIES.SELECT_TICKER_NEWS', [
ticker.toUpperCase(),
sinceDay,
]);
return this.db.all<NewsArticleRow>(qb);
}
recent(limit: number): NewsArticleRow[] {
const qb = new QueryBuilder('NEWS_QUERIES.SELECT_RECENT', [limit]);
return this.db.all<NewsArticleRow>(qb);
}
/** Retention: null out bodies older than cutoff. Returns rows changed. */
purgeBodiesBefore(cutoffIso: string): number {
return this.db.run(new QueryBuilder('NEWS_QUERIES.PURGE_BODIES_BEFORE', [cutoffIso]));
}
/** Retention: delete old rows no ticker references. Returns rows deleted. */
deleteUnreferencedBefore(cutoffIso: string): number {
return this.db.run(new QueryBuilder('NEWS_QUERIES.DELETE_UNREFERENCED_BEFORE', [cutoffIso]));
}
}
-106
View File
@@ -1,106 +0,0 @@
import { NewsPipeline } from './NewsPipeline';
import { UniverseProvider } from './UniverseProvider';
import { EdgarPoller } from './pollers/EdgarPoller';
import { PrWirePoller } from './pollers/PrWirePoller';
import type { IngestStats, Logger } from '../shared/types';
/**
* In-process polling scheduler (FREE-DATA-STACK §2). No Redis/BullMQ at the
* free tier — plain intervals, unref'd so they never hold the process open.
*
* Cadences: EDGAR 10 min, PR-wire 15 min, retention daily.
* Disable entirely with NEWS_POLL=off (e.g. when running bin/poll-news.ts
* from cron instead of inside the server).
*/
export class NewsScheduler {
private static readonly EDGAR_INTERVAL_MS = 10 * 60 * 1000;
private static readonly PRWIRE_INTERVAL_MS = 15 * 60 * 1000;
private static readonly RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
private timers: NodeJS.Timeout[] = [];
constructor(
private readonly pipeline: NewsPipeline,
private readonly universe: UniverseProvider,
private readonly edgar: EdgarPoller,
private readonly prwire: PrWirePoller,
private readonly logger: Logger,
) {}
start(): void {
if (this.timers.length > 0) return; // already running
const every = (ms: number, fn: () => void) => {
const t = setInterval(fn, ms);
t.unref(); // never keep the process alive just for polling
this.timers.push(t);
};
every(NewsScheduler.EDGAR_INTERVAL_MS, () => void this.runEdgar());
every(NewsScheduler.PRWIRE_INTERVAL_MS, () => void this.runPrWire());
every(NewsScheduler.RETENTION_INTERVAL_MS, () => this.runRetention());
// Prime once shortly after boot (delay keeps server startup fast)
const boot = setTimeout(() => void this.runOnce(), 15_000);
boot.unref();
this.timers.push(boot);
this.logger.log('News scheduler started (EDGAR 10m, PR-wire 15m, retention 24h)');
}
stop(): void {
for (const t of this.timers) clearInterval(t);
this.timers = [];
}
/** One full cycle of everything — used at boot and by bin/poll-news.ts. */
async runOnce(): Promise<{ edgar: IngestStats; prwire: IngestStats }> {
const edgar = await this.runEdgar();
const prwire = await this.runPrWire();
return { edgar, prwire };
}
private async runEdgar(): Promise<IngestStats> {
try {
const stories = await this.edgar.poll(this.universe.getUniverse());
const stats = this.pipeline.ingest(stories, this.universe.getUniverse());
if (stats.stored > 0) this.logger.log(`EDGAR: stored ${stats.stored}/${stats.fetched}`);
return stats;
} catch (err) {
this.logger.warn('EDGAR poll cycle failed:', (err as Error).message);
return NewsScheduler.emptyStats();
}
}
private async runPrWire(): Promise<IngestStats> {
try {
const stories = await this.prwire.poll();
const stats = this.pipeline.ingest(stories, this.universe.getUniverse());
if (stats.stored > 0) this.logger.log(`PR-wire: stored ${stats.stored}/${stats.fetched}`);
return stats;
} catch (err) {
this.logger.warn('PR-wire poll cycle failed:', (err as Error).message);
return NewsScheduler.emptyStats();
}
}
private runRetention(): void {
try {
const { bodiesPurged, rowsDeleted } = this.pipeline.runRetention();
this.logger.log(`News retention: ${bodiesPurged} bodies purged, ${rowsDeleted} rows deleted`);
} catch (err) {
this.logger.warn('News retention failed:', (err as Error).message);
}
}
private static emptyStats(): IngestStats {
return {
fetched: 0,
stored: 0,
droppedNoUniverseTicker: 0,
droppedNoise: 0,
droppedDuplicate: 0,
droppedCapped: 0,
};
}
}
-50
View File
@@ -1,50 +0,0 @@
import { DatabaseConnection } from '../shared/db/index';
import { QueryBuilder } from '../shared/utils/QueryBuilder';
/**
* The tracked-ticker universe (FREE-DATA-STACK §4.1):
* watchlist holdings tickers screened in the last 30 days.
*
* This is the news pipeline's first and biggest filter — stories about
* tickers outside the universe are never stored. Cached for 10 minutes;
* the universe changes slowly.
*/
export class UniverseProvider {
private static readonly CACHE_TTL_MS = 10 * 60 * 1000;
private static readonly SNAPSHOT_LOOKBACK_DAYS = 30;
private cache: { universe: Set<string>; expiresAt: number } = {
universe: new Set(),
expiresAt: 0,
};
constructor(private readonly db: DatabaseConnection) {}
getUniverse(): Set<string> {
if (Date.now() < this.cache.expiresAt) return this.cache.universe;
const sinceDay = new Date(
Date.now() - UniverseProvider.SNAPSHOT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
)
.toISOString()
.slice(0, 10);
const tickers = new Set<string>();
const add = (rows: { ticker: string }[]) =>
rows.forEach((r) => tickers.add(r.ticker.toUpperCase()));
add(this.db.all(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_WATCHLIST_TICKERS')));
add(this.db.all(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_HOLDING_TICKERS')));
add(
this.db.all(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_SNAPSHOT_TICKERS_SINCE', [sinceDay])),
);
this.cache = { universe: tickers, expiresAt: Date.now() + UniverseProvider.CACHE_TTL_MS };
return tickers;
}
/** Force next getUniverse() to re-read (e.g. after a watchlist change). */
invalidate(): void {
this.cache.expiresAt = 0;
}
}
-10
View File
@@ -1,10 +0,0 @@
// News domain — free-tier news ingestion pipeline (FREE-DATA-STACK.md)
export { NewsController } from './news.controller';
export { NewsRepository } from './NewsRepository';
export { NewsPipeline } from './NewsPipeline';
export { UniverseProvider } from './UniverseProvider';
export { NewsScheduler } from './NewsScheduler';
export { EdgarPoller } from './pollers/EdgarPoller';
export { PrWirePoller } from './pollers/PrWirePoller';
export { RssParser } from './rss';
-90
View File
@@ -1,90 +0,0 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { NewsRepository } from './NewsRepository';
import { YahooFinanceClient } from '../shared';
import type { NewsArticleRow } from '../shared/types';
interface StoryView {
headline: string;
tickers: string[];
source: string;
catalyst: string | null;
url: string;
publishedAt: string;
}
/**
* Read side of the news pipeline. Stored pipeline stories (curated, catalyst-
* tagged, historical) are merged with a live per-ticker Yahoo search on
* request — stored gives depth, live gives freshness. The RSS firehoses
* can't be queried per-ticker on demand, which is why they go through the
* polling pipeline instead.
*/
export class NewsController {
constructor(
private readonly repo: NewsRepository,
private readonly yahoo?: YahooFinanceClient,
) {}
register(app: FastifyInstance): void {
app.get('/api/news/recent', this.recent.bind(this));
app.get('/api/news/:ticker', this.byTicker.bind(this));
}
/** GET /api/news/:ticker?days=7&live=1 (live Yahoo merge on by default) */
private async byTicker(req: FastifyRequest) {
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
const query = req.query as { days?: string; live?: string };
const days = Math.min(Number(query.days ?? 7) || 7, 90);
const live = query.live !== '0';
const sinceDay = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const stored = this.repo.newsForTicker(ticker, sinceDay).map(NewsController.serialize);
const fresh = live ? await this.fetchLive(ticker) : [];
// Merge, dedupe by URL, newest first
const byUrl = new Map<string, StoryView>();
for (const s of [...stored, ...fresh]) byUrl.set(s.url, byUrl.get(s.url) ?? s);
const stories = [...byUrl.values()].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
return { ticker, days, stories };
}
/** Live per-ticker Yahoo news search — freshness layer, best-effort. */
private async fetchLive(ticker: string): Promise<StoryView[]> {
if (!this.yahoo) return [];
try {
const items = await this.yahoo.search(ticker, { newsCount: 8 });
return items
.filter((n) => n.title && n.link)
.map((n) => ({
headline: n.title as string,
tickers: [ticker],
source: 'yahoo',
catalyst: null,
url: n.link as string,
publishedAt: n.providerPublishTime
? new Date(n.providerPublishTime).toISOString()
: new Date().toISOString(),
}));
} catch {
return [];
}
}
/** GET /api/news/recent?limit=50 */
private async recent(req: FastifyRequest) {
const limit = Math.min(Number((req.query as { limit?: string }).limit ?? 50) || 50, 200);
return { stories: this.repo.recent(limit).map(NewsController.serialize) };
}
private static serialize(row: NewsArticleRow) {
return {
headline: row.headline,
tickers: JSON.parse(row.ticker_list) as string[],
source: row.source,
catalyst: row.catalyst,
url: row.url,
publishedAt: row.published_at,
};
}
}
-122
View File
@@ -1,122 +0,0 @@
import { RssParser } from '../rss';
import type { CatalystType, Logger, NormalizedStory } from '../../shared/types';
/**
* SEC EDGAR poller (FREE-DATA-STACK §1.3 / P1.2 Tier 2). Free forever, and
* the highest-value source: filings frequently precede the headline.
*
* Strategy: poll the site-wide "current filings" atom feed once per form
* type (4 requests/cycle total, well inside SEC fair use), map filer CIK →
* ticker via the daily-cached company_tickers.json, and emit stories only
* for universe tickers. The pipeline applies its own universe filter again —
* defense in depth.
*
* SEC requires a descriptive User-Agent with contact info: set
* EDGAR_USER_AGENT in .env (e.g. "market-screener/1.0 you@example.com").
*/
export class EdgarPoller {
private static readonly TICKER_MAP_URL = 'https://www.sec.gov/files/company_tickers.json';
private static readonly TICKER_MAP_TTL_MS = 24 * 60 * 60 * 1000;
/** form type → catalyst classification (overrides keyword classify). */
private static readonly FORMS: Array<{ form: string; catalyst: CatalystType }> = [
{ form: '8-K', catalyst: 'regulatory' }, // material events
{ form: 'SC 13D', catalyst: 'ma' }, // activist stake >5% — classic pre-M&A tell
{ form: 'S-4', catalyst: 'ma' }, // merger registration
{ form: 'DEFM14A', catalyst: 'ma' }, // merger proxy
];
private cikToTicker: Map<string, string> = new Map();
private mapExpiresAt = 0;
constructor(
private readonly logger: Logger,
private readonly userAgent = process.env.EDGAR_USER_AGENT ??
'market-screener/1.0 (set EDGAR_USER_AGENT in .env)',
) {}
/** Fetch all form feeds and return normalized stories for universe tickers. */
async poll(universe: Set<string>): Promise<NormalizedStory[]> {
if (universe.size === 0) return [];
await this.refreshTickerMap();
const stories: NormalizedStory[] = [];
for (const { form, catalyst } of EdgarPoller.FORMS) {
try {
const xml = await this.fetchText(EdgarPoller.feedUrl(form));
stories.push(...this.parseFeed(xml, form, catalyst, universe));
} catch (err) {
this.logger.warn(`EDGAR ${form} feed failed:`, (err as Error).message);
}
}
return stories;
}
/** Parse one atom feed. Public for fixture tests. */
parseFeed(
xml: string,
form: string,
catalyst: CatalystType,
universe: Set<string>,
): NormalizedStory[] {
const stories: NormalizedStory[] = [];
for (const entry of RssParser.blocks(xml, 'entry')) {
const title = RssParser.tag(entry, 'title') ?? '';
const updated = RssParser.tag(entry, 'updated');
const url = RssParser.link(entry);
if (!title || !url || !updated) continue;
// Title format: "8-K - APPLE INC (0000320193) (Filer)"
const cikMatch = title.match(/\((\d{10})\)/);
if (!cikMatch) continue;
const ticker = this.cikToTicker.get(cikMatch[1]);
if (!ticker || !universe.has(ticker)) continue;
const company = title
.replace(/^[^-]+-\s*/, '')
.replace(/\(\d{10}\)/g, '')
.replace(/\((Filer|Subject|Reporting)\)/gi, '')
.trim();
stories.push({
tickers: [ticker],
headline: `${form} filing: ${company}`,
body: null,
source: 'edgar',
url,
publishedAt: new Date(updated).toISOString(),
catalystHint: catalyst,
});
}
return stories;
}
/** Inject a CIK→ticker map directly (tests). CIKs are 10-digit zero-padded. */
setTickerMap(map: Map<string, string>): void {
this.cikToTicker = map;
this.mapExpiresAt = Date.now() + EdgarPoller.TICKER_MAP_TTL_MS;
}
private async refreshTickerMap(): Promise<void> {
if (Date.now() < this.mapExpiresAt && this.cikToTicker.size > 0) return;
const raw = await this.fetchText(EdgarPoller.TICKER_MAP_URL);
const data = JSON.parse(raw) as Record<string, { cik_str: number; ticker: string }>;
const map = new Map<string, string>();
for (const entry of Object.values(data)) {
map.set(String(entry.cik_str).padStart(10, '0'), entry.ticker.toUpperCase());
}
this.setTickerMap(map);
this.logger.log(`EDGAR ticker map refreshed: ${map.size} companies`);
}
private static feedUrl(form: string): string {
const type = encodeURIComponent(form);
return `https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=${type}&company=&dateb=&owner=include&count=100&output=atom`;
}
private async fetchText(url: string): Promise<string> {
const res = await fetch(url, { headers: { 'User-Agent': this.userAgent } });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.text();
}
}
@@ -1,91 +0,0 @@
import { RssParser } from '../rss';
import type { Logger, NormalizedStory } from '../../shared/types';
/**
* PR-wire RSS poller (FREE-DATA-STACK §1.4 / P1.2 Tier 3) — press releases
* that the other free feeds miss, mostly small-caps.
*
* Ticker extraction relies on the wire convention of exchange tags in the
* text: "(NYSE: ABC)", "(Nasdaq: XYZ)". Stories without an exchange tag
* produce no tickers and are dropped by the pipeline's universe filter —
* that's intentional; untagged wire stories are rarely decision-grade.
*
* Feed list is overridable: NEWS_PRWIRE_FEEDS="url1,url2" in .env
* (wire RSS URLs change occasionally — if a feed 404s, update the env var).
*/
export class PrWirePoller {
private static readonly DEFAULT_FEEDS = [
// GlobeNewswire — public-company news
'https://www.globenewswire.com/RssFeed/orgclass/1/feedTitle/GlobeNewswire%20-%20News%20about%20Public%20Companies',
// PR Newswire — all news releases
'https://www.prnewswire.com/rss/news-releases-list.rss',
];
private static readonly EXCHANGE_TAG =
/\((?:NYSE(?:\s+American)?|NASDAQ|Nasdaq|AMEX|CBOE|OTC(?:QB|QX|MKTS)?)\s*:\s*([A-Za-z][A-Za-z.]{0,5})\)/g;
private readonly feeds: string[];
constructor(
private readonly logger: Logger,
feeds?: string[],
) {
const env = process.env.NEWS_PRWIRE_FEEDS;
this.feeds = feeds ?? (env ? env.split(',').map((s) => s.trim()) : PrWirePoller.DEFAULT_FEEDS);
}
async poll(): Promise<NormalizedStory[]> {
const stories: NormalizedStory[] = [];
for (const feed of this.feeds) {
try {
const xml = await this.fetchText(feed);
stories.push(...PrWirePoller.parseFeed(xml));
} catch (err) {
this.logger.warn(`PR-wire feed failed (${feed}):`, (err as Error).message);
}
}
return stories;
}
/** Parse one RSS feed. Public static for fixture tests. */
static parseFeed(xml: string): NormalizedStory[] {
const stories: NormalizedStory[] = [];
for (const item of RssParser.blocks(xml, 'item')) {
const title = RssParser.tag(item, 'title');
const url = RssParser.link(item);
const pubDate = RssParser.tag(item, 'pubDate');
if (!title || !url) continue;
const description = RssParser.tag(item, 'description') ?? '';
const tickers = PrWirePoller.extractTickers(`${title} ${description}`);
if (tickers.length === 0) continue; // no exchange tag → skip early
stories.push({
tickers,
headline: title,
body: description || null,
source: 'prwire',
url,
publishedAt: pubDate ? new Date(pubDate).toISOString() : new Date().toISOString(),
});
}
return stories;
}
/** "(NYSE: ABC)" / "(Nasdaq: XYZ)" → ['ABC', 'XYZ']. Public for tests. */
static extractTickers(text: string): string[] {
const out = new Set<string>();
for (const m of text.matchAll(PrWirePoller.EXCHANGE_TAG)) {
out.add(m[1].toUpperCase());
}
return [...out];
}
private async fetchText(url: string): Promise<string> {
const res = await fetch(url, {
headers: { 'User-Agent': 'market-screener/1.0 (+rss reader)' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
}
-43
View File
@@ -1,43 +0,0 @@
/**
* Minimal RSS/Atom extraction — enough for EDGAR atom feeds and PR-wire RSS.
* Deliberately dependency-free; if a feed outgrows this, swap in
* fast-xml-parser without touching the pollers' output shape.
*/
export class RssParser {
/** Extract raw <item>…</item> or <entry>…</entry> blocks. */
static blocks(xml: string, tag: 'item' | 'entry'): string[] {
const re = new RegExp(`<${tag}[\\s>][\\s\\S]*?<\\/${tag}>`, 'g');
return xml.match(re) ?? [];
}
/** First occurrence of a simple tag's text content, entity-decoded. */
static tag(block: string, name: string): string | null {
const re = new RegExp(`<${name}[^>]*>([\\s\\S]*?)<\\/${name}>`, 'i');
const m = block.match(re);
return m ? RssParser.clean(m[1]) : null;
}
/** Atom-style <link href="…"/> (self-closing) or RSS <link>…</link>. */
static link(block: string): string | null {
const href = block.match(/<link[^>]*href="([^"]+)"/i);
if (href) return RssParser.decode(href[1].trim());
return RssParser.tag(block, 'link');
}
private static clean(raw: string): string {
const noCdata = raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
const noTags = noCdata.replace(/<[^>]+>/g, ' ');
return RssParser.decode(noTags).replace(/\s+/g, ' ').trim();
}
private static decode(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}
}
@@ -1,178 +0,0 @@
import { SIGNAL, YahooFinanceClient } from '../../domains/shared';
import type {
PortfolioHolding,
Signal,
ScreenerResult,
AssetResult,
AdviceRow,
PositionCalc,
AdviceOutput,
} from '../../domains/shared';
export class PortfolioAdvisor {
constructor(private readonly client: YahooFinanceClient) {}
async advise(
holdings: PortfolioHolding[],
screenedResults: ScreenerResult,
): Promise<AdviceRow[]> {
const resultMap: Record<string, AssetResult> = {};
for (const r of [...screenedResults.STOCK, ...screenedResults.ETF, ...screenedResults.BOND]) {
const t = r.asset.ticker;
resultMap[t] = r;
resultMap[t.replace(/-/g, '.')] = r;
resultMap[t.replace(/\./g, '-')] = r;
}
const cryptoPrices = await this.cryptoPrices(holdings.filter((h) => h.type === 'crypto'));
return holdings.map((holding) => {
const type = (holding.type ?? 'stock').toLowerCase();
const source = holding.source ?? '—';
const price: number | null =
type === 'crypto'
? (cryptoPrices[holding.ticker.toUpperCase()] ?? null)
: (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null);
return type === 'crypto'
? this.row(holding, price, source, '—', '—', '—', this.cryptoAdvice(holding, price))
: this.stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]);
});
}
private stockRow(
holding: PortfolioHolding,
price: number | null,
source: string,
result: AssetResult | undefined,
): AdviceRow {
if (!result) {
return this.row(holding, price, source, '—', '—', '—', {
action: '⚪ Not screened',
reason: 'No screener data available — Yahoo Finance may not support this ticker.',
});
}
return this.row(
holding,
price,
source,
result.signal,
result.inflated.label,
result.fundamental.label,
this.advice(result.signal, holding, price),
);
}
private row(
holding: PortfolioHolding,
currentPrice: number | null,
source: string,
signal: Signal | '—',
inflated: string,
fundamental: string,
{ action, reason }: AdviceOutput,
): AdviceRow {
const { marketValue, totalCost, gainLossPct } = this.position(holding, currentPrice);
return {
ticker: holding.ticker,
type: holding.type ?? 'stock',
source,
shares: holding.shares,
costBasis: holding.costBasis,
currentPrice,
marketValue,
totalCost,
gainLossPct,
signal,
inflated,
fundamental,
advice: action,
reason,
};
}
private position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc {
return {
totalCost: (holding.costBasis * holding.shares).toFixed(2),
marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null,
gainLossPct:
currentPrice != null && holding.costBasis > 0
? (((currentPrice - holding.costBasis) / holding.costBasis) * 100).toFixed(1)
: null,
};
}
private cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this.position(holding, price);
const g = parseFloat(gainLossPct ?? 'NaN');
if (gainLossPct == null)
return {
action: '⚪ No price data',
reason: 'Crypto — track price and manage risk manually.',
};
if (g > 100)
return {
action: '🟠 Consider taking profits',
reason: 'Up significantly — no fundamental analysis for crypto.',
};
if (g < -30)
return {
action: '🔴 Review position',
reason: 'Down significantly — no fundamental analysis for crypto.',
};
return {
action: '🟡 Hold',
reason: 'Crypto — no fundamental analysis. Track price and manage risk manually.',
};
}
private advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput {
const { gainLossPct } = this.position(holding, price);
const gain = parseFloat(gainLossPct ?? '0');
switch (signal) {
case SIGNAL.STRONG_BUY:
return { action: '🟢 Hold & Add', reason: 'Passes both analyses. Strong conviction.' };
case SIGNAL.MOMENTUM:
return {
action: '🟡 Hold',
reason:
gain > 30
? 'Up on momentum — consider partial profit-taking.'
: 'Set a stop-loss — not fundamentally justified.',
};
case SIGNAL.SPECULATION:
return {
action: gain > 20 ? '🟠 Reduce Position' : '🟡 Hold (small size)',
reason:
gain > 20
? 'In profit on speculation — take partial profits.'
: 'Overvalued fundamentally. Keep position small.',
};
case SIGNAL.NEUTRAL:
return { action: '🟡 Hold', reason: 'No clear edge. Review on any catalyst.' };
case SIGNAL.AVOID:
return {
action: gain > 0 ? '🔴 Sell (Take Profits)' : '🔴 Sell (Cut Loss)',
reason:
gain > 0
? "Fails both analyses — you're in profit, take it."
: 'Fails both analyses — stop the loss from growing.',
};
default:
return { action: '⚪ Review', reason: 'Signal unclear.' };
}
}
private async cryptoPrices(holdings: PortfolioHolding[]): Promise<Record<string, number | null>> {
const prices: Record<string, number | null> = {};
for (const h of holdings) {
try {
const summary = await this.client.fetchSummary(h.ticker);
prices[h.ticker.toUpperCase()] = summary?.price?.regularMarketPrice ?? null;
} catch {
prices[h.ticker.toUpperCase()] = null;
}
}
return prices;
}
}
-2
View File
@@ -1,2 +0,0 @@
// Portfolio domain — holdings management and advice
export { PortfolioAdvisor } from './PortfolioAdvisor';
-208
View File
@@ -1,208 +0,0 @@
import {
YahooFinanceClient,
BenchmarkProvider,
chunkArray,
Stock,
Etf,
Bond,
SIGNAL,
SIGNAL_ORDER,
SCORE_MODE,
ASSET_TYPE,
} from '../../domains/shared';
import { DataMapper } from './transform/DataMapper';
import { RuleMerger } from './transform/RuleMerger';
import { StockScorer } from './scorers/StockScorer';
import { EtfScorer } from './scorers/EtfScorer';
import { BondScorer } from './scorers/BondScorer';
import type {
Logger,
MarketContext,
Signal,
AssetType,
ScoreResult,
ScreenerResult,
ScreenerEngineOptions,
ErrorResult,
MappedData,
StockData,
EtfData,
BondData,
} from '../../domains/shared';
export class ScreenerEngine {
private static readonly BATCH_SIZE = 5;
private static readonly BATCH_DELAY_MS = 1000;
private logger: Logger;
private readonly batchDelayMs: number;
constructor(
private readonly client: YahooFinanceClient,
private readonly benchmarkProvider: BenchmarkProvider,
{ logger, batchDelayMs }: ScreenerEngineOptions = {},
) {
this.batchDelayMs = batchDelayMs ?? ScreenerEngine.BATCH_DELAY_MS;
// eslint-disable-next-line no-console
this.logger = logger ?? {
write: (msg: string) => process.stdout.write(msg),
// eslint-disable-next-line no-console
log: (...args: unknown[]) => console.log(...args),
// eslint-disable-next-line no-console
warn: (...args: unknown[]) => console.warn(...args),
};
}
async screenTickers(tickers: string[]): Promise<ScreenerResult> {
return this.screenInternal(tickers, false);
}
async screenWithProgress(tickers: string[]): Promise<ScreenerResult> {
return this.screenInternal(tickers, true);
}
private async screenInternal(tickers: string[], showProgress: boolean): Promise<ScreenerResult> {
const marketContext = await this.fetchMarketContext(showProgress);
const results = this.initializeResults();
const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE);
let processed = 0;
for (let i = 0; i < chunks.length; i++) {
await this.processBatch(chunks[i], marketContext, results);
processed += chunks[i].length;
this.logProgress(showProgress, processed, tickers.length);
// Rate-limit pause between batches — never after the last one
if (i < chunks.length - 1) await this.rateLimitDelay();
}
if (showProgress) {
this.logger.write('\n');
}
return { ...results, marketContext };
}
private async fetchMarketContext(showProgress: boolean): Promise<MarketContext> {
if (showProgress) {
this.logger.write('⏳ Fetching market context...');
}
const context = await this.benchmarkProvider.getMarketContext();
if (showProgress) {
this.logger.write(' done\n');
}
return context;
}
private initializeResults(): Omit<ScreenerResult, 'marketContext'> {
return { STOCK: [], ETF: [], BOND: [], ERROR: [] };
}
private async processBatch(
tickers: string[],
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): Promise<void> {
const batch = await Promise.all(tickers.map((t) => this.fetch(t)));
batch.forEach((data) => this.process(data, marketContext, results));
}
private logProgress(showProgress: boolean, processed: number, total: number): void {
if (showProgress) {
this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`);
}
}
private async rateLimitDelay(): Promise<void> {
if (this.batchDelayMs <= 0) return;
await new Promise<void>((r) => setTimeout(r, this.batchDelayMs));
}
private async fetch(ticker: string): Promise<MappedData | ErrorResult> {
try {
const summary = await this.client.fetchSummary(ticker);
if (!summary?.price) throw new Error('Empty response from Yahoo');
return DataMapper.mapToStandardFormat(ticker, summary);
} catch (err) {
return { isError: true, ticker: ticker.toUpperCase(), message: (err as Error).message };
}
}
private process(
data: MappedData | ErrorResult,
marketContext: MarketContext,
results: Omit<ScreenerResult, 'marketContext'>,
): void {
if ('isError' in data && data.isError) {
const e = data as ErrorResult;
results.ERROR.push({ ticker: e.ticker, message: e.message });
return;
}
try {
const asset = this.buildAsset(data as MappedData);
const fundamental = this.score(asset, marketContext, SCORE_MODE.FUNDAMENTAL);
const inflated = this.score(asset, marketContext, SCORE_MODE.INFLATED);
(results[asset.type as AssetType] as unknown[]).push({
asset,
fundamental,
inflated,
signal: this.signal(fundamental, inflated),
});
} catch (err) {
results.ERROR.push({
ticker: ((data as { ticker?: string }).ticker || 'UNKNOWN').toUpperCase(),
message: (err as Error).message,
});
}
}
// Typed scorer dispatch — instanceof narrows the asset so each scorer receives
// its exact metrics type. No `as never` or unsafe casts required.
private score(
asset: Stock | Etf | Bond,
marketContext: MarketContext,
mode: string,
): ScoreResult {
const rules = RuleMerger.getRulesForAsset(
asset.type as AssetType,
asset.metrics as { sector?: string },
marketContext,
mode,
);
if (asset instanceof Stock) return StockScorer.score(asset.metrics, rules);
if (asset instanceof Etf) return EtfScorer.score(asset.metrics, rules);
if (asset instanceof Bond) return BondScorer.score(asset.metrics, rules, marketContext);
// TypeScript exhaustive check: all three branches are handled above.
throw new Error('No scorer for unknown asset type');
}
private buildAsset(data: Record<string, unknown>): Stock | Etf | Bond {
switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) {
case ASSET_TYPE.BOND:
return new Bond(data as BondData);
case ASSET_TYPE.ETF:
return new Etf(data as EtfData);
default:
return new Stock(data as StockData);
}
}
// Signal derives from the structured verdict tier — never from label strings.
// Rewording a display label can no longer silently corrupt signals.
private signal(fundamental: ScoreResult, inflated: ScoreResult): Signal {
if (fundamental.tier === 'PASS') return SIGNAL.STRONG_BUY;
if (inflated.tier === 'PASS' && fundamental.tier === 'HOLD') return SIGNAL.MOMENTUM;
if (inflated.tier === 'PASS') return SIGNAL.SPECULATION;
if (fundamental.tier === 'HOLD' || inflated.tier === 'HOLD') return SIGNAL.NEUTRAL;
return SIGNAL.AVOID;
}
signalOrder(signal: Signal): number {
return SIGNAL_ORDER[signal] ?? 5;
}
getMarketContext(): Promise<MarketContext> {
return this.benchmarkProvider.getMarketContext();
}
}
@@ -1,46 +0,0 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import type { LLMAnalyst } from '../../domains/shared';
import { CatalystCache, CatalystAnalyst } from '../../domains/shared';
import { analyzeSchema } from '../../domains/shared/types/schemas';
export class AnalyzeController {
constructor(
private readonly catalystCache: CatalystCache,
private readonly llm: LLMAnalyst,
) {}
register(app: FastifyInstance): void {
app.post(
'/api/analyze',
{ schema: analyzeSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
this.analyze.bind(this),
);
}
private async analyze(req: FastifyRequest, reply: FastifyReply) {
if (!this.llm.isAvailable) {
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
}
const requestedTickers = (req.body as { tickers: string[] }).tickers.map((t) =>
t.toUpperCase(),
);
const { stories: allStories } = await this.catalystCache.get();
const stories = allStories.filter((story) =>
story.tickers.some((t) => requestedTickers.includes(t)),
);
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
const { tickerFrequency } = CatalystAnalyst.rankTickers(stories);
let analysis = null;
try {
analysis = await this.llm.analyze(stories, requestedTickers, tickerFrequency);
} catch (err) {
req.log.error({ err }, 'LLM analysis failed');
}
return { analysis };
}
}
-18
View File
@@ -1,18 +0,0 @@
// Screener domain — stock/ETF/bond filtering and scoring
// Controllers
export { ScreenerController } from './screener.controller';
export { AnalyzeController } from './analyze.controller';
// Services
export { ScreenerEngine } from './ScreenerEngine';
export { PersonalFinanceAnalyzer } from './PersonalFinanceAnalyzer';
// Scorers
export { StockScorer } from './scorers/StockScorer';
export { EtfScorer } from './scorers/EtfScorer';
export { BondScorer } from './scorers/BondScorer';
// Transform utilities
export { DataMapper } from './transform/DataMapper';
export { RuleMerger } from './transform/RuleMerger';
@@ -1,64 +0,0 @@
import type {
BondMetrics,
MarketContext,
ScoreResult,
SanitizedBondMetrics,
} from '../../../domains/shared';
export class BondScorer {
static score(
m: BondMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
context?: MarketContext | null,
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = BondScorer.sanitize(m);
const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100;
if (metrics.creditRatingNumeric < gates.minCreditRating) {
return {
label: '🔴 REJECT',
tier: 'REJECT',
score: null,
scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
audit: {
passedGates: false,
failures: [
`creditRating: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
],
},
};
}
const spreadPct = (metrics.ytm - riskFreeRate) * 100;
const breakdown: Record<string, number> = {
spread: spreadPct >= thresholds.minSpread ? weights.yieldSpread : -2,
duration: metrics.duration <= thresholds.maxDuration ? weights.duration : -1,
};
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
return {
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
tier: score >= 4 ? 'PASS' : score >= 1 ? 'HOLD' : 'REJECT',
score,
scoreSummary: `Score: ${score}`,
audit: { passedGates: true, breakdown },
};
}
private static sanitize(m: BondMetrics): SanitizedBondMetrics {
const pct = (v: unknown): number =>
parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0;
return {
ytm: pct(m.ytm),
duration: parseFloat(String(m.duration)) || 0,
creditRating: m.creditRating || 'BBB',
creditRatingNumeric: m.creditRatingNumeric ?? 7,
};
}
}
@@ -1,94 +0,0 @@
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
export class EtfScorer {
/** Parse to a finite number, preserving null for missing data. */
private static n(v: unknown): number | null {
if (v == null) return null;
const f = parseFloat(String(v));
return Number.isFinite(f) ? f : null;
}
static score(
m: EtfMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = {
expenseRatio: EtfScorer.n(m.expenseRatio),
yield: EtfScorer.n(m.yield),
volume: EtfScorer.n(m.volume),
fiveYearReturn: EtfScorer.n(m.fiveYearReturn),
};
// Gates are only checked when the underlying data exists — missing data
// skips the gate (same convention as StockScorer) instead of auto-failing.
const failures: string[] = [];
if (metrics.expenseRatio != null && metrics.expenseRatio > gates.maxExpenseRatio) {
failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`);
}
if (
metrics.fiveYearReturn != null &&
thresholds.minFiveYearReturn != null &&
metrics.fiveYearReturn < thresholds.minFiveYearReturn
) {
failures.push(`5-year return: ${metrics.fiveYearReturn}% < ${thresholds.minFiveYearReturn}%`);
}
if (
metrics.volume != null &&
thresholds.minVolume != null &&
metrics.volume < thresholds.minVolume
) {
failures.push(`Volume: ${metrics.volume} < ${thresholds.minVolume}`);
}
if (failures.length > 0) {
return {
label: '🔴 REJECT',
tier: 'REJECT',
score: null,
scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`,
audit: { passedGates: false, failures },
};
}
// Factors only fire when the underlying data exists.
const breakdown: Record<string, number> = {};
if (metrics.expenseRatio != null) {
breakdown.cost = metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3;
}
if (metrics.yield != null) {
breakdown.yield = metrics.yield >= thresholds.minYield ? weights.yield : -1;
}
if (metrics.volume != null) {
breakdown.vol = metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2;
}
if (metrics.fiveYearReturn != null && thresholds.minFiveYearReturn != null) {
breakdown.fiveYearReturn =
metrics.fiveYearReturn >= thresholds.minFiveYearReturn ? (weights.fiveYearReturn ?? 1) : -1;
}
const activeFactors = Object.keys(breakdown).length;
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
if (activeFactors === 0) {
return {
label: '🟡 Neutral (No Data)',
tier: 'HOLD',
score: 0,
scoreSummary: 'Score: 0 (no metrics available)',
audit: { passedGates: true, breakdown, coverage: { active: 0, total: 4 } },
};
}
return {
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
tier: score >= 3 ? 'PASS' : score >= 0 ? 'HOLD' : 'REJECT',
score,
scoreSummary: `Score: ${score}`,
audit: { passedGates: true, breakdown, coverage: { active: activeFactors, total: 4 } },
};
}
}
@@ -1,300 +0,0 @@
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
export class StockScorer {
/**
* Parse to a finite number, preserving 0 — zero is a real value for metrics
* like revenueGrowth (stagnant), debtToEquity (debt-free), or
* dcfMarginOfSafety (exactly fair value).
*/
private static n(v: unknown): NumVal {
if (v == null) return null;
const f = parseFloat(String(v));
return Number.isFinite(f) ? f : null;
}
/**
* Parse to a strictly positive number. Used for ratios where 0 is
* impossible and indicates junk/missing data (P/E, PEG, P/B, P/FFO).
*/
private static pos(v: unknown): NumVal {
const f = StockScorer.n(v);
return f != null && f > 0 ? f : null;
}
private static scoreValue(val: number, high: number, med: number, weight: number): number {
return val >= high ? weight : val >= med ? 1 : -1;
}
private static scorePeg(val: number, high: number, med: number, weight: number): number {
return val <= high ? weight : val <= med ? 1 : -1;
}
static score(
metrics: StockMetrics,
rules: {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
},
): ScoreResult {
const { gates, weights, thresholds } = rules;
const m = StockScorer.sanitize(metrics);
const failures = [
m.debtToEquity != null &&
m.debtToEquity > gates.maxDebtToEquity &&
`D/E ${m.debtToEquity.toFixed(1)} > ${gates.maxDebtToEquity}`,
m.quickRatio != null &&
m.quickRatio < gates.minQuickRatio &&
`Quick ${m.quickRatio.toFixed(2)} < ${gates.minQuickRatio}`,
m.peRatio != null &&
m.peRatio > gates.maxPERatio &&
`P/E ${m.peRatio.toFixed(0)} > ${gates.maxPERatio}`,
m.pegRatio != null &&
m.pegRatio > gates.maxPegGate &&
`PEG ${m.pegRatio.toFixed(1)} > ${gates.maxPegGate}`,
m.priceToBook != null &&
gates.maxPriceToBook &&
m.priceToBook > gates.maxPriceToBook &&
`P/B ${m.priceToBook.toFixed(1)} > ${gates.maxPriceToBook}`,
].filter(Boolean) as string[];
if (failures.length > 0) {
return {
label: '🔴 REJECT',
tier: 'REJECT',
score: null,
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
audit: { passedGates: false, failures },
};
}
const factors = [
{
key: 'roe',
active: weights.roe > 0 && m.returnOnEquity != null,
fn: () =>
StockScorer.scoreValue(
m.returnOnEquity!,
thresholds.roeHigh,
thresholds.roeMed,
weights.roe,
),
},
{
key: 'opMargin',
active: weights.opMargin > 0 && m.operatingMargin != null,
fn: () =>
StockScorer.scoreValue(
m.operatingMargin!,
thresholds.opMarginHigh,
thresholds.opMarginMed,
weights.opMargin,
),
},
{
key: 'margin',
active: weights.margin > 0 && m.netProfitMargin != null,
fn: () =>
StockScorer.scoreValue(
m.netProfitMargin!,
thresholds.marginHigh,
thresholds.marginMed,
weights.margin,
),
},
{
key: 'peg',
active: weights.peg > 0 && m.pegRatio != null,
fn: () =>
StockScorer.scorePeg(m.pegRatio!, thresholds.pegHigh, thresholds.pegMed, weights.peg),
},
{
key: 'revenue',
active: weights.revenue > 0 && m.revenueGrowth != null,
fn: () =>
StockScorer.scoreValue(
m.revenueGrowth!,
thresholds.revHigh,
thresholds.revMed,
weights.revenue,
),
},
{
key: 'fcf',
active: weights.fcf > 0 && m.fcfYield != null,
fn: () =>
StockScorer.scoreValue(
m.fcfYield!,
thresholds.fcfHigh ?? 5,
thresholds.fcfMed ?? 2,
weights.fcf,
),
},
{
key: 'yield',
active: (weights.yield ?? 0) > 0 && m.dividendYield != null,
fn: () => (m.dividendYield! >= (thresholds.minYield ?? 4) ? weights.yield : -1),
},
{
key: 'pFFO',
active: (weights.pFFO ?? 0) > 0 && m.pFFO != null,
fn: () => (m.pFFO! <= (thresholds.maxPFFO ?? 15) ? weights.pFFO : -2),
},
{
key: 'priceToBook',
active: (weights.priceToBook ?? 0) > 0 && m.priceToBook != null,
fn: () => StockScorer.scoreValue(1 / m.priceToBook!, 1 / 1.0, 1 / 2.0, weights.priceToBook),
},
// ── Expert features ────────────────────────────────────────────────
{
// Analyst consensus: Yahoo recommendationMean 1=Strong Buy → 5=Strong Sell.
// We invert and score: ≤ analystBuy gets full weight, ≤ analystHold gets 1pt,
// above Hold loses weight. Requires ≥ 3 analysts to avoid noise from thin coverage.
key: 'analyst',
active:
(weights.analyst ?? 0) > 0 &&
m.analystRating != null &&
(metrics.numberOfAnalysts ?? 0) >= 3,
fn: (): number => {
const r = m.analystRating!;
const buyThreshold = thresholds.analystBuy ?? 2.0;
const holdThreshold = thresholds.analystHold ?? 3.0;
if (r <= buyThreshold) return weights.analyst ?? 2;
if (r <= holdThreshold) return 1;
if (r <= 4.0) return -1;
return -(weights.analyst ?? 2); // Strong Sell
},
},
{
// DCF margin of safety: how undervalued the stock is vs. 2-stage FCF model.
// Positive = undervalued (good), negative = overvalued (bad).
// Only fires when DCF could be computed (positive FCF required).
key: 'dcf',
active: (weights.dcf ?? 0) > 0 && m.dcfMarginOfSafety != null,
fn: (): number => {
const mos = m.dcfMarginOfSafety!;
const undervalued = thresholds.dcfUndervalued ?? 20;
const fairValue = thresholds.dcfFairValue ?? 0;
if (mos >= undervalued) return weights.dcf ?? 2;
if (mos >= fairValue) return 1;
if (mos >= -20) return -1;
return -(weights.dcf ?? 2); // significantly overvalued
},
},
];
const breakdown: Record<string, number> = {};
const totalScore = factors.reduce((sum, f) => {
if (!f.active) return sum;
breakdown[f.key] = f.fn() as number;
return sum + breakdown[f.key];
}, 0);
const activeFactors = Object.keys(breakdown).length;
const coverage = { active: activeFactors, total: factors.length };
const riskFlags = [
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
m.beta != null && m.beta < 0 && `Inverse market correlation (β ${m.beta.toFixed(2)})`,
// 52-week position flags
m.week52Position != null && m.week52Position > 0.9 && 'Near 52-week high — crowded trade',
m.week52Position != null &&
m.week52Position < 0.1 &&
'Near 52-week low — potential opportunity',
// 52-week momentum flags
m.week52Change != null &&
m.week52Change >= 50 &&
`Strong uptrend: +${m.week52Change.toFixed(0)}% in 52 weeks`,
m.week52Change != null &&
m.week52Change <= -30 &&
`Significant drawdown: ${m.week52Change.toFixed(0)}% in 52 weeks`,
// Distance from 52-week high
m.week52FromHigh != null &&
m.week52FromHigh <= -20 &&
`${Math.abs(m.week52FromHigh).toFixed(0)}% off 52-week high`,
// Analyst/DCF divergence signal
m.analystUpside != null &&
m.analystUpside >= 25 &&
`Analyst consensus: ${m.analystUpside.toFixed(0)}% upside to target`,
m.analystUpside != null &&
m.analystUpside <= -15 &&
`Analyst consensus: target ${Math.abs(m.analystUpside).toFixed(0)}% below current price`,
m.dcfMarginOfSafety != null &&
m.dcfMarginOfSafety >= 30 &&
`DCF: ${m.dcfMarginOfSafety.toFixed(0)}% margin of safety`,
m.dcfMarginOfSafety != null &&
m.dcfMarginOfSafety <= -30 &&
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
].filter(Boolean) as string[];
// No factor had data — distinguish "insufficient data" from a genuine
// neutral score so the UI doesn't present an unknown as a Hold verdict.
if (activeFactors === 0) {
return {
label: '🟡 HOLD (No Data)',
tier: 'HOLD',
score: 0,
scoreSummary: 'Score: 0 (no scoring factors had data)',
audit: {
passedGates: true,
breakdown,
riskFlags: riskFlags.length ? riskFlags : null,
coverage,
},
};
}
return {
label: StockScorer.label(totalScore),
tier: StockScorer.tier(totalScore),
score: totalScore,
scoreSummary: `Score: ${totalScore}`,
audit: {
passedGates: true,
breakdown,
riskFlags: riskFlags.length ? riskFlags : null,
coverage,
},
};
}
private static label(score: number): string {
if (score >= 8) return '🟢 BUY (High Conviction)';
if (score >= 4) return '🟢 BUY (Speculative)';
if (score >= 0) return '🟡 HOLD';
return '🔴 REJECT';
}
private static tier(score: number): 'PASS' | 'HOLD' | 'REJECT' {
if (score >= 4) return 'PASS';
if (score >= 0) return 'HOLD';
return 'REJECT';
}
private static sanitize(m: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)
: null;
return {
debtToEquity: StockScorer.n(m.debtToEquity),
quickRatio: StockScorer.n(m.quickRatio),
peRatio: StockScorer.pos(m.peRatio),
pegRatio: StockScorer.pos(m.pegRatio),
priceToBook: StockScorer.pos(m.priceToBook),
netProfitMargin: StockScorer.n(m.netProfitMargin),
operatingMargin: StockScorer.n(m.operatingMargin),
returnOnEquity: StockScorer.n(m.returnOnEquity),
revenueGrowth: StockScorer.n(m.revenueGrowth),
fcfYield: StockScorer.n(m.fcfYield),
dividendYield: StockScorer.n(m.dividendYield),
pFFO: StockScorer.pos(m.pFFO),
beta: StockScorer.n(m.beta),
week52Position: w52,
week52Change: StockScorer.n(m.week52Change),
week52FromHigh: StockScorer.n(m.week52FromHigh),
analystRating: StockScorer.n(m.analystRating),
analystUpside: StockScorer.n(m.analystUpside),
dcfMarginOfSafety: StockScorer.n(m.dcfMarginOfSafety),
};
}
}
@@ -1,352 +0,0 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ScreenerEngine } from './ScreenerEngine';
import { CatalystCache, SignalSnapshotRepository, YahooFinanceClient } from '../../domains/shared';
import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared';
import type { NewsRepository } from '../news/NewsRepository';
import { screenSchema } from '../../domains/shared/types/schemas';
export class ScreenerController {
/** Company profiles change rarely — cache for an hour. */
private static readonly PROFILE_TTL_MS = 60 * 60 * 1000;
private profileCache = new Map<string, { data: unknown; expiresAt: number }>();
/** Sector pulse — SPDR sector ETFs as the standard proxy, cached 15 min. */
private static readonly SECTOR_TTL_MS = 15 * 60 * 1000;
private static readonly SECTOR_ETFS: Array<{ etf: string; sector: string; name: string }> = [
{ etf: 'XLK', sector: 'TECHNOLOGY', name: 'Technology' },
{ etf: 'XLF', sector: 'FINANCIAL', name: 'Financials' },
{ etf: 'XLE', sector: 'ENERGY', name: 'Energy' },
{ etf: 'XLV', sector: 'HEALTHCARE', name: 'Healthcare' },
{ etf: 'XLC', sector: 'COMMUNICATION', name: 'Communication' },
{ etf: 'XLP', sector: 'CONSUMER_STAPLES', name: 'Staples' },
{ etf: 'XLY', sector: 'CONSUMER_DISCRETIONARY', name: 'Discretionary' },
{ etf: 'XLRE', sector: 'REIT', name: 'Real Estate' },
{ etf: 'XLI', sector: 'GENERAL', name: 'Industrials' },
{ etf: 'XLU', sector: 'GENERAL', name: 'Utilities' },
];
private sectorCache: { data: unknown; expiresAt: number } | null = null;
/** Sector drill-down (holdings + screen + news) — cached 30 min per sector. */
private static readonly SECTOR_DETAIL_TTL_MS = 30 * 60 * 1000;
private sectorDetailCache = new Map<string, { data: unknown; expiresAt: number }>();
constructor(
private readonly engine: ScreenerEngine,
private readonly catalystCache: CatalystCache,
// Optional so tests and minimal setups work without a database.
private readonly snapshots?: SignalSnapshotRepository,
private readonly yahoo?: YahooFinanceClient,
private readonly news?: NewsRepository,
) {}
register(app: FastifyInstance): void {
app.post(
'/api/screen',
{ schema: screenSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
this.screen.bind(this),
);
app.get(
'/api/screen/catalysts',
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
this.catalysts.bind(this),
);
app.get('/api/screen/history/:ticker', this.history.bind(this));
app.get('/api/screen/profile/:ticker', this.profile.bind(this));
app.get('/api/screen/chart/:ticker', this.chart.bind(this));
app.get('/api/screen/sectors', this.sectors.bind(this));
app.get('/api/screen/sector/:sector', this.sectorDetail.bind(this));
}
/**
* Sector drill-down: the sector ETF's top 10 holdings, freshly screened
* (signal + advice-ready rows), plus recent news for those tickers and
* macro stories — "what's in this sector and why is it moving".
*/
private async sectorDetail(req: FastifyRequest) {
const sector = (req.params as { sector: string }).sector.toUpperCase();
const entry = ScreenerController.SECTOR_ETFS.find((s) => s.sector === sector);
if (!entry || !this.yahoo) return { sector, etf: null, stocks: [], news: [] };
const cached = this.sectorDetailCache.get(sector);
if (cached && Date.now() < cached.expiresAt) return cached.data;
const holdings = await this.yahoo.fetchTopHoldings(entry.etf, 10);
const results = holdings.length > 0 ? await this.engine.screenTickers(holdings) : null;
const stocks = results
? ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[])
: [];
// News: stored stories for these tickers (last 3 days), deduped by URL
const newsSince = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const byUrl = new Map<string, unknown>();
if (this.news) {
for (const ticker of holdings) {
for (const row of this.news.newsForTicker(ticker, newsSince)) {
byUrl.set(row.url, {
headline: row.headline,
tickers: JSON.parse(row.ticker_list),
source: row.source,
catalyst: row.catalyst,
url: row.url,
publishedAt: row.published_at,
});
}
}
}
const data = {
sector,
etf: entry.etf,
name: entry.name,
stocks,
news: [...byUrl.values()],
};
this.sectorDetailCache.set(sector, {
data,
expiresAt: Date.now() + ScreenerController.SECTOR_DETAIL_TTL_MS,
});
return data;
}
/**
* Sector pulse — today's % change per sector via SPDR sector ETFs (the
* standard proxy). Returns sectors sorted best→worst plus the leader.
*/
private async sectors() {
if (this.sectorCache && Date.now() < this.sectorCache.expiresAt) {
return this.sectorCache.data;
}
if (!this.yahoo) return { asOf: null, leader: null, sectors: [] };
const results = await Promise.all(
ScreenerController.SECTOR_ETFS.map(async ({ etf, sector, name }) => {
try {
const summary = await this.yahoo!.fetchSummary(etf);
const pr = summary?.price ?? {};
const price = pr.regularMarketPrice ?? null;
const prev = pr.regularMarketPreviousClose ?? null;
const changePct =
price != null && prev != null && prev > 0
? +(((price - prev) / prev) * 100).toFixed(2)
: null;
return { etf, sector, name, changePct };
} catch {
return { etf, sector, name, changePct: null };
}
}),
);
const sectors = results
.filter((s) => s.changePct != null)
.sort((a, b) => (b.changePct as number) - (a.changePct as number));
const data = {
asOf: new Date().toISOString(),
leader: sectors[0] ?? null,
sectors,
};
this.sectorCache = { data, expiresAt: Date.now() + ScreenerController.SECTOR_TTL_MS };
return data;
}
/** Company profile for the ticker modal — name, description, sector. */
private async profile(req: FastifyRequest) {
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
if (!this.yahoo) return { ticker, profile: null };
const cached = this.profileCache.get(ticker);
if (cached && Date.now() < cached.expiresAt) return cached.data;
try {
const summary = await this.yahoo.fetchSummary(ticker);
const ap = summary?.assetProfile ?? {};
const pr = summary?.price ?? {};
const fd = summary?.financialData ?? {};
const price = pr.regularMarketPrice ?? null;
const targetMean = fd.targetMeanPrice ?? null;
const data = {
ticker,
profile: {
name: pr.longName ?? pr.shortName ?? ticker,
summary: ap.longBusinessSummary ?? null,
sector: ap.sector ?? null,
industry: ap.industry ?? null,
website: ap.website ?? null,
employees: ap.fullTimeEmployees ?? null,
marketCap: pr.marketCap ?? null,
currentPrice: price,
// Analyst price targets (Yahoo sell-side consensus)
targets: {
mean: targetMean,
high: fd.targetHighPrice ?? null,
low: fd.targetLowPrice ?? null,
analysts: fd.numberOfAnalystOpinions ?? null,
recommendationMean: fd.recommendationMean ?? null, // 1=Strong Buy … 5=Strong Sell
upsidePct:
targetMean != null && price != null && price > 0
? +(((targetMean - price) / price) * 100).toFixed(1)
: null,
},
},
};
this.profileCache.set(ticker, {
data,
expiresAt: Date.now() + ScreenerController.PROFILE_TTL_MS,
});
return data;
} catch {
return { ticker, profile: null };
}
}
/** Closes for the ticker modal chart. ?range=1d|5d|1mo|3mo|6mo|1y. */
private async chart(req: FastifyRequest) {
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
const raw = (req.query as { range?: string }).range ?? '6mo';
const range = raw in YahooFinanceClient.CHART_RANGES ? raw : '6mo';
if (!this.yahoo) return { ticker, range, points: [] };
return { ticker, range, points: await this.yahoo.fetchCloses(ticker, range) };
}
/** Signal snapshot history for one ticker (P0.1 ledger read side). */
private async history(req: FastifyRequest) {
if (!this.snapshots) return { ticker: null, snapshots: [] };
const { ticker } = req.params as { ticker: string };
return {
ticker: ticker.toUpperCase(),
snapshots: this.snapshots.history(ticker).map((row) => ({
date: row.snapshot_date,
signal: row.signal,
price: row.price,
fundamental: { tier: row.fundamental_tier, score: row.fundamental_score },
inflated: { tier: row.inflated_tier, score: row.inflated_score },
coverage:
row.coverage_active != null
? { active: row.coverage_active, total: row.coverage_total }
: null,
riskFlags: row.risk_flags ? JSON.parse(row.risk_flags) : [],
rateRegime: row.rate_regime,
})),
};
}
private static serializeAssets(arr: LiveAssetResult[]) {
return arr.map((r) => ({
...r,
asset: {
ticker: r.asset.ticker,
type: r.asset.type,
currentPrice: r.asset.currentPrice,
metrics: r.asset.metrics,
displayMetrics: r.asset.getDisplayMetrics(),
},
}));
}
private async screen(req: FastifyRequest) {
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const results = await this.engine.screenTickers(tickers);
this.recordSnapshots(results, req);
this.flagTurnarounds(results);
const dataHealth = ScreenerController.assessDataHealth(results);
if (dataHealth.degraded) {
req.log?.warn?.({ dataHealth }, 'screen batch returned degraded fundamentals data');
}
return {
...results,
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
dataHealth,
};
}
/**
* Turnaround-watch (candidate flag, NOT a prediction): the stock's style is
* already Turnaround (earnings down, revenue holding) AND its fundamental
* score improved vs the previous snapshot in the ledger. Both legs must
* hold — style alone is static, improvement alone is noise.
*/
private flagTurnarounds(results: ScreenerResult): void {
if (!this.snapshots) return;
for (const row of results.STOCK as LiveAssetResult[]) {
const metrics = row.asset.metrics as { growthCategory?: string };
if (metrics?.growthCategory !== 'Turnaround') continue;
if (row.fundamental.tier === 'REJECT' || row.fundamental.score == null) continue;
try {
// History includes today's snapshot (recorded just above) — compare
// today's score against the most recent prior day with a score.
const history = this.snapshots.history(row.asset.ticker);
const prior = [...history]
.reverse()
.find((h) => h.snapshot_date < history[history.length - 1]?.snapshot_date);
if (prior?.fundamental_score != null && row.fundamental.score > prior.fundamental_score) {
row.turnaroundWatch = true;
}
} catch {
// best-effort — never fail the screen for a highlight
}
}
}
/**
* P0.4 data-sanity sentinel — if a large share of screened stocks come back
* with null core fundamentals (P/E, ROE), the upstream source has likely
* changed schema or is throttling. Surface it loudly instead of letting
* everything silently degrade to "No Data" rows.
*/
private static assessDataHealth(results: ScreenerResult): DataHealth {
const THRESHOLD = 0.3; // >30% nulls = degraded
const MIN_SAMPLE = 3; // don't alarm on tiny batches
const stocks = results.STOCK as LiveAssetResult[];
const metrics = stocks.map(
(r) => r.asset.metrics as { peRatio?: number | null; returnOnEquity?: number | null },
);
const nullPeRatio = metrics.filter((m) => m.peRatio == null).length;
const nullRoe = metrics.filter((m) => m.returnOnEquity == null).length;
const total = metrics.length;
const degraded =
total >= MIN_SAMPLE && (nullPeRatio / total > THRESHOLD || nullRoe / total > THRESHOLD);
return {
degraded,
stocksChecked: total,
nullPeRatio,
nullRoe,
message: degraded
? `${Math.max(nullPeRatio, nullRoe)} of ${total} stocks returned no core fundamentals — data source may be degraded; treat this screen with caution`
: null,
};
}
/**
* P0.1 signal track record — persist one snapshot per asset per day.
* Best-effort: a snapshot failure must never fail the screen response.
*/
private recordSnapshots(results: ScreenerResult, req: FastifyRequest): void {
if (!this.snapshots) return;
try {
const rateRegime = results.marketContext?.rateRegime ?? null;
const inputs = [...results.STOCK, ...results.ETF, ...results.BOND].map((r) => ({
ticker: r.asset.ticker,
assetType: r.asset.type,
price: r.asset.currentPrice ?? null,
signal: r.signal,
fundamental: r.fundamental,
inflated: r.inflated,
rateRegime,
}));
this.snapshots.recordBatch(inputs);
} catch (err) {
req.log?.warn?.({ err }, 'signal snapshot recording failed');
}
}
private async catalysts() {
const { tickers, stories } = await this.catalystCache.get();
return { tickers, stories };
}
}
@@ -1,247 +0,0 @@
import type { MappedData } from '../../../domains/shared';
// Internal: Yahoo Finance API response shape
type YahooSummary = Record<string, Record<string, unknown>>;
export class DataMapper {
// ── Public entry point ────────────────────────────────────────────────────
static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData {
const quoteType = summary.price?.quoteType as string | undefined;
// Prefer fundProfile.categoryName (Morningstar category, e.g. "Intermediate
// Core Bond") — assetProfile.category is rarely populated for ETFs. A
// dividend-yield heuristic is deliberately NOT used: high-yield equity ETFs
// (SCHD, VYM) are not bonds.
const category = (
(summary.fundProfile?.categoryName as string) ||
(summary.assetProfile?.category as string) ||
''
).toLowerCase();
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury');
if (quoteType === 'ETF') {
return isBond
? { type: 'BOND', ticker, ...DataMapper.mapBondData(summary) }
: { type: 'ETF', ticker, ...DataMapper.mapEtfData(summary) };
}
return { type: 'STOCK', ticker, ...DataMapper.mapStockData(summary) };
}
// ── Stock ─────────────────────────────────────────────────────────────────
private static mapStockData(summary: YahooSummary) {
const fd = (summary.financialData ?? {}) as Record<string, number | null>;
const ks = (summary.defaultKeyStatistics ?? {}) as Record<string, number | null>;
const sd = (summary.summaryDetail ?? {}) as Record<string, number | null>;
const pr = (summary.price ?? {}) as Record<string, number | null>;
const currentPrice = pr.regularMarketPrice ?? 0;
const sharesOutstanding = ks.sharesOutstanding ?? 0;
// Today's % change — powers the sector drill-down "Today" sort
const prevClose = pr.regularMarketPreviousClose ?? null;
const dayChangePct =
prevClose != null && prevClose > 0 && (currentPrice as number) > 0
? +((((currentPrice as number) - prevClose) / prevClose) * 100).toFixed(2)
: null;
const operatingCashflow = fd.operatingCashflow ?? 0;
const freeCashflow = fd.freeCashflow ?? 0;
// P/FFO proxy — used for REIT scoring
const pFFO =
operatingCashflow > 0 && sharesOutstanding > 0
? (currentPrice as number) / (operatingCashflow / sharesOutstanding)
: null;
// FCF yield — negative FCF preserved so cash-burning companies fail the gate
const fcfYield =
freeCashflow !== 0 && sharesOutstanding > 0 && (currentPrice as number) > 0
? ((freeCashflow as number) / (sharesOutstanding as number) / (currentPrice as number)) *
100
: null;
// PEG: prefer Yahoo's value, fall back to trailingPE / earningsGrowth
const yahoosPEG = ks.pegRatio ?? null;
const trailingPE = sd.trailingPE ?? null;
const earningsGrowth = fd.earningsGrowth != null ? (fd.earningsGrowth as number) * 100 : null;
const computedPEG =
trailingPE != null && earningsGrowth != null && earningsGrowth > 0
? +((trailingPE as number) / earningsGrowth).toFixed(2)
: null;
const pegRatio = yahoosPEG ?? computedPEG;
// Quick ratio — fall back to currentRatio when missing
const quickRatio = fd.quickRatio ?? fd.currentRatio ?? null;
// ── 52-week movement ──────────────────────────────────────────────────
const week52High = sd.fiftyTwoWeekHigh ?? null;
const week52Low = sd.fiftyTwoWeekLow ?? null;
const week52Change =
ks['52WeekChange'] != null ? +((ks['52WeekChange'] as number) * 100).toFixed(1) : null;
const week52FromHigh =
week52High != null && week52High > 0 && (currentPrice as number) > 0
? +(((currentPrice - week52High) / week52High) * 100).toFixed(1)
: null;
const week52FromLow =
week52Low != null && week52Low > 0 && (currentPrice as number) > 0
? +(((currentPrice - week52Low) / week52Low) * 100).toFixed(1)
: null;
// ── Analyst consensus ─────────────────────────────────────────────────
const analystRating = fd.recommendationMean ?? null;
const analystTargetPrice = fd.targetMeanPrice ?? null;
const numberOfAnalysts =
fd.numberOfAnalystOpinions != null ? Math.round(fd.numberOfAnalystOpinions as number) : null;
const analystUpside =
analystTargetPrice != null && (currentPrice as number) > 0
? +(((analystTargetPrice - currentPrice) / currentPrice) * 100).toFixed(1)
: null;
// ── Gross margin ──────────────────────────────────────────────────────
const grossMargin =
fd.grossMargins != null ? +((fd.grossMargins as number) * 100).toFixed(1) : null;
// ── DCF intrinsic value ───────────────────────────────────────────────
const revenueGrowthDecimal = fd.revenueGrowth != null ? (fd.revenueGrowth as number) : null;
const earningsGrowthDecimal = fd.earningsGrowth != null ? (fd.earningsGrowth as number) : null;
const dcfGrowthRate =
earningsGrowthDecimal ?? (revenueGrowthDecimal != null ? revenueGrowthDecimal * 0.7 : null);
const dcf = DataMapper.computeDCF(
freeCashflow as number,
sharesOutstanding as number,
currentPrice as number,
dcfGrowthRate,
);
return {
peRatio: trailingPE ?? ks.forwardPE,
trailingPE,
pegRatio,
priceToBook: ks.priceToBook ?? null,
evToEbitda: ks.enterpriseToEbitda ?? null,
grossMargin,
netProfitMargin: fd.profitMargins != null ? (fd.profitMargins as number) * 100 : null,
operatingMargin: fd.operatingMargins != null ? (fd.operatingMargins as number) * 100 : null,
returnOnEquity: fd.returnOnEquity != null ? (fd.returnOnEquity as number) * 100 : null,
revenueGrowth: fd.revenueGrowth != null ? (fd.revenueGrowth as number) * 100 : null,
earningsGrowth,
debtToEquity: fd.debtToEquity != null ? (fd.debtToEquity as number) / 100 : null,
quickRatio,
fcfYield,
pFFO,
dividendYield:
sd.trailingAnnualDividendYield != null
? (sd.trailingAnnualDividendYield as number) * 100
: null,
beta: sd.beta ?? null,
dayChangePct,
week52High,
week52Low,
week52Change,
week52FromHigh,
week52FromLow,
marketCap: pr.marketCap ?? null,
analystRating,
analystTargetPrice,
analystUpside,
numberOfAnalysts,
dcfIntrinsicValue: dcf?.intrinsicValue ?? null,
dcfMarginOfSafety: dcf?.marginOfSafety ?? null,
currentPrice,
assetProfile: summary.assetProfile || {},
};
}
// ── ETF ───────────────────────────────────────────────────────────────────
// Missing fields are preserved as null (not coerced to 0) so EtfScorer can
// skip the corresponding gate instead of auto-failing on absent Yahoo data.
private static mapEtfData(summary: YahooSummary) {
const num = (v: unknown): number | null =>
typeof v === 'number' && Number.isFinite(v) ? v : null;
const expenseRatio = num(summary.summaryDetail?.expenseRatio);
const dividendYield = num(summary.summaryDetail?.trailingAnnualDividendYield);
const fiveYearReturn = num(summary.defaultKeyStatistics?.fiveYearAverageReturn);
return {
expenseRatio: expenseRatio != null ? expenseRatio * 100 : null,
totalAssets: num(summary.summaryDetail?.totalAssets),
yield: dividendYield != null ? dividendYield * 100 : null,
fiveYearReturn: fiveYearReturn != null ? fiveYearReturn * 100 : null,
volume: num(summary.summaryDetail?.averageVolume) ?? num(summary.price?.averageVolume),
currentPrice: num(summary.price?.regularMarketPrice) ?? 0,
};
}
// ── Bond ──────────────────────────────────────────────────────────────────
private static mapBondData(summary: YahooSummary) {
return {
yieldToMaturity: ((summary.summaryDetail?.yield as number) ?? 0) * 100,
duration: DataMapper.inferDuration(summary.assetProfile?.category as string),
creditRating: DataMapper.inferCreditRating(summary.assetProfile?.category as string),
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
};
}
private static inferCreditRating(category: string | undefined): string {
const cat = (category || '').toLowerCase();
if (cat.includes('government') || cat.includes('treasury')) return 'AAA';
if (cat.includes('muni')) return 'AA';
if (cat.includes('high yield') || cat.includes('junk')) return 'BB';
if (cat.includes('corporate') || cat.includes('investment grade')) return 'A';
return 'BBB';
}
private static inferDuration(category: string | undefined): number {
const cat = (category || '').toLowerCase();
if (cat.includes('short') || cat.includes('ultrashort') || cat.includes('1-3')) return 2;
if (cat.includes('intermediate') || cat.includes('3-7') || cat.includes('3-10')) return 5;
if (cat.includes('long') || cat.includes('10+') || cat.includes('20+')) return 18;
if (cat.includes('target maturity') || cat.includes('defined maturity')) return 4;
return 6;
}
// ── DCF ───────────────────────────────────────────────────────────────────
// Two-stage model:
// Stage 1 — FCF/share grows at `growthRate` for 5 years, discounted at 9.5% WACC.
// Stage 2 — Terminal value via Gordon Growth Model at 2.5% perpetuity rate.
// Only fires when TTM FCF per share is positive.
private static computeDCF(
freeCashflow: number,
sharesOutstanding: number,
currentPrice: number,
growthRate: number | null,
riskFreeRate = 0.04,
): { intrinsicValue: number; marginOfSafety: number } | null {
if (!freeCashflow || freeCashflow <= 0) return null;
if (!sharesOutstanding || sharesOutstanding <= 0) return null;
if (!currentPrice || currentPrice <= 0) return null;
const fcfPerShare = freeCashflow / sharesOutstanding;
if (fcfPerShare <= 0) return null;
const discountRate = riskFreeRate + 0.055; // WACC proxy
const terminalGrowth = 0.025; // long-run GDP growth
const years = 5;
const g = Math.min(Math.max(growthRate ?? 0.08, -0.05), 0.3);
let pv = 0;
let fcfT = fcfPerShare;
for (let t = 1; t <= years; t++) {
fcfT *= 1 + g;
pv += fcfT / Math.pow(1 + discountRate, t);
}
const terminalValue = (fcfT * (1 + terminalGrowth)) / (discountRate - terminalGrowth);
pv += terminalValue / Math.pow(1 + discountRate, years);
const intrinsicValue = +pv.toFixed(2);
const marginOfSafety = +(((intrinsicValue - currentPrice) / intrinsicValue) * 100).toFixed(1);
return { intrinsicValue, marginOfSafety };
}
}
@@ -1,43 +0,0 @@
import { ScoringRules } from '../../../domains/shared/scoring/ScoringConfig';
import { MarketRegime } from '../../../domains/shared/scoring/MarketRegime';
import { SCORE_MODE } from '../../../domains/shared';
import type { AssetType, MarketContext, RuleSet } from '../../../domains/shared';
export class RuleMerger {
static getRulesForAsset(
type: AssetType,
metrics: { sector?: string },
marketContext: Partial<MarketContext> = {},
mode: string = SCORE_MODE.FUNDAMENTAL,
): RuleSet {
const base = ScoringRules[type as keyof typeof ScoringRules];
if (!base) throw new Error(`No rules configured for asset type: ${type}`);
// Deep clone to avoid mutating the source config
const rules: RuleSet & { SECTOR_OVERRIDE?: unknown } = JSON.parse(JSON.stringify(base));
if (type === 'STOCK' && metrics.sector) {
const stockBase = ScoringRules.STOCK;
const override =
stockBase.SECTOR_OVERRIDE?.[
metrics.sector.toUpperCase() as keyof typeof stockBase.SECTOR_OVERRIDE
];
if (override) {
rules.gates = { ...rules.gates, ...override.gates };
rules.weights = { ...rules.weights, ...override.weights };
rules.thresholds = { ...rules.thresholds, ...override.thresholds };
}
}
delete rules.SECTOR_OVERRIDE;
if (mode === SCORE_MODE.INFLATED) {
const { gates, thresholds } = new MarketRegime(
marketContext as MarketContext,
).getInflatedOverrides(type, metrics.sector);
rules.gates = { ...rules.gates, ...gates };
rules.thresholds = { ...rules.thresholds, ...thresholds };
}
return rules;
}
}
@@ -1,31 +0,0 @@
import Anthropic from '@anthropic-ai/sdk';
/**
* Thin wrapper around the Anthropic SDK.
* Handles initialisation and raw message completion only —
* prompt construction and response parsing stay in LLMAnalyst (service layer).
*/
export class AnthropicClient {
private client: Anthropic | null;
constructor() {
this.client = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null;
}
get isAvailable(): boolean {
return this.client !== null;
}
async complete(system: string, userMessage: string): Promise<string | null> {
if (!this.client) return null;
const response = await this.client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
system,
messages: [{ role: 'user', content: userMessage }],
});
return (response.content[0] as { text?: string })?.text ?? null;
}
}
@@ -1,119 +0,0 @@
import YahooFinance from 'yahoo-finance2';
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib, PricePoint } from '../types';
import { YAHOO_MODULES } from '../config/constants';
export class YahooFinanceClient {
private lib: YahooFinanceLib;
constructor() {
this.lib = new (YahooFinance as unknown as new (_opts: object) => YahooFinanceLib)({
suppressNotices: ['yahooSurvey'],
});
}
/** Normalise ticker before hitting Yahoo: BRK.B → BRK-B */
private static normalise(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
const normalised = YahooFinanceClient.normalise(ticker);
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await this.lib.quoteSummary(
normalised,
{ modules: YAHOO_MODULES },
{ validateResult: false },
);
} catch (error) {
if (attempt === retries - 1) throw error;
await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
}
}
}
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const result = await this.lib.quoteSummary(
YahooFinanceClient.normalise(ticker),
{ modules: ['calendarEvents'] },
{ validateResult: false },
);
return result.calendarEvents ?? null;
} catch {
return null;
}
}
async search(query: string, opts: YahooSearchOptions = {}): Promise<YahooNewsItem[]> {
const { news = [] } = await this.lib.search(query, opts);
return news;
}
/**
* Top holdings of an ETF (ticker symbols, largest weight first).
* Used for sector drill-down. Returns [] on any failure.
*/
async fetchTopHoldings(etf: string, limit = 10): Promise<string[]> {
try {
const result = await this.lib.quoteSummary(
YahooFinanceClient.normalise(etf),
{ modules: ['topHoldings'] },
{ validateResult: false },
);
const holdings = (result?.topHoldings?.holdings ?? []) as Array<{ symbol?: string }>;
return holdings
.map((h) => h.symbol)
.filter((s): s is string => Boolean(s))
.slice(0, limit)
.map((s) => s.toUpperCase());
} catch {
return [];
}
}
/** Chart range presets — Robinhood/Yahoo-style. Intraday for short ranges. */
static readonly CHART_RANGES: Record<string, { days: number; interval: string }> = {
'1d': { days: 1, interval: '5m' },
'5d': { days: 5, interval: '30m' },
'1mo': { days: 30, interval: '1d' },
'3mo': { days: 91, interval: '1d' },
'6mo': { days: 182, interval: '1d' },
ytd: { days: 0, interval: '1d' }, // days computed dynamically (Jan 1 → now)
'1y': { days: 365, interval: '1d' },
'5y': { days: 1826, interval: '1wk' }, // weekly bars keep ~260 points
};
/**
* Closing prices for a named range (ticker modal chart). Intraday ranges
* keep the full timestamp; daily ranges keep the date only.
* Returns [] on any failure — the chart is a nice-to-have, never a blocker.
*/
async fetchCloses(ticker: string, range = '6mo'): Promise<PricePoint[]> {
const preset = YahooFinanceClient.CHART_RANGES[range] ?? YahooFinanceClient.CHART_RANGES['6mo'];
try {
const period1 =
range === 'ytd'
? new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1))
: new Date(Date.now() - preset.days * 24 * 60 * 60 * 1000);
const result = await this.lib.chart(
YahooFinanceClient.normalise(ticker),
{ period1, interval: preset.interval },
{ validateResult: false },
);
const quotes = (result?.quotes ?? []) as Array<{ date?: string | Date; close?: number }>;
const intraday = preset.interval !== '1d';
return quotes
.filter((q) => q.close != null && q.date != null)
.map((q) => {
const iso = new Date(q.date as string | Date).toISOString();
return {
date: intraday ? iso : iso.slice(0, 10),
close: +(q.close as number).toFixed(2),
};
});
} catch {
return [];
}
}
}
-98
View File
@@ -1,98 +0,0 @@
import type { Signal, AssetType, RateRegime } from '../types';
export const SIGNAL = {
STRONG_BUY: '✅ Strong Buy' as Signal,
MOMENTUM: '⚡ Momentum' as Signal,
SPECULATION: '⚠️ Speculation' as Signal,
NEUTRAL: '🔄 Neutral' as Signal,
AVOID: '❌ Avoid' as Signal,
};
export const ASSET_TYPE = {
STOCK: 'STOCK' as AssetType,
ETF: 'ETF' as AssetType,
BOND: 'BOND' as AssetType,
CRYPTO: 'crypto',
};
// ── Why some constants use `as const` and others don't ────────────────────
//
// SIGNAL / ASSET_TYPE / REGIME — each member is individually cast to its
// named type (e.g. `'✅ Strong Buy' as Signal`). TypeScript already knows
// the exact literal type of each value, so `as const` on the object would
// be redundant.
//
// SECTOR / SCORE_MODE / CAP_CATEGORY / GROWTH_CATEGORY — these use
// `as const` because their public type aliases are *derived* from the
// object itself via `(typeof X)[keyof typeof X]`. Without `as const`,
// TypeScript widens every value to `string`, and the derived union
// collapses to `string` instead of `'TECHNOLOGY' | 'REIT' | ...`.
// ──────────────────────────────────────────────────────────────────────────
export const SECTOR = {
TECHNOLOGY: 'TECHNOLOGY',
REIT: 'REIT',
FINANCIAL: 'FINANCIAL',
ENERGY: 'ENERGY',
HEALTHCARE: 'HEALTHCARE',
COMMUNICATION: 'COMMUNICATION',
CONSUMER_STAPLES: 'CONSUMER_STAPLES',
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
GENERAL: 'GENERAL',
} as const;
export type Sector = (typeof SECTOR)[keyof typeof SECTOR];
export const SCORE_MODE = {
FUNDAMENTAL: 'FUNDAMENTAL',
INFLATED: 'INFLATED',
} as const;
export const REGIME = {
LOW: 'LOW' as RateRegime,
NORMAL: 'NORMAL' as RateRegime,
HIGH: 'HIGH' as RateRegime,
};
export const YAHOO_MODULES: string[] = [
'assetProfile',
'financialData',
'defaultKeyStatistics',
'price',
'summaryDetail',
'fundProfile', // categoryName drives ETF vs bond-fund classification in DataMapper
];
export const SIGNAL_ORDER: Record<string, number> = {
[SIGNAL.STRONG_BUY]: 0,
[SIGNAL.MOMENTUM]: 1,
[SIGNAL.NEUTRAL]: 2,
[SIGNAL.SPECULATION]: 3,
[SIGNAL.AVOID]: 4,
};
// ── Market capitalisation tiers ───────────────────────────────────────────
// Thresholds follow institutional convention (MSCI/Russell definitions).
export const CAP_CATEGORY = {
MEGA: 'Mega Cap', // > $200B
LARGE: 'Large Cap', // $10B $200B
MID: 'Mid Cap', // $2B $10B
SMALL: 'Small Cap', // $300M $2B
MICRO: 'Micro Cap', // < $300M
} as const;
export type CapCategory = (typeof CAP_CATEGORY)[keyof typeof CAP_CATEGORY];
// ── Growth / style classification ─────────────────────────────────────────
// Derived from revenue growth, earnings growth, and dividend yield.
// Used for display and to contextualise signals within each cap tier.
export const GROWTH_CATEGORY = {
HIGH_GROWTH: 'High Growth', // rev >15% or earnings >20%
MODERATE_GROWTH: 'Growth', // rev 515%
STABLE: 'Stable', // low growth, modest or no dividend
VALUE: 'Value', // low growth + dividend yield ≥ 3%
TURNAROUND: 'Turnaround', // negative earnings, positive revenue
DECLINING: 'Declining', // negative revenue growth
} as const;
export type GrowthCategory = (typeof GROWTH_CATEGORY)[keyof typeof GROWTH_CATEGORY];
@@ -1,223 +0,0 @@
/**
* DatabaseConnection — High-level database abstraction.
*
* Wraps better-sqlite3 with:
* - QueryBuilder for type-safe, injection-proof queries
* - QueryAudit for logging and compliance
* - Statement caching for performance
* - Transaction support
*
* Usage:
* const db = new DatabaseConnection(betterSqlite3Db, options);
* const qb = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['stock']);
* const rows = db.all(qb);
* const row = db.get(qb);
* db.run(qb);
*/
import type BetterSqlite3 from 'better-sqlite3';
import type { DatabaseOptions } from '../types/index';
import { AuditAction } from '../types/index';
import { QueryBuilder } from '../utils/QueryBuilder';
import { QueryAudit } from './QueryAudit';
/**
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
*/
export class DatabaseConnection {
private db: BetterSqlite3.Database;
private audit: QueryAudit;
private logSlowQueries: number;
private statementCache = new Map<string, BetterSqlite3.Statement>();
constructor(db: BetterSqlite3.Database, options: DatabaseOptions = {}) {
this.db = db;
this.audit = options.audit ?? new QueryAudit();
this.logSlowQueries = options.logSlowQueries ?? 100; // 100ms default
}
/**
* Execute a SELECT query and return all rows.
* Logs the query to the audit trail.
*/
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
const sql = qb.sql;
const params = qb.queryParams;
const startMs = performance.now();
try {
const stmt = this.getOrCacheStatement(sql);
const rows = stmt.all(...params) as T[];
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, AuditAction.READ, durationMs, rows.length);
this.logIfSlow(sql, durationMs);
return rows;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
throw err;
}
}
/**
* Execute a SELECT query and return the first row only.
* Returns null if no rows match.
* Logs the query to the audit trail.
*/
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
const sql = qb.sql;
const params = qb.queryParams;
const startMs = performance.now();
try {
const stmt = this.getOrCacheStatement(sql);
const row = stmt.get(...params) as T | undefined;
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, AuditAction.READ, durationMs, row ? 1 : 0);
this.logIfSlow(sql, durationMs);
return row ?? null;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
throw err;
}
}
/**
* Execute an INSERT, UPDATE, or DELETE query.
* Returns the number of rows affected.
* Logs the query to the audit trail.
*/
run(qb: QueryBuilder): number {
const sql = qb.sql;
const params = qb.queryParams;
const startMs = performance.now();
// Determine audit action from SQL
const sqlUpper = sql.toUpperCase().trim();
const action = sqlUpper.startsWith('DELETE')
? AuditAction.DELETE
: sqlUpper.startsWith('INSERT')
? AuditAction.WRITE
: AuditAction.WRITE;
try {
const stmt = this.getOrCacheStatement(sql);
const result = stmt.run(...params);
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, action, durationMs, result.changes);
this.logIfSlow(sql, durationMs);
return result.changes;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, action, durationMs, 0, errorMsg);
throw err;
}
}
/**
* Execute a transaction — multiple queries as an atomic unit.
* All queries must succeed, or all are rolled back.
*
* Usage:
* db.transaction(() => {
* db.run(qb1);
* db.run(qb2);
* });
*/
transaction<T>(fn: () => T): T {
const txn = this.db.transaction(fn);
return txn();
}
/**
* Execute a raw SQL SELECT and return all rows.
* Use only when QueryBuilder is not practical (e.g. static named queries).
*/
rawAll<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
const stmt = this.getOrCacheStatement(sql);
return stmt.all(...params) as T[];
}
/**
* Execute a raw SQL SELECT and return the first row.
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
*/
rawGet<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T | undefined {
const stmt = this.getOrCacheStatement(sql);
return stmt.get(...params) as T | undefined;
}
/**
* Execute a raw SQL INSERT/UPDATE/DELETE.
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
*/
rawRun(sql: string, params: unknown[] = []): number {
const stmt = this.getOrCacheStatement(sql);
return stmt.run(...params).changes;
}
/**
* Get the raw better-sqlite3 Db instance (for advanced use only).
* Prefer the DatabaseConnection methods.
*/
raw(): BetterSqlite3.Database {
return this.db;
}
/**
* Get the audit trail instance.
*/
getAudit(): QueryAudit {
return this.audit;
}
/**
* Clear the statement cache (for testing or extreme memory pressure).
*/
clearStatementCache(): void {
this.statementCache.clear();
}
/**
* Get the audit trail instance.
* Call db.printAudit() to see the most recent 100 queries.
*/
printAudit(): void {
// eslint-disable-next-line no-console
console.log(this.audit.report());
}
// ── Private helpers ─────────────────────────────────────────────────────
/**
* Get or create a cached prepared statement.
* Reduces compilation overhead for frequently-run queries.
*/
private getOrCacheStatement(sql: string): BetterSqlite3.Statement {
let stmt = this.statementCache.get(sql);
if (!stmt) {
stmt = this.db.prepare(sql);
this.statementCache.set(sql, stmt);
}
return stmt;
}
/**
* Log slow queries to console.
*/
private logIfSlow(sql: string, durationMs: number): void {
if (durationMs > this.logSlowQueries) {
console.warn(`[SLOW QUERY] ${durationMs.toFixed(2)}ms\n ${sql}`);
}
}
}
@@ -1,195 +0,0 @@
/**
* Database initialization and migration.
*
* Handles:
* - Creating/opening SQLite database
* - Running DDL schema setup
* - Runtime ALTER TABLE migrations (safe to re-run)
* - Seeding the admin user from ADMIN_EMAIL + ADMIN_PASSWORD env vars
* - Migrating legacy JSON files (one-time)
*/
import BetterSqlite3 from 'better-sqlite3';
import { existsSync, readFileSync, renameSync } from 'fs';
import { randomUUID, randomBytes, scryptSync } from 'crypto';
import { DDL, RUNTIME_MIGRATIONS, HOLDINGS_QUERIES, USER_QUERIES } from './queries.constant.js';
export type Db = BetterSqlite3.Database;
// ── Types ────────────────────────────────────────────────────────────────────
interface LegacyHolding {
ticker: string;
shares: number;
costBasis: number;
type: string;
source: string;
}
interface LegacyCall {
id?: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, unknown>;
createdAt: string;
}
// ── Main Export ──────────────────────────────────────────────────────────────
/**
* Initialize and open the SQLite database.
*
* Steps:
* 1. Create/open database file
* 2. Enable WAL mode + foreign keys
* 3. Run DDL (create tables if missing)
* 4. Run runtime ALTER TABLE migrations (adds user_id etc. to existing DBs)
* 5. Seed admin user from env vars
* 6. Migrate legacy JSON files (one-time)
*/
export function createDb(path = './market-screener.db'): Db {
const db = new BetterSqlite3(path);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = OFF'); // off during schema changes, back on after
db.exec(DDL);
runRuntimeMigrations(db);
db.pragma('foreign_keys = ON');
seedAdmin(db);
// Upgrade any legacy 'viewer' accounts to 'trader' so all users have full access
db.prepare("UPDATE users SET role = 'trader' WHERE role = 'viewer'").run();
migrateJson(db);
return db;
}
// ── Runtime migrations ───────────────────────────────────────────────────────
/**
* Run ALTER TABLE statements that bring existing DBs up to the current schema.
* Each statement is wrapped in try/catch — SQLite throws if column already exists.
*/
function runRuntimeMigrations(db: Db): void {
for (const sql of RUNTIME_MIGRATIONS) {
try {
db.exec(sql);
} catch {
// Column already exists — safe to ignore
}
}
}
// ── Admin seeding ────────────────────────────────────────────────────────────
/**
* Create the admin account on first boot if ADMIN_EMAIL + ADMIN_PASSWORD are set.
* No-ops if the admin already exists.
*/
function seedAdmin(db: Db): void {
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) return;
const existing = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(email);
if (existing) {
// Migrate any ownerless holdings from before auth was added to this admin
const adminRow = existing as { id: string };
db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(adminRow.id);
return;
}
// Hash password using the same scrypt approach as AuthService
// (inline here to avoid circular imports with the auth domain)
const salt = randomBytes(16).toString('hex');
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }).toString('hex');
const passwordHash = `${salt}:${hash}`;
const id = randomUUID();
const createdAt = new Date().toISOString();
db.prepare(USER_QUERIES.INSERT).run(id, email, passwordHash, 'admin', createdAt);
// Migrate any ownerless holdings to this new admin
db.prepare(HOLDINGS_QUERIES.MIGRATE_TO_ADMIN).run(id);
}
// ── JSON migration helpers ───────────────────────────────────────────────────
function migrateJson(db: Db): void {
migratePortfolio(db);
migrateCalls(db);
}
function migratePortfolio(db: Db): void {
const src = './portfolio.json';
if (!existsSync(src)) return;
// Need admin id to assign migrated holdings
const adminEmail = process.env.ADMIN_EMAIL;
if (!adminEmail) return;
const adminRow = db.prepare(USER_QUERIES.SELECT_BY_EMAIL).get(adminEmail) as
| { id: string }
| undefined;
if (!adminRow) return;
try {
const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as {
holdings: LegacyHolding[];
};
const insertAll = db.transaction((rows: LegacyHolding[]) => {
const stmt = db.prepare(`
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const h of rows) {
stmt.run(
h.ticker.toUpperCase(),
h.shares,
h.costBasis ?? 0,
h.type ?? 'stock',
h.source ?? 'Manual',
adminRow.id,
);
}
});
insertAll(holdings);
renameSync(src, `${src}.migrated`);
} catch {
// Non-fatal
}
}
function migrateCalls(db: Db): void {
const src = './market-calls.json';
if (!existsSync(src)) return;
try {
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as { calls: LegacyCall[] };
const insertAll = db.transaction((rows: LegacyCall[]) => {
const stmt = db.prepare(`
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const c of rows) {
stmt.run(
c.id ?? randomUUID(),
c.title,
c.quarter,
c.date,
c.thesis,
JSON.stringify(c.tickers ?? []),
JSON.stringify(c.snapshot ?? {}),
c.createdAt,
);
}
});
insertAll(calls);
renameSync(src, `${src}.migrated`);
} catch {
// Non-fatal
}
}
-126
View File
@@ -1,126 +0,0 @@
/**
* Query audit logging — tracks all database mutations.
*
* Usage:
* const audit = new QueryAudit();
* audit.log('SELECT * FROM holdings', [], AuditAction.READ, 1.5);
* audit.log('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], AuditAction.WRITE, 0.8, 1);
*
* Provides:
* - Audit trail of all queries executed
* - Timing information (for performance monitoring)
* - Clear distinction between READ/WRITE operations
* - Optional persistent storage for compliance
*/
import type { AuditAction, AuditEntry } from '../types/index';
/**
* QueryAudit — in-memory audit trail with optional callbacks.
*/
export class QueryAudit {
private entries: AuditEntry[] = [];
private onLog?: (entry: AuditEntry) => void | Promise<void>;
constructor(onLog?: (entry: AuditEntry) => void | Promise<void>) {
this.onLog = onLog;
}
/**
* Log a query execution.
* @param sql The SQL string (with ? placeholders intact)
* @param params The parameter array (safe to log; no raw values in SQL)
* @param action The operation type (READ, WRITE, DELETE)
* @param durationMs Execution time in milliseconds
* @param rowsAffected Number of rows affected (for INSERT/UPDATE/DELETE)
* @param error If execution failed, the error message
*/
log(
sql: string,
params: unknown[],
action: AuditAction,
durationMs: number,
rowsAffected?: number,
error?: string,
): void {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
sql,
params,
durationMs,
rowsAffected,
error,
};
this.entries.push(entry);
// Call the optional callback (could write to file, logger, or remote service)
if (this.onLog) {
const result = this.onLog(entry);
if (result instanceof Promise) {
result.catch((err) => {
console.error('QueryAudit callback failed:', err);
});
}
}
}
/**
* Get all audit entries.
*/
all(): AuditEntry[] {
return [...this.entries];
}
/**
* Filter audit entries by action type.
*/
byAction(action: AuditAction): AuditEntry[] {
return this.entries.filter((e) => e.action === action);
}
/**
* Get the most recent N entries.
*/
recent(count: number = 100): AuditEntry[] {
return this.entries.slice(-count);
}
/**
* Clear the audit trail.
* (Typically not needed unless for testing or cleanup.)
*/
clear(): void {
this.entries = [];
}
/**
* Generate a human-readable audit report.
*/
report(limitEntries: number = 100): string {
const recent = this.recent(limitEntries);
let report = `\n=== Query Audit Report ===\n`;
report += `Total entries: ${this.entries.length}\n`;
report += `Showing last ${recent.length} entries:\n\n`;
for (const entry of recent) {
report += `[${entry.timestamp}] ${entry.action}`;
if (entry.error) {
report += ` ❌ (${entry.error})`;
} else {
report += ` ✓ (${entry.durationMs}ms)`;
if (entry.rowsAffected !== undefined) {
report += `${entry.rowsAffected} rows`;
}
}
report += `\n SQL: ${entry.sql}\n`;
if (entry.params.length > 0) {
report += ` Params: [${entry.params.map((p) => JSON.stringify(p)).join(', ')}]\n`;
}
report += '\n';
}
return report;
}
}
-32
View File
@@ -1,32 +0,0 @@
/**
* Database layer — barrel export (ONLY re-exports, no logic).
*
* This file is the SINGLE public API for all database functionality.
* All imports should come from here, not from individual files.
*
* USAGE:
* import { createDb, DatabaseConnection, QueryAudit } from './db/index.js';
* import type { AuditEntry } from './db/index.js';
*
* FILE ORGANIZATION:
* - DatabaseInitializer.ts: createDb() function + migrations (pure functions)
* - QueryAudit.ts: class QueryAudit (logging service)
* - DatabaseConnection.ts: class DatabaseConnection (data access service)
* - index.ts: THIS FILE (barrel re-exports only)
*
* SECURITY:
* - All queries use parameterized statements (QueryBuilder + DatabaseConnection)
* - No SQL injection possible via table/column/parameter names
* - Audit trail tracks all mutations for compliance
*/
// Initialization
export { createDb, type Db } from './DatabaseInitializer';
// Data access
export { DatabaseConnection } from './DatabaseConnection';
export { QueryAudit } from './QueryAudit';
// Types
export { AuditAction } from '../types/database.model';
export type { AuditEntry, DatabaseOptions } from '../types/database.model';
@@ -1,385 +0,0 @@
/**
* SQL Query Constants
*
* All SQL queries used in the application.
* Repositories reference these by name.
*
* All queries use parameterized statements (?) for security.
* User input NEVER goes into the SQL string.
*/
// ── Holdings Table Queries ───────────────────────────────────────────────────
export const HOLDINGS_QUERIES = {
// Check if any holdings exist for a user
EXISTS: 'SELECT COUNT(*) AS n FROM holdings WHERE user_id = ?',
// Get all holdings for a user, sorted by ticker
SELECT_ALL: `
SELECT ticker, shares, cost_basis, type, source
FROM holdings
WHERE user_id = ?
ORDER BY ticker ASC
`,
// Insert or update a holding scoped to a user
UPSERT: `
INSERT INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker, user_id) DO UPDATE SET
shares = excluded.shares,
cost_basis = excluded.cost_basis,
type = excluded.type,
source = excluded.source
`,
// Delete a holding by ticker for a specific user
DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ? AND user_id = ?',
// Migrate ownerless holdings to admin user (one-time)
MIGRATE_TO_ADMIN: "UPDATE holdings SET user_id = ? WHERE user_id IS NULL OR user_id = ''",
};
// ── Market Calls Table Queries ───────────────────────────────────────────────
export const MARKET_CALLS_QUERIES = {
// Get all market calls, newest first
SELECT_ALL: `
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
FROM market_calls
ORDER BY created_at DESC
`,
// Get a single market call by ID
SELECT_BY_ID: `
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
FROM market_calls
WHERE id = ?
`,
// Insert a new market call
INSERT: `
INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
// Delete a market call by ID
DELETE_BY_ID: 'DELETE FROM market_calls WHERE id = ?',
};
// ── Migration Queries (for DatabaseInitializer) ──────────────────────────────
export const MIGRATION_QUERIES = {
// Insert holdings during migration
HOLDINGS_INSERT_OR_IGNORE: `
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source, user_id)
VALUES (?, ?, ?, ?, ?, ?)
`,
// Insert market calls during migration
MARKET_CALLS_INSERT_OR_IGNORE: `
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
};
// ── User Table Queries ───────────────────────────────────────────────────────
export const USER_QUERIES = {
SELECT_BY_EMAIL: `
SELECT id, email, password_hash, role, created_at, last_login
FROM users WHERE email = ?
`,
SELECT_BY_ID: `
SELECT id, email, role, created_at, last_login
FROM users WHERE id = ?
`,
INSERT: `
INSERT INTO users (id, email, password_hash, role, created_at)
VALUES (?, ?, ?, ?, ?)
`,
UPDATE_LAST_LOGIN: `
UPDATE users SET last_login = ? WHERE id = ?
`,
};
// ── Password Reset Token Queries ─────────────────────────────────────────────
export const RESET_TOKEN_QUERIES = {
INSERT: `
INSERT INTO password_reset_tokens (token, user_id, expires_at)
VALUES (?, ?, ?)
`,
FIND: `
SELECT token, user_id, expires_at, used
FROM password_reset_tokens
WHERE token = ?
`,
MARK_USED: `
UPDATE password_reset_tokens SET used = 1 WHERE token = ?
`,
// Clean up expired/used tokens older than 24h
PURGE: `
DELETE FROM password_reset_tokens
WHERE used = 1 OR expires_at < ?
`,
};
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
// ── Watchlist Queries ────────────────────────────────────────────────────────
export const WATCHLIST_QUERIES = {
SELECT_ALL: `
SELECT ticker, pinned_at
FROM watchlist
WHERE user_id = ?
ORDER BY pinned_at DESC
`,
INSERT: `
INSERT OR IGNORE INTO watchlist (ticker, user_id, pinned_at)
VALUES (?, ?, ?)
`,
DELETE: `
DELETE FROM watchlist WHERE ticker = ? AND user_id = ?
`,
EXISTS: `
SELECT 1 FROM watchlist WHERE ticker = ? AND user_id = ?
`,
};
// ── Screening Universe Queries (bin/daily-screen.ts) ────────────────────────
export const UNIVERSE_QUERIES = {
// Every ticker pinned by any user
DISTINCT_WATCHLIST_TICKERS: 'SELECT DISTINCT ticker FROM watchlist ORDER BY ticker',
// Every ticker held by any user (crypto excluded — not fundamentally scored)
DISTINCT_HOLDING_TICKERS: `
SELECT DISTINCT ticker FROM holdings
WHERE type != 'crypto'
ORDER BY ticker
`,
// Every ticker screened recently (snapshot ledger) — part of the news universe
DISTINCT_SNAPSHOT_TICKERS_SINCE: `
SELECT DISTINCT ticker FROM signal_snapshots
WHERE snapshot_date >= ?
ORDER BY ticker
`,
};
// ── News Queries (FREE-DATA-STACK §25 — free-tier news pipeline) ───────────
export const NEWS_QUERIES = {
// INSERT OR IGNORE — url_hash PK is the first dedupe line (returns 0 changes on dup)
INSERT_ARTICLE: `
INSERT OR IGNORE INTO news_articles
(url_hash, title_hash, ticker_list, headline, body, source, catalyst, url, published_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
// Second dedupe line: same (normalized) title seen recently → syndicated copy
TITLE_SEEN_SINCE: `
SELECT 1 FROM news_articles
WHERE title_hash = ? AND published_at >= ?
LIMIT 1
`,
INSERT_CATALYST_LINK: `
INSERT OR IGNORE INTO ticker_catalysts (ticker, day, url_hash)
VALUES (?, ?, ?)
`,
// Per-ticker daily cap check (FREE-DATA-STACK §4.4)
COUNT_TICKER_DAY: `
SELECT COUNT(*) AS n FROM ticker_catalysts
WHERE ticker = ? AND day = ?
`,
// Stories for one ticker since a given day — what the UI reads (never Yahoo live)
SELECT_TICKER_NEWS: `
SELECT a.* FROM ticker_catalysts c
JOIN news_articles a ON a.url_hash = c.url_hash
WHERE c.ticker = ? AND c.day >= ?
ORDER BY a.published_at DESC
`,
SELECT_RECENT: `
SELECT * FROM news_articles
ORDER BY published_at DESC
LIMIT ?
`,
// Retention (FREE-DATA-STACK §5): purge bodies after 90d, drop unreferenced after 18mo
PURGE_BODIES_BEFORE: `
UPDATE news_articles SET body = NULL
WHERE body IS NOT NULL AND published_at < ?
`,
DELETE_UNREFERENCED_BEFORE: `
DELETE FROM news_articles
WHERE published_at < ?
AND url_hash NOT IN (SELECT url_hash FROM ticker_catalysts)
`,
};
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
export const SIGNAL_SNAPSHOT_QUERIES = {
// One row per ticker per day — repeated screens the same day keep the latest
UPSERT: `
INSERT INTO signal_snapshots (
ticker, snapshot_date, asset_type, price, signal,
fundamental_tier, fundamental_score, fundamental_label,
inflated_tier, inflated_score, inflated_label,
coverage_active, coverage_total, risk_flags, rate_regime, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker, snapshot_date) DO UPDATE SET
asset_type = excluded.asset_type,
price = excluded.price,
signal = excluded.signal,
fundamental_tier = excluded.fundamental_tier,
fundamental_score = excluded.fundamental_score,
fundamental_label = excluded.fundamental_label,
inflated_tier = excluded.inflated_tier,
inflated_score = excluded.inflated_score,
inflated_label = excluded.inflated_label,
coverage_active = excluded.coverage_active,
coverage_total = excluded.coverage_total,
risk_flags = excluded.risk_flags,
rate_regime = excluded.rate_regime,
created_at = excluded.created_at
`,
// Full history for one ticker, oldest first (for trend/backtest views)
SELECT_BY_TICKER: `
SELECT * FROM signal_snapshots
WHERE ticker = ?
ORDER BY snapshot_date ASC
`,
// All snapshots for one day (for daily diff jobs)
SELECT_BY_DATE: `
SELECT * FROM signal_snapshots
WHERE snapshot_date = ?
ORDER BY ticker ASC
`,
// Latest snapshot per ticker on or before a given date (for change detection)
SELECT_LATEST_BEFORE: `
SELECT s.* FROM signal_snapshots s
JOIN (
SELECT ticker, MAX(snapshot_date) AS d
FROM signal_snapshots
WHERE snapshot_date < ?
GROUP BY ticker
) latest ON latest.ticker = s.ticker AND latest.d = s.snapshot_date
`,
};
export const DDL = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('trader', 'viewer', 'admin')),
created_at TEXT NOT NULL,
last_login TEXT
);
CREATE TABLE IF NOT EXISTS holdings (
ticker TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id),
shares REAL NOT NULL,
cost_basis REAL NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'stock',
source TEXT NOT NULL DEFAULT 'Manual',
PRIMARY KEY (ticker, user_id)
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS market_calls (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
quarter TEXT NOT NULL,
date TEXT NOT NULL,
thesis TEXT NOT NULL,
tickers TEXT NOT NULL, -- JSON array
snapshot TEXT NOT NULL, -- JSON object
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS watchlist (
ticker TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pinned_at TEXT NOT NULL,
PRIMARY KEY (ticker, user_id)
);
CREATE TABLE IF NOT EXISTS signal_snapshots (
ticker TEXT NOT NULL,
snapshot_date TEXT NOT NULL, -- YYYY-MM-DD
asset_type TEXT NOT NULL, -- STOCK / ETF / BOND
price REAL,
signal TEXT NOT NULL, -- ✅ Strong Buy etc.
fundamental_tier TEXT NOT NULL, -- PASS / HOLD / REJECT
fundamental_score REAL,
fundamental_label TEXT,
inflated_tier TEXT NOT NULL,
inflated_score REAL,
inflated_label TEXT,
coverage_active INTEGER,
coverage_total INTEGER,
risk_flags TEXT, -- JSON array
rate_regime TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (ticker, snapshot_date)
);
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON signal_snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_snapshots_signal ON signal_snapshots(signal, snapshot_date);
CREATE TABLE IF NOT EXISTS news_articles (
url_hash TEXT PRIMARY KEY, -- sha256(url)
title_hash TEXT NOT NULL, -- sha256(normalized headline) — syndication dedupe
ticker_list TEXT NOT NULL, -- JSON array of matched universe tickers
headline TEXT NOT NULL,
body TEXT, -- nullable; purged after 90 days (retention job)
source TEXT NOT NULL, -- 'edgar' | 'prwire' | 'yahoo'
catalyst TEXT, -- 'earnings'|'ma'|'guidance'|'regulatory'|'macro'|NULL
url TEXT NOT NULL,
published_at TEXT NOT NULL, -- ISO timestamp
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_news_published ON news_articles(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_news_title ON news_articles(title_hash, published_at);
CREATE TABLE IF NOT EXISTS ticker_catalysts (
ticker TEXT NOT NULL,
day TEXT NOT NULL, -- YYYY-MM-DD (published date)
url_hash TEXT NOT NULL REFERENCES news_articles(url_hash),
PRIMARY KEY (ticker, day, url_hash)
);
CREATE INDEX IF NOT EXISTS idx_catalysts_ticker ON ticker_catalysts(ticker, day DESC);
`;
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
// These are safe to run repeatedly — they no-op if the column already exists.
export const RUNTIME_MIGRATIONS = [
// Add user_id to holdings if upgrading from pre-auth schema
`ALTER TABLE holdings ADD COLUMN user_id TEXT NOT NULL DEFAULT '' REFERENCES users(id)`,
];
-26
View File
@@ -1,26 +0,0 @@
import type { AssetType } from '../types';
import type { AssetData } from '../types/models.model';
export class Asset {
ticker: string;
currentPrice: number;
type: AssetType;
constructor(data: AssetData) {
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
this.currentPrice = (data.currentPrice as number) || 0;
this.type = (data.type || 'STOCK').toUpperCase() as AssetType;
}
formatCurrency(val: number | null | undefined): string {
return val ? `$${val.toFixed(2)}` : 'N/A';
}
formatLargeNumber(num: number | null | undefined): string {
if (!num) return 'N/A';
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
return num.toString();
}
}
-39
View File
@@ -1,39 +0,0 @@
import { Asset } from './Asset';
import type { EtfData, EtfMetrics } from '../types/models.model';
export class Etf extends Asset {
metrics: EtfMetrics;
constructor(data: EtfData) {
super(data);
// Preserve null for missing fields — coercing to 0 would auto-fail gates
// in EtfScorer for data Yahoo simply didn't return.
const num = (v: unknown): number | null => {
if (v == null) return null;
const f = parseFloat(String(v));
return Number.isFinite(f) ? f : null;
};
this.metrics = {
expenseRatio: num(data.expenseRatio),
totalAssets: num(data.totalAssets),
yield: num(data.yield),
volume: num(data.volume),
fiveYearReturn: num(data.fiveYearReturn),
};
}
getDisplayMetrics(): Record<string, string> {
const m = this.metrics;
const fmt = (v: number | null, dec: number, suffix = '') =>
v != null ? `${v.toFixed(dec)}${suffix}` : '—';
return {
Ticker: this.ticker,
Type: 'ETF',
Price: this.formatCurrency(this.currentPrice),
'Exp Ratio%': fmt(m.expenseRatio, 2, '%'),
'Yield%': fmt(m.yield, 2, '%'),
AUM: m.totalAssets != null ? this.formatLargeNumber(m.totalAssets) : '—',
'5Y Return%': fmt(m.fiveYearReturn, 1, '%'),
};
}
}
-224
View File
@@ -1,224 +0,0 @@
import { Asset } from './Asset';
import { CAP_CATEGORY, GROWTH_CATEGORY } from '../config/constants';
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
import type { StockData, StockMetrics } from '../types/models.model';
export class Stock extends Asset {
sector: Sector;
metrics: StockMetrics;
constructor(data: StockData) {
super(data);
this.sector = this.mapToStandardSector(data);
this.metrics = {
sector: this.sector,
capCategory: this.classifyMarketCap(data.marketCap ?? null),
growthCategory: this.classifyGrowth(
data.revenueGrowth ?? null,
data.earningsGrowth ?? null,
data.dividendYield ?? null,
),
peRatio: data.peRatio ?? null,
pegRatio: data.pegRatio ?? null,
priceToBook: data.priceToBook ?? null,
grossMargin: data.grossMargin ?? null,
netProfitMargin: data.netProfitMargin ?? null,
operatingMargin: data.operatingMargin ?? null,
returnOnEquity: data.returnOnEquity ?? null,
revenueGrowth: data.revenueGrowth ?? null,
earningsGrowth: data.earningsGrowth ?? null,
debtToEquity: data.debtToEquity ?? null,
quickRatio: data.quickRatio ?? null,
fcfYield: data.fcfYield ?? null,
pFFO: data.pFFO ?? null,
dividendYield: data.dividendYield ?? null,
beta: data.beta ?? null,
dayChangePct: data.dayChangePct ?? null,
week52High: data.week52High ?? null,
week52Low: data.week52Low ?? null,
week52Change: data.week52Change ?? null,
week52FromHigh: data.week52FromHigh ?? null,
week52FromLow: data.week52FromLow ?? null,
marketCap: data.marketCap ?? null,
analystRating: data.analystRating ?? null,
analystTargetPrice: data.analystTargetPrice ?? null,
analystUpside: data.analystUpside ?? null,
numberOfAnalysts: data.numberOfAnalysts ?? null,
dcfIntrinsicValue: data.dcfIntrinsicValue ?? null,
dcfMarginOfSafety: data.dcfMarginOfSafety ?? null,
currentPrice: (data.currentPrice as number) || 0,
};
}
// ── Market cap tier classification ──────────────────────────────────────
// Thresholds follow MSCI/Russell institutional convention.
classifyMarketCap(marketCap: number | null): CapCategory {
if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default
if (marketCap >= 200e9) return CAP_CATEGORY.MEGA;
if (marketCap >= 10e9) return CAP_CATEGORY.LARGE;
if (marketCap >= 2e9) return CAP_CATEGORY.MID;
if (marketCap >= 300e6) return CAP_CATEGORY.SMALL;
return CAP_CATEGORY.MICRO;
}
// ── Growth / style classification ───────────────────────────────────────
// revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%).
// dividendYield is also in percentage form (e.g. 3.5 = 3.5%).
classifyGrowth(
revenueGrowth: number | null,
earningsGrowth: number | null,
dividendYield: number | null,
): GrowthCategory {
const rev = revenueGrowth ?? 0;
const earn = earningsGrowth ?? 0;
const div = dividendYield ?? 0;
if (rev < -5) return GROWTH_CATEGORY.DECLINING;
if (earn < 0 && rev >= 0) return GROWTH_CATEGORY.TURNAROUND;
if (rev >= 15 || earn >= 20) return GROWTH_CATEGORY.HIGH_GROWTH;
if (rev >= 5) return GROWTH_CATEGORY.MODERATE_GROWTH;
if (div >= 3 && rev < 5) return GROWTH_CATEGORY.VALUE;
return GROWTH_CATEGORY.STABLE;
}
mapToStandardSector(data: StockData): Sector {
const profile = data.assetProfile ?? {};
const industry = (profile.industry || '').toLowerCase();
const sector = (profile.sector || '').toLowerCase();
const combined = `${industry} ${sector}`;
if (
combined.includes('technology') ||
combined.includes('electronic') ||
combined.includes('semiconductor') ||
combined.includes('software')
)
return 'TECHNOLOGY';
if (combined.includes('real estate') || combined.includes('reit')) return 'REIT';
if (
combined.includes('financial') ||
combined.includes('bank') ||
combined.includes('insurance') ||
combined.includes('asset management')
)
return 'FINANCIAL';
if (
combined.includes('energy') ||
combined.includes('oil') ||
combined.includes('gas') ||
combined.includes('petroleum')
)
return 'ENERGY';
if (
combined.includes('health') ||
combined.includes('biotech') ||
combined.includes('pharmaceutical') ||
combined.includes('medical')
)
return 'HEALTHCARE';
if (
combined.includes('communication') ||
combined.includes('media') ||
combined.includes('entertainment') ||
combined.includes('telecom')
)
return 'COMMUNICATION';
if (
combined.includes('consumer defensive') ||
combined.includes('consumer staples') ||
combined.includes('household') ||
combined.includes('beverage') ||
combined.includes('food')
)
return 'CONSUMER_STAPLES';
if (
combined.includes('consumer cyclical') ||
combined.includes('consumer discretionary') ||
combined.includes('retail') ||
combined.includes('apparel') ||
combined.includes('auto')
)
return 'CONSUMER_DISCRETIONARY';
return 'GENERAL';
}
getDisplayMetrics(): Record<string, string | null> {
const fmt = (v: number | null, dec = 1, suffix = '') =>
v != null ? `${v.toFixed(dec)}${suffix}` : null;
const fmtSign = (v: number | null, suffix = '%') =>
v != null ? `${v >= 0 ? '+' : ''}${v.toFixed(1)}${suffix}` : null;
const m = this.metrics;
const w52pos =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
: null;
// Analyst label: convert Yahoo's 15 scale to a readable string
const analystLabel = (rating: number | null): string | null => {
if (rating == null) return null;
if (rating <= 1.5) return 'Strong Buy';
if (rating <= 2.5) return 'Buy';
if (rating <= 3.5) return 'Hold';
if (rating <= 4.5) return 'Sell';
return 'Strong Sell';
};
const display: Record<string, string | null> = {
Ticker: this.ticker,
Price: this.formatCurrency(this.currentPrice),
Sector: this.sector,
'Cap Tier': m.capCategory,
Style: m.growthCategory,
};
// Valuation
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2);
// Quality
if (m.grossMargin != null) display['GrossM%'] = fmt(m.grossMargin, 1, '%');
if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%');
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
// Risk
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
// Movement
if (m.dayChangePct != null) display['Day %'] = fmtSign(m.dayChangePct, '%');
if (w52pos != null) display['52W Pos'] = w52pos;
if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%');
if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%');
if (m.week52FromLow != null) display['From Low'] = fmtSign(m.week52FromLow, '%');
// REIT-specific
if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1);
// Analyst consensus
if (m.analystRating != null) {
display['Analyst'] = analystLabel(m.analystRating);
display['# Analysts'] = m.numberOfAnalysts != null ? String(m.numberOfAnalysts) : null;
display['Target'] =
m.analystTargetPrice != null ? this.formatCurrency(m.analystTargetPrice) : null;
display['Upside'] = fmtSign(m.analystUpside, '%');
}
// DCF
if (m.dcfIntrinsicValue != null) {
display['DCF Value'] = this.formatCurrency(m.dcfIntrinsicValue);
display['DCF Safety'] =
m.dcfMarginOfSafety != null ? fmtSign(m.dcfMarginOfSafety, '%') : null;
}
return display;
}
}
-49
View File
@@ -1,49 +0,0 @@
// Shared domain — re-exports all shared infrastructure
// Import from here, not from individual subdirectories
// Entities
export { Asset } from './entities/Asset';
export { Stock } from './entities/Stock';
export { Etf } from './entities/Etf';
export { Bond } from './entities/Bond';
// Adapters (external API clients)
export { YahooFinanceClient } from './adapters/YahooFinanceClient';
export { AnthropicClient } from './adapters/AnthropicClient';
export { SimpleFINClient } from './adapters/SimpleFINClient';
// Services
export { BenchmarkProvider } from './services/BenchmarkProvider';
export { CatalystAnalyst } from './services/CatalystAnalyst';
export { CatalystCache } from './services/CatalystCache';
export { LLMAnalyst } from './services/LLMAnalyst';
// Scoring
export { CREDIT_RATING_SCALE } from './scoring/ScoringConfig';
export { MarketRegime } from './scoring/MarketRegime';
// Persistence (repositories)
export { MarketCallRepository } from './persistence/MarketCallRepository';
export { PortfolioRepository } from './persistence/PortfolioRepository';
export { SignalSnapshotRepository } from './persistence/SignalSnapshotRepository';
export type { SnapshotInput } from './persistence/SignalSnapshotRepository';
export { DatabaseConnection, QueryAudit, createDb } from './db/index';
// Config & Constants
export {
SIGNAL,
SIGNAL_ORDER,
SCORE_MODE,
ASSET_TYPE,
REGIME,
CAP_CATEGORY,
GROWTH_CATEGORY,
SECTOR,
} from './config/constants';
// Types — re-export everything from types barrel
export type * from './types/index';
// Utils
export { noopLogger } from './utils/logger';
export { chunkArray } from './utils/Chunker';
@@ -1,96 +0,0 @@
import { randomUUID } from 'crypto';
import { DatabaseConnection } from '../db/index';
import { QueryBuilder } from '../utils/QueryBuilder';
import { sanitizeString, sanitizeDate } from '../utils/sanitizer';
import type { MarketCall, CreateCallInput, MarketCallRow } from '../types';
export class MarketCallRepository {
constructor(private readonly db: DatabaseConnection) {}
/**
* Get all market calls, newest first.
*/
list(): (MarketCall & { createdAt: string })[] {
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.SELECT_ALL');
const rows = this.db.all<MarketCallRow>(qb);
return rows.map(MarketCallRepository.toCall);
}
/**
* Get a single market call by ID.
*/
get(id: string): (MarketCall & { createdAt: string }) | null {
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.SELECT_BY_ID', [id]);
const row = this.db.get<MarketCallRow>(qb);
return row ? MarketCallRepository.toCall(row) : null;
}
/**
* Create a new market call with snapshot of current prices.
*/
create({
title,
quarter,
date,
thesis,
tickers,
snapshot,
}: CreateCallInput): MarketCall & { createdAt: string } {
// Sanitize inputs
const sanitizedTitle = sanitizeString(title, 'title', 255);
const sanitizedQuarter = sanitizeString(quarter, 'quarter', 10);
const sanitizedThesis = sanitizeString(thesis, 'thesis', 2000);
const sanitizedDate = date ? sanitizeDate(date, 'date') : new Date().toISOString().slice(0, 10);
const call = {
id: randomUUID(),
title: sanitizedTitle,
quarter: sanitizedQuarter,
date: sanitizedDate,
thesis: sanitizedThesis,
tickers: tickers ?? [],
snapshot: snapshot ?? {},
createdAt: new Date().toISOString(),
};
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.INSERT', [
call.id,
call.title,
call.quarter,
call.date,
call.thesis,
JSON.stringify(call.tickers),
JSON.stringify(call.snapshot),
call.createdAt,
]);
this.db.run(qb);
return call as MarketCall & { createdAt: string };
}
/**
* Delete a market call by ID.
* Returns true if the call existed and was deleted, false otherwise.
*/
delete(id: string): boolean {
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.DELETE_BY_ID', [id]);
const changes = this.db.run(qb);
return changes > 0;
}
/**
* Convert database row to domain object.
*/
private static toCall(row: MarketCallRow): MarketCall & { createdAt: string } {
return {
id: row.id,
title: row.title,
quarter: row.quarter,
date: row.date,
thesis: row.thesis,
tickers: JSON.parse(row.tickers),
snapshot: JSON.parse(row.snapshot),
createdAt: row.created_at,
} as MarketCall & { createdAt: string };
}
}
@@ -1,69 +0,0 @@
import { DatabaseConnection } from '../db/index.js';
import { QueryBuilder } from '../utils/QueryBuilder.js';
import { sanitizeTicker, sanitizeNumber } from '../utils/sanitizer.js';
import type { PortfolioData, PortfolioHolding, HoldingRow } from '../types/index.js';
export class PortfolioRepository {
constructor(private readonly db: DatabaseConnection) {}
/**
* Check if a user has any holdings.
*/
exists(userId: string): boolean {
const qb = new QueryBuilder('HOLDINGS_QUERIES.EXISTS', [userId]);
const row = this.db.get<{ n: number }>(qb);
return row ? row.n > 0 : false;
}
/**
* Read all holdings for a user.
*/
read(userId: string): PortfolioData {
const qb = new QueryBuilder('HOLDINGS_QUERIES.SELECT_ALL', [userId]);
const rows = this.db.all<HoldingRow>(qb);
return { holdings: rows.map(PortfolioRepository.toHolding) };
}
/**
* Insert or update a holding scoped to a user (UPSERT).
*/
upsert(entry: PortfolioHolding, userId: string): PortfolioHolding {
const ticker = sanitizeTicker(entry.ticker);
const shares = sanitizeNumber(entry.shares, 'shares', { min: 0 });
const costBasis = sanitizeNumber(entry.costBasis ?? 0, 'costBasis', { min: 0 });
const type = entry.type ?? 'stock';
const source = entry.source ?? 'Manual';
const qb = new QueryBuilder('HOLDINGS_QUERIES.UPSERT', [
ticker,
shares,
costBasis,
type,
source,
userId,
]);
this.db.run(qb);
return { ...entry, ticker };
}
/**
* Delete a holding by ticker for a specific user.
*/
remove(ticker: string, userId: string): boolean {
const sanitizedTicker = sanitizeTicker(ticker);
const qb = new QueryBuilder('HOLDINGS_QUERIES.DELETE_BY_TICKER', [sanitizedTicker, userId]);
const changes = this.db.run(qb);
return changes > 0;
}
private static toHolding(row: HoldingRow): PortfolioHolding {
return {
ticker: row.ticker,
shares: row.shares,
costBasis: row.cost_basis,
type: row.type as PortfolioHolding['type'],
source: row.source,
};
}
}
@@ -1,90 +0,0 @@
import { DatabaseConnection } from '../db/index';
import { QueryBuilder } from '../utils/QueryBuilder';
import type { ScoreResult, SignalSnapshotRow } from '../types';
/**
* Signal snapshot ledger (PRODUCT.md P0.1).
*
* Persists one row per ticker per day on every /api/screen call so the
* product builds a verifiable signal track record. This data cannot be
* backfilled — the backtest dashboard (Phase 10.5e), thesis review (10.6d),
* and calibration features all depend on it accumulating from day one.
*
* Recording is best-effort: failures are logged by the caller and must never
* fail the screen request itself.
*/
export interface SnapshotInput {
ticker: string;
assetType: string;
price: number | null;
signal: string;
fundamental: ScoreResult;
inflated: ScoreResult;
rateRegime?: string | null;
}
export class SignalSnapshotRepository {
constructor(private readonly db: DatabaseConnection) {}
/**
* Upsert today's snapshot for a batch of screened assets.
* Repeated screens on the same day keep the latest result.
*/
recordBatch(inputs: SnapshotInput[], date = SignalSnapshotRepository.today()): number {
let written = 0;
for (const input of inputs) {
this.record(input, date);
written++;
}
return written;
}
record(input: SnapshotInput, date = SignalSnapshotRepository.today()): void {
const { ticker, assetType, price, signal, fundamental, inflated, rateRegime } = input;
const coverage = fundamental.audit?.coverage ?? inflated.audit?.coverage ?? null;
const riskFlags = fundamental.audit?.riskFlags ?? inflated.audit?.riskFlags ?? null;
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.UPSERT', [
ticker.toUpperCase(),
date,
assetType,
price,
signal,
fundamental.tier,
fundamental.score,
fundamental.label,
inflated.tier,
inflated.score,
inflated.label,
coverage?.active ?? null,
coverage?.total ?? null,
riskFlags ? JSON.stringify(riskFlags) : null,
rateRegime ?? null,
new Date().toISOString(),
]);
this.db.run(qb);
}
/** Full history for one ticker, oldest first. */
history(ticker: string): SignalSnapshotRow[] {
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_TICKER', [ticker.toUpperCase()]);
return this.db.all<SignalSnapshotRow>(qb);
}
/** All snapshots for a given day (YYYY-MM-DD). */
byDate(date: string): SignalSnapshotRow[] {
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_DATE', [date]);
return this.db.all<SignalSnapshotRow>(qb);
}
/** Latest snapshot per ticker strictly before a date — for daily diffing. */
latestBefore(date: string): SignalSnapshotRow[] {
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_LATEST_BEFORE', [date]);
return this.db.all<SignalSnapshotRow>(qb);
}
private static today(): string {
return new Date().toISOString().slice(0, 10);
}
}
@@ -1,69 +0,0 @@
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants';
import type { MarketContext, AssetType, InflatedOverrides } from '../types';
export class MarketRegime {
private marketPE: number;
private techPE: number;
private reitYield: number;
private igSpread: number;
private rateRegime: string;
private volatilityRegime: string;
constructor(marketContext: Partial<MarketContext>) {
const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']);
this.marketPE = b.marketPE ?? 22;
this.techPE = b.techPE ?? 30;
this.reitYield = b.reitYield ?? 3.5;
this.igSpread = b.igSpread ?? 1.0;
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
}
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
if (type === ASSET_TYPE.STOCK) return this.stock(sector);
if (type === ASSET_TYPE.ETF) return this.etf();
if (type === ASSET_TYPE.BOND) return this.bond();
return { gates: {}, thresholds: {} };
}
private stock(sector?: string): InflatedOverrides {
if (sector === SECTOR.REIT) {
return {
gates: {},
thresholds: {
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
maxPFFO: 20,
},
};
}
if (sector === SECTOR.TECHNOLOGY) {
return {
gates: {
maxPERatio: Math.round(this.techPE * 1.3),
maxPegGate: +(this.techPE / 15).toFixed(1),
},
thresholds: {},
};
}
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
return {
gates: {
maxPERatio: Math.round(this.marketPE * peMultiplier),
maxPegGate: +(this.marketPE / 12).toFixed(1),
},
thresholds: {},
};
}
private etf(): InflatedOverrides {
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
}
private bond(): InflatedOverrides {
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
return {
gates: {},
thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) },
};
}
}
@@ -1,155 +0,0 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
import { REGIME } from '../config/constants';
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types/index';
interface CacheFile {
data: MarketContext;
expiresAt: number;
}
export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000;
private static readonly CACHE_PATH = '.benchmark-cache.json';
// NOTE: regimes must stay consistent with rateRegime()/volRegime() below —
// 4.5% ⇒ NORMAL (25%), VIX 20 ⇒ NORMAL (1525).
private static readonly DEFAULTS: MarketContext = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
rateRegime: 'NORMAL',
volatilityRegime: 'NORMAL',
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
/** Hysteresis band: the 10Y must cross a regime boundary by this much to flip. */
private static readonly REGIME_HYSTERESIS = 0.25;
private static rateRegime(rate: number): MarketContext['rateRegime'] {
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
}
/**
* Rate regime with hysteresis (PRODUCT.md P0.5).
*
* The raw thresholds (2% / 5%) flip the INFLATED scoring gates between
* back-to-back requests when the 10Y hovers near a boundary. With a known
* previous regime, the rate must cross the boundary by ±0.25% before the
* regime switches. A two-step jump (LOW→HIGH) applies immediately.
* Public static for direct unit testing.
*/
static resolveRateRegime(
rate: number,
previous: MarketContext['rateRegime'] | null,
): MarketContext['rateRegime'] {
const raw = BenchmarkProvider.rateRegime(rate);
if (!previous || raw === previous) return raw;
const h = BenchmarkProvider.REGIME_HYSTERESIS;
if (previous === REGIME.NORMAL && raw === REGIME.HIGH)
return rate > 5 + h ? REGIME.HIGH : REGIME.NORMAL;
if (previous === REGIME.HIGH && raw === REGIME.NORMAL)
return rate < 5 - h ? REGIME.NORMAL : REGIME.HIGH;
if (previous === REGIME.NORMAL && raw === REGIME.LOW)
return rate < 2 - h ? REGIME.LOW : REGIME.NORMAL;
if (previous === REGIME.LOW && raw === REGIME.NORMAL)
return rate > 2 + h ? REGIME.NORMAL : REGIME.LOW;
return raw; // LOW↔HIGH double jump — no damping
}
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
}
private static pe(summary: any): number | null {
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
}
private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger;
/** Last known rate regime — survives cache expiry so hysteresis has memory. */
private lastRegime: MarketContext['rateRegime'] | null = null;
constructor(
private readonly client: YahooFinanceClient,
{ logger }: BenchmarkProviderOptions = {},
) {
this.cache = this.loadDiskCache();
this.logger = logger ?? (console as unknown as Logger);
}
private loadDiskCache(): { data: MarketContext | null; expiresAt: number } {
try {
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
// Even an expired cache remembers the previous regime for hysteresis
this.lastRegime = file.data?.rateRegime ?? null;
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
} catch {
// corrupt or missing — ignore
}
return { data: null, expiresAt: 0 };
}
private saveDiskCache(data: MarketContext, expiresAt: number): void {
try {
writeFileSync(
BenchmarkProvider.CACHE_PATH,
JSON.stringify({ data, expiresAt } satisfies CacheFile, null, 2),
'utf8',
);
} catch {
// non-fatal — in-memory cache still works
}
}
async getMarketContext(): Promise<MarketContext> {
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
try {
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
this.client.fetchSummary('^GSPC'),
this.client.fetchSummary('^TNX'),
this.client.fetchSummary('^VIX'),
this.client.fetchSummary('SPY'),
this.client.fetchSummary('XLK'),
this.client.fetchSummary('XLRE'),
this.client.fetchSummary('LQD'),
]);
const riskFreeRate =
(sp500 as any)?.price?.regularMarketPrice !== undefined
? ((tn10y as any)?.price?.regularMarketPrice ?? 0)
: 0;
const sp500Price = (sp500 as any)?.price?.regularMarketPrice ?? 0;
const vixLevel = (vix as any)?.price?.regularMarketPrice ?? 0;
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
const lqdYield = ((lqd as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
const context: MarketContext = {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: BenchmarkProvider.resolveRateRegime(riskFreeRate, this.lastRegime),
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
benchmarks: {
marketPE: BenchmarkProvider.pe(spy) ?? 22,
techPE: BenchmarkProvider.pe(xlk) ?? 30,
reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
},
};
const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
this.cache = { data: context, expiresAt };
this.lastRegime = context.rateRegime;
this.saveDiskCache(context, expiresAt);
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
return this.cache.data ?? BenchmarkProvider.DEFAULTS;
}
}
}
@@ -1,108 +0,0 @@
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types/index';
export class CatalystAnalyst {
private static readonly NEWS_QUERIES = [
'stock market today',
'earnings report today',
'market news catalyst',
'federal reserve interest rates',
'stock upgrade downgrade analyst',
];
private static readonly MAX_STORIES = 20;
private static readonly TICKER_REGEX = /^[A-Z]{1,6}$/;
private client: YahooFinanceClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooFinanceClient();
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...');
const rawStories = await this.fetchNews();
if (!rawStories.length) {
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
return { tickers: [], tickerFrequency: {}, stories: [] };
}
const stories = rawStories.map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
const { tickers, tickerFrequency } = CatalystAnalyst.rankTickers(stories);
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
return { tickers, tickerFrequency, stories };
}
// Search by specific ticker for the /api/analyze endpoint.
async fetchStoriesForTickers(tickers: string[]): Promise<Story[]> {
const seen = new Map<string, YahooNewsItem>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
try {
const news = await this.client.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const item of news) {
if (!seen.has(item.title)) seen.set(item.title, item);
}
} catch {
/* skip tickers Yahoo can't resolve */
}
}),
);
return [...seen.values()].slice(0, 15).map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
}
private async fetchNews(): Promise<YahooNewsItem[]> {
const seen = new Map<string, YahooNewsItem>();
let successCount = 0;
for (const query of CatalystAnalyst.NEWS_QUERIES) {
try {
const news = await this.client.search(query, { newsCount: 8, quotesCount: 0 });
successCount++;
for (const s of news) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip failed query — tracked via successCount */
}
}
if (successCount === 0) return [];
return [...seen.values()].slice(0, CatalystAnalyst.MAX_STORIES);
}
static rankTickers(stories: Story[]): {
tickers: string[];
tickerFrequency: Record<string, number>;
} {
const freq: Record<string, number> = {};
for (const { tickers } of stories) {
for (const t of tickers) {
freq[t] = (freq[t] ?? 0) + 1;
}
}
const tickers = Object.keys(freq).sort((a, b) => freq[b] - freq[a]);
return { tickers, tickerFrequency: freq };
}
}
@@ -1,71 +0,0 @@
import type { CatalystResult, Logger } from '../types/index';
import { CatalystAnalyst } from './CatalystAnalyst';
export class CatalystCache {
private static readonly TTL_MS = 15 * 60 * 1000; // 15 minutes
private cached: CatalystResult | null = null;
private cachedAt: number | null = null;
private isRefreshing = false;
private analyst: CatalystAnalyst;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.analyst = new CatalystAnalyst({ logger });
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async get(): Promise<CatalystResult> {
const now = Date.now();
const isStale = !this.cachedAt || now - this.cachedAt > CatalystCache.TTL_MS;
if (!isStale && this.cached) {
return this.cached;
}
if (this.isRefreshing) {
// Return stale cache while refresh in progress
if (this.cached) {
return this.cached;
}
// If no cache exists yet, wait for refresh to complete
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this.isRefreshing && this.cached) {
clearInterval(checkInterval);
resolve(this.cached!);
}
}, 100);
// Timeout after 30s
setTimeout(() => clearInterval(checkInterval), 30000);
});
}
// Trigger refresh
this.isRefreshing = true;
try {
this.logger.write('📡 Refreshing catalyst cache...\n');
this.cached = await this.analyst.run();
this.cachedAt = now;
} catch (error) {
this.logger.write(`⚠️ Catalyst refresh failed: ${error}\n`);
// Return stale cache on error
if (!this.cached) {
this.cached = { tickers: [], tickerFrequency: {}, stories: [] };
}
} finally {
this.isRefreshing = false;
}
return this.cached;
}
isExpired(): boolean {
if (!this.cachedAt) return true;
return Date.now() - this.cachedAt > CatalystCache.TTL_MS;
}
clear(): void {
this.cached = null;
this.cachedAt = null;
}
}
@@ -1,60 +0,0 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { AnthropicClient } from '../adapters/AnthropicClient';
import type { Logger, LLMAnalysis, Story } from '../types/index';
export class LLMAnalyst {
private logger: Pick<Logger, 'log' | 'warn'>;
private client: AnthropicClient;
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
// eslint-disable-next-line no-console
this.logger = logger ?? { log: console.log, warn: console.warn };
this.client = new AnthropicClient();
}
get isAvailable(): boolean {
return this.client.isAvailable;
}
async analyze(
stories: Story[],
existingTickers: string[] = [],
tickerFrequency: Record<string, number> = {},
): Promise<LLMAnalysis | null> {
if (!this.client.isAvailable) {
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
return null;
}
if (!stories?.length) return null;
const headlines = stories
.slice(0, 15)
.map((s, i) => {
const tickers = s.tickers.length ? ` [${s.tickers.join(', ')}]` : '';
return `${i + 1}. ${s.title} (${s.source})${tickers}`;
})
.join('\n');
const freqLines = Object.entries(tickerFrequency)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([t, n]) => ` ${t}: ${n} ${n === 1 ? 'story' : 'stories'}`)
.join('\n');
const freqSection = freqLines ? `\nTicker mention frequency (ranked):\n${freqLines}\n` : '';
const userMessage = `Today's market news headlines:\n\n${headlines}\n${freqSection}\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
const PROMPT_PATH = join(process.cwd(), 'prompts', 'llm-analyst.md');
const SYSTEM_PROMPT = readFileSync(PROMPT_PATH, 'utf8');
const raw = await this.client.complete(SYSTEM_PROMPT, userMessage);
if (!raw) return null;
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned) as LLMAnalysis;
}
}
-118
View File
@@ -1,118 +0,0 @@
// ── Asset & screener domain types ─────────────────────────────────────────
import type { Sector } from '../config/constants';
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export interface ScoringRules {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
// ── ScoringConfig structural shapes (server/config/ScoringConfig.ts) ───────
export type GateSet = Record<string, number>;
export type WeightSet = Record<string, number>;
export type ThresholdSet = Record<string, number>;
export interface RuleBlock {
gates: GateSet;
weights: WeightSet;
thresholds: ThresholdSet;
}
export interface StockRules extends RuleBlock {
SECTOR_OVERRIDE: Partial<Record<Sector, Partial<RuleBlock>>>;
}
export interface ScoringRulesShape {
STOCK: StockRules;
ETF: RuleBlock;
BOND: RuleBlock;
}
export interface ScoreAudit {
passedGates: boolean;
breakdown?: Record<string, number>;
riskFlags?: string[] | null;
failures?: string[];
/** Data coverage: how many scoring factors had data vs. were defined. */
coverage?: { active: number; total: number };
}
/**
* Structured verdict tier — the machine-readable counterpart of `label`.
* Signal derivation and persistence MUST use this, never the label string.
* PASS = green (buy-quality), HOLD = yellow (neutral), REJECT = red (gate fail / negative).
*/
export type VerdictTier = 'PASS' | 'HOLD' | 'REJECT';
export interface ScoreResult {
label: string;
scoreSummary: string;
audit: ScoreAudit;
/** Machine-readable verdict tier. Use this for signal logic, not the label. */
tier: VerdictTier;
/** Numeric factor score. Null when gates failed (no score computed). */
score: number | null;
}
// AssetResult with runtime methods still attached — used at the HTTP boundary
// before class instances are serialised to plain objects for API responses.
export type LiveAssetResult = AssetResult & {
asset: AssetResult['asset'] & {
getDisplayMetrics: () => Record<string, unknown>;
metrics: unknown;
};
};
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: Record<string, string | number | null>;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
/**
* Turnaround-watch highlight: style is Turnaround AND the fundamental
* score improved vs the previous snapshot. A candidate flag, not a
* prediction — set by the screener controller, absent for ETFs/bonds.
*/
turnaroundWatch?: boolean;
}
/**
* Data-source health for one screen batch (PRODUCT.md P0.4).
* Degraded = a large share of stocks came back without core fundamentals,
* which usually means the upstream data source changed or is throttling —
* not that the companies are actually missing data.
*/
export interface DataHealth {
degraded: boolean;
stocksChecked: number;
nullPeRatio: number;
nullRoe: number;
message: string | null;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: import('./market.model.js').MarketContext;
/** Set by the screener controller on API responses, not by the engine. */
dataHealth?: DataHealth;
}
@@ -1,39 +0,0 @@
// ── Market calls domain types ──────────────────────────────────────────────
import type { Signal } from './asset.model';
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
// Input shape for MarketCallRepository.create()
export interface CreateCallInput {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
snapshot?: Record<string, TickerSnapshot>;
}
// Re-screened snapshot returned by GET /api/calls/:id for price comparison.
export interface SnapshotEntry {
price: number | null;
signal: string | null;
inflatedVerdict: string | null;
fundamentalVerdict: string | null;
pe: string | null;
roe: string | null;
fcf: string | null;
}
@@ -1,25 +0,0 @@
/**
* Database layer types.
* Defines interfaces for query building, auditing, and data access.
*/
export enum AuditAction {
READ = 'READ',
WRITE = 'WRITE',
DELETE = 'DELETE',
}
export interface AuditEntry {
timestamp: string; // ISO 8601
action: AuditAction;
sql: string;
params: unknown[];
durationMs: number;
rowsAffected?: number;
error?: string;
}
export interface DatabaseOptions {
audit?: import('../db/QueryAudit').QueryAudit;
logSlowQueries?: number; // milliseconds
}
@@ -1,30 +0,0 @@
/**
* Daily change digest types (PRODUCT.md P1.1).
*/
export interface DigestCatalyst {
headline: string;
catalyst: string | null; // 'earnings' | 'ma' | 'guidance' | 'regulatory' | 'macro' | null
source: string; // 'edgar' | 'prwire' | 'yahoo'
url: string;
publishedAt: string;
}
/** A ticker whose signal changed since the previous snapshot. */
export interface DigestChange {
ticker: string;
previousSignal: string;
newSignal: string;
previousDate: string; // day of the previous snapshot
scoreDelta: number | null; // fundamental score change, when both sides have one
price: number | null;
catalysts: DigestCatalyst[]; // recent stories for this ticker (the "why", maybe)
}
export interface DigestReport {
date: string; // YYYY-MM-DD the digest covers
changes: DigestChange[]; // signal flips, strongest-impact first
newTickers: string[]; // first-ever snapshot today (no baseline to diff)
maStories: DigestCatalyst[]; // all M&A-classified stories in the window, always surfaced
snapshotCount: number; // tickers snapshotted today
}
@@ -1,117 +0,0 @@
// ── Finance & analyst API response types ──────────────────────────────────
import type { Logger } from './logger.model';
export interface AffectedIndustry {
name: string;
reason: string;
}
export interface RelatedTicker {
ticker: string;
reason: string;
bias: 'BULL' | 'BEAR';
horizon: 'SHORT' | 'MEDIUM' | 'LONG';
sensitivity: 1 | 2 | 3 | 4 | 5;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: AffectedIndustry[];
relatedTickers: RelatedTicker[];
}
export interface CatalystStory {
title: string;
link: string;
publisher: string;
publishedAt: string;
relatedTickers: string[];
}
export interface CalendarEvent {
ticker: string;
type: 'earnings' | 'dividend' | 'exdividend';
date: string;
label?: string;
detail?: string | null;
isPast?: boolean;
epsEstimate?: number | null;
revEstimate?: number | null;
}
// ── Yahoo Finance client types ─────────────────────────────────────────────
// Raw shapes returned by the yahoo-finance2 search endpoint.
// Used by YahooFinanceClient, CatalystAnalyst, and AnalyzeController.
export interface YahooNewsItem {
title: string;
publisher: string;
link: string;
relatedTickers?: string[];
providerPublishTime?: string | number | Date;
}
export interface YahooSearchOptions {
newsCount?: number;
quotesCount?: number;
}
// Narrow interface over the yahoo-finance2 instance — only the methods this
// codebase actually calls. Keeps `any` contained to this one declaration.
export interface YahooFinanceLib {
quoteSummary(
ticker: string,
opts: { modules: string[] },
queryOpts?: { validateResult?: boolean },
): Promise<any>;
search(query: string, opts?: YahooSearchOptions): Promise<{ news?: YahooNewsItem[] }>;
chart(
ticker: string,
opts: { period1: Date | string; interval?: string },
queryOpts?: { validateResult?: boolean },
): Promise<any>;
}
/** One point of daily price history (ticker modal chart). */
export interface PricePoint {
date: string; // YYYY-MM-DD
close: number;
}
// ── SimpleFIN client types ─────────────────────────────────────────────────
export interface SimpleFINOptions {
logger?: Logger;
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
}
export interface SimpleFINTransaction {
id: string;
date: string;
amount: number;
description: string;
category: string;
}
export interface SimpleFINAccount {
id: string;
name: string;
currency: string;
balance: number;
balanceDate: string;
org: string;
type: string;
transactions: SimpleFINTransaction[];
}
export interface SimpleFINData {
accounts: SimpleFINAccount[];
errors: string[];
}
export interface GetAccountsOptions {
startDate?: number;
endDate?: number;
}
-84
View File
@@ -1,84 +0,0 @@
// ── Single source of truth for all domain types ───────────────────────────
// Import from specific model files for clarity, or from here for convenience.
export type {
Signal,
AssetType,
ScoreMode,
ScoringRules,
ScoreAudit,
ScoreResult,
VerdictTier,
DataHealth,
AssetResult,
LiveAssetResult,
ScreenerResult,
GateSet,
WeightSet,
ThresholdSet,
RuleBlock,
StockRules,
ScoringRulesShape,
} from './asset.model';
export type { RateRegime, VolatilityRegime, Benchmarks, MarketContext } from './market.model';
export type { HoldingType, PortfolioHolding, PortfolioAdvice, AdviceRow } from './portfolio.model';
export type { TickerSnapshot, MarketCall, SnapshotEntry, CreateCallInput } from './calls.model';
export type {
AffectedIndustry,
RelatedTicker,
LLMAnalysis,
CatalystStory,
CalendarEvent,
YahooNewsItem,
YahooSearchOptions,
YahooFinanceLib,
PricePoint,
SimpleFINOptions,
SimpleFINTransaction,
SimpleFINAccount,
SimpleFINData,
GetAccountsOptions,
} from './finance.model';
export type { Logger } from './logger.model';
export type {
AssetData,
StockData,
StockMetrics,
EtfData,
EtfMetrics,
BondData,
BondMetrics,
} from './models.model';
export type {
StoreData,
PortfolioData,
MarketCallRow,
HoldingRow,
SignalSnapshotRow,
} from './repositories.model';
export type {
NewsSource,
CatalystType,
NormalizedStory,
NewsArticleRow,
IngestStats,
} from './news.model';
export type { DigestCatalyst, DigestChange, DigestReport } from './digest.model';
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
export type {
BenchmarkProviderOptions,
InflatedOverrides,
PositionCalc,
AdviceOutput,
ErrorResult,
Headline,
Story,
CatalystResult,
MappedData,
CategoryBreakdown,
FinanceAnalysis,
RuleSet,
ScreenerEngineOptions,
} from './services.model';
export type { AuditEntry, DatabaseOptions } from './database.model';
export { AuditAction } from './database.model';
@@ -1,7 +0,0 @@
// ── Logger interface ───────────────────────────────────────────────────────
export interface Logger {
write: (msg: string) => void;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
@@ -1,21 +0,0 @@
// ── Market context types ───────────────────────────────────────────────────
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: RateRegime;
volatilityRegime: VolatilityRegime;
benchmarks: Benchmarks;
}
-125
View File
@@ -1,125 +0,0 @@
// ── Model data input and metrics shapes ────────────────────────────────────
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
// ── Asset base ─────────────────────────────────────────────────────────────
export interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
// ── Stock ──────────────────────────────────────────────────────────────────
export interface StockData {
ticker?: string;
currentPrice?: number;
assetProfile?: { industry?: string; sector?: string };
peRatio?: number | null;
pegRatio?: number | null;
priceToBook?: number | null;
grossMargin?: number | null;
netProfitMargin?: number | null;
operatingMargin?: number | null;
returnOnEquity?: number | null;
revenueGrowth?: number | null;
earningsGrowth?: number | null;
debtToEquity?: number | null;
quickRatio?: number | null;
fcfYield?: number | null;
pFFO?: number | null;
dividendYield?: number | null;
beta?: number | null;
dayChangePct?: number | null;
week52High?: number | null;
week52Low?: number | null;
week52Change?: number | null;
week52FromHigh?: number | null;
week52FromLow?: number | null;
marketCap?: number | null;
analystRating?: number | null;
analystTargetPrice?: number | null;
analystUpside?: number | null;
numberOfAnalysts?: number | null;
dcfIntrinsicValue?: number | null;
dcfMarginOfSafety?: number | null;
[key: string]: unknown;
}
export interface StockMetrics {
sector: Sector;
capCategory: CapCategory;
growthCategory: GrowthCategory;
peRatio: number | null;
pegRatio: number | null;
priceToBook: number | null;
grossMargin: number | null;
netProfitMargin: number | null;
operatingMargin: number | null;
returnOnEquity: number | null;
revenueGrowth: number | null;
earningsGrowth: number | null;
debtToEquity: number | null;
quickRatio: number | null;
fcfYield: number | null;
pFFO: number | null;
dividendYield: number | null;
beta: number | null;
dayChangePct: number | null;
week52High: number | null;
week52Low: number | null;
week52Change: number | null;
week52FromHigh: number | null;
week52FromLow: number | null;
marketCap: number | null;
analystRating: number | null;
analystTargetPrice: number | null;
analystUpside: number | null;
numberOfAnalysts: number | null;
dcfIntrinsicValue: number | null;
dcfMarginOfSafety: number | null;
currentPrice: number;
}
// ── ETF ────────────────────────────────────────────────────────────────────
export interface EtfData {
ticker?: string;
currentPrice?: number;
expenseRatio?: string | number | null;
totalAssets?: string | number | null;
yield?: string | number | null;
volume?: string | number | null;
fiveYearReturn?: string | number | null;
[key: string]: unknown;
}
// Missing Yahoo data is preserved as null so EtfScorer skips the
// corresponding gate instead of auto-failing on a coerced 0.
export interface EtfMetrics {
expenseRatio: number | null;
totalAssets: number | null;
yield: number | null;
volume: number | null;
fiveYearReturn: number | null;
}
// ── Bond ───────────────────────────────────────────────────────────────────
export interface BondData {
ticker?: string;
currentPrice?: number;
creditRating?: string;
yieldToMaturity?: string | number;
duration?: string | number;
[key: string]: unknown;
}
export interface BondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
-43
View File
@@ -1,43 +0,0 @@
/**
* News pipeline types (FREE-DATA-STACK.md).
*/
export type NewsSource = 'edgar' | 'prwire' | 'yahoo';
export type CatalystType = 'earnings' | 'ma' | 'guidance' | 'regulatory' | 'macro';
/** One story after a poller has normalized it — the only shape the pipeline accepts. */
export interface NormalizedStory {
tickers: string[];
headline: string;
body?: string | null;
source: NewsSource;
url: string;
publishedAt: string; // ISO timestamp
/** Poller-supplied classification (e.g. EDGAR form type); overrides keyword classify. */
catalystHint?: CatalystType | null;
}
/** Raw row from news_articles (snake_case, as stored). */
export interface NewsArticleRow {
url_hash: string;
title_hash: string;
ticker_list: string; // JSON array stringified
headline: string;
body: string | null;
source: string;
catalyst: string | null;
url: string;
published_at: string;
created_at: string;
}
/** What one ingest run did — logged by pollers and bin/poll-news. */
export interface IngestStats {
fetched: number;
stored: number;
droppedNoUniverseTicker: number;
droppedNoise: number;
droppedDuplicate: number;
droppedCapped: number;
}
@@ -1,40 +0,0 @@
// ── Portfolio domain types ─────────────────────────────────────────────────
import type { Signal } from './asset.model';
export type HoldingType = 'stock' | 'etf' | 'bond' | 'crypto';
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: HoldingType;
}
export interface PortfolioAdvice {
ticker: string;
action: 'hold' | 'sell' | 'add' | 'watch';
reason: string;
signal: Signal | null;
currentPrice: number | null;
gainLossPct: number | null;
}
// Public return shape of PortfolioAdvisor.advise() — one row per holding.
export interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: number | null;
marketValue: string | null;
totalCost: string;
gainLossPct: string | null;
signal: Signal | '—';
inflated: string;
fundamental: string;
advice: string;
reason: string;
}
@@ -1,70 +0,0 @@
/**
* Repository model types.
*
* Defines:
* - Row shapes: how data comes FROM the database (snake_case, as-is)
* - Persistence shapes: collection types returned by repositories
*/
import type { MarketCall, PortfolioHolding } from './index';
// ── Database Row Shapes (internal to repositories) ──────────────────────────
/**
* Raw database row from market_calls table.
* Uses snake_case columns exactly as they exist in SQLite.
*/
export interface MarketCallRow {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string; // JSON array stringified
snapshot: string; // JSON object stringified
created_at: string;
}
/**
* Raw database row from holdings table.
* Uses snake_case columns exactly as they exist in SQLite.
*/
export interface HoldingRow {
ticker: string;
shares: number;
cost_basis: number;
type: string;
source: string;
}
/**
* Raw database row from signal_snapshots table (P0.1 signal track record).
*/
export interface SignalSnapshotRow {
ticker: string;
snapshot_date: string;
asset_type: string;
price: number | null;
signal: string;
fundamental_tier: string;
fundamental_score: number | null;
fundamental_label: string | null;
inflated_tier: string;
inflated_score: number | null;
inflated_label: string | null;
coverage_active: number | null;
coverage_total: number | null;
risk_flags: string | null; // JSON array stringified
rate_regime: string | null;
created_at: string;
}
// ── Persistence Shapes (returned by repositories) ───────────────────────────
export interface StoreData {
calls: (MarketCall & { createdAt: string })[];
}
export interface PortfolioData {
holdings: PortfolioHolding[];
}
-54
View File
@@ -1,54 +0,0 @@
// ── Fastify request body schemas ──────────────────────────────────────────
// Fastify validates incoming request bodies against these JSON Schemas before
// the handler runs. If validation fails it replies 400 automatically.
// One schema per route that has a body; GET routes need no schema.
import type { FastifySchema } from 'fastify';
export const screenSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const analyzeSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const holdingSchema: FastifySchema = {
body: {
type: 'object',
required: ['ticker', 'shares'],
properties: {
ticker: { type: 'string', minLength: 1, maxLength: 10 },
shares: { type: 'number', exclusiveMinimum: 0 },
costBasis: { type: 'number', minimum: 0 },
type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] },
source: { type: 'string' },
},
},
};
export const callSchema: FastifySchema = {
body: {
type: 'object',
required: ['title', 'quarter', 'thesis', 'tickers'],
properties: {
title: { type: 'string', minLength: 3 },
quarter: { type: 'string', minLength: 2 },
date: { type: 'string' },
thesis: { type: 'string', minLength: 10 },
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 },
},
},
};
@@ -1,33 +0,0 @@
// ── Scorer internal metric shapes ──────────────────────────────────────────
export type NumVal = number | null;
export interface SanitizedMetrics {
debtToEquity: NumVal;
quickRatio: NumVal;
peRatio: NumVal;
pegRatio: NumVal;
priceToBook: NumVal;
netProfitMargin: NumVal;
operatingMargin: NumVal;
returnOnEquity: NumVal;
revenueGrowth: NumVal;
fcfYield: NumVal;
dividendYield: NumVal;
pFFO: NumVal;
beta: NumVal;
week52Position: NumVal;
// Expert features
week52Change: NumVal; // % total return over last 52 weeks
week52FromHigh: NumVal; // % below 52-week high (negative = down from high)
analystRating: NumVal; // Yahoo scale: 1=Strong Buy … 5=Strong Sell
analystUpside: NumVal; // % price upside to consensus analyst target
dcfMarginOfSafety: NumVal; // % undervaluation vs DCF intrinsic value
}
export interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}

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