Compare commits
2 Commits
main
..
3513024fc6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3513024fc6 | |||
| cd74497de6 |
+2
-13
@@ -2,21 +2,10 @@
|
||||
#
|
||||
# FIRST RUN: paste your Setup Token from https://beta-bridge.simplefin.org
|
||||
# (Settings → Connect an app → copy the token)
|
||||
## Get your key at: https://console.anthropic.com
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# do not give below details if simplefin is not setup.
|
||||
SIMPLEFIN_SETUP_TOKEN=
|
||||
#
|
||||
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3Jn...
|
||||
#
|
||||
# AFTER FIRST RUN: the Access URL is written here automatically.
|
||||
# Remove SIMPLEFIN_SETUP_TOKEN once this appears.
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -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
@@ -4,11 +4,6 @@ ui/node_modules
|
||||
# Sensitive data — never commit
|
||||
portfolio.json
|
||||
market-calls.json
|
||||
portfolio.json.migrated
|
||||
market-calls.json.migrated
|
||||
market-screener.db
|
||||
market-screener.db-shm
|
||||
market-screener.db-wal
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -16,10 +11,6 @@ market-screener.db-wal
|
||||
ui/.svelte-kit
|
||||
ui/build
|
||||
|
||||
# Runtime cache
|
||||
.benchmark-cache.json
|
||||
|
||||
# Documentation (except CLAUDE.md)
|
||||
*.md
|
||||
!PHASES.md
|
||||
!CLAUDE.md
|
||||
# Reports
|
||||
screener-report.html
|
||||
finance-report.html
|
||||
Regular → Executable
+1
-1
@@ -1,2 +1,2 @@
|
||||
# Lint and auto-fix staged files only (fast)
|
||||
npx lint-staged
|
||||
npm test
|
||||
|
||||
Regular → Executable
-1
@@ -1,2 +1 @@
|
||||
# Run full test suite before push
|
||||
npm test
|
||||
|
||||
-49
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -1,989 +0,0 @@
|
||||
# PHASES.md
|
||||
|
||||
---
|
||||
|
||||
## 📍 Roadmap Status & Realignment — June 2026
|
||||
|
||||
Cross-reference: **PRODUCT.md** (P0–P3 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, 1D–5Y 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) | $50–100 | Most cached; 90% cost reduction |
|
||||
| OpenAI API (fallback, optional) | $50 | Only if GPT-4 fallback added |
|
||||
| Alpaca/Interactive Brokers | $30–100 | Depends on which feed |
|
||||
| BullMQ (Redis queue, if scaled) | $0–30 | Free if self-hosted |
|
||||
| **Total** | **~$330–450/month** | Scales well (no per-user seat cost) |
|
||||
|
||||
---
|
||||
|
||||
## Final Architecture Summary
|
||||
|
||||
| Layer | Tech | Status |
|
||||
|-------|------|--------|
|
||||
| **Auth** | JWT + RBAC | Phase 11 (weeks 1-2) |
|
||||
| **Data** | SQLite → Postgres if 1000+ users | Phase 11 |
|
||||
| **News** | Polygon.io webhooks | Phase 12 (weeks 3-4) |
|
||||
| **LLM** | Anthropic + OpenAI w/ prompt caching | Phase 13-14 (weeks 5-6) |
|
||||
| **Trading** | Real-time price monitoring + Discord | Phase 14 (weeks 7-10) |
|
||||
| **Tracking** | Trade journal + stats | Phase 15 (weeks 11-12) |
|
||||
| **UI** | Svelte 5 + Phase 10 structure | Phase 10 (weeks 1-5 parallel) |
|
||||
|
||||
**Total time to "trading ready":** 12-16 weeks solo, 8 weeks with 1-2 junior devs.
|
||||
|
||||
**Go-live target:** Q3 2026 (July–September).
|
||||
|
||||
---
|
||||
|
||||
## Postgres Migration Path (When Needed)
|
||||
|
||||
If you grow to 10+ active traders:
|
||||
|
||||
1. Create Postgres RDS instance (AWS: ~$15/mo, db.t3.micro)
|
||||
2. Update connection string to point to Postgres
|
||||
3. Run schema dump SQLite → Postgres
|
||||
4. Test on staging first
|
||||
5. Blue-green deploy: run both DBs in parallel for 1 day, switch, keep SQLite as backup
|
||||
|
||||
**Time:** 2–4 hours. No code changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
**Q: How many traders can this system handle?**
|
||||
|
||||
A:
|
||||
- **10–50 traders:** Single instance. Costs ~$450/mo.
|
||||
- **50–500 traders:** Add Postgres + Redis queue. Costs ~$1000/mo.
|
||||
- **500+ traders:** Add Kubernetes + load balancing. Costs ~$5000+/mo.
|
||||
|
||||
**Q: What if Polygon.io goes down?**
|
||||
|
||||
A: Have fallback plan:
|
||||
1. Switch to Finnhub webhooks (similar API, different provider)
|
||||
2. Or fall back to polling (5s instead of real-time, less expensive)
|
||||
3. Add circuit breaker: if Polygon fails for >5 min, automatically switch
|
||||
|
||||
**Q: Can I trade with real money?**
|
||||
|
||||
A: Yes, but:
|
||||
1. Start with **paper trading** (Alpaca's paper account, no real money)
|
||||
2. Test for 2+ weeks on real market conditions
|
||||
3. Once you hit 55%+ win rate on paper, go live with small position sizes
|
||||
4. Scale up gradually (1% → 5% → 10%)
|
||||
5. Always have manual kill-switch
|
||||
|
||||
**Q: Should I use local LLM training?**
|
||||
|
||||
A: Not yet. Only consider if:
|
||||
- You have 6+ months of clean trade data
|
||||
- Your LLM bill is >$1000/mo
|
||||
- You have $20K+ to spend on GPU infrastructure
|
||||
|
||||
For now, optimize prompts instead. Good prompt beats fine-tuned model.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -1,649 +1,225 @@
|
||||
# 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
|
||||
|
||||
- [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
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install server dependencies
|
||||
# API + Dashboard (recommended)
|
||||
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)
|
||||
npm run ui:install
|
||||
```
|
||||
|
||||
### 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/...
|
||||
# CLI only
|
||||
npm start # screen today's news catalyst tickers → screener-report.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 ui:install` | Install UI dependencies (first time / after `git pull`) |
|
||||
| `npm test` | Run all unit + integration tests |
|
||||
| `npm run test:watch` | Watch mode — re-run on file changes |
|
||||
| `npm run typecheck` | TypeScript type check without emitting |
|
||||
| `npm start` | CLI: fetch today's market news, extract tickers, screen them |
|
||||
| `npm start -- watch` | CLI: screen the default watchlist |
|
||||
| `npm start -- AAPL MSFT VOO` | CLI: screen specific tickers |
|
||||
| `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: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
|
||||
npm test
|
||||
Every asset is scored **twice** under different rule sets:
|
||||
|
||||
### 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 (2–5%) |
|
||||
| **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
|
||||
2. **Lints & fixes** staged files with ESLint
|
||||
3. **Runs tests** to catch errors early
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
**Phase 9: Domain-Driven Architecture** (completed)
|
||||
|
||||
```
|
||||
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/
|
||||
app.ts Fastify app factory — wires DI, rate limiting, auth hook
|
||||
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
|
||||
scripts/
|
||||
summary-reporter.js Custom node:test reporter (silent pass, shows failures + summary)
|
||||
|
||||
ui/
|
||||
src/
|
||||
routes/ SvelteKit pages: /, /portfolio, /calls, /safe-buys
|
||||
lib/
|
||||
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
|
||||
src/
|
||||
config/
|
||||
ScoringConfig.js All gates, weights, thresholds (single source of truth)
|
||||
constants.js Shared enums: SIGNAL, ASSET_TYPE, SECTOR, SCORE_MODE, REGIME
|
||||
|
||||
tests/ Unit + integration tests (9 files, 114 test cases)
|
||||
Controllers, services, scorers fully covered
|
||||
market/
|
||||
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)
|
||||
market-calls.json Persisted market thesis calls (gitignored)
|
||||
.benchmark-cache.json Benchmark data cache — survives server restart (gitignored)
|
||||
screener/
|
||||
ScreenerEngine.js Orchestrates fetch → score × 2.
|
||||
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.
|
||||
|
||||
#### Market context strip
|
||||
|
||||
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.
|
||||
# Optional server config
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
CLIENT_ORIGIN=http://localhost:5173 # CORS allowed origin for SvelteKit UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Portfolio tab
|
||||
## Testing
|
||||
|
||||
Track your holdings and get hold/sell/add advice cross-referenced with screener signals.
|
||||
|
||||
**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
|
||||
61 unit tests, no external test framework:
|
||||
|
||||
```bash
|
||||
# Navigate to the project root
|
||||
cd market-screener
|
||||
|
||||
# Import the Postman collection
|
||||
bru import api_collections/market-screener.postman_collection.json -o api_collections/market-screener
|
||||
|
||||
# Output: Collection imported to api_collections/market-screener/
|
||||
npm test # summary output: "✅ 61 tests: 61 passed (0.02s)"
|
||||
npm run test:watch # verbose spec output for development
|
||||
```
|
||||
|
||||
#### Method 3: Convert Postman to Bruno Format (Manual)
|
||||
|
||||
If you prefer to manually convert the collection:
|
||||
|
||||
```bash
|
||||
# Install conversion dependencies (if needed)
|
||||
pip install requests
|
||||
|
||||
# Run the conversion script
|
||||
python3 api_collections/convert_postman_to_bruno.py \
|
||||
api_collections/market-screener.postman_collection.json \
|
||||
api_collections/market-screener
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
#### Via Bruno GUI
|
||||
|
||||
1. **Open the imported collection** in Bruno
|
||||
2. **Set the `baseUrl` variable** (default: `http://localhost:3000`)
|
||||
3. **Click the Play button** to run all tests
|
||||
4. **View results** for each request in the UI
|
||||
|
||||
#### Via Bruno CLI (brucli)
|
||||
|
||||
```bash
|
||||
# Navigate to the collection directory
|
||||
cd api_collections/market-screener
|
||||
|
||||
# Run all tests in the collection
|
||||
bru run
|
||||
|
||||
# Run with specific environment
|
||||
bru run --env local
|
||||
|
||||
# Run with output format
|
||||
bru run --output json > test-results.json
|
||||
|
||||
# Run specific test file
|
||||
bru run "Screener/Screen - Mixed.bru"
|
||||
```
|
||||
|
||||
### Collection Structure
|
||||
|
||||
After import, you'll have:
|
||||
|
||||
```
|
||||
api_collections/market-screener/
|
||||
├── bruno.json # Collection metadata
|
||||
├── Health/
|
||||
│ └── Health Check.bru
|
||||
├── Screener/
|
||||
│ ├── Screen - Mixed.bru
|
||||
│ ├── Screen - Tech Stocks.bru
|
||||
│ ├── Screen - REIT.bru
|
||||
│ ├── Validation empty tickers.bru
|
||||
│ ├── Validation 50 plus tickers.bru
|
||||
│ └── Get Catalysts.bru
|
||||
├── Market Context/
|
||||
│ └── Get Market Context.bru
|
||||
├── Portfolio/
|
||||
│ ├── Add Holding AAPL.bru
|
||||
│ ├── Add Holding VOO.bru
|
||||
│ ├── Add Holding BTC-USD.bru
|
||||
│ ├── Add Holding Validation.bru
|
||||
│ ├── Get Portfolio.bru
|
||||
│ ├── Remove Holding AAPL.bru
|
||||
│ └── Remove Holding Non-existent.bru
|
||||
├── Market Calls/
|
||||
│ ├── List Calls.bru
|
||||
│ ├── Create Market Call.bru
|
||||
│ ├── Get Call by ID.bru
|
||||
│ ├── Get Call Non-existent.bru
|
||||
│ ├── Get Earnings Calendar.bru
|
||||
│ ├── Get Calendar Specific Tickers.bru
|
||||
│ ├── Create Call Validation.bru
|
||||
│ ├── Delete Call.bru
|
||||
│ └── Delete Call Already Deleted.bru
|
||||
└── LLM Analysis/
|
||||
├── Analyze Tickers.bru
|
||||
└── Analyze Validation.bru
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
#### Setting Variables
|
||||
|
||||
Variables are stored in `bruno.json` and can be overridden per request:
|
||||
|
||||
**Default variables:**
|
||||
- `baseUrl`: `http://localhost:3000`
|
||||
- `callId`: (auto-populated by Create Market Call request)
|
||||
|
||||
To change variables in the GUI:
|
||||
1. Right-click collection → **Settings**
|
||||
2. Click **Variables** tab
|
||||
3. Edit `baseUrl` or other variables
|
||||
4. Click **Save**
|
||||
|
||||
#### Environment Files
|
||||
|
||||
Create a `.env.bruno` file in the collection directory for local overrides:
|
||||
|
||||
```env
|
||||
baseUrl=http://localhost:3000
|
||||
apiKey=your-secret-key
|
||||
```
|
||||
|
||||
### Common Workflows
|
||||
|
||||
#### 1. Test the full API flow
|
||||
|
||||
```bash
|
||||
cd api_collections/market-screener
|
||||
bru run
|
||||
```
|
||||
|
||||
#### 2. Test just the Screener endpoints
|
||||
|
||||
```bash
|
||||
cd api_collections/market-screener
|
||||
bru run "Screener"
|
||||
```
|
||||
|
||||
#### 3. Test and save results
|
||||
|
||||
```bash
|
||||
cd api_collections/market-screener
|
||||
bru run --output json > test-results-$(date +%Y%m%d).json
|
||||
```
|
||||
|
||||
#### 4. Continuous testing (while developing)
|
||||
|
||||
```bash
|
||||
# Terminal 1: Run the API server
|
||||
npm run dev
|
||||
|
||||
# Terminal 2: Watch and run tests every 5 seconds
|
||||
cd api_collections/market-screener
|
||||
watch -n 5 'bru run'
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### "You can run only at the root of a collection" error
|
||||
|
||||
Make sure you're in the correct directory:
|
||||
|
||||
```bash
|
||||
# ❌ Wrong — project root
|
||||
cd market-screener
|
||||
bru run
|
||||
|
||||
# ✅ Correct — collection root
|
||||
cd api_collections/market-screener
|
||||
bru run
|
||||
```
|
||||
|
||||
#### Variables not found
|
||||
|
||||
Verify variable names in `bruno.json`:
|
||||
|
||||
```bash
|
||||
# Check variables
|
||||
cat api_collections/market-screener/bruno.json | grep -A 10 "vars"
|
||||
```
|
||||
|
||||
#### Tests failing with "undefined" errors
|
||||
|
||||
Common causes:
|
||||
- Variable name mismatch (case-sensitive)
|
||||
- Server not running on the expected port
|
||||
- Port conflict (try `lsof -i :3000` to check)
|
||||
|
||||
### Postman vs Bruno
|
||||
|
||||
| Feature | Postman | Bruno |
|
||||
|---------|---------|-------|
|
||||
| **Download Size** | ~380MB | ~50MB |
|
||||
| **Collection Format** | Single JSON blob | Plain text `.bru` files |
|
||||
| **Git-Friendly** | ❌ Binary | ✅ Text-based, diffable |
|
||||
| **API Response** | UI-only | CLI + GUI |
|
||||
| **Cost** | Free tier + paid | ✅ Completely free |
|
||||
| **IDE Integration** | None | Can edit `.bru` files directly |
|
||||
|
||||
### References
|
||||
|
||||
- **Bruno Docs**: [docs.usebruno.com](https://docs.usebruno.com)
|
||||
- **Bruno GitHub**: [github.com/usebruno/bruno](https://github.com/usebruno/bruno)
|
||||
- **Postman Collection**: `api_collections/market-screener.postman_collection.json`
|
||||
Pre-commit: Prettier (auto-format staged files) + full test suite.
|
||||
Pre-push: full test suite.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,5 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
import { buildApp } from '../server/app';
|
||||
import { buildApp } from '../src/server/app.js';
|
||||
|
||||
const PORT = process.env.PORT ?? 3000;
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
@@ -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 */
|
||||
@@ -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:
|
||||
@@ -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 & 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
@@ -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 & 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 & 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 & 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 & 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 & 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 & 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 & 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>
|
||||
@@ -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;
|
||||
# }
|
||||
# }
|
||||
Generated
+1
-4134
File diff suppressed because it is too large
Load Diff
+10
-29
@@ -3,52 +3,33 @@
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx bin/server.ts",
|
||||
"dev": "concurrently -n api,ui -c cyan,magenta \"tsx bin/server.ts\" \"npm run dev --prefix ui\"",
|
||||
"start": "node bin/screen.js",
|
||||
"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",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test --test-reporter=spec tests/*.test.ts",
|
||||
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts",
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"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\"",
|
||||
"finance": "node bin/finance.js",
|
||||
"test": "node --test --test-reporter=./scripts/summary-reporter.js tests/*.test.js",
|
||||
"test:watch": "node --test --watch --test-reporter=spec tests/*.test.js",
|
||||
"format": "prettier --write \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"",
|
||||
"format:check": "prettier --check \"src/**/*.js\" \"bin/**/*.js\" \"tests/**/*.js\"",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"{server,bin,tests}/**/*.{ts,js}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"ui/src/**/*.ts": [
|
||||
"*.js": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.100.1",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/rate-limit": "^10.2.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"fastify": "^5.8.5",
|
||||
"yahoo-finance2": "^3.15.2"
|
||||
},
|
||||
"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",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"husky": "^9.0.0",
|
||||
"lint-staged": "^15.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "holdings": [] }
|
||||
@@ -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';
|
||||
@@ -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 2–3 sentence market summary capturing the dominant theme and tone.
|
||||
2. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH.
|
||||
3. Identify up to 4 industries secondarily affected — not directly mentioned, but impacted via contagion, supply chain, regulation, or macro.
|
||||
4. Suggest up to 6 tickers worth screening. For each one provide:
|
||||
- **ticker** — must have ADV > 500k; exclude generic analyst upgrades with no valuation catalyst
|
||||
- **reason** — one mechanistic sentence (revenue/cost/supply-chain logic, not sentiment)
|
||||
- **bias** — BULL or BEAR
|
||||
- **horizon** — SHORT (1–5 days) | MEDIUM (1–4 weeks) | LONG (1+ quarter)
|
||||
- **sensitivity** — how exposed this ticker is to the catalyst:
|
||||
- 5 = direct revenue impact > 20% of annual sales
|
||||
- 4 = direct revenue impact 10–20%
|
||||
- 3 = indirect exposure via cost structure or supply chain
|
||||
- 2 = sector correlation, limited direct exposure
|
||||
- 1 = macro tailwind/headwind only
|
||||
|
||||
Constraints:
|
||||
- Prioritise tickers that appear multiple times in the frequency list — repeated mentions signal broader market awareness.
|
||||
- For BEAR picks: require at least one of — elevated short interest, negative earnings revision trend, or sector rotation evidence.
|
||||
- Do not suggest tickers already in the "already identified" list unless the story adds a new directional angle.
|
||||
- Prefer ripple-effect tickers (supply chain partners, direct competitors, sector peers) over the primary ticker already in the news — those are where the alpha is.
|
||||
|
||||
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
|
||||
```json
|
||||
{
|
||||
"summary": "string",
|
||||
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
|
||||
"affectedIndustries": [
|
||||
{ "name": "string", "reason": "string" }
|
||||
],
|
||||
"relatedTickers": [
|
||||
{
|
||||
"ticker": "string",
|
||||
"reason": "string",
|
||||
"bias": "BULL" | "BEAR",
|
||||
"horizon": "SHORT" | "MEDIUM" | "LONG",
|
||||
"sensitivity": 1 | 2 | 3 | 4 | 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Calls domain — market call tracking and calendar
|
||||
export { CallsController } from './calls.controller';
|
||||
export { CalendarService } from './CalendarService';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Finance domain — portfolio metrics and reporting
|
||||
export { FinanceController } from './finance.controller';
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/'/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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Portfolio domain — holdings management and advice
|
||||
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 5–15%
|
||||
STABLE: 'Stable', // low growth, modest or no dividend
|
||||
VALUE: 'Value', // low growth + dividend yield ≥ 3%
|
||||
TURNAROUND: 'Turnaround', // negative earnings, positive revenue
|
||||
DECLINING: 'Declining', // negative revenue growth
|
||||
} as const;
|
||||
|
||||
export type GrowthCategory = (typeof GROWTH_CATEGORY)[keyof typeof GROWTH_CATEGORY];
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 §2–5 — 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)`,
|
||||
];
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, '%'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 1–5 scale to a readable string
|
||||
const analystLabel = (rating: number | null): string | null => {
|
||||
if (rating == null) return null;
|
||||
if (rating <= 1.5) return 'Strong Buy';
|
||||
if (rating <= 2.5) return 'Buy';
|
||||
if (rating <= 3.5) return 'Hold';
|
||||
if (rating <= 4.5) return 'Sell';
|
||||
return 'Strong Sell';
|
||||
};
|
||||
|
||||
const display: Record<string, string | null> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 (2–5%), VIX 20 ⇒ NORMAL (15–25).
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user