Compare commits
39 Commits
19fc052d14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aadbef308 | |||
| 6cb4c93a0e | |||
| 65907a9b8d | |||
| bf2a85b5c4 | |||
| f0c794f0c0 | |||
| 5655cde6bf | |||
| fbadd7fb6e | |||
| 7bc242911e | |||
| ad1c3fe3c9 | |||
| a289cda704 | |||
| 0dac8128bd | |||
| 09f2444157 | |||
| ca449b4300 | |||
| 8bdcb422aa | |||
| 7f2108129a | |||
| 7cb1b03fd5 | |||
| 5b32bd7a04 | |||
| 2b785aa861 | |||
| de8427d578 | |||
| e4ab9ce45b | |||
| f5a338fc4e | |||
| 96e48aebe5 | |||
| 640004a921 | |||
| 6878bd1f2b | |||
| 6356415789 | |||
| b9c5dbb7c7 | |||
| 76c2a671f4 | |||
| 2e7860637e | |||
| fbd166b1b7 | |||
| cea0ef4396 | |||
| 9fb3808eb5 | |||
| bd373ab69b | |||
| 617703e91d | |||
| 73db0fe7a8 | |||
| 69d13c3dbe | |||
| 16bd95aa85 | |||
| 104ed81b9f | |||
| 0a0a368b87 | |||
| b75e8bda72 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "market-screener-ui",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "ui"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ── SimpleFIN personal finance ───────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 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=
|
||||||
|
#
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"es2020": true
|
||||||
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2020,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"plugin:import/typescript"
|
||||||
|
],
|
||||||
|
"ignorePatterns": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"ui"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"prefer-arrow-callback": "warn",
|
||||||
|
"no-console": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"allow": [
|
||||||
|
"warn",
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"no-undef": "off",
|
||||||
|
"import/order": "off",
|
||||||
|
"import/no-unresolved": "off"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"bin/**/*.ts",
|
||||||
|
"tests/**/*.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-console": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"server/types/**/*.ts",
|
||||||
|
"server/schemas/**/*.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+24
@@ -1 +1,25 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
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.*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
ui/.svelte-kit
|
||||||
|
ui/build
|
||||||
|
|
||||||
|
# Runtime cache
|
||||||
|
.benchmark-cache.json
|
||||||
|
|
||||||
|
# Documentation (except CLAUDE.md)
|
||||||
|
*.md
|
||||||
|
!PHASES.md
|
||||||
|
!CLAUDE.md
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Lint and auto-fix staged files only (fast)
|
||||||
|
npx lint-staged
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Run full test suite before push
|
||||||
|
npm test
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"trailingComma": "all",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
# ── 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"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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"]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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"]
|
||||||
+150
-31
@@ -1,46 +1,165 @@
|
|||||||
# Market News Analysis & Catalyst Screener
|
# Market News Analysis & Catalyst Screener
|
||||||
|
|
||||||
## 1. High-Alpha Catalyst Analysis Prompt
|
A structured workflow for converting daily news into actionable trade ideas, validated by the screener's fundamental and market-adjusted analysis.
|
||||||
|
|
||||||
Copy and paste this into your LLM daily to filter noise into actionable data:
|
|
||||||
|
|
||||||
> **Role:** You are a Quant-driven Financial Analyst specialized in Catalyst-Driven Trading.
|
|
||||||
>
|
|
||||||
> **Task:** Analyze today’s top 3 high-impact news stories and map them to the specific assets that are structurally forced to respond.
|
|
||||||
>
|
|
||||||
> **Instructions:**
|
|
||||||
>
|
|
||||||
> 1. **Identify the Catalyst:** Select one Macro event, one Sector-wide (regulatory/supply-chain) shift, and one Company-specific surprise.
|
|
||||||
> 2. **Correlation Logic:** For each catalyst, identify:
|
|
||||||
> - **Primary Target:** The ticker directly mentioned.
|
|
||||||
> - **Ripple-Effect Target:** A ticker in the supply chain or direct competitor (The "Alpha" play).
|
|
||||||
> 3. **Quantitative Impact Matrix:** Produce a table with: `Catalyst` | `Tickers (Primary/Ripple)` | `Bias` | `Sensitivity (1-5)` | `Mechanics`.
|
|
||||||
> 4. **Constraint:** Exclude "Market Sentiment" or generic analyst upgrades. Only include events with a measurable impact on valuation or supply chain fundamentals.
|
|
||||||
> 5. **Liquidity Filter:** Do not suggest tickers with daily volume below 500k.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Quantitative Impact Matrix (Template)
|
## 1. How This Fits Into the Screener Workflow
|
||||||
|
|
||||||
Use this table to log the results from the prompt above:
|
```
|
||||||
|
Daily News
|
||||||
|
↓
|
||||||
|
Catalyst Prompt (Section 2) → Generates tickers + bias + horizon
|
||||||
|
↓
|
||||||
|
market_screener (npm start) → Fundamental + Market-Adjusted scoring
|
||||||
|
↓
|
||||||
|
Validation (Section 4) → Is the fundamental thesis intact?
|
||||||
|
↓
|
||||||
|
Decision → Act / Monitor / Discard
|
||||||
|
```
|
||||||
|
|
||||||
| Catalyst | Tickers (Primary / Ripple) | Bias | Sensitivity (1-5) | Mechanics |
|
**Key principle:** The screener doesn't tell you *when* to trade — catalysts do that. The screener tells you whether the *underlying business* supports the trade or whether you're purely momentum-chasing.
|
||||||
| :----------- | :------------------------- | :-------- | :---------------- | :------------------------ |
|
|
||||||
| [Event Name] | [Ticker1] / [Ticker2] | Bull/Bear | [1-5] | [Concise financial logic] |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Implementation Workflow
|
## 2. Catalyst Analysis Prompt
|
||||||
|
|
||||||
1. **Fetch:** Run the prompt above using live news sources (e.g., Bloomberg, Nasdaq, Briefing.com).
|
Copy and paste this into your LLM daily. Provide it with 3–5 news headlines.
|
||||||
2. **Screen:** Plug the resulting tickers into your `ScreenerEngine.js`.
|
|
||||||
3. **Validate:** Use your "Verdict Justification" table to verify if the fundamentals (PEG, Margins, Debt) support the AI's suggested bias.
|
> **Role:** You are a quantitative financial analyst specialising in catalyst-driven trading.
|
||||||
4. **Execute:** Monitor the "Ripple-Effect" targets, as they often capture volatility before the broader market catches on.
|
>
|
||||||
|
> **Task:** Analyse the provided news and map each story to the assets structurally forced to respond.
|
||||||
|
>
|
||||||
|
> **For each catalyst, identify:**
|
||||||
|
> 1. **Type:** Macro (Fed, rates, GDP) | Sector (regulatory, supply chain, commodity) | Company (earnings, guidance, M&A)
|
||||||
|
> 2. **Primary ticker:** The asset directly impacted.
|
||||||
|
> 3. **Ripple-effect ticker:** A supply chain partner, direct competitor, or sector peer that moves *before* the market catches on. This is the alpha play.
|
||||||
|
> 4. **Bias:** Bull or Bear — with a one-sentence mechanistic reason (not sentiment).
|
||||||
|
> 5. **Horizon:** Short (1–5 days) | Medium (1–4 weeks) | Long (1+ quarter).
|
||||||
|
> 6. **Sensitivity:** How exposed is this ticker 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:**
|
||||||
|
> - Exclude generic analyst upgrades and "market sentiment" stories.
|
||||||
|
> - Only include events with a measurable impact on valuation or supply chain fundamentals.
|
||||||
|
> - Do not suggest tickers with average daily volume below 500k.
|
||||||
|
> - For Bear plays: require at least one of — elevated short interest (>5% of float), negative earnings revision trend, or sector rotation evidence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. June 2026 Focus Areas
|
## 3. Quantitative Impact Matrix
|
||||||
|
|
||||||
- **Macro:** Watch the ISM Manufacturing PMI (June 1) and Nonfarm Payrolls (June 5).
|
Output from the prompt above. Log results here before running the screener.
|
||||||
- **Geopolitical:** Monitor US-Iran negotiations regarding the Strait of Hormuz (impacts Oil/Energy supply chains).
|
|
||||||
- **Sectoral:** Continued AI momentum—look for infrastructure and cybersecurity earnings/guidance.
|
| Catalyst | Type | Primary | Ripple | Bias | Sensitivity | Horizon | Mechanics |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| [Event] | Macro/Sector/Co. | [TICKER] | [TICKER] | Bull/Bear | 1–5 | Short/Med/Long | [One-line financial logic] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ripple-Effect Reference Map
|
||||||
|
|
||||||
|
When a catalyst hits a primary ticker, these are the typical second-order targets by category.
|
||||||
|
|
||||||
|
| Primary Event | Ripple Targets | Logic |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Semis beat** (NVDA, AMD) | TSMC, ASML, AMAT, KLAC | Fab capacity demand follows chip demand |
|
||||||
|
| **Semis miss** | INTC, MU, WDC | Inventory builds at competitors |
|
||||||
|
| **Cloud CapEx guidance up** (MSFT, GOOGL, AMZN) | EQIX, DLR (data center REITs), NFLX infra | Power + cooling demand, bandwidth |
|
||||||
|
| **Oil supply shock** | XOM, CVX (Bull); DAL, UAL (Bear) | Energy input costs hit airlines directly |
|
||||||
|
| **Fed rate hike** | TLT, IEF (Bear); XLF, BRK (Bull) | Long-duration bonds reprice; bank margins expand |
|
||||||
|
| **Fed rate cut** | TLT, XLRE (Bull); XLF (Bear) | REITs re-rate; bank NIM compresses |
|
||||||
|
| **Strong USD** | EEM, multinational exporters (Bear) | Revenue headwind for USD-earners abroad |
|
||||||
|
| **Retail sales miss** | WMT, TGT (Bear); AMZN (neutral/Bull) | Discretionary demand shift to e-commerce |
|
||||||
|
| **Pharma approval** | Competitor biotech (Bear) | Market share displacement |
|
||||||
|
| **Cybersecurity breach (major)** | CRWD, PANW, FTNT (Bull) | Accelerates enterprise security spend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Validation Checklist
|
||||||
|
|
||||||
|
Before acting on a catalyst, run the tickers through the screener and answer:
|
||||||
|
|
||||||
|
### For Bull plays:
|
||||||
|
- [ ] Does it pass the **Market-Adjusted** analysis? (minimum bar — if not, it's pure momentum)
|
||||||
|
- [ ] Does it pass **Fundamental** analysis? (if yes → Strong Buy conviction; if not → Speculation)
|
||||||
|
- [ ] Is FCF yield positive? (sustains the business through the catalyst period)
|
||||||
|
- [ ] Is D/E manageable? (high leverage + catalyst = binary outcome, size accordingly)
|
||||||
|
- [ ] Is the 52-week position below 85%? (if near highs, the market may have priced it in)
|
||||||
|
|
||||||
|
### For Bear plays:
|
||||||
|
- [ ] Does it **fail both** analyses? (confirms the fundamental short thesis)
|
||||||
|
- [ ] Is short interest > 5% of float? (existing agreement in the market)
|
||||||
|
- [ ] Is the horizon realistic? (overvalued stocks can stay overvalued — Bear plays need a catalyst *timeframe*)
|
||||||
|
|
||||||
|
### Horizon vs screener relevance:
|
||||||
|
| Horizon | Use screener for... |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Short (1–5 days) | Confirm the stock isn't already broken (avoid catching falling knives on longs) |
|
||||||
|
| Medium (1–4 weeks) | Gate check — does fundamental quality support a re-rating? |
|
||||||
|
| Long (1+ quarter) | Full weight on both analyses — you need the fundamentals on your side |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Current Market Regime Context
|
||||||
|
|
||||||
|
> **This section should be refreshed from `npm start` output before each session.**
|
||||||
|
|
||||||
|
The screener derives the current regime from live Yahoo Finance data on startup:
|
||||||
|
|
||||||
|
| Signal | What it means for catalysts |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Rate Regime: HIGH** (10Y > 5%) | Long-duration trades are punished. Favour cash-generative, short-horizon plays. Short TLT, long XLF. |
|
||||||
|
| **Rate Regime: NORMAL** (2–5%) | Standard playbook applies. |
|
||||||
|
| **Rate Regime: LOW** (< 2%) | Growth and duration trades work. REITs and long bonds are viable longs. |
|
||||||
|
| **Volatility: HIGH** (VIX > 25) | Position sizes down. Mean-reversion trades outperform momentum. |
|
||||||
|
| **Volatility: NORMAL** (VIX 15–25) | Trend-following works. |
|
||||||
|
| **Volatility: LOW** (VIX < 15) | Risk-on. Momentum and growth outperform. Watch for complacency reversals. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Bear Catalyst Template
|
||||||
|
|
||||||
|
A structured short thesis requires more rigour than a bull thesis. Use this template.
|
||||||
|
|
||||||
|
> **Ticker:** [TICKER]
|
||||||
|
>
|
||||||
|
> **Catalyst:** [What event breaks the bull narrative?]
|
||||||
|
>
|
||||||
|
> **Fundamental support:**
|
||||||
|
> - Fails screener gate: [which gate, e.g. "P/E 120x > inflated gate of 57x"]
|
||||||
|
> - Trend: [revenue decelerating / margins compressing / FCF turning negative]
|
||||||
|
>
|
||||||
|
> **Market structure support (need at least one):**
|
||||||
|
> - Short interest: [X% of float]
|
||||||
|
> - Earnings revision trend: [# of downward revisions last 90 days]
|
||||||
|
> - Sector rotation: [which sector ETF is seeing outflows]
|
||||||
|
>
|
||||||
|
> **Risk to thesis:** [What would invalidate the short — e.g. "earnings beat with raised guidance"]
|
||||||
|
>
|
||||||
|
> **Horizon:** [Short / Medium / Long]
|
||||||
|
> **Stop:** [Price level or event that closes the trade]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Adding Catalyst Tickers to the Screener
|
||||||
|
|
||||||
|
Edit `index.js` and add tickers from the Impact Matrix to the `tickers` array, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The screener will score each ticker under both the **Market-Adjusted** and **Fundamental** lenses and open `screener-report.html` with the full breakdown. Cross-reference the Signal column with your catalyst thesis:
|
||||||
|
|
||||||
|
| Signal | Catalyst interpretation |
|
||||||
|
| :--- | :--- |
|
||||||
|
| ✅ Strong Buy | Fundamental quality + catalyst momentum aligned. Highest conviction. |
|
||||||
|
| ⚡ Momentum | Catalyst works in today's market but price is stretched on fundamentals. Respect the stop. |
|
||||||
|
| ⚠️ Speculation | Pure catalyst play — fundamentals don't support it. Small size, tight stop. |
|
||||||
|
| 🔄 Neutral | Catalyst may be already priced in. Wait for a better entry or skip. |
|
||||||
|
| ❌ Avoid | Screener and catalyst are both negative. Only valid as a Bear trade. |
|
||||||
|
|||||||
@@ -0,0 +1,989 @@
|
|||||||
|
# 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,58 +1,649 @@
|
|||||||
# Financial Screener & Personal Finance Assistant
|
# Market Screener
|
||||||
|
|
||||||
## Project Overview
|
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.
|
||||||
|
|
||||||
This project is a modular, rule-based financial analysis engine designed to evaluate assets and manage personal investment portfolios. It separates data acquisition, strategy configuration, and evaluation logic to provide actionable investment insights.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture Structure
|
## Table of Contents
|
||||||
|
|
||||||
### 1. Data Pipeline (`/src/data/`)
|
- [Developer Setup](#developer-setup)
|
||||||
|
- [Environment Variables](#environment-variables)
|
||||||
- **Fetcher:** Handles API communication (e.g., Yahoo Finance).
|
- [Commands](#commands)
|
||||||
- **Mapper:** Normalizes disparate API responses into a unified flat object structure.
|
- [Running Tests](#running-tests)
|
||||||
- **Asset Models (`/models/`):** Defines common properties for `Stock`, `Etf`, and `Bond`.
|
- [Project Structure](#project-structure)
|
||||||
|
- [User Guide](#user-guide)
|
||||||
### 2. Logic & Configuration (`/src/config/` & `/src/utils/`)
|
- [API Testing with Bruno](#api-testing-with-bruno)
|
||||||
|
|
||||||
- **`ScoringConfig.js`:** Houses all thresholds, gates, and weights.
|
|
||||||
- **`RuleMerger.js`:** Dynamically applies sector-specific overrides to base rules.
|
|
||||||
|
|
||||||
### 3. Evaluation & Personal Assistant (`/src/engine/` & `/src/assistant/`)
|
|
||||||
|
|
||||||
- **`ScoringEngine.js`:** Orchestrates evaluation, applying market context and sector overrides.
|
|
||||||
- **`PortfolioManager.js` (NEW):** Tracks individual holdings, cost basis, and performance metrics.
|
|
||||||
- **`AdvisorModule.js` (NEW):** Provides personalized suggestions based on screening results and portfolio health.
|
|
||||||
- **`EventMonitor.js` (NEW):** Tracks calendar events (Earnings Calls) to trigger alerts.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Flow Diagram
|
## Developer Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Node.js 20+** (v22 recommended)
|
||||||
|
- **npm 10+**
|
||||||
|
|
||||||
|
**Check your versions:**
|
||||||
|
```bash
|
||||||
|
node --version # Should output v20.x.x or higher
|
||||||
|
npm --version # Should output 10.x.x or higher
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not on Node 20+?** See [NODE_VERSION_FIX.md](./NODE_VERSION_FIX.md) for upgrade instructions.
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install server dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Enhancements
|
## Environment Variables
|
||||||
|
|
||||||
### Phase 1: Core Engine & Soft Scoring
|
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.
|
||||||
|
|
||||||
- **Soft Scoring System:** Transition from "Hard Gates" to a weighted point-based system.
|
### `ANTHROPIC_API_KEY` — LLM news analysis *(optional)*
|
||||||
- **Market Context Integration:** Automate the `marketContext` parameter by fetching real-time 10Y Treasury Yields.
|
|
||||||
|
|
||||||
### Phase 2: Personal Finance Features
|
Powers the **Analyze** button on each screener section. Without this key the button is disabled.
|
||||||
|
|
||||||
- **Personal Portfolio Tracking:** Implement a `PortfolioManager` to track custom user holdings, monitor unrealized P&L, and calculate weightings relative to total assets.
|
1. Go to [console.anthropic.com](https://console.anthropic.com)
|
||||||
- **Automated Financial Coaching:** Develop an `AdvisorModule` that analyzes the portfolio and provides suggestions (e.g., "Reduce exposure to High-Debt REITs," or "Rebalance to increase Technology allocation").
|
2. Create an API key under **API Keys**
|
||||||
- **Earnings Call Notification System:** \* Integrate an earnings calendar API.
|
3. Add to `.env`:
|
||||||
- Implement a polling or webhook service to monitor for upcoming calls.
|
|
||||||
- Add a notification service (Email, Push, or CLI log) to alert the user 24 hours prior to a scheduled earnings call.
|
|
||||||
|
|
||||||
### Phase 3: Infrastructure & Intelligence
|
```env
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
```
|
||||||
|
|
||||||
- **Caching Layer:** Use local JSON caching to reduce API overhead.
|
### `SIMPLEFIN_SETUP_TOKEN` — Live bank/brokerage balances *(optional)*
|
||||||
- **Sentiment Analysis:** Integrate news-scrapers to weight "Buy" signals based on recent headlines.
|
|
||||||
- **Backtesting Module:** Run historical simulations to test strategy performance.
|
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/...
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Maintained by: AI Collaborator_
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Start API (port 3000) + 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 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses Node's built-in `node:test` runner — no external framework. **114 test cases** across 9 files cover:
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
On `git commit`, the **pre-commit hook** automatically:
|
||||||
|
|
||||||
|
1. **Formats** all files with Prettier
|
||||||
|
2. **Lints & fixes** staged files with ESLint
|
||||||
|
3. **Runs tests** to catch errors early
|
||||||
|
|
||||||
|
On `git push`, the **pre-push hook** runs tests again for safety.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
**Phase 9: Domain-Driven Architecture** (completed)
|
||||||
|
|
||||||
|
```
|
||||||
|
bin/
|
||||||
|
server.ts 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
tests/ Unit + integration tests (9 files, 114 test cases)
|
||||||
|
Controllers, services, scorers fully covered
|
||||||
|
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
See **[CLAUDE.md](./CLAUDE.md)** for detailed architecture and **[PHASES.md](./PHASES.md)** for the complete roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Guide
|
||||||
|
|
||||||
|
### Screener tab
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Portfolio tab
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to the project root
|
||||||
|
cd market-screener
|
||||||
|
|
||||||
|
# Import the Postman collection
|
||||||
|
bru import api_collections/market-screener.postman_collection.json -o api_collections/market-screener
|
||||||
|
|
||||||
|
# Output: Collection imported to api_collections/market-screener/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Method 3: Convert Postman to Bruno Format (Manual)
|
||||||
|
|
||||||
|
If you prefer to manually convert the collection:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install conversion dependencies (if needed)
|
||||||
|
pip install requests
|
||||||
|
|
||||||
|
# Run the conversion script
|
||||||
|
python3 api_collections/convert_postman_to_bruno.py \
|
||||||
|
api_collections/market-screener.postman_collection.json \
|
||||||
|
api_collections/market-screener
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
#### Via Bruno GUI
|
||||||
|
|
||||||
|
1. **Open the imported collection** in Bruno
|
||||||
|
2. **Set the `baseUrl` variable** (default: `http://localhost:3000`)
|
||||||
|
3. **Click the Play button** to run all tests
|
||||||
|
4. **View results** for each request in the UI
|
||||||
|
|
||||||
|
#### Via Bruno CLI (brucli)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to the collection directory
|
||||||
|
cd api_collections/market-screener
|
||||||
|
|
||||||
|
# Run all tests in the collection
|
||||||
|
bru run
|
||||||
|
|
||||||
|
# Run with specific environment
|
||||||
|
bru run --env local
|
||||||
|
|
||||||
|
# Run with output format
|
||||||
|
bru run --output json > test-results.json
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
bru run "Screener/Screen - Mixed.bru"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collection Structure
|
||||||
|
|
||||||
|
After import, you'll have:
|
||||||
|
|
||||||
|
```
|
||||||
|
api_collections/market-screener/
|
||||||
|
├── bruno.json # Collection metadata
|
||||||
|
├── Health/
|
||||||
|
│ └── Health Check.bru
|
||||||
|
├── Screener/
|
||||||
|
│ ├── Screen - Mixed.bru
|
||||||
|
│ ├── Screen - Tech Stocks.bru
|
||||||
|
│ ├── Screen - REIT.bru
|
||||||
|
│ ├── Validation empty tickers.bru
|
||||||
|
│ ├── Validation 50 plus tickers.bru
|
||||||
|
│ └── Get Catalysts.bru
|
||||||
|
├── Market Context/
|
||||||
|
│ └── Get Market Context.bru
|
||||||
|
├── Portfolio/
|
||||||
|
│ ├── Add Holding AAPL.bru
|
||||||
|
│ ├── Add Holding VOO.bru
|
||||||
|
│ ├── Add Holding BTC-USD.bru
|
||||||
|
│ ├── Add Holding Validation.bru
|
||||||
|
│ ├── Get Portfolio.bru
|
||||||
|
│ ├── Remove Holding AAPL.bru
|
||||||
|
│ └── Remove Holding Non-existent.bru
|
||||||
|
├── Market Calls/
|
||||||
|
│ ├── List Calls.bru
|
||||||
|
│ ├── Create Market Call.bru
|
||||||
|
│ ├── Get Call by ID.bru
|
||||||
|
│ ├── Get Call Non-existent.bru
|
||||||
|
│ ├── Get Earnings Calendar.bru
|
||||||
|
│ ├── Get Calendar Specific Tickers.bru
|
||||||
|
│ ├── Create Call Validation.bru
|
||||||
|
│ ├── Delete Call.bru
|
||||||
|
│ └── Delete Call Already Deleted.bru
|
||||||
|
└── LLM Analysis/
|
||||||
|
├── Analyze Tickers.bru
|
||||||
|
└── Analyze Validation.bru
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
#### Setting Variables
|
||||||
|
|
||||||
|
Variables are stored in `bruno.json` and can be overridden per request:
|
||||||
|
|
||||||
|
**Default variables:**
|
||||||
|
- `baseUrl`: `http://localhost:3000`
|
||||||
|
- `callId`: (auto-populated by Create Market Call request)
|
||||||
|
|
||||||
|
To change variables in the GUI:
|
||||||
|
1. Right-click collection → **Settings**
|
||||||
|
2. Click **Variables** tab
|
||||||
|
3. Edit `baseUrl` or other variables
|
||||||
|
4. Click **Save**
|
||||||
|
|
||||||
|
#### Environment Files
|
||||||
|
|
||||||
|
Create a `.env.bruno` file in the collection directory for local overrides:
|
||||||
|
|
||||||
|
```env
|
||||||
|
baseUrl=http://localhost:3000
|
||||||
|
apiKey=your-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Workflows
|
||||||
|
|
||||||
|
#### 1. Test the full API flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api_collections/market-screener
|
||||||
|
bru run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Test just the Screener endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api_collections/market-screener
|
||||||
|
bru run "Screener"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Test and save results
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd api_collections/market-screener
|
||||||
|
bru run --output json > test-results-$(date +%Y%m%d).json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Continuous testing (while developing)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Run the API server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Terminal 2: Watch and run tests every 5 seconds
|
||||||
|
cd api_collections/market-screener
|
||||||
|
watch -n 5 'bru run'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
#### "You can run only at the root of a collection" error
|
||||||
|
|
||||||
|
Make sure you're in the correct directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ❌ Wrong — project root
|
||||||
|
cd market-screener
|
||||||
|
bru run
|
||||||
|
|
||||||
|
# ✅ Correct — collection root
|
||||||
|
cd api_collections/market-screener
|
||||||
|
bru run
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Variables not found
|
||||||
|
|
||||||
|
Verify variable names in `bruno.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check variables
|
||||||
|
cat api_collections/market-screener/bruno.json | grep -A 10 "vars"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Tests failing with "undefined" errors
|
||||||
|
|
||||||
|
Common causes:
|
||||||
|
- Variable name mismatch (case-sensitive)
|
||||||
|
- Server not running on the expected port
|
||||||
|
- Port conflict (try `lsof -i :3000` to check)
|
||||||
|
|
||||||
|
### Postman vs Bruno
|
||||||
|
|
||||||
|
| Feature | Postman | Bruno |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| **Download Size** | ~380MB | ~50MB |
|
||||||
|
| **Collection Format** | Single JSON blob | Plain text `.bru` files |
|
||||||
|
| **Git-Friendly** | ❌ Binary | ✅ Text-based, diffable |
|
||||||
|
| **API Response** | UI-only | CLI + GUI |
|
||||||
|
| **Cost** | Free tier + paid | ✅ Completely free |
|
||||||
|
| **IDE Integration** | None | Can edit `.bru` files directly |
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
- **Bruno Docs**: [docs.usebruno.com](https://docs.usebruno.com)
|
||||||
|
- **Bruno GitHub**: [github.com/usebruno/bruno](https://github.com/usebruno/bruno)
|
||||||
|
- **Postman Collection**: `api_collections/market-screener.postman_collection.json`
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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,67 @@
|
|||||||
|
/**
|
||||||
|
* 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,14 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { buildApp } from '../server/app';
|
||||||
|
|
||||||
|
const PORT = process.env.PORT ?? 3000;
|
||||||
|
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||||
|
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: Number(PORT), host: HOST });
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 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 */
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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:
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<!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,32 +0,0 @@
|
|||||||
import { ScreenerEngine } from './src/core/engine/ScreenerEngine.js';
|
|
||||||
|
|
||||||
const tickers = [
|
|
||||||
'PLTR',
|
|
||||||
'AAPL',
|
|
||||||
'VOO',
|
|
||||||
'MSFT',
|
|
||||||
'TSLA',
|
|
||||||
'QQQ',
|
|
||||||
'O',
|
|
||||||
'BND',
|
|
||||||
'AGG',
|
|
||||||
'LQD',
|
|
||||||
'GOVT',
|
|
||||||
'MUB',
|
|
||||||
'SHY',
|
|
||||||
'IEF',
|
|
||||||
'TLT',
|
|
||||||
];
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('🚀 Starting Screener Evaluation...');
|
|
||||||
const engine = new ScreenerEngine();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await engine.runParallelScreener(tickers);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('\n Execution Failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
<!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>
|
||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
### Request: Optimize Investment Strategy Configuration
|
|
||||||
|
|
||||||
I am updating my investment strategy configuration. You are acting as a Senior Quantitative Financial Strategist. Please analyze my current market thesis and update the configuration parameters to align with this view.
|
|
||||||
|
|
||||||
**Market Thesis:** [INSERT YOUR THESIS HERE]
|
|
||||||
|
|
||||||
### Reasoning Phase (Before the JSON)
|
|
||||||
|
|
||||||
1. Briefly summarize your logic for the changes (e.g., "Raising the `maxDebtToEquity` gate because high-interest environments make capital-intensive businesses riskier").
|
|
||||||
2. Ensure all values are mathematically sound and consistent with the requested thesis.
|
|
||||||
|
|
||||||
### JSON Output Requirements
|
|
||||||
|
|
||||||
- Return a valid JSON object matching the schema below.
|
|
||||||
- Ensure all numbers are appropriate for the asset class (e.g., Debt/Equity usually 0-5, P/E usually 0-100).
|
|
||||||
- **Crucial:** Provide _only_ the JSON inside a single code block. No conversational text after the code block.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"STOCK": {
|
|
||||||
"gates": {
|
|
||||||
"maxDebtToEquity": 0.0,
|
|
||||||
"minQuickRatio": 0.0,
|
|
||||||
"maxPERatio": 0.0
|
|
||||||
},
|
|
||||||
"weights": { "margin": 0, "peg": 0, "revenue": 0, "fcf": 0 },
|
|
||||||
"thresholds": {
|
|
||||||
"marginHigh": 0,
|
|
||||||
"marginMed": 0,
|
|
||||||
"pegHigh": 0,
|
|
||||||
"pegMed": 0,
|
|
||||||
"revHigh": 0,
|
|
||||||
"revMed": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ETF": {
|
|
||||||
"gates": { "maxExpenseRatio": 0.0 },
|
|
||||||
"weights": { "yield": 0, "lowCost": 0 },
|
|
||||||
"thresholds": { "minYield": 0.0, "maxExpense": 0.0 }
|
|
||||||
},
|
|
||||||
"BOND": {
|
|
||||||
"gates": { "minCreditRating": 0 },
|
|
||||||
"weights": { "yieldSpread": 0, "duration": 0 },
|
|
||||||
"thresholds": { "minSpread": 0.0, "maxDuration": 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# 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
+5535
-4
File diff suppressed because it is too large
Load Diff
+46
-3
@@ -1,11 +1,54 @@
|
|||||||
{
|
{
|
||||||
"name": "stock-screener",
|
"name": "market-screener",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js"
|
"server": "tsx bin/server.ts",
|
||||||
|
"dev": "concurrently -n api,ui -c cyan,magenta \"tsx bin/server.ts\" \"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\"",
|
||||||
|
"prepare": "husky"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"{server,bin,tests}/**/*.{ts,js}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"ui/src/**/*.ts": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{ "holdings": [] }
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Central export point for all system prompts
|
||||||
|
// Add new prompts here as they're created
|
||||||
|
|
||||||
|
export { LLM_ANALYST_PROMPT } from './llm-analyst';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
You are a professional equity analyst specialising in catalyst-driven trading.
|
||||||
|
|
||||||
|
You will be given today's market news headlines (with Yahoo-tagged tickers per story) and a ranked ticker frequency list showing how many stories mention each ticker.
|
||||||
|
|
||||||
|
Your job:
|
||||||
|
1. Write a 2–3 sentence market summary capturing the dominant theme and tone.
|
||||||
|
2. Assess overall market sentiment as BULLISH, NEUTRAL, or BEARISH.
|
||||||
|
3. Identify up to 4 industries secondarily affected — not directly mentioned, but impacted via contagion, supply chain, regulation, or macro.
|
||||||
|
4. Suggest up to 6 tickers worth screening. For each one provide:
|
||||||
|
- **ticker** — must have ADV > 500k; exclude generic analyst upgrades with no valuation catalyst
|
||||||
|
- **reason** — one mechanistic sentence (revenue/cost/supply-chain logic, not sentiment)
|
||||||
|
- **bias** — BULL or BEAR
|
||||||
|
- **horizon** — SHORT (1–5 days) | MEDIUM (1–4 weeks) | LONG (1+ quarter)
|
||||||
|
- **sensitivity** — how exposed this ticker is to the catalyst:
|
||||||
|
- 5 = direct revenue impact > 20% of annual sales
|
||||||
|
- 4 = direct revenue impact 10–20%
|
||||||
|
- 3 = indirect exposure via cost structure or supply chain
|
||||||
|
- 2 = sector correlation, limited direct exposure
|
||||||
|
- 1 = macro tailwind/headwind only
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Prioritise tickers that appear multiple times in the frequency list — repeated mentions signal broader market awareness.
|
||||||
|
- For BEAR picks: require at least one of — elevated short interest, negative earnings revision trend, or sector rotation evidence.
|
||||||
|
- Do not suggest tickers already in the "already identified" list unless the story adds a new directional angle.
|
||||||
|
- Prefer ripple-effect tickers (supply chain partners, direct competitors, sector peers) over the primary ticker already in the news — those are where the alpha is.
|
||||||
|
|
||||||
|
Return ONLY valid JSON in this exact shape — no markdown, no explanation:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": "string",
|
||||||
|
"sentiment": "BULLISH" | "NEUTRAL" | "BEARISH",
|
||||||
|
"affectedIndustries": [
|
||||||
|
{ "name": "string", "reason": "string" }
|
||||||
|
],
|
||||||
|
"relatedTickers": [
|
||||||
|
{
|
||||||
|
"ticker": "string",
|
||||||
|
"reason": "string",
|
||||||
|
"bias": "BULL" | "BEAR",
|
||||||
|
"horizon": "SHORT" | "MEDIUM" | "LONG",
|
||||||
|
"sensitivity": 1 | 2 | 3 | 4 | 5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// ── 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Calls domain — market call tracking and calendar
|
||||||
|
export { CallsController } from './calls.controller';
|
||||||
|
export { CalendarService } from './CalendarService';
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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)}…`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Digest domain — daily change detection (PRODUCT.md P1.1)
|
||||||
|
|
||||||
|
export { DigestService } from './DigestService';
|
||||||
|
export { DiscordNotifier } from './DiscordNotifier';
|
||||||
|
export { DigestController } from './digest.controller';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Finance domain — portfolio metrics and reporting
|
||||||
|
export { FinanceController } from './finance.controller';
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// 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';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Portfolio domain — holdings management and advice
|
||||||
|
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../../domains/shared';
|
||||||
|
|
||||||
|
export class PersonalFinanceAnalyzer {
|
||||||
|
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
|
||||||
|
const assets = accounts.filter((a) => !['CREDIT', 'LOAN'].includes(a.type));
|
||||||
|
const liabilities = accounts.filter((a) => ['CREDIT', 'LOAN'].includes(a.type));
|
||||||
|
|
||||||
|
const totalAssets = assets.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||||
|
const totalLiabilities = liabilities.reduce((s, a) => s + Math.abs(Math.min(0, a.balance)), 0);
|
||||||
|
const netWorth = totalAssets - totalLiabilities;
|
||||||
|
|
||||||
|
const cash = accounts.filter((a) => ['CHECKING', 'SAVINGS'].includes(a.type));
|
||||||
|
const investments = accounts.filter((a) => a.type === 'INVESTMENT');
|
||||||
|
const totalCash = cash.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||||
|
const totalInvest = investments.reduce((s, a) => s + Math.max(0, a.balance), 0);
|
||||||
|
|
||||||
|
const allTx = accounts.flatMap((a) => a.transactions);
|
||||||
|
const spending = allTx.filter((tx) => tx.amount < 0 && tx.category !== 'Transfer');
|
||||||
|
const income = allTx.filter((tx) => tx.amount > 0 && tx.category === 'Income');
|
||||||
|
|
||||||
|
const totalSpend = spending.reduce((s, tx) => s + Math.abs(tx.amount), 0);
|
||||||
|
const totalIncome = income.reduce((s, tx) => s + tx.amount, 0);
|
||||||
|
|
||||||
|
const byCategory: Record<string, number> = {};
|
||||||
|
for (const tx of spending) {
|
||||||
|
byCategory[tx.category] = (byCategory[tx.category] ?? 0) + Math.abs(tx.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryBreakdown: CategoryBreakdown[] = Object.entries(byCategory)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([category, amount]) => ({
|
||||||
|
category,
|
||||||
|
amount,
|
||||||
|
pct: totalSpend > 0 ? ((amount / totalSpend) * 100).toFixed(1) : '0',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
netWorth,
|
||||||
|
totalAssets,
|
||||||
|
totalLiabilities,
|
||||||
|
totalCash,
|
||||||
|
totalInvestments: totalInvest,
|
||||||
|
cashPct: totalAssets > 0 ? ((totalCash / totalAssets) * 100).toFixed(1) : '0',
|
||||||
|
investPct: totalAssets > 0 ? ((totalInvest / totalAssets) * 100).toFixed(1) : '0',
|
||||||
|
totalIncome,
|
||||||
|
totalSpend,
|
||||||
|
savingsRate:
|
||||||
|
totalIncome > 0 ? (((totalIncome - totalSpend) / totalIncome) * 100).toFixed(1) : null,
|
||||||
|
categoryBreakdown,
|
||||||
|
accounts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// 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';
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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 } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ASSET_TYPE, REGIME, SECTOR } from '../../shared';
|
||||||
|
import type { MarketContext, AssetType, InflatedOverrides } from '../../shared';
|
||||||
|
|
||||||
|
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) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around the Anthropic SDK.
|
||||||
|
* Handles initialisation and raw message completion only —
|
||||||
|
* prompt construction and response parsing stay in LLMAnalyst (service layer).
|
||||||
|
*/
|
||||||
|
export class AnthropicClient {
|
||||||
|
private client: Anthropic | null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = process.env.ANTHROPIC_API_KEY
|
||||||
|
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAvailable(): boolean {
|
||||||
|
return this.client !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(system: string, userMessage: string): Promise<string | null> {
|
||||||
|
if (!this.client) return null;
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-haiku-4-5-20251001',
|
||||||
|
max_tokens: 1024,
|
||||||
|
system,
|
||||||
|
messages: [{ role: 'user', content: userMessage }],
|
||||||
|
});
|
||||||
|
return (response.content[0] as { text?: string })?.text ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
import type { Logger, GetAccountsOptions, SimpleFINData, SimpleFINOptions } from '../types';
|
||||||
|
|
||||||
|
export class SimpleFINClient {
|
||||||
|
private accessUrl: string | null;
|
||||||
|
private logger: Logger;
|
||||||
|
private onAccessUrlClaimed: ((_url: string) => Promise<void> | void) | null;
|
||||||
|
|
||||||
|
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
|
||||||
|
this.accessUrl = null;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
this.logger = logger ?? {
|
||||||
|
write: (msg) => process.stdout.write(msg),
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
warn: (...args) => console.warn(...args),
|
||||||
|
};
|
||||||
|
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (process.env.SIMPLEFIN_ACCESS_URL) {
|
||||||
|
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
|
||||||
|
this.accessUrl = await this.claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
|
||||||
|
if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'SimpleFIN not configured.\nAdd to .env:\n SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\nThe Access URL will be saved automatically on first run.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
|
||||||
|
if (!this.accessUrl) await this.init();
|
||||||
|
|
||||||
|
const startDate = options.startDate ?? this.daysAgo(30);
|
||||||
|
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const parsed = new URL(this.accessUrl!);
|
||||||
|
const auth = parsed.username
|
||||||
|
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
|
||||||
|
: null;
|
||||||
|
parsed.username = '';
|
||||||
|
parsed.password = '';
|
||||||
|
const cleanBase = parsed.toString().replace(/\/$/, '');
|
||||||
|
|
||||||
|
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
|
||||||
|
const response = await fetch(url, { headers: auth ? { Authorization: auth } : {} });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { accounts?: unknown[]; errors?: string[] };
|
||||||
|
if (data.errors?.length) {
|
||||||
|
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalise(data as { accounts: unknown[]; errors: string[] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claimAccessUrl(setupToken: string): Promise<string> {
|
||||||
|
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
|
||||||
|
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`);
|
||||||
|
const accessUrl = await this.post(claimUrl);
|
||||||
|
if (!accessUrl || !accessUrl.startsWith('http')) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.write('✅ Access URL received\n');
|
||||||
|
return accessUrl.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private post(url: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const lib = parsed.protocol === 'https:' ? https : http;
|
||||||
|
const options = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
};
|
||||||
|
const req = lib.request(options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk: string) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(body.trim());
|
||||||
|
else reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
|
||||||
|
const accounts = (data.accounts ?? []).map((acc: any) => ({
|
||||||
|
id: acc.id,
|
||||||
|
name: acc.name,
|
||||||
|
currency: acc.currency ?? 'USD',
|
||||||
|
balance: parseFloat(acc.balance) ?? 0,
|
||||||
|
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
|
||||||
|
org: acc.org?.name ?? 'Unknown',
|
||||||
|
type: this.classifyAccount(acc.name),
|
||||||
|
transactions: (acc.transactions ?? []).map((tx: any) => ({
|
||||||
|
id: tx.id,
|
||||||
|
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
|
||||||
|
amount: parseFloat(tx.amount) ?? 0,
|
||||||
|
description: tx.description ?? '',
|
||||||
|
category: this.categorise(tx.description ?? ''),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
return { accounts, errors: data.errors ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private classifyAccount(name: string): string {
|
||||||
|
const n = name.toLowerCase();
|
||||||
|
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
|
||||||
|
if (n.includes('saving')) return 'SAVINGS';
|
||||||
|
if (n.includes('credit') || n.includes('card')) return 'CREDIT';
|
||||||
|
if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira'))
|
||||||
|
return 'INVESTMENT';
|
||||||
|
if (n.includes('loan') || n.includes('mortgage')) return 'LOAN';
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
private categorise(description: string): string {
|
||||||
|
const d = description.toLowerCase();
|
||||||
|
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
|
||||||
|
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
|
||||||
|
if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions';
|
||||||
|
if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining';
|
||||||
|
if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas';
|
||||||
|
if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport';
|
||||||
|
if (d.match(/rent|mortgage|hoa|property/)) return 'Housing';
|
||||||
|
if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities';
|
||||||
|
if (d.match(/payroll|salary|direct deposit/)) return 'Income';
|
||||||
|
if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer';
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
private daysAgo(n: number): number {
|
||||||
|
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAccessUrlToEnv(accessUrl: string): void {
|
||||||
|
try {
|
||||||
|
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
|
||||||
|
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
|
||||||
|
fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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];
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
/**
|
||||||
|
* 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)`,
|
||||||
|
];
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { CREDIT_RATING_SCALE } from '../scoring/ScoringConfig';
|
||||||
|
import { Asset } from './Asset';
|
||||||
|
import type { BondData, BondMetrics } from '../types/index';
|
||||||
|
|
||||||
|
export class Bond extends Asset {
|
||||||
|
metrics: BondMetrics;
|
||||||
|
|
||||||
|
constructor(data: BondData) {
|
||||||
|
super(data);
|
||||||
|
|
||||||
|
const creditRating = data.creditRating || 'BBB';
|
||||||
|
const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7;
|
||||||
|
|
||||||
|
this.metrics = {
|
||||||
|
ytm: parseFloat(String(data.yieldToMaturity)) || 0,
|
||||||
|
duration: parseFloat(String(data.duration)) || 0,
|
||||||
|
creditRating,
|
||||||
|
creditRatingNumeric,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayMetrics(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Ticker: this.ticker,
|
||||||
|
Type: 'BOND',
|
||||||
|
Price: this.formatCurrency(this.currentPrice),
|
||||||
|
'YTM%': `${this.metrics.ytm.toFixed(2)}%`,
|
||||||
|
Duration: this.metrics.duration.toFixed(1),
|
||||||
|
Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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, '%'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 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';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import type { ScoringRulesShape } from '../types';
|
||||||
|
|
||||||
|
// ── Credit rating scale (S&P convention) ─────────────────────────────────
|
||||||
|
// Bond.ts converts letter ratings to these numbers; BondScorer uses them for gate checks.
|
||||||
|
// Investment grade = BBB (7) and above.
|
||||||
|
export const CREDIT_RATING_SCALE: Record<string, number> = {
|
||||||
|
AAA: 10,
|
||||||
|
AA: 9,
|
||||||
|
A: 8,
|
||||||
|
BBB: 7,
|
||||||
|
BB: 6,
|
||||||
|
B: 5,
|
||||||
|
CCC: 4,
|
||||||
|
CC: 3,
|
||||||
|
C: 2,
|
||||||
|
D: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Scoring rule shape ────────────────────────────────────────────────────
|
||||||
|
// Structural shapes (GateSet/WeightSet/ThresholdSet/RuleBlock/StockRules/
|
||||||
|
// ScoringRulesShape) live in server/types/asset.model.ts.
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Fundamental baseline — Graham / value-investing style.
|
||||||
|
// MarketRegime.ts overrides the valuation gates for INFLATED-mode analysis.
|
||||||
|
// Sector overrides are structural — they apply in both modes.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
export const ScoringRules: ScoringRulesShape = {
|
||||||
|
STOCK: {
|
||||||
|
gates: {
|
||||||
|
maxDebtToEquity: 1.5, // Graham ceiling; most distress starts above 2x
|
||||||
|
minQuickRatio: 0.8, // below 0.8 signals real liquidity stress in non-tech
|
||||||
|
maxPERatio: 15, // Graham's actual rule: never pay more than 15x trailing earnings
|
||||||
|
maxPegGate: 1.0, // PEG > 1.0 means you're paying full price for growth (Lynch standard)
|
||||||
|
},
|
||||||
|
weights: {
|
||||||
|
margin: 2, // net profit margin
|
||||||
|
opMargin: 2, // operating margin (pricing power)
|
||||||
|
roe: 3, // return on equity — Buffett's primary quality metric
|
||||||
|
peg: 2, // valuation relative to growth
|
||||||
|
revenue: 2, // revenue growth
|
||||||
|
fcf: 3, // FCF is the most manipulation-resistant quality signal
|
||||||
|
analyst: 2, // Wall Street consensus (1=Strong Buy … 5=Strong Sell, inverted in scorer)
|
||||||
|
dcf: 2, // DCF margin of safety: positive = undervalued vs intrinsic value
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
|
||||||
|
marginMed: 8, // 8% is the realistic mid-tier for industrials/retail
|
||||||
|
opMarginHigh: 20,
|
||||||
|
opMarginMed: 10,
|
||||||
|
roeHigh: 15, // sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting
|
||||||
|
roeMed: 10, // 10% is the cost-of-equity floor for most businesses
|
||||||
|
pegHigh: 0.75, // PEG < 0.75 is genuinely cheap relative to growth
|
||||||
|
pegMed: 1.0,
|
||||||
|
revHigh: 10, // 10% organic revenue growth is strong for mature cos
|
||||||
|
revMed: 5,
|
||||||
|
fcfHigh: 5,
|
||||||
|
fcfMed: 2,
|
||||||
|
// Analyst consensus thresholds (Yahoo recommendationMean scale: 1=Strong Buy, 5=Strong Sell)
|
||||||
|
analystBuy: 2.0, // ≤ 2.0 → consensus is Buy or better
|
||||||
|
analystHold: 3.0, // ≤ 3.0 → consensus is Hold or better
|
||||||
|
// DCF margin-of-safety thresholds (% undervaluation vs intrinsic value)
|
||||||
|
dcfUndervalued: 20, // ≥ 20% margin of safety → undervalued
|
||||||
|
dcfFairValue: 0, // 0–20% → fairly valued; negative → overvalued
|
||||||
|
},
|
||||||
|
|
||||||
|
SECTOR_OVERRIDE: {
|
||||||
|
TECHNOLOGY: {
|
||||||
|
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 },
|
||||||
|
weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 },
|
||||||
|
thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 },
|
||||||
|
},
|
||||||
|
|
||||||
|
REIT: {
|
||||||
|
gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 },
|
||||||
|
weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 },
|
||||||
|
thresholds: { minYield: 4.5, maxPFFO: 20 },
|
||||||
|
},
|
||||||
|
|
||||||
|
FINANCIAL: {
|
||||||
|
gates: {
|
||||||
|
maxDebtToEquity: 9999,
|
||||||
|
minQuickRatio: 0.1,
|
||||||
|
maxPERatio: 9999,
|
||||||
|
maxPegGate: 9999,
|
||||||
|
maxPriceToBook: 1.5,
|
||||||
|
},
|
||||||
|
weights: { margin: 0, opMargin: 0, peg: 0, roe: 5, revenue: 1, fcf: 1, priceToBook: 3 },
|
||||||
|
thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 },
|
||||||
|
},
|
||||||
|
|
||||||
|
ENERGY: {
|
||||||
|
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 },
|
||||||
|
weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 },
|
||||||
|
thresholds: {
|
||||||
|
opMarginHigh: 20,
|
||||||
|
opMarginMed: 10,
|
||||||
|
roeHigh: 15,
|
||||||
|
roeMed: 8,
|
||||||
|
fcfHigh: 8,
|
||||||
|
fcfMed: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
HEALTHCARE: {
|
||||||
|
gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 },
|
||||||
|
weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
|
||||||
|
thresholds: {
|
||||||
|
marginHigh: 20,
|
||||||
|
marginMed: 10,
|
||||||
|
roeHigh: 20,
|
||||||
|
roeMed: 12,
|
||||||
|
revHigh: 15,
|
||||||
|
revMed: 8,
|
||||||
|
fcfHigh: 8,
|
||||||
|
fcfMed: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
COMMUNICATION: {
|
||||||
|
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 25, maxPegGate: 1.5 },
|
||||||
|
weights: { margin: 2, opMargin: 3, roe: 2, peg: 2, revenue: 3, fcf: 4 },
|
||||||
|
thresholds: {
|
||||||
|
marginHigh: 25,
|
||||||
|
marginMed: 12,
|
||||||
|
opMarginHigh: 30,
|
||||||
|
opMarginMed: 15,
|
||||||
|
roeHigh: 20,
|
||||||
|
roeMed: 12,
|
||||||
|
pegHigh: 1.0,
|
||||||
|
pegMed: 1.5,
|
||||||
|
revHigh: 15,
|
||||||
|
revMed: 5,
|
||||||
|
fcfHigh: 8,
|
||||||
|
fcfMed: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
CONSUMER_STAPLES: {
|
||||||
|
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.5, maxPERatio: 22, maxPegGate: 2.0 },
|
||||||
|
weights: { margin: 3, opMargin: 3, roe: 3, peg: 1, revenue: 1, fcf: 3 },
|
||||||
|
thresholds: {
|
||||||
|
marginHigh: 12,
|
||||||
|
marginMed: 7,
|
||||||
|
opMarginHigh: 18,
|
||||||
|
opMarginMed: 10,
|
||||||
|
roeHigh: 20,
|
||||||
|
roeMed: 12,
|
||||||
|
pegHigh: 1.5,
|
||||||
|
pegMed: 2.0,
|
||||||
|
revHigh: 5,
|
||||||
|
revMed: 2,
|
||||||
|
fcfHigh: 5,
|
||||||
|
fcfMed: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
CONSUMER_DISCRETIONARY: {
|
||||||
|
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.5, maxPERatio: 25, maxPegGate: 1.5 },
|
||||||
|
weights: { margin: 2, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
|
||||||
|
thresholds: {
|
||||||
|
marginHigh: 10,
|
||||||
|
marginMed: 5,
|
||||||
|
opMarginHigh: 15,
|
||||||
|
opMarginMed: 8,
|
||||||
|
roeHigh: 20,
|
||||||
|
roeMed: 12,
|
||||||
|
pegHigh: 1.0,
|
||||||
|
pegMed: 1.5,
|
||||||
|
revHigh: 12,
|
||||||
|
revMed: 5,
|
||||||
|
fcfHigh: 5,
|
||||||
|
fcfMed: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ETF: {
|
||||||
|
gates: { maxExpenseRatio: 0.2 },
|
||||||
|
weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 },
|
||||||
|
thresholds: {
|
||||||
|
minYield: 1.5,
|
||||||
|
maxExpense: 0.05,
|
||||||
|
minVolume: 1_000_000,
|
||||||
|
minFiveYearReturn: 8.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
BOND: {
|
||||||
|
gates: { minCreditRating: 7 }, // BBB = investment-grade floor
|
||||||
|
weights: { yieldSpread: 3, duration: 2 },
|
||||||
|
thresholds: { minSpread: 1.5, maxDuration: 7 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alias used by tests — shape: ScoringConfig.base.gates.STOCK etc.
|
||||||
|
export const ScoringConfig = {
|
||||||
|
base: {
|
||||||
|
gates: {
|
||||||
|
STOCK: ScoringRules.STOCK.gates,
|
||||||
|
ETF: ScoringRules.ETF.gates,
|
||||||
|
BOND: ScoringRules.BOND.gates,
|
||||||
|
},
|
||||||
|
weights: {
|
||||||
|
STOCK: ScoringRules.STOCK.weights,
|
||||||
|
ETF: ScoringRules.ETF.weights,
|
||||||
|
BOND: ScoringRules.BOND.weights,
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
STOCK: ScoringRules.STOCK.thresholds,
|
||||||
|
ETF: ScoringRules.ETF.thresholds,
|
||||||
|
BOND: ScoringRules.BOND.thresholds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// ── 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// ── Market calls domain types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
import type { Signal } from './asset.model';
|
||||||
|
|
||||||
|
export interface TickerSnapshot {
|
||||||
|
price: number | null;
|
||||||
|
signal: Signal | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketCall {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
quarter: string;
|
||||||
|
date: string;
|
||||||
|
thesis: string;
|
||||||
|
tickers: string[];
|
||||||
|
snapshot: Record<string, TickerSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input shape for MarketCallRepository.create()
|
||||||
|
export interface CreateCallInput {
|
||||||
|
title: string;
|
||||||
|
quarter: string;
|
||||||
|
date?: string;
|
||||||
|
thesis: string;
|
||||||
|
tickers: string[];
|
||||||
|
snapshot?: Record<string, TickerSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-screened snapshot returned by GET /api/calls/:id for price comparison.
|
||||||
|
export interface SnapshotEntry {
|
||||||
|
price: number | null;
|
||||||
|
signal: string | null;
|
||||||
|
inflatedVerdict: string | null;
|
||||||
|
fundamentalVerdict: string | null;
|
||||||
|
pe: string | null;
|
||||||
|
roe: string | null;
|
||||||
|
fcf: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// ── 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// ── 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';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Logger interface ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
write: (msg: string) => void;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
warn: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// ── Market context types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
|
||||||
|
|
||||||
|
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
|
||||||
|
|
||||||
|
export interface Benchmarks {
|
||||||
|
marketPE: number | null;
|
||||||
|
techPE: number | null;
|
||||||
|
reitYield: number | null;
|
||||||
|
igSpread: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketContext {
|
||||||
|
sp500Price: number | null;
|
||||||
|
riskFreeRate: number | null;
|
||||||
|
vixLevel: number | null;
|
||||||
|
rateRegime: RateRegime;
|
||||||
|
volatilityRegime: VolatilityRegime;
|
||||||
|
benchmarks: Benchmarks;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user