phase-10.5: screener enhancements
This commit is contained in:
@@ -8,6 +8,40 @@ Guidance for working in this repository.
|
|||||||
|
|
||||||
`market-screener` is a Node.js project consisting of a Fastify API server that powers the SvelteKit dashboard in the `ui/` subdirectory. **Evolved to support day trading**: real-time news webhooks, LLM-driven stock analysis with prompt caching, multi-user authentication, and Discord alerts for price movements.
|
`market-screener` is a Node.js project consisting of a Fastify API server that powers the SvelteKit dashboard in the `ui/` subdirectory. **Evolved to support day trading**: real-time news webhooks, LLM-driven stock analysis with prompt caching, multi-user authentication, and Discord alerts for price movements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Status Update — Shipped June 2026 (post-Phase-10 sprint)
|
||||||
|
|
||||||
|
See **PRODUCT.md** (priorities P0–P3) and **FREE-DATA-STACK.md** (zero-cost data architecture) for design rationale. Shipped since the Phase 9/10 reports below:
|
||||||
|
|
||||||
|
**Correctness / foundations (PRODUCT.md P0):**
|
||||||
|
- Structured verdict tiers — scorers return `{ tier: PASS|HOLD|REJECT, score }`; `signal()` no longer string-matches emoji (P0.3)
|
||||||
|
- Signal snapshot ledger — `signal_snapshots` table, written on every screen; `GET /api/screen/history/:ticker`; `npm run screen:daily` (P0.1)
|
||||||
|
- Rate-regime hysteresis (±0.25% band, survives restarts) (P0.5)
|
||||||
|
- Data-sanity sentinel — `dataHealth` on `/api/screen` + UI banner when >30% of stocks return null fundamentals (P0.4)
|
||||||
|
- Bug-fix pass: ETF null-handling (missing data no longer auto-REJECTs), dividend-ETF↔bond classification via `fundProfile.categoryName`, zero-as-null sanitization, no-data verdicts labeled honestly, coverage field in every audit
|
||||||
|
|
||||||
|
**Free-tier news pipeline (`server/domains/news/` — Phase-12-lite, $0):**
|
||||||
|
- EDGAR poller (8-K, SC 13D, S-4, DEFM14A; CIK→ticker map) + PR-wire RSS poller (GlobeNewswire, PR Newswire; exchange-tag ticker extraction)
|
||||||
|
- Shared pipeline: universe filter → noise blocklist → dedupe → keyword catalyst classifier → `news_articles` / `ticker_catalysts` tables; retention jobs
|
||||||
|
- In-server scheduler (EDGAR 10 min, PR 15 min; `NEWS_POLL=off` to disable) + `npm run news:poll` for cron
|
||||||
|
- `GET /api/news/:ticker` (stored + live Yahoo merge), `GET /api/news/recent`
|
||||||
|
|
||||||
|
**Daily change digest (`server/domains/digest/` — PRODUCT P1.1, partial Phase 14):**
|
||||||
|
- Diffs today's snapshots vs previous, attaches news catalysts, M&A always surfaced
|
||||||
|
- `GET /api/digest`, `npm run digest:daily`, Discord webhook (forum-channel aware), `npm run discord:test`
|
||||||
|
|
||||||
|
**UI (screener page):**
|
||||||
|
- Market Pulse header band — full-bleed sector bubbles (SPDR ETFs, 15-min cache), leader headline, loading/unavailable states; `GET /api/screen/sectors`
|
||||||
|
- Sector drill-down panel — top-10 ETF holdings screened on demand, Today/1Y gain sort, 3-day sector news; `GET /api/screen/sector/:sector`
|
||||||
|
- Ticker modal — company profile, range-switchable chart (1D…5Y, hover crosshair), Yahoo analyst target bar + Zacks link, latest news; `GET /api/screen/profile/:ticker`, `GET /api/screen/chart/:ticker?range=`
|
||||||
|
- Plain-language advice layer (`adviceFor`) — "Buy — stable growth" / "Buy, but expect dips" on Strong Buys + no-data honesty; full text in modal
|
||||||
|
- ↗ Turnaround watch + 💎 Quality dips filters (with live counts + self-explaining empty states) in the STOCK table header
|
||||||
|
- Score cell: negative-score fix, coverage chip, "No data" state
|
||||||
|
|
||||||
|
**New env vars:** `EDGAR_USER_AGENT` (recommended), `DISCORD_WEBHOOK_URL`, `NEWS_PRWIRE_FEEDS`, `NEWS_POLL`.
|
||||||
|
**Pages NOT yet touched in this sprint:** Portfolio, Market Calls, Safe Buys (still at their Phase 7 state — see realigned roadmap in PHASES.md).
|
||||||
|
|
||||||
### Two Scoring Lenses (Original)
|
### Two Scoring Lenses (Original)
|
||||||
|
|
||||||
Every asset is scored under two lenses:
|
Every asset is scored under two lenses:
|
||||||
@@ -1278,9 +1312,22 @@ lib/types/
|
|||||||
| **Regime badge colors** | `HIGH` = amber, `NORMAL` = muted gray, `LOW` = blue (driven by `data-regime` CSS attribute) |
|
| **Regime badge colors** | `HIGH` = amber, `NORMAL` = muted gray, `LOW` = blue (driven by `data-regime` CSS attribute) |
|
||||||
| **Signal Summary hidden** | Removed from `+page.svelte` — table section no longer renders |
|
| **Signal Summary hidden** | Removed from `+page.svelte` — table section no longer renders |
|
||||||
|
|
||||||
#### 🔲 Next Up (Phase 10.5 Remaining)
|
#### 🔲 Next Up (Phase 10.5 Remaining — status corrected June 2026)
|
||||||
|
|
||||||
These five items are the immediate next build targets, in priority order:
|
Item 1 (tearsheet) is **partially superseded**: the Ticker Modal now delivers
|
||||||
|
the chart, company profile, analyst targets, and news. Still genuinely
|
||||||
|
pending from the original list:
|
||||||
|
|
||||||
|
1. **P/E + ROE + 52W columns in main table** (10.5c) — not started
|
||||||
|
2. **Valuation context / peer comparison** (10.5d §2) — modal has analyst
|
||||||
|
targets but no sector/S&P comparison table
|
||||||
|
3. **Numeric range filters for P/E and ROE** (10.5b) — price min/max and
|
||||||
|
score min exist; P/E-max / ROE-min do not
|
||||||
|
4. **Threshold sensitivity what-ifs** (10.5d §5) — not started
|
||||||
|
5. **Decision logging + backtest** (10.5e) — not started, but the snapshot
|
||||||
|
ledger (P0.1) now provides its data foundation
|
||||||
|
|
||||||
|
Original spec below for reference:
|
||||||
|
|
||||||
**1. Slide-in tearsheet panel** (`10.5d`)
|
**1. Slide-in tearsheet panel** (`10.5d`)
|
||||||
- Replace the current inline expand row with a 420px right-side slide-in panel (CSS `transform: translateX` animation, 0.2s)
|
- Replace the current inline expand row with a 420px right-side slide-in panel (CSS `transform: translateX` animation, 0.2s)
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
# PHASES.md
|
# 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+.
|
Complete roadmap for market-screener evolution from Phase 9 through Phase 16+.
|
||||||
|
|
||||||
## Phase 9 — Subdomain Restructure: Server Layer Organization
|
## Phase 9 — Subdomain Restructure: Server Layer Organization
|
||||||
@@ -389,6 +422,8 @@ Consider consistency across three locations, visual hierarchy differences, and m
|
|||||||
|
|
||||||
## Phase 10.9 — Strong Buys: Professional Dip Opportunity Monitor
|
## 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.
|
**Goal:** Flag quality stocks when they drop 5%+ from 52W high, with market analysis of why.
|
||||||
|
|
||||||
### 10.9a — Data Structure
|
### 10.9a — Data Structure
|
||||||
@@ -566,6 +601,8 @@ Create `lib/stores/auth.store.svelte.ts` for currentUser, JWT, login/logout.
|
|||||||
|
|
||||||
## Phase 12 — Day Trading: News Webhooks
|
## 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.
|
**Goal:** Ingest real-time market news via Polygon.io webhooks.
|
||||||
|
|
||||||
**Timeline:** 2-3 weeks.
|
**Timeline:** 2-3 weeks.
|
||||||
@@ -670,6 +707,8 @@ Route to cheaper models (Sonnet) when cost-sensitive. Fallback to OpenAI if rate
|
|||||||
|
|
||||||
## Phase 14 — Day Trading: Safe Buys Monitor with Discord Alerts
|
## 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.
|
**Goal:** Monitor safe-buy stocks in real-time, detect 5%+ dips, notify via Discord.
|
||||||
|
|
||||||
**Timeline:** 3-4 weeks.
|
**Timeline:** 3-4 weeks.
|
||||||
|
|||||||
@@ -102,6 +102,37 @@ Defaults to `http://localhost:5173`. Change if the UI is served from a different
|
|||||||
CLIENT_ORIGIN=https://yourdomain.com
|
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
|
### Complete `.env` example
|
||||||
|
|
||||||
```env
|
```env
|
||||||
@@ -109,6 +140,8 @@ ANTHROPIC_API_KEY=sk-ant-...
|
|||||||
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
|
SIMPLEFIN_SETUP_TOKEN=aHR0cHM6Ly...
|
||||||
API_KEY=optional-secret
|
API_KEY=optional-secret
|
||||||
CLIENT_ORIGIN=http://localhost:5173
|
CLIENT_ORIGIN=http://localhost:5173
|
||||||
|
EDGAR_USER_AGENT=market-screener/1.0 you@example.com
|
||||||
|
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -127,6 +160,16 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
| `npm run format:check` | Check formatting without writing (used in CI) |
|
| `npm run format:check` | Check formatting without writing (used in CI) |
|
||||||
| `npm run lint` | Run ESLint on all TypeScript files |
|
| `npm run lint` | Run ESLint on all TypeScript files |
|
||||||
| `npm run lint:fix` | Auto-fix ESLint issues |
|
| `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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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 */
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
"lint": "eslint . --ext .ts,.js",
|
"lint": "eslint . --ext .ts,.js",
|
||||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||||
"screen:daily": "tsx bin/daily-screen.ts",
|
"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": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
|
||||||
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
|
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|||||||
+43
-1
@@ -11,6 +11,16 @@ import { CallsController, CalendarService } from './domains/calls';
|
|||||||
import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth';
|
import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth';
|
||||||
import type { TokenPayload } from './domains/auth';
|
import type { TokenPayload } from './domains/auth';
|
||||||
import { WatchlistController, WatchlistRepository } from './domains/watchlist';
|
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
|
// Shared infrastructure
|
||||||
import {
|
import {
|
||||||
@@ -141,7 +151,14 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
|||||||
|
|
||||||
// Register controllers
|
// Register controllers
|
||||||
// Public routes (GET) remain open; write routes require JWT + trader role
|
// Public routes (GET) remain open; write routes require JWT + trader role
|
||||||
new ScreenerController(engine, catalystCache, new SignalSnapshotRepository(db)).register(app);
|
const newsRepo = new NewsRepository(db);
|
||||||
|
new ScreenerController(
|
||||||
|
engine,
|
||||||
|
catalystCache,
|
||||||
|
new SignalSnapshotRepository(db),
|
||||||
|
yahoo,
|
||||||
|
newsRepo,
|
||||||
|
).register(app);
|
||||||
new FinanceController(engine, new PortfolioRepository(db), advisor, {
|
new FinanceController(engine, new PortfolioRepository(db), advisor, {
|
||||||
authGuard,
|
authGuard,
|
||||||
traderGuard,
|
traderGuard,
|
||||||
@@ -154,6 +171,31 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
|||||||
|
|
||||||
new WatchlistController(new WatchlistRepository(db), { authGuard }).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' }));
|
app.get('/health', async () => ({ status: 'ok' }));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -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,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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,42 @@
|
|||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { ScreenerEngine } from './ScreenerEngine';
|
import { ScreenerEngine } from './ScreenerEngine';
|
||||||
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
|
import { CatalystCache, SignalSnapshotRepository, YahooFinanceClient } from '../../domains/shared';
|
||||||
import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
||||||
|
import type { NewsRepository } from '../news/NewsRepository';
|
||||||
import { screenSchema } from '../../domains/shared/types/schemas';
|
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||||
|
|
||||||
export class ScreenerController {
|
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(
|
constructor(
|
||||||
private readonly engine: ScreenerEngine,
|
private readonly engine: ScreenerEngine,
|
||||||
private readonly catalystCache: CatalystCache,
|
private readonly catalystCache: CatalystCache,
|
||||||
// Optional so tests and minimal setups work without a database.
|
// Optional so tests and minimal setups work without a database.
|
||||||
private readonly snapshots?: SignalSnapshotRepository,
|
private readonly snapshots?: SignalSnapshotRepository,
|
||||||
|
private readonly yahoo?: YahooFinanceClient,
|
||||||
|
private readonly news?: NewsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
register(app: FastifyInstance): void {
|
register(app: FastifyInstance): void {
|
||||||
@@ -24,6 +51,161 @@ export class ScreenerController {
|
|||||||
this.catalysts.bind(this),
|
this.catalysts.bind(this),
|
||||||
);
|
);
|
||||||
app.get('/api/screen/history/:ticker', this.history.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). */
|
/** Signal snapshot history for one ticker (P0.1 ledger read side). */
|
||||||
@@ -65,6 +247,7 @@ export class ScreenerController {
|
|||||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||||
const results = await this.engine.screenTickers(tickers);
|
const results = await this.engine.screenTickers(tickers);
|
||||||
this.recordSnapshots(results, req);
|
this.recordSnapshots(results, req);
|
||||||
|
this.flagTurnarounds(results);
|
||||||
const dataHealth = ScreenerController.assessDataHealth(results);
|
const dataHealth = ScreenerController.assessDataHealth(results);
|
||||||
if (dataHealth.degraded) {
|
if (dataHealth.degraded) {
|
||||||
req.log?.warn?.({ dataHealth }, 'screen batch returned degraded fundamentals data');
|
req.log?.warn?.({ dataHealth }, 'screen batch returned degraded fundamentals data');
|
||||||
@@ -78,6 +261,35 @@ export class ScreenerController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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
|
* with null core fundamentals (P/E, ROE), the upstream source has likely
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export class DataMapper {
|
|||||||
|
|
||||||
const currentPrice = pr.regularMarketPrice ?? 0;
|
const currentPrice = pr.regularMarketPrice ?? 0;
|
||||||
const sharesOutstanding = ks.sharesOutstanding ?? 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 operatingCashflow = fd.operatingCashflow ?? 0;
|
||||||
const freeCashflow = fd.freeCashflow ?? 0;
|
const freeCashflow = fd.freeCashflow ?? 0;
|
||||||
|
|
||||||
@@ -131,6 +138,7 @@ export class DataMapper {
|
|||||||
? (sd.trailingAnnualDividendYield as number) * 100
|
? (sd.trailingAnnualDividendYield as number) * 100
|
||||||
: null,
|
: null,
|
||||||
beta: sd.beta ?? null,
|
beta: sd.beta ?? null,
|
||||||
|
dayChangePct,
|
||||||
week52High,
|
week52High,
|
||||||
week52Low,
|
week52Low,
|
||||||
week52Change,
|
week52Change,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import YahooFinance from 'yahoo-finance2';
|
import YahooFinance from 'yahoo-finance2';
|
||||||
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib } from '../types';
|
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib, PricePoint } from '../types';
|
||||||
import { YAHOO_MODULES } from '../config/constants';
|
import { YAHOO_MODULES } from '../config/constants';
|
||||||
|
|
||||||
export class YahooFinanceClient {
|
export class YahooFinanceClient {
|
||||||
@@ -49,4 +49,71 @@ export class YahooFinanceClient {
|
|||||||
const { news = [] } = await this.lib.search(query, opts);
|
const { news = [] } = await this.lib.search(query, opts);
|
||||||
return news;
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,68 @@ export const UNIVERSE_QUERIES = {
|
|||||||
WHERE type != 'crypto'
|
WHERE type != 'crypto'
|
||||||
ORDER BY ticker
|
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) ────────────────────
|
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
|
||||||
@@ -287,6 +349,31 @@ export const DDL = `
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON signal_snapshots(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 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) ────────────────────────
|
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class Stock extends Asset {
|
|||||||
pFFO: data.pFFO ?? null,
|
pFFO: data.pFFO ?? null,
|
||||||
dividendYield: data.dividendYield ?? null,
|
dividendYield: data.dividendYield ?? null,
|
||||||
beta: data.beta ?? null,
|
beta: data.beta ?? null,
|
||||||
|
dayChangePct: data.dayChangePct ?? null,
|
||||||
week52High: data.week52High ?? null,
|
week52High: data.week52High ?? null,
|
||||||
week52Low: data.week52Low ?? null,
|
week52Low: data.week52Low ?? null,
|
||||||
week52Change: data.week52Change ?? null,
|
week52Change: data.week52Change ?? null,
|
||||||
@@ -192,7 +193,8 @@ export class Stock extends Asset {
|
|||||||
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
|
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
|
||||||
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
|
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
|
||||||
|
|
||||||
// 52-week movement
|
// Movement
|
||||||
|
if (m.dayChangePct != null) display['Day %'] = fmtSign(m.dayChangePct, '%');
|
||||||
if (w52pos != null) display['52W Pos'] = w52pos;
|
if (w52pos != null) display['52W Pos'] = w52pos;
|
||||||
if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%');
|
if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%');
|
||||||
if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%');
|
if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%');
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ export interface AssetResult {
|
|||||||
signal: Signal;
|
signal: Signal;
|
||||||
inflated: ScoreResult;
|
inflated: ScoreResult;
|
||||||
fundamental: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ export interface YahooNewsItem {
|
|||||||
publisher: string;
|
publisher: string;
|
||||||
link: string;
|
link: string;
|
||||||
relatedTickers?: string[];
|
relatedTickers?: string[];
|
||||||
|
providerPublishTime?: string | number | Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YahooSearchOptions {
|
export interface YahooSearchOptions {
|
||||||
@@ -66,6 +67,17 @@ export interface YahooFinanceLib {
|
|||||||
queryOpts?: { validateResult?: boolean },
|
queryOpts?: { validateResult?: boolean },
|
||||||
): Promise<any>;
|
): Promise<any>;
|
||||||
search(query: string, opts?: YahooSearchOptions): Promise<{ news?: YahooNewsItem[] }>;
|
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 ─────────────────────────────────────────────────
|
// ── SimpleFIN client types ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type {
|
|||||||
YahooNewsItem,
|
YahooNewsItem,
|
||||||
YahooSearchOptions,
|
YahooSearchOptions,
|
||||||
YahooFinanceLib,
|
YahooFinanceLib,
|
||||||
|
PricePoint,
|
||||||
SimpleFINOptions,
|
SimpleFINOptions,
|
||||||
SimpleFINTransaction,
|
SimpleFINTransaction,
|
||||||
SimpleFINAccount,
|
SimpleFINAccount,
|
||||||
@@ -55,6 +56,14 @@ export type {
|
|||||||
HoldingRow,
|
HoldingRow,
|
||||||
SignalSnapshotRow,
|
SignalSnapshotRow,
|
||||||
} from './repositories.model';
|
} 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 { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
|
||||||
export type {
|
export type {
|
||||||
BenchmarkProviderOptions,
|
BenchmarkProviderOptions,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface StockData {
|
|||||||
pFFO?: number | null;
|
pFFO?: number | null;
|
||||||
dividendYield?: number | null;
|
dividendYield?: number | null;
|
||||||
beta?: number | null;
|
beta?: number | null;
|
||||||
|
dayChangePct?: number | null;
|
||||||
week52High?: number | null;
|
week52High?: number | null;
|
||||||
week52Low?: number | null;
|
week52Low?: number | null;
|
||||||
week52Change?: number | null;
|
week52Change?: number | null;
|
||||||
@@ -66,6 +67,7 @@ export interface StockMetrics {
|
|||||||
pFFO: number | null;
|
pFFO: number | null;
|
||||||
dividendYield: number | null;
|
dividendYield: number | null;
|
||||||
beta: number | null;
|
beta: number | null;
|
||||||
|
dayChangePct: number | null;
|
||||||
week52High: number | null;
|
week52High: number | null;
|
||||||
week52Low: number | null;
|
week52Low: number | null;
|
||||||
week52Change: number | null;
|
week52Change: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* News pipeline types (FREE-DATA-STACK.md).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NewsSource = 'edgar' | 'prwire' | 'yahoo';
|
||||||
|
|
||||||
|
export type CatalystType = 'earnings' | 'ma' | 'guidance' | 'regulatory' | 'macro';
|
||||||
|
|
||||||
|
/** One story after a poller has normalized it — the only shape the pipeline accepts. */
|
||||||
|
export interface NormalizedStory {
|
||||||
|
tickers: string[];
|
||||||
|
headline: string;
|
||||||
|
body?: string | null;
|
||||||
|
source: NewsSource;
|
||||||
|
url: string;
|
||||||
|
publishedAt: string; // ISO timestamp
|
||||||
|
/** Poller-supplied classification (e.g. EDGAR form type); overrides keyword classify. */
|
||||||
|
catalystHint?: CatalystType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw row from news_articles (snake_case, as stored). */
|
||||||
|
export interface NewsArticleRow {
|
||||||
|
url_hash: string;
|
||||||
|
title_hash: string;
|
||||||
|
ticker_list: string; // JSON array stringified
|
||||||
|
headline: string;
|
||||||
|
body: string | null;
|
||||||
|
source: string;
|
||||||
|
catalyst: string | null;
|
||||||
|
url: string;
|
||||||
|
published_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one ingest run did — logged by pollers and bin/poll-news. */
|
||||||
|
export interface IngestStats {
|
||||||
|
fetched: number;
|
||||||
|
stored: number;
|
||||||
|
droppedNoUniverseTicker: number;
|
||||||
|
droppedNoise: number;
|
||||||
|
droppedDuplicate: number;
|
||||||
|
droppedCapped: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DigestService } from '../server/domains/digest/DigestService.js';
|
||||||
|
import { DiscordNotifier } from '../server/domains/digest/DiscordNotifier.js';
|
||||||
|
import type { SignalSnapshotRepository } from '../server/domains/shared/persistence/SignalSnapshotRepository.js';
|
||||||
|
import type { NewsRepository } from '../server/domains/news/NewsRepository.js';
|
||||||
|
import type { NewsArticleRow, SignalSnapshotRow } from '../server/domains/shared/types/index.js';
|
||||||
|
|
||||||
|
function snap(over: Partial<SignalSnapshotRow>): SignalSnapshotRow {
|
||||||
|
return {
|
||||||
|
ticker: 'AAPL',
|
||||||
|
snapshot_date: '2026-06-09',
|
||||||
|
asset_type: 'STOCK',
|
||||||
|
price: 189.5,
|
||||||
|
signal: '✅ Strong Buy',
|
||||||
|
fundamental_tier: 'PASS',
|
||||||
|
fundamental_score: 9,
|
||||||
|
fundamental_label: '🟢 BUY (High Conviction)',
|
||||||
|
inflated_tier: 'PASS',
|
||||||
|
inflated_score: 9,
|
||||||
|
inflated_label: '🟢 BUY (High Conviction)',
|
||||||
|
coverage_active: 8,
|
||||||
|
coverage_total: 11,
|
||||||
|
risk_flags: null,
|
||||||
|
rate_regime: 'NORMAL',
|
||||||
|
created_at: '2026-06-09T21:00:00.000Z',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function article(over: Partial<NewsArticleRow>): NewsArticleRow {
|
||||||
|
return {
|
||||||
|
url_hash: 'h1',
|
||||||
|
title_hash: 't1',
|
||||||
|
ticker_list: '["AAPL"]',
|
||||||
|
headline: '8-K filing: APPLE INC',
|
||||||
|
body: null,
|
||||||
|
source: 'edgar',
|
||||||
|
catalyst: 'regulatory',
|
||||||
|
url: 'https://sec.gov/x',
|
||||||
|
published_at: '2026-06-08T20:00:00.000Z',
|
||||||
|
created_at: '2026-06-08T20:01:00.000Z',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
today: SignalSnapshotRow[],
|
||||||
|
prev: SignalSnapshotRow[],
|
||||||
|
newsByTicker: Record<string, NewsArticleRow[]> = {},
|
||||||
|
): DigestService {
|
||||||
|
const snapshots = {
|
||||||
|
byDate: () => today,
|
||||||
|
latestBefore: () => prev,
|
||||||
|
} as unknown as SignalSnapshotRepository;
|
||||||
|
const news = {
|
||||||
|
newsForTicker: (t: string) => newsByTicker[t] ?? [],
|
||||||
|
} as unknown as NewsRepository;
|
||||||
|
return new DigestService(snapshots, news);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('DigestService', async (t) => {
|
||||||
|
await t.test('detects signal change and attaches catalysts', () => {
|
||||||
|
const service = makeService(
|
||||||
|
[snap({ signal: '🔄 Neutral', fundamental_score: 2 })],
|
||||||
|
[snap({ snapshot_date: '2026-06-08', signal: '✅ Strong Buy', fundamental_score: 9 })],
|
||||||
|
{ AAPL: [article({})] },
|
||||||
|
);
|
||||||
|
const report = service.build('2026-06-09');
|
||||||
|
assert.equal(report.changes.length, 1);
|
||||||
|
const c = report.changes[0];
|
||||||
|
assert.equal(c.previousSignal, '✅ Strong Buy');
|
||||||
|
assert.equal(c.newSignal, '🔄 Neutral');
|
||||||
|
assert.equal(c.scoreDelta, -7);
|
||||||
|
assert.equal(c.catalysts.length, 1);
|
||||||
|
assert.equal(c.catalysts[0].catalyst, 'regulatory');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('no change → empty digest', () => {
|
||||||
|
const service = makeService([snap({})], [snap({ snapshot_date: '2026-06-08' })]);
|
||||||
|
const report = service.build('2026-06-09');
|
||||||
|
assert.equal(report.changes.length, 0);
|
||||||
|
assert.equal(report.snapshotCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('first-ever snapshot lands in newTickers, not changes', () => {
|
||||||
|
const service = makeService([snap({ ticker: 'NVDA' })], []);
|
||||||
|
const report = service.build('2026-06-09');
|
||||||
|
assert.equal(report.changes.length, 0);
|
||||||
|
assert.deepEqual(report.newTickers, ['NVDA']);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('M&A stories surface even without a signal change', () => {
|
||||||
|
const service = makeService(
|
||||||
|
[snap({})],
|
||||||
|
[snap({ snapshot_date: '2026-06-08' })], // same signal — no change
|
||||||
|
{
|
||||||
|
AAPL: [
|
||||||
|
article({
|
||||||
|
catalyst: 'ma',
|
||||||
|
headline: 'SC 13D filing: APPLE INC',
|
||||||
|
url_hash: 'h2',
|
||||||
|
url: 'https://sec.gov/13d',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const report = service.build('2026-06-09');
|
||||||
|
assert.equal(report.changes.length, 0);
|
||||||
|
assert.equal(report.maStories.length, 1);
|
||||||
|
assert.ok(report.maStories[0].headline.includes('SC 13D'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('sorts changes by signal-distance impact', () => {
|
||||||
|
const service = makeService(
|
||||||
|
[
|
||||||
|
snap({ ticker: 'SMALL', signal: '⚡ Momentum' }), // Strong Buy(0) → Momentum(1): impact 1
|
||||||
|
snap({ ticker: 'BIG', signal: '❌ Avoid' }), // Strong Buy(0) → Avoid(4): impact 4
|
||||||
|
],
|
||||||
|
[
|
||||||
|
snap({ ticker: 'SMALL', snapshot_date: '2026-06-08', signal: '✅ Strong Buy' }),
|
||||||
|
snap({ ticker: 'BIG', snapshot_date: '2026-06-08', signal: '✅ Strong Buy' }),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const report = service.build('2026-06-09');
|
||||||
|
assert.equal(report.changes[0].ticker, 'BIG');
|
||||||
|
assert.equal(report.changes[1].ticker, 'SMALL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DiscordNotifier.buildPayload', async (t) => {
|
||||||
|
await t.test('returns null when nothing to report', () => {
|
||||||
|
assert.equal(
|
||||||
|
DiscordNotifier.buildPayload({
|
||||||
|
date: '2026-06-09',
|
||||||
|
changes: [],
|
||||||
|
newTickers: [],
|
||||||
|
maStories: [],
|
||||||
|
snapshotCount: 5,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('builds embed with change fields and M&A section', () => {
|
||||||
|
const payload = DiscordNotifier.buildPayload({
|
||||||
|
date: '2026-06-09',
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
ticker: 'AAPL',
|
||||||
|
previousSignal: '✅ Strong Buy',
|
||||||
|
newSignal: '🔄 Neutral',
|
||||||
|
previousDate: '2026-06-08',
|
||||||
|
scoreDelta: -7,
|
||||||
|
price: 189.5,
|
||||||
|
catalysts: [
|
||||||
|
{
|
||||||
|
headline: '8-K filing: APPLE INC',
|
||||||
|
catalyst: 'regulatory',
|
||||||
|
source: 'edgar',
|
||||||
|
url: 'https://sec.gov/x',
|
||||||
|
publishedAt: '2026-06-08T20:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
newTickers: [],
|
||||||
|
maStories: [
|
||||||
|
{
|
||||||
|
headline: 'SC 13D filing: APPLE INC',
|
||||||
|
catalyst: 'ma',
|
||||||
|
source: 'edgar',
|
||||||
|
url: 'https://sec.gov/13d',
|
||||||
|
publishedAt: '2026-06-08T21:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
snapshotCount: 12,
|
||||||
|
});
|
||||||
|
assert.ok(payload);
|
||||||
|
const embed = payload.embeds[0] as {
|
||||||
|
title: string;
|
||||||
|
fields: Array<{ name: string; value: string }>;
|
||||||
|
};
|
||||||
|
assert.ok(embed.title.includes('2026-06-09'));
|
||||||
|
assert.equal(embed.fields.length, 2); // 1 change + 1 M&A section
|
||||||
|
assert.ok(embed.fields[0].name.includes('AAPL'));
|
||||||
|
assert.ok(embed.fields[0].name.includes('score -7'));
|
||||||
|
assert.ok(embed.fields[0].value.includes('regulatory'));
|
||||||
|
assert.ok(embed.fields[1].name.includes('M&A'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { NewsPipeline } from '../server/domains/news/NewsPipeline.js';
|
||||||
|
import type { NewsRepository } from '../server/domains/news/NewsRepository.js';
|
||||||
|
import type { NormalizedStory } from '../server/domains/shared/types/index.js';
|
||||||
|
|
||||||
|
/** In-memory stub that records what the pipeline stores. */
|
||||||
|
class StubRepo {
|
||||||
|
articles: Array<{ urlHash: string; tickers: string[]; catalyst: string | null }> = [];
|
||||||
|
links: Array<{ ticker: string; day: string }> = [];
|
||||||
|
seenTitles = new Set<string>();
|
||||||
|
capCounts = new Map<string, number>(); // `${ticker}|${day}` → count
|
||||||
|
|
||||||
|
insertArticle(a: { urlHash: string; tickers: string[]; catalyst: string | null }): boolean {
|
||||||
|
if (this.articles.some((x) => x.urlHash === a.urlHash)) return false;
|
||||||
|
this.articles.push(a);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
titleSeenSince(titleHash: string): boolean {
|
||||||
|
return this.seenTitles.has(titleHash);
|
||||||
|
}
|
||||||
|
linkTicker(ticker: string, day: string): void {
|
||||||
|
this.links.push({ ticker, day });
|
||||||
|
}
|
||||||
|
countTickerDay(ticker: string, day: string): number {
|
||||||
|
return this.capCounts.get(`${ticker}|${day}`) ?? 0;
|
||||||
|
}
|
||||||
|
purgeBodiesBefore(): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
deleteUnreferencedBefore(): number {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIVERSE = new Set(['AAPL', 'MSFT']);
|
||||||
|
|
||||||
|
function story(overrides: Partial<NormalizedStory> = {}): NormalizedStory {
|
||||||
|
return {
|
||||||
|
tickers: ['AAPL'],
|
||||||
|
headline: 'Apple announces quarterly results beat estimates',
|
||||||
|
source: 'prwire',
|
||||||
|
url: `https://example.com/${Math.random()}`,
|
||||||
|
publishedAt: '2026-06-09T14:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePipeline(repo: StubRepo): NewsPipeline {
|
||||||
|
return new NewsPipeline(repo as unknown as NewsRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('NewsPipeline', async (t) => {
|
||||||
|
await t.test('stores universe stories and links tickers', () => {
|
||||||
|
const repo = new StubRepo();
|
||||||
|
const stats = makePipeline(repo).ingest([story()], UNIVERSE);
|
||||||
|
assert.equal(stats.stored, 1);
|
||||||
|
assert.equal(repo.links.length, 1);
|
||||||
|
assert.equal(repo.links[0].ticker, 'AAPL');
|
||||||
|
assert.equal(repo.links[0].day, '2026-06-09');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('drops stories with no universe ticker (§4.1)', () => {
|
||||||
|
const repo = new StubRepo();
|
||||||
|
const stats = makePipeline(repo).ingest([story({ tickers: ['ZZZZ'] })], UNIVERSE);
|
||||||
|
assert.equal(stats.stored, 0);
|
||||||
|
assert.equal(stats.droppedNoUniverseTicker, 1);
|
||||||
|
assert.equal(repo.articles.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('drops noise headlines, but never filings (§4.2)', () => {
|
||||||
|
const repo = new StubRepo();
|
||||||
|
const noise = story({ headline: '5 best stocks to buy now including Apple' });
|
||||||
|
const filing = story({
|
||||||
|
headline: '8-K filing: 5 best stocks edge case',
|
||||||
|
source: 'edgar',
|
||||||
|
catalystHint: 'regulatory',
|
||||||
|
});
|
||||||
|
const stats = makePipeline(repo).ingest([noise, filing], UNIVERSE);
|
||||||
|
assert.equal(stats.droppedNoise, 1);
|
||||||
|
assert.equal(stats.stored, 1);
|
||||||
|
assert.equal(repo.articles[0].catalyst, 'regulatory');
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('drops syndicated duplicates by normalized title (§4.3)', () => {
|
||||||
|
const repo = new StubRepo();
|
||||||
|
const pipeline = makePipeline(repo);
|
||||||
|
// First copy stored; mark its normalized-title hash as seen
|
||||||
|
pipeline.ingest([story({ headline: 'Apple Beats Q2 Estimates!' })], UNIVERSE);
|
||||||
|
repo.seenTitles.add(sha256(NewsPipeline.normalizeTitle('Apple Beats Q2 Estimates!')));
|
||||||
|
// Same story, different casing/punctuation/URL → syndicated copy
|
||||||
|
const stats = pipeline.ingest(
|
||||||
|
[story({ headline: 'APPLE BEATS Q2 ESTIMATES', url: 'https://other.com/copy' })],
|
||||||
|
UNIVERSE,
|
||||||
|
);
|
||||||
|
assert.equal(stats.droppedDuplicate, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('enforces per-ticker daily cap, filings exempt (§4.4)', () => {
|
||||||
|
const repo = new StubRepo();
|
||||||
|
repo.capCounts.set('AAPL|2026-06-09', 25); // at cap
|
||||||
|
const wire = story();
|
||||||
|
const filing = story({ source: 'edgar', catalystHint: 'ma', url: 'https://sec.gov/x' });
|
||||||
|
const stats = makePipeline(repo).ingest([wire, filing], UNIVERSE);
|
||||||
|
assert.equal(stats.droppedCapped, 1);
|
||||||
|
assert.equal(stats.stored, 1); // the filing
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('classifies catalysts with M&A taking priority', () => {
|
||||||
|
assert.equal(NewsPipeline.classify('Acme to be acquired by MegaCorp in Q2 deal'), 'ma');
|
||||||
|
assert.equal(NewsPipeline.classify('Acme reports record quarterly results'), 'earnings');
|
||||||
|
assert.equal(NewsPipeline.classify('Acme raises full-year guidance'), 'guidance');
|
||||||
|
assert.equal(NewsPipeline.classify('FDA approval granted for Acme drug'), 'regulatory');
|
||||||
|
assert.equal(NewsPipeline.classify('Fed holds rates steady amid CPI data'), 'macro');
|
||||||
|
assert.equal(NewsPipeline.classify('Acme appoints new CMO'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('noise detector catches listicles and target reiterations', () => {
|
||||||
|
assert.ok(NewsPipeline.isNoise('3 Top Stocks to Watch This Week'));
|
||||||
|
assert.ok(NewsPipeline.isNoise('Analyst price target raised on momentum'));
|
||||||
|
assert.ok(!NewsPipeline.isNoise('Apple announces $90B buyback'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper mirroring NewsPipeline's title hashing for the dedupe test
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
function sha256(input: string): string {
|
||||||
|
return createHash('sha256').update(input).digest('hex');
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EdgarPoller } from '../server/domains/news/pollers/EdgarPoller.js';
|
||||||
|
import { PrWirePoller } from '../server/domains/news/pollers/PrWirePoller.js';
|
||||||
|
import { RssParser } from '../server/domains/news/rss.js';
|
||||||
|
import { noopLogger } from '../server/domains/shared/utils/logger.js';
|
||||||
|
|
||||||
|
const EDGAR_ATOM = `<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<title>Latest Filings</title>
|
||||||
|
<entry>
|
||||||
|
<title>8-K - APPLE INC (0000320193) (Filer)</title>
|
||||||
|
<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/320193/000032019326000001-index.htm"/>
|
||||||
|
<updated>2026-06-09T13:01:02-04:00</updated>
|
||||||
|
<id>urn:tag:sec.gov,2008:accession-number=0000320193-26-000001</id>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<title>8-K - UNKNOWN CO (0009999999) (Filer)</title>
|
||||||
|
<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/9999999/x-index.htm"/>
|
||||||
|
<updated>2026-06-09T13:05:00-04:00</updated>
|
||||||
|
<id>urn:tag:sec.gov,2008:accession-number=x</id>
|
||||||
|
</entry>
|
||||||
|
</feed>`;
|
||||||
|
|
||||||
|
const PRWIRE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<item>
|
||||||
|
<title>Acme Corp (NYSE: ACME) Announces Record Q2 Results</title>
|
||||||
|
<link>https://www.example.com/acme-q2</link>
|
||||||
|
<pubDate>Tue, 09 Jun 2026 12:00:00 GMT</pubDate>
|
||||||
|
<description><![CDATA[Acme Corp (NYSE: ACME) and partner Beta Inc (Nasdaq: BETA) today announced...]]></description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Local bakery wins award</title>
|
||||||
|
<link>https://www.example.com/bakery</link>
|
||||||
|
<pubDate>Tue, 09 Jun 2026 11:00:00 GMT</pubDate>
|
||||||
|
<description>No public companies here.</description>
|
||||||
|
</item>
|
||||||
|
</channel></rss>`;
|
||||||
|
|
||||||
|
test('news pollers', async (t) => {
|
||||||
|
await t.test('EdgarPoller maps CIK to ticker and filters by universe', () => {
|
||||||
|
const poller = new EdgarPoller(noopLogger, 'test-agent');
|
||||||
|
poller.setTickerMap(new Map([['0000320193', 'AAPL']]));
|
||||||
|
|
||||||
|
const stories = poller.parseFeed(EDGAR_ATOM, '8-K', 'regulatory', new Set(['AAPL']));
|
||||||
|
assert.equal(stories.length, 1); // unknown CIK dropped
|
||||||
|
assert.deepEqual(stories[0].tickers, ['AAPL']);
|
||||||
|
assert.equal(stories[0].source, 'edgar');
|
||||||
|
assert.equal(stories[0].catalystHint, 'regulatory');
|
||||||
|
assert.ok(stories[0].headline.startsWith('8-K filing:'));
|
||||||
|
assert.ok(stories[0].headline.includes('APPLE INC'));
|
||||||
|
assert.ok(stories[0].url.includes('sec.gov'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('EdgarPoller drops universe misses', () => {
|
||||||
|
const poller = new EdgarPoller(noopLogger, 'test-agent');
|
||||||
|
poller.setTickerMap(new Map([['0000320193', 'AAPL']]));
|
||||||
|
const stories = poller.parseFeed(EDGAR_ATOM, '8-K', 'regulatory', new Set(['MSFT']));
|
||||||
|
assert.equal(stories.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('PrWirePoller extracts exchange-tagged tickers', () => {
|
||||||
|
const stories = PrWirePoller.parseFeed(PRWIRE_RSS);
|
||||||
|
assert.equal(stories.length, 1); // bakery story has no tickers → skipped
|
||||||
|
assert.deepEqual(stories[0].tickers.sort(), ['ACME', 'BETA']);
|
||||||
|
assert.equal(stories[0].source, 'prwire');
|
||||||
|
assert.ok(stories[0].publishedAt.startsWith('2026-06-09'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('extractTickers handles exchange tag variants', () => {
|
||||||
|
assert.deepEqual(PrWirePoller.extractTickers('(NYSE: ABC)'), ['ABC']);
|
||||||
|
assert.deepEqual(PrWirePoller.extractTickers('(Nasdaq: xyz)'), ['XYZ']);
|
||||||
|
assert.deepEqual(PrWirePoller.extractTickers('(NYSE American: BRK.B)'), ['BRK.B']);
|
||||||
|
assert.deepEqual(PrWirePoller.extractTickers('(OTCQB: TINY)'), ['TINY']);
|
||||||
|
assert.deepEqual(PrWirePoller.extractTickers('no tags here'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test('RssParser decodes entities and strips CDATA', () => {
|
||||||
|
const block = '<item><title>A & B say "hi"</title></item>';
|
||||||
|
assert.equal(RssParser.tag(block, 'title'), 'A & B say "hi"');
|
||||||
|
const cdata = '<item><description><![CDATA[Text <b>bold</b> here]]></description></item>';
|
||||||
|
assert.equal(RssParser.tag(cdata, 'description'), 'Text bold here');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,21 @@
|
|||||||
// Existing imports from '$lib/api.js' continue to work via api.ts re-export.
|
// Existing imports from '$lib/api.js' continue to work via api.ts re-export.
|
||||||
|
|
||||||
export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js';
|
export { screenTickers, fetchCatalysts, analyzeTickers } from './screener.js';
|
||||||
|
export {
|
||||||
|
fetchProfile,
|
||||||
|
fetchChart,
|
||||||
|
fetchTickerNews,
|
||||||
|
fetchSectorPulse,
|
||||||
|
fetchSectorDetail,
|
||||||
|
} from './screener.js';
|
||||||
|
export type {
|
||||||
|
CompanyProfile,
|
||||||
|
PricePoint,
|
||||||
|
TickerNewsStory,
|
||||||
|
SectorPulse,
|
||||||
|
SectorPulseEntry,
|
||||||
|
SectorDetail,
|
||||||
|
} from './screener.js';
|
||||||
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
|
export { fetchPortfolio, addHolding, removeHolding, fetchMarketContext } from './finance.js';
|
||||||
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
|
export { fetchCalls, fetchCall, createCall, deleteCall, fetchCallsCalendar } from './calls.js';
|
||||||
export { login, register, authFetch } from './auth.js';
|
export { login, register, authFetch } from './auth.js';
|
||||||
|
|||||||
@@ -19,6 +19,99 @@ export async function fetchCatalysts(): Promise<{ tickers: string[]; stories: Ca
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ticker modal data (profile + chart + news) ─────────────────────────────
|
||||||
|
|
||||||
|
export interface AnalystTargets {
|
||||||
|
mean: number | null;
|
||||||
|
high: number | null;
|
||||||
|
low: number | null;
|
||||||
|
analysts: number | null;
|
||||||
|
recommendationMean: number | null; // 1=Strong Buy … 5=Strong Sell
|
||||||
|
upsidePct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyProfile {
|
||||||
|
name: string;
|
||||||
|
summary: string | null;
|
||||||
|
sector: string | null;
|
||||||
|
industry: string | null;
|
||||||
|
website: string | null;
|
||||||
|
employees: number | null;
|
||||||
|
marketCap: number | null;
|
||||||
|
currentPrice: number | null;
|
||||||
|
targets?: AnalystTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChartRange = '1d' | '5d' | '1mo' | '3mo' | '6mo' | 'ytd' | '1y' | '5y';
|
||||||
|
|
||||||
|
export interface PricePoint {
|
||||||
|
date: string;
|
||||||
|
close: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TickerNewsStory {
|
||||||
|
headline: string;
|
||||||
|
tickers: string[];
|
||||||
|
source: string;
|
||||||
|
catalyst: string | null;
|
||||||
|
url: string;
|
||||||
|
publishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProfile(ticker: string): Promise<CompanyProfile | null> {
|
||||||
|
const res = await fetch(`${BASE}/screen/profile/${encodeURIComponent(ticker)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = (await res.json()) as { profile: CompanyProfile | null };
|
||||||
|
return body.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChart(ticker: string, range: ChartRange = '6mo'): Promise<PricePoint[]> {
|
||||||
|
const res = await fetch(`${BASE}/screen/chart/${encodeURIComponent(ticker)}?range=${range}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = (await res.json()) as { points: PricePoint[] };
|
||||||
|
return body.points ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectorPulseEntry {
|
||||||
|
etf: string;
|
||||||
|
sector: string; // internal constant: TECHNOLOGY, FINANCIAL, …
|
||||||
|
name: string; // display name
|
||||||
|
changePct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectorPulse {
|
||||||
|
asOf: string | null;
|
||||||
|
leader: SectorPulseEntry | null;
|
||||||
|
sectors: SectorPulseEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSectorPulse(): Promise<SectorPulse | null> {
|
||||||
|
const res = await fetch(`${BASE}/screen/sectors`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectorDetail {
|
||||||
|
sector: string;
|
||||||
|
etf: string | null;
|
||||||
|
name?: string;
|
||||||
|
stocks: import('$lib/types.js').AssetResult[];
|
||||||
|
news: TickerNewsStory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSectorDetail(sector: string): Promise<SectorDetail | null> {
|
||||||
|
const res = await fetch(`${BASE}/screen/sector/${encodeURIComponent(sector)}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTickerNews(ticker: string, days = 14): Promise<TickerNewsStory[]> {
|
||||||
|
const res = await fetch(`${BASE}/news/${encodeURIComponent(ticker)}?days=${days}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const body = (await res.json()) as { stories: TickerNewsStory[] };
|
||||||
|
return body.stories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function analyzeTickers(
|
export async function analyzeTickers(
|
||||||
tickers: string[],
|
tickers: string[],
|
||||||
): Promise<{ analysis: LLMAnalysis | null; reason?: string | null }> {
|
): Promise<{ analysis: LLMAnalysis | null; reason?: string | null }> {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { sigOrd, sorted } from '$lib/utils.js';
|
import { sigOrd, sorted, adviceFor, isQualityDip } from '$lib/utils.js';
|
||||||
import Spinner from '$lib/components/shared/Spinner.svelte';
|
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||||
import GlossaryPanel from '$lib/components/screener/GlossaryPanel.svelte';
|
import GlossaryPanel from '$lib/components/screener/GlossaryPanel.svelte';
|
||||||
import SignalModal from '$lib/components/screener/SignalModal.svelte';
|
import SignalModal from '$lib/components/screener/SignalModal.svelte';
|
||||||
|
import TickerModal from '$lib/components/screener/TickerModal.svelte';
|
||||||
import type { AssetType, AssetResult } from '$lib/types.js';
|
import type { AssetType, AssetResult } from '$lib/types.js';
|
||||||
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
import { watchlistStore } from '$lib/stores/watchlist.store.svelte.js';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
let expanded = $state<string | null>(null);
|
let expanded = $state<string | null>(null);
|
||||||
let glossaryOpen = $state(false);
|
let glossaryOpen = $state(false);
|
||||||
let specModalRow = $state<AssetResult | null>(null);
|
let specModalRow = $state<AssetResult | null>(null);
|
||||||
|
let tickerModal = $state<AssetResult | null>(null);
|
||||||
let glossaryFocusKey = $state<string | null>(null);
|
let glossaryFocusKey = $state<string | null>(null);
|
||||||
let sortCol = $state<string | null>(null);
|
let sortCol = $state<string | null>(null);
|
||||||
let sortAsc = $state(true);
|
let sortAsc = $state(true);
|
||||||
@@ -33,21 +35,29 @@
|
|||||||
let filterPriceMax = $state('');
|
let filterPriceMax = $state('');
|
||||||
let filterScoreMin = $state('');
|
let filterScoreMin = $state('');
|
||||||
let filterFlags = $state(false);
|
let filterFlags = $state(false);
|
||||||
|
let filterTA = $state(false); // turnaround-watch only
|
||||||
|
let filterQD = $state(false); // quality dips only
|
||||||
|
|
||||||
const STYLE_OPTIONS = ['High Growth', 'Growth', 'Value', 'Stable', 'Turnaround', 'Declining'];
|
const STYLE_OPTIONS = ['High Growth', 'Growth', 'Value', 'Stable', 'Turnaround', 'Declining'];
|
||||||
const CAP_OPTIONS = ['Mega Cap', 'Large Cap', 'Mid Cap', 'Small Cap', 'Micro Cap'];
|
const CAP_OPTIONS = ['Mega Cap', 'Large Cap', 'Mid Cap', 'Small Cap', 'Micro Cap'];
|
||||||
|
|
||||||
function hasFilter() {
|
function hasFilter() {
|
||||||
return !!(filterTicker || filterSignal || filterStyle || filterCap || filterPriceMin || filterPriceMax || filterScoreMin || filterFlags);
|
return !!(filterTicker || filterSignal || filterStyle || filterCap || filterPriceMin || filterPriceMax || filterScoreMin || filterFlags || filterTA || filterQD);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
filterTicker = ''; filterSignal = ''; filterStyle = ''; filterCap = '';
|
filterTicker = ''; filterSignal = ''; filterStyle = ''; filterCap = '';
|
||||||
filterPriceMin = ''; filterPriceMax = ''; filterScoreMin = ''; filterFlags = false;
|
filterPriceMin = ''; filterPriceMax = ''; filterScoreMin = ''; filterFlags = false; filterTA = false; filterQD = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filteredRows(rows: AssetResult[]): AssetResult[] {
|
function filteredRows(rows: AssetResult[]): AssetResult[] {
|
||||||
let out = rows;
|
let out = rows;
|
||||||
|
if (filterTA) {
|
||||||
|
out = out.filter(r => r.turnaroundWatch);
|
||||||
|
}
|
||||||
|
if (filterQD) {
|
||||||
|
out = out.filter(isQualityDip);
|
||||||
|
}
|
||||||
if (filterTicker.trim()) {
|
if (filterTicker.trim()) {
|
||||||
const q = filterTicker.trim().toUpperCase();
|
const q = filterTicker.trim().toUpperCase();
|
||||||
out = out.filter(r => r.asset.ticker.includes(q));
|
out = out.filter(r => r.asset.ticker.includes(q));
|
||||||
@@ -286,6 +296,20 @@
|
|||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>{type}S</h2>
|
<h2>{type}S</h2>
|
||||||
<span class="count">{filteredRows(rows).length === rows.length ? rows.length : `${filteredRows(rows).length} / ${rows.length}`}</span>
|
<span class="count">{filteredRows(rows).length === rows.length ? rows.length : `${filteredRows(rows).length} / ${rows.length}`}</span>
|
||||||
|
{#if type === 'STOCK'}
|
||||||
|
<button
|
||||||
|
class="ta-filter-btn"
|
||||||
|
class:active={filterTA}
|
||||||
|
onclick={() => (filterTA = !filterTA)}
|
||||||
|
title="Turnaround style AND score improved vs the previous screen. Needs 2+ days of snapshot history per ticker (run screen:daily) — a candidate flag, not a prediction."
|
||||||
|
>↗ Turnaround watch ({rows.filter(r => r.turnaroundWatch).length})</button>
|
||||||
|
<button
|
||||||
|
class="ta-filter-btn qd"
|
||||||
|
class:active={filterQD}
|
||||||
|
onclick={() => (filterQD = !filterQD)}
|
||||||
|
title="Passes strict OR market-adjusted quality gates AND trades 10%+ below its 52-week high — solid companies knocked down, candidates to recover."
|
||||||
|
>💎 Quality dips ({rows.filter(isQualityDip).length})</button>
|
||||||
|
{/if}
|
||||||
{#if hasFilter()}
|
{#if hasFilter()}
|
||||||
<button class="filter-clear-btn" onclick={clearFilters}>✕ Clear filters</button>
|
<button class="filter-clear-btn" onclick={clearFilters}>✕ Clear filters</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -449,9 +473,18 @@
|
|||||||
title={watchlistStore.isPinned(r.asset.ticker) ? 'Remove from watchlist' : 'Add to watchlist'}
|
title={watchlistStore.isPinned(r.asset.ticker) ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||||
>{watchlistStore.isPinned(r.asset.ticker) ? '📌' : '🔖'}</button>
|
>{watchlistStore.isPinned(r.asset.ticker) ? '📌' : '🔖'}</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="ticker">{r.asset.ticker}</td>
|
<td class="ticker">
|
||||||
|
<button
|
||||||
|
class="ticker-btn"
|
||||||
|
onclick={(e) => { e.stopPropagation(); tickerModal = r; }}
|
||||||
|
title="Company details, chart & news"
|
||||||
|
>{r.asset.ticker}</button>
|
||||||
|
{#if r.turnaroundWatch}
|
||||||
|
<span class="ta-badge" title="Turnaround watch: style is Turnaround AND score improved vs previous screen. A candidate flag — not a prediction.">↗ TA</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td class="num">{m.Price ?? '—'}</td>
|
<td class="num">{m.Price ?? '—'}</td>
|
||||||
<!-- Signal pill -->
|
<!-- Signal pill + plain-language advice -->
|
||||||
<td>
|
<td>
|
||||||
<div class="signal-verdict-cell">
|
<div class="signal-verdict-cell">
|
||||||
<button
|
<button
|
||||||
@@ -461,6 +494,12 @@
|
|||||||
>
|
>
|
||||||
{(r.signal ?? '').replace(/^[^\w\s]+\s*/, '').trim() || '—'}
|
{(r.signal ?? '').replace(/^[^\w\s]+\s*/, '').trim() || '—'}
|
||||||
</button>
|
</button>
|
||||||
|
{#if r.signal}
|
||||||
|
{@const adv = adviceFor(r)}
|
||||||
|
{#if adv.addsInfo}
|
||||||
|
<div class="advice-line advice-{adv.tone}" title={adv.detail}>{adv.text}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<!-- Score as dot scale -->
|
<!-- Score as dot scale -->
|
||||||
@@ -675,6 +714,23 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<tr class="empty-row">
|
||||||
|
<td colspan="10">
|
||||||
|
{#if filterTA && rows.length > 0}
|
||||||
|
No turnaround-watch stocks right now. The ↗ flag needs: Turnaround style AND a score
|
||||||
|
that improved vs the previous screen — so it requires 2+ days of snapshot history
|
||||||
|
(run the daily screen) and at least one Turnaround-style stock in your results.
|
||||||
|
{:else if filterQD && rows.length > 0}
|
||||||
|
No quality dips right now: nothing you screened both passes a quality gate AND
|
||||||
|
trades 10%+ below its 52-week high. That's a real answer, not an error.
|
||||||
|
{:else if hasFilter()}
|
||||||
|
No rows match the active filters.
|
||||||
|
{:else}
|
||||||
|
No results yet — run a screen.
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -695,3 +751,12 @@
|
|||||||
row={specModalRow}
|
row={specModalRow}
|
||||||
onClose={() => (specModalRow = null)}
|
onClose={() => (specModalRow = null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Ticker modal — company profile, price chart, latest news -->
|
||||||
|
{#if tickerModal}
|
||||||
|
<TickerModal
|
||||||
|
ticker={tickerModal.asset.ticker}
|
||||||
|
advice={adviceFor(tickerModal)}
|
||||||
|
onClose={() => (tickerModal = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||||
|
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||||
|
import TickerModal from '$lib/components/screener/TickerModal.svelte';
|
||||||
|
import { adviceFor } from '$lib/utils.js';
|
||||||
|
import type { AssetResult } from '$lib/types.js';
|
||||||
|
|
||||||
|
const s = screenerStore;
|
||||||
|
|
||||||
|
let sortBy = $state<'today' | 'year'>('today');
|
||||||
|
let tickerModal = $state<AssetResult | null>(null);
|
||||||
|
|
||||||
|
function num(v: string | number | null | undefined): number {
|
||||||
|
if (v == null || v === '—') return -Infinity;
|
||||||
|
const n = parseFloat(String(v).replace(/[%$,+]/g, ''));
|
||||||
|
return Number.isFinite(n) ? n : -Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedStocks = $derived.by(() => {
|
||||||
|
const stocks = s.sectorDetail?.stocks ?? [];
|
||||||
|
const key = sortBy === 'today' ? 'Day %' : '52W Chg';
|
||||||
|
return [...stocks].sort(
|
||||||
|
(a, b) => num(b.asset.displayMetrics?.[key]) - num(a.asset.displayMetrics?.[key]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pulseEntry = $derived(
|
||||||
|
s.sectorPulse?.sectors.find((sec) => sec.sector === s.sectorFilter) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
function signCls(v: string | number | null | undefined): string {
|
||||||
|
const n = num(v);
|
||||||
|
return n === -Infinity ? '' : n >= 0 ? 'pos' : 'neg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same signal→class mapping AssetTable uses for sv-pill colors
|
||||||
|
function sigKey(signal: string | undefined): string {
|
||||||
|
const sig = signal ?? '';
|
||||||
|
if (sig.includes('Strong')) return 'strong';
|
||||||
|
if (sig.includes('Momentum')) return 'momentum';
|
||||||
|
if (sig.includes('Speculation')) return 'spec';
|
||||||
|
if (sig.includes('Neutral')) return 'neutral';
|
||||||
|
return 'avoid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if s.sectorFilter}
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>{(s.sectorDetail?.name ?? s.sectorFilter).toUpperCase()} — TOP HOLDINGS</h2>
|
||||||
|
<span class="count">{sortedStocks.length}</span>
|
||||||
|
{#if pulseEntry?.changePct != null}
|
||||||
|
<span class="secp-pct" class:pos={pulseEntry.changePct >= 0} class:neg={pulseEntry.changePct < 0}>
|
||||||
|
{pulseEntry.changePct >= 0 ? '+' : ''}{pulseEntry.changePct.toFixed(2)}% today
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if s.sectorDetail?.etf}
|
||||||
|
<span class="secp-etf">via {s.sectorDetail.etf}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mode-tabs">
|
||||||
|
<button class:active={sortBy === 'today'} onclick={() => (sortBy = 'today')}>Today's gain</button>
|
||||||
|
<button class:active={sortBy === 'year'} onclick={() => (sortBy = 'year')}>1Y gain</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-ghost secp-close" onclick={() => void s.selectSector(null)} title="Close sector panel">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if s.sectorDetailLoading}
|
||||||
|
<div class="secp-loading"><Spinner size="md" label="Screening sector holdings…" /></div>
|
||||||
|
{:else if s.sectorDetail}
|
||||||
|
{#if sortedStocks.length > 0}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ticker</th>
|
||||||
|
<th class="num">Price</th>
|
||||||
|
<th class="num">Today</th>
|
||||||
|
<th class="num">1Y</th>
|
||||||
|
<th>Signal</th>
|
||||||
|
<th>Advice</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each sortedStocks as r}
|
||||||
|
{@const m = r.asset.displayMetrics ?? {}}
|
||||||
|
{@const adv = adviceFor(r)}
|
||||||
|
<tr>
|
||||||
|
<td class="ticker">
|
||||||
|
<button class="ticker-btn" onclick={() => (tickerModal = r)} title="Company details, chart & news">
|
||||||
|
{r.asset.ticker}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="num">{m['Price'] ?? '—'}</td>
|
||||||
|
<td class="num {signCls(m['Day %'])}">{m['Day %'] ?? '—'}</td>
|
||||||
|
<td class="num {signCls(m['52W Chg'])}">{m['52W Chg'] ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="sv-pill sv-{sigKey(r.signal)}">
|
||||||
|
{(r.signal ?? '—').replace(/^[^\w\s]+\s*/, '').trim()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="advice-line advice-{adv.tone}" title={adv.detail}>{adv.text}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="secp-empty">Couldn't load holdings for this sector right now.</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="secp-news-block">
|
||||||
|
<div class="secp-news-title">Recent sector news (3 days)</div>
|
||||||
|
{#if s.sectorDetail.news.length > 0}
|
||||||
|
<ul class="secp-news">
|
||||||
|
{#each s.sectorDetail.news.slice(0, 6) as story}
|
||||||
|
<li>
|
||||||
|
<a href={story.url} target="_blank" rel="noopener noreferrer">{story.headline}</a>
|
||||||
|
<span class="secp-news-meta">{story.tickers.join(', ')} · {fmtDate(story.publishedAt)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<div class="secp-empty">
|
||||||
|
No stored stories for these tickers in the last 3 days — often the honest answer is
|
||||||
|
"no sector-specific catalyst; the whole market moved." News accumulates as the pollers run.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if tickerModal}
|
||||||
|
<TickerModal
|
||||||
|
ticker={tickerModal.asset.ticker}
|
||||||
|
advice={adviceFor(tickerModal)}
|
||||||
|
onClose={() => (tickerModal = null)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Only panel-specific bits — table/section/pill styling comes from the
|
||||||
|
global design system so this matches every other table on the page. */
|
||||||
|
.secp-pct { font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
.secp-pct.pos { color: var(--green); }
|
||||||
|
.secp-pct.neg { color: var(--red); }
|
||||||
|
.secp-etf { font-size: 10.5px; color: var(--text-muted); }
|
||||||
|
.secp-close { margin-left: 8px; }
|
||||||
|
.secp-loading { display: grid; place-items: center; min-height: 90px; }
|
||||||
|
|
||||||
|
td.pos { color: var(--green); }
|
||||||
|
td.neg { color: var(--red); }
|
||||||
|
|
||||||
|
.secp-news-block { padding: 10px var(--space-xl) 14px; }
|
||||||
|
.secp-news-title {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.secp-news { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.secp-news a { font-size: 12px; color: var(--text-secondary); text-decoration: none; }
|
||||||
|
.secp-news a:hover { color: var(--blue); }
|
||||||
|
.secp-news-meta { font-size: 10px; color: var(--text-muted); margin-left: 8px; }
|
||||||
|
.secp-empty { font-size: 11.5px; color: var(--text-muted); font-style: italic; padding: 10px var(--space-xl); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { screenerStore } from '$lib/stores/screener.store.svelte.js';
|
||||||
|
|
||||||
|
const s = screenerStore;
|
||||||
|
|
||||||
|
function toggleSector(sector: string) {
|
||||||
|
void s.selectSector(s.sectorFilter === sector ? null : sector);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(v: number | null): string {
|
||||||
|
if (v == null) return '—';
|
||||||
|
return `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asOfLabel = $derived(
|
||||||
|
s.sectorPulse?.asOf
|
||||||
|
? new Date(s.sectorPulse.asOf).toLocaleTimeString(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mp-band">
|
||||||
|
{#if s.sectorPulseLoading}
|
||||||
|
<div class="mp-head">
|
||||||
|
<span class="mp-eyebrow">Market pulse</span>
|
||||||
|
<span class="mp-asof">loading sector data…</span>
|
||||||
|
</div>
|
||||||
|
{:else if !s.sectorPulse || s.sectorPulse.sectors.length === 0}
|
||||||
|
<div class="mp-head">
|
||||||
|
<span class="mp-eyebrow">Market pulse</span>
|
||||||
|
<span class="mp-asof">sector data unavailable right now — retrying on next page load</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@const pulse = s.sectorPulse}
|
||||||
|
<div class="mp-head">
|
||||||
|
<span class="mp-eyebrow">Market pulse</span>
|
||||||
|
{#if pulse.leader}
|
||||||
|
<span class="mp-leader">
|
||||||
|
{pulse.leader.name} leads today
|
||||||
|
<span class="mp-leader-pct">{fmtPct(pulse.leader.changePct)}</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if asOfLabel}
|
||||||
|
<span class="mp-asof">sector ETFs · {asOfLabel}</span>
|
||||||
|
{/if}
|
||||||
|
{#if s.sectorFilter}
|
||||||
|
<button class="mp-clear" onclick={() => void s.selectSector(null)}>
|
||||||
|
✕ Close {pulse.sectors.find((x) => x.sector === s.sectorFilter)?.name ?? 'sector'} panel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mp-bubbles">
|
||||||
|
{#each pulse.sectors as sec}
|
||||||
|
<button
|
||||||
|
class="mp-bubble"
|
||||||
|
class:up={sec.changePct != null && sec.changePct >= 0}
|
||||||
|
class:down={sec.changePct != null && sec.changePct < 0}
|
||||||
|
class:active={s.sectorFilter === sec.sector}
|
||||||
|
onclick={() => toggleSector(sec.sector)}
|
||||||
|
title="{sec.name} ({sec.etf}): {fmtPct(sec.changePct)} today — click to open the sector panel (top holdings + news). Does not filter the tables below."
|
||||||
|
>
|
||||||
|
<span class="mp-bubble-pct">{fmtPct(sec.changePct)}</span>
|
||||||
|
<span class="mp-bubble-name">{sec.name}</span>
|
||||||
|
<span class="mp-bubble-etf">{sec.etf}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Full-bleed page header band — stretches across main's padding so it
|
||||||
|
reads as page chrome, not as another table widget. */
|
||||||
|
.mp-band {
|
||||||
|
margin: -28px -32px 22px;
|
||||||
|
padding: 12px 32px 14px;
|
||||||
|
background: var(--bg-elevated, #0e1626);
|
||||||
|
border-bottom: 1px solid var(--border, #1e293b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-dimmer, #3d5166);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-leader {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-leader-pct {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--green, #4ade80);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-asof {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted, #3d5166);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-clear {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border, #1e293b);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mp-clear:hover { color: var(--red, #f87171); border-color: var(--red, #f87171); }
|
||||||
|
|
||||||
|
/* ── Bubble cards ── */
|
||||||
|
.mp-bubbles {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
/* A scroll container clips vertically too — leave room for the 1px
|
||||||
|
hover lift and the 1px active ring so card tops never get shaved. */
|
||||||
|
padding: 3px 2px 4px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-bubble {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 96px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border, #1e293b);
|
||||||
|
background: var(--bg-card, #111a2c);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
transform 0.12s,
|
||||||
|
background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-bubble:hover { transform: translateY(-1px); border-color: var(--text-muted, #3d5166); }
|
||||||
|
|
||||||
|
.mp-bubble.up { background: rgba(74, 222, 128, 0.05); }
|
||||||
|
.mp-bubble.down { background: rgba(248, 113, 113, 0.05); }
|
||||||
|
|
||||||
|
.mp-bubble.active {
|
||||||
|
border-color: var(--blue, #60a5fa);
|
||||||
|
box-shadow: 0 0 0 1px var(--blue, #60a5fa);
|
||||||
|
background: rgba(96, 165, 250, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-bubble-pct {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.mp-bubble.up .mp-bubble-pct { color: var(--green, #4ade80); }
|
||||||
|
.mp-bubble.down .mp-bubble-pct { color: var(--red, #f87171); }
|
||||||
|
|
||||||
|
.mp-bubble-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #94a3b8);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-bubble-etf {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dimmer, #3d5166);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fetchProfile, fetchChart, fetchTickerNews } from '$lib/api.js';
|
||||||
|
import type { ChartRange, CompanyProfile, PricePoint, TickerNewsStory } from '$lib/api/screener.js';
|
||||||
|
import type { Advice } from '$lib/utils/advice.js';
|
||||||
|
import Spinner from '$lib/components/shared/Spinner.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ticker,
|
||||||
|
advice = null,
|
||||||
|
onClose,
|
||||||
|
}: { ticker: string; advice?: Advice | null; onClose: () => void } = $props();
|
||||||
|
|
||||||
|
const RANGES: Array<{ key: ChartRange; label: string }> = [
|
||||||
|
{ key: '1d', label: '1D' },
|
||||||
|
{ key: '5d', label: '5D' },
|
||||||
|
{ key: '1mo', label: '1M' },
|
||||||
|
{ key: '3mo', label: '3M' },
|
||||||
|
{ key: '6mo', label: '6M' },
|
||||||
|
{ key: 'ytd', label: 'YTD' },
|
||||||
|
{ key: '1y', label: '1Y' },
|
||||||
|
{ key: '5y', label: '5Y' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let chartLoading = $state(false);
|
||||||
|
let range = $state<ChartRange>('6mo');
|
||||||
|
let profile = $state<CompanyProfile | null>(null);
|
||||||
|
let points = $state<PricePoint[]>([]);
|
||||||
|
let news = $state<TickerNewsStory[]>([]);
|
||||||
|
let expanded = $state(false); // company summary read-more
|
||||||
|
|
||||||
|
// Profile + news load once per ticker
|
||||||
|
$effect(() => {
|
||||||
|
loading = true;
|
||||||
|
Promise.all([fetchProfile(ticker), fetchTickerNews(ticker, 14)])
|
||||||
|
.then(([p, n]) => {
|
||||||
|
profile = p;
|
||||||
|
news = n;
|
||||||
|
})
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chart reloads whenever the range changes
|
||||||
|
$effect(() => {
|
||||||
|
chartLoading = true;
|
||||||
|
fetchChart(ticker, range)
|
||||||
|
.then((c) => (points = c))
|
||||||
|
.finally(() => (chartLoading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SVG line chart (no chart library — a simple path over closes) ────────
|
||||||
|
const W = 560;
|
||||||
|
const H = 160;
|
||||||
|
const PAD = 6;
|
||||||
|
|
||||||
|
type ChartGeo = { path: string; area: string; up: boolean; min: number; max: number };
|
||||||
|
|
||||||
|
function chartGeo(pts: PricePoint[]): ChartGeo | null {
|
||||||
|
if (pts.length < 2) return null;
|
||||||
|
const closes = pts.map((p) => p.close);
|
||||||
|
const min = Math.min(...closes);
|
||||||
|
const max = Math.max(...closes);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const x = (i: number) => PAD + (i / (pts.length - 1)) * (W - 2 * PAD);
|
||||||
|
const y = (c: number) => PAD + (1 - (c - min) / span) * (H - 2 * PAD);
|
||||||
|
const path = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(p.close).toFixed(1)}`).join(' ');
|
||||||
|
const area = `${path} L${x(pts.length - 1).toFixed(1)},${H - PAD} L${x(0).toFixed(1)},${H - PAD} Z`;
|
||||||
|
return { path, area, up: closes[closes.length - 1] >= closes[0], min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = $derived(chartGeo(points));
|
||||||
|
const changePct = $derived(
|
||||||
|
points.length >= 2 ? ((points[points.length - 1].close - points[0].close) / points[0].close) * 100 : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rangeLabel = $derived(RANGES.find((r) => r.key === range)?.label ?? range);
|
||||||
|
const isIntraday = $derived(range === '1d' || range === '5d');
|
||||||
|
|
||||||
|
/** Axis label: time-of-day for intraday ranges, date otherwise. */
|
||||||
|
function fmtAxis(d: string): string {
|
||||||
|
if (!isIntraday) return d;
|
||||||
|
return new Date(d).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hover crosshair (Robinhood-style scrub) ───────────────────────────────
|
||||||
|
let hoverIdx = $state<number | null>(null);
|
||||||
|
|
||||||
|
function onChartMove(e: PointerEvent): void {
|
||||||
|
if (points.length < 2) return;
|
||||||
|
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
|
||||||
|
const ratio = (e.clientX - rect.left) / rect.width;
|
||||||
|
const usable = (W - 2 * PAD) / W; // chart area as fraction of viewBox width
|
||||||
|
const adjusted = (ratio - PAD / W) / usable;
|
||||||
|
hoverIdx = Math.min(points.length - 1, Math.max(0, Math.round(adjusted * (points.length - 1))));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hover = $derived.by(() => {
|
||||||
|
if (hoverIdx == null || points.length < 2) return null;
|
||||||
|
const p = points[hoverIdx];
|
||||||
|
const closes = points.map((q) => q.close);
|
||||||
|
const min = Math.min(...closes);
|
||||||
|
const span = (Math.max(...closes) - min) || 1;
|
||||||
|
const cx = PAD + (hoverIdx / (points.length - 1)) * (W - 2 * PAD);
|
||||||
|
const cy = PAD + (1 - (p.close - min) / span) * (H - 2 * PAD);
|
||||||
|
const fromStart = ((p.close - points[0].close) / points[0].close) * 100;
|
||||||
|
const label = isIntraday
|
||||||
|
? new Date(p.date).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||||
|
: p.date;
|
||||||
|
return { p, cx, cy, fromStart, label, flip: cx > W * 0.6 };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Position of a price between target low and high, clamped 0–100%. */
|
||||||
|
function targetPos(price: number, low: number, high: number): number {
|
||||||
|
if (high <= low) return 50;
|
||||||
|
return Math.min(100, Math.max(0, ((price - low) / (high - low)) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function recLabel(mean: number | null): string {
|
||||||
|
if (mean == null) return '—';
|
||||||
|
if (mean <= 1.5) return 'Strong Buy';
|
||||||
|
if (mean <= 2.5) return 'Buy';
|
||||||
|
if (mean <= 3.5) return 'Hold';
|
||||||
|
if (mean <= 4.5) return 'Sell';
|
||||||
|
return 'Strong Sell';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCap(v: number | null): string {
|
||||||
|
if (v == null) return '—';
|
||||||
|
if (v >= 1e12) return `$${(v / 1e12).toFixed(2)}T`;
|
||||||
|
if (v >= 1e9) return `$${(v / 1e9).toFixed(1)}B`;
|
||||||
|
if (v >= 1e6) return `$${(v / 1e6).toFixed(0)}M`;
|
||||||
|
return `$${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel: Record<string, string> = { edgar: 'SEC filing', prwire: 'Press release', yahoo: 'Yahoo' };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="tm-backdrop" role="presentation" onclick={onClose}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="tm-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Company details for {ticker}"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="tm-header">
|
||||||
|
<div>
|
||||||
|
<span class="tm-ticker">{ticker}</span>
|
||||||
|
{#if profile}<span class="tm-name">{profile.name}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="tm-header-right">
|
||||||
|
{#if profile?.currentPrice != null}
|
||||||
|
<span class="tm-price">${profile.currentPrice.toFixed(2)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if changePct != null}
|
||||||
|
<span class="tm-change" class:up={changePct >= 0} class:down={changePct < 0}>
|
||||||
|
{changePct >= 0 ? '+' : ''}{changePct.toFixed(1)}% / {rangeLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<button class="tm-close" onclick={onClose} title="Close (Esc)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="tm-loading"><Spinner size="md" label="Loading {ticker}…" /></div>
|
||||||
|
{:else}
|
||||||
|
<div class="tm-body">
|
||||||
|
<!-- ── Plain-language advice ── -->
|
||||||
|
{#if advice}
|
||||||
|
<div class="tm-advice tm-advice-{advice.tone}" title={advice.detail}>
|
||||||
|
{advice.text}
|
||||||
|
<span class="tm-advice-note">— based on your screener’s data, not financial advice</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Range switcher ── -->
|
||||||
|
<div class="tm-ranges" role="tablist" aria-label="Chart range">
|
||||||
|
{#each RANGES as r}
|
||||||
|
<button
|
||||||
|
class="tm-range-btn"
|
||||||
|
class:active={range === r.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={range === r.key}
|
||||||
|
onclick={() => (range = r.key)}
|
||||||
|
>{r.label}</button>
|
||||||
|
{/each}
|
||||||
|
{#if chartLoading}<span class="tm-chart-spin"><Spinner size="sm" /></span>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Price chart ── -->
|
||||||
|
{#if geo}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 {W} {H}"
|
||||||
|
class="tm-chart"
|
||||||
|
class:dim={chartLoading}
|
||||||
|
role="img"
|
||||||
|
aria-label="{rangeLabel} price chart"
|
||||||
|
onpointermove={onChartMove}
|
||||||
|
onpointerleave={() => (hoverIdx = null)}
|
||||||
|
>
|
||||||
|
<path d={geo.area} class="tm-chart-area" class:up={geo.up} class:down={!geo.up} />
|
||||||
|
<path d={geo.path} class="tm-chart-line" class:up={geo.up} class:down={!geo.up} fill="none" />
|
||||||
|
{#if hover}
|
||||||
|
<line x1={hover.cx} y1={PAD} x2={hover.cx} y2={H - PAD} class="tm-xhair-line" />
|
||||||
|
<circle cx={hover.cx} cy={hover.cy} r="3.5" class="tm-xhair-dot" class:up={geo.up} class:down={!geo.up} />
|
||||||
|
{@const tipW = 132}
|
||||||
|
{@const tipX = hover.flip ? hover.cx - tipW - 10 : hover.cx + 10}
|
||||||
|
<g class="tm-xhair-tip">
|
||||||
|
<rect x={tipX} y={PAD} width={tipW} height="34" rx="5" />
|
||||||
|
<text x={tipX + 8} y={PAD + 14}>${hover.p.close.toFixed(2)}
|
||||||
|
<tspan class:pos={hover.fromStart >= 0} class:neg={hover.fromStart < 0}>
|
||||||
|
({hover.fromStart >= 0 ? '+' : ''}{hover.fromStart.toFixed(1)}%)
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
<text x={tipX + 8} y={PAD + 27} class="tm-xhair-date">{hover.label}</text>
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
<div class="tm-chart-range">
|
||||||
|
<span>{fmtAxis(points[0]?.date ?? '')}</span>
|
||||||
|
<span>low ${geo.min.toFixed(2)} · high ${geo.max.toFixed(2)}</span>
|
||||||
|
<span>{fmtAxis(points[points.length - 1]?.date ?? '')}</span>
|
||||||
|
</div>
|
||||||
|
{:else if !chartLoading}
|
||||||
|
<div class="tm-empty">No price history for this range</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Analyst price targets ── -->
|
||||||
|
{#if profile?.targets?.mean != null}
|
||||||
|
{@const t = profile.targets}
|
||||||
|
<div class="tm-targets">
|
||||||
|
<div class="tm-targets-head">
|
||||||
|
<span class="tm-targets-title">Analyst targets</span>
|
||||||
|
<span class="tm-targets-meta">
|
||||||
|
{recLabel(t.recommendationMean)}{t.analysts != null ? ` · ${t.analysts} analysts` : ''}
|
||||||
|
{#if t.upsidePct != null}
|
||||||
|
· <span class:up={t.upsidePct >= 0} class:down={t.upsidePct < 0} class="tm-upside">
|
||||||
|
{t.upsidePct >= 0 ? '+' : ''}{t.upsidePct}% to mean
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if t.mean != null && t.low != null && t.high != null && profile.currentPrice != null}
|
||||||
|
<div class="tm-target-bar">
|
||||||
|
<div class="tm-target-track"></div>
|
||||||
|
<div class="tm-target-mark mean" style="left: {targetPos(t.mean, t.low, t.high)}%" title="Mean target ${t.mean}"></div>
|
||||||
|
<div class="tm-target-mark price" style="left: {targetPos(profile.currentPrice, t.low, t.high)}%" title="Current price ${profile.currentPrice}"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tm-target-labels">
|
||||||
|
<span>Low ${t.low.toFixed(2)}</span>
|
||||||
|
<span class="tm-target-mean">Mean ${t.mean.toFixed(2)}</span>
|
||||||
|
<span>High ${t.high.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="tm-target-foot">
|
||||||
|
Source: Yahoo Finance consensus ·
|
||||||
|
<a href="https://www.zacks.com/stock/quote/{ticker}" target="_blank" rel="noopener noreferrer">Zacks view ↗</a>
|
||||||
|
<span class="tm-target-note">(Zacks has no free API — opens their page)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Company info ── -->
|
||||||
|
{#if profile}
|
||||||
|
<div class="tm-facts">
|
||||||
|
{#if profile.sector}<span class="tm-fact">{profile.sector}</span>{/if}
|
||||||
|
{#if profile.industry}<span class="tm-fact">{profile.industry}</span>{/if}
|
||||||
|
<span class="tm-fact">Mkt cap {fmtCap(profile.marketCap)}</span>
|
||||||
|
{#if profile.employees}<span class="tm-fact">{profile.employees.toLocaleString()} employees</span>{/if}
|
||||||
|
{#if profile.website}
|
||||||
|
<a class="tm-fact tm-link" href={profile.website} target="_blank" rel="noopener noreferrer">Website ↗</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if profile.summary}
|
||||||
|
<p class="tm-summary" class:clamped={!expanded}>{profile.summary}</p>
|
||||||
|
{#if profile.summary.length > 280}
|
||||||
|
<button class="tm-more" onclick={() => (expanded = !expanded)}>
|
||||||
|
{expanded ? 'Show less' : 'Read more'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="tm-empty">No company profile available</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Latest news ── -->
|
||||||
|
<div class="tm-news-title">Latest news (14 days)</div>
|
||||||
|
{#if news.length === 0}
|
||||||
|
<div class="tm-empty">
|
||||||
|
No stored stories for {ticker} yet — news accumulates as the pollers run.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="tm-news">
|
||||||
|
{#each news.slice(0, 8) as story}
|
||||||
|
<li>
|
||||||
|
<a href={story.url} target="_blank" rel="noopener noreferrer">{story.headline}</a>
|
||||||
|
<div class="tm-news-meta">
|
||||||
|
{#if story.catalyst}<span class="tm-cat tm-cat-{story.catalyst}">{story.catalyst}</span>{/if}
|
||||||
|
<span>{sourceLabel[story.source] ?? story.source}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{fmtDate(story.publishedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tm-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-modal {
|
||||||
|
background: var(--bg-base, #0b1220);
|
||||||
|
border: 1px solid var(--border, #1e293b);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border, #1e293b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-ticker { font-size: 18px; font-weight: 700; color: var(--text-primary); }
|
||||||
|
.tm-name { margin-left: 10px; color: var(--text-muted); font-size: 13px; }
|
||||||
|
.tm-header-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.tm-price { font-family: var(--font-mono); font-size: 15px; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.tm-change { font-family: var(--font-mono); font-size: 12px; }
|
||||||
|
.tm-change.up { color: var(--green, #4ade80); }
|
||||||
|
.tm-change.down { color: var(--red, #f87171); }
|
||||||
|
|
||||||
|
.tm-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.tm-close:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.tm-loading { display: grid; place-items: center; min-height: 240px; }
|
||||||
|
.tm-body { padding: 16px 18px; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* ── Plain-language advice banner ── */
|
||||||
|
.tm-advice {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.tm-advice-note { font-size: 10px; font-weight: 400; opacity: 0.65; }
|
||||||
|
.tm-advice-buy { color: var(--green, #4ade80); background: rgba(74, 222, 128, 0.07); }
|
||||||
|
.tm-advice-mindful { color: var(--amber, #f0b429); background: rgba(240, 180, 41, 0.07); }
|
||||||
|
.tm-advice-caution { color: var(--orange, #f0b429); background: rgba(240, 180, 41, 0.07); }
|
||||||
|
.tm-advice-wait { color: var(--blue, #60a5fa); background: rgba(96, 165, 250, 0.07); }
|
||||||
|
.tm-advice-skip { color: var(--red, #f87171); background: rgba(248, 113, 113, 0.07); }
|
||||||
|
.tm-advice-unknown { color: var(--text-muted, #64748b); font-style: italic; }
|
||||||
|
|
||||||
|
.tm-ranges { display: flex; align-items: center; gap: 4px; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.tm-range-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tm-range-btn:hover { color: var(--text-primary); }
|
||||||
|
.tm-range-btn.active {
|
||||||
|
color: var(--blue, #60a5fa);
|
||||||
|
border-color: var(--blue, #60a5fa);
|
||||||
|
background: rgba(96, 165, 250, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-chart-spin { margin-left: 6px; }
|
||||||
|
|
||||||
|
.tm-chart { width: 100%; height: auto; display: block; cursor: crosshair; touch-action: none; }
|
||||||
|
.tm-chart.dim { opacity: 0.45; }
|
||||||
|
|
||||||
|
/* ── Hover crosshair ── */
|
||||||
|
.tm-xhair-line {
|
||||||
|
stroke: var(--text-muted, #3d5166);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
|
.tm-xhair-dot { stroke: var(--bg-base, #0b1220); stroke-width: 1.5; }
|
||||||
|
.tm-xhair-dot.up { fill: var(--green, #4ade80); }
|
||||||
|
.tm-xhair-dot.down { fill: var(--red, #f87171); }
|
||||||
|
|
||||||
|
.tm-xhair-tip rect {
|
||||||
|
fill: var(--bg-card, #111a2c);
|
||||||
|
stroke: var(--border, #1e293b);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.tm-xhair-tip text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
fill: var(--text-primary, #e2e8f0);
|
||||||
|
}
|
||||||
|
.tm-xhair-tip tspan.pos { fill: var(--green, #4ade80); }
|
||||||
|
.tm-xhair-tip tspan.neg { fill: var(--red, #f87171); }
|
||||||
|
.tm-xhair-tip .tm-xhair-date { font-size: 9.5px; fill: var(--text-muted, #64748b); }
|
||||||
|
|
||||||
|
/* ── Analyst targets ── */
|
||||||
|
.tm-targets {
|
||||||
|
border: 1px solid var(--border, #1e293b);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-targets-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.tm-targets-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
}
|
||||||
|
.tm-targets-meta { font-size: 11.5px; color: var(--text-dim); }
|
||||||
|
.tm-upside.up { color: var(--green, #4ade80); }
|
||||||
|
.tm-upside.down { color: var(--red, #f87171); }
|
||||||
|
|
||||||
|
.tm-target-bar { position: relative; height: 14px; margin: 10px 4px 2px; }
|
||||||
|
.tm-target-track {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border-input, #263447);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.tm-target-mark {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.tm-target-mark.mean { background: var(--blue, #60a5fa); }
|
||||||
|
.tm-target-mark.price { background: var(--text-primary, #e2e8f0); border: 2px solid var(--bg-base, #0b1220); }
|
||||||
|
|
||||||
|
.tm-target-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.tm-target-mean { color: var(--blue, #60a5fa); }
|
||||||
|
|
||||||
|
.tm-target-foot { font-size: 10.5px; color: var(--text-muted); margin-top: 8px; }
|
||||||
|
.tm-target-foot a { color: var(--blue, #60a5fa); text-decoration: none; }
|
||||||
|
.tm-target-note { font-style: italic; }
|
||||||
|
.tm-chart-line { stroke-width: 1.75; }
|
||||||
|
.tm-chart-line.up { stroke: var(--green, #4ade80); }
|
||||||
|
.tm-chart-line.down { stroke: var(--red, #f87171); }
|
||||||
|
.tm-chart-area.up { fill: rgba(74, 222, 128, 0.08); }
|
||||||
|
.tm-chart-area.down { fill: rgba(248, 113, 113, 0.08); }
|
||||||
|
|
||||||
|
.tm-chart-range {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 4px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-facts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.tm-fact {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-card, #111a2c);
|
||||||
|
border: 1px solid var(--border, #1e293b);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2px 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-link { color: var(--blue, #60a5fa); text-decoration: none; }
|
||||||
|
|
||||||
|
.tm-summary {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.tm-summary.clamped {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-more {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--blue, #60a5fa);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-news-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dimmer);
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
border-top: 1px solid var(--border, #1e293b);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-news { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.tm-news a { color: var(--text-secondary); text-decoration: none; font-size: 12.5px; line-height: 1.4; }
|
||||||
|
.tm-news a:hover { color: var(--blue, #60a5fa); }
|
||||||
|
|
||||||
|
.tm-news-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tm-cat {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
}
|
||||||
|
.tm-cat-ma { color: var(--purple, #a78bfa); }
|
||||||
|
.tm-cat-earnings { color: var(--blue, #60a5fa); }
|
||||||
|
.tm-cat-guidance { color: var(--amber, #f0b429); }
|
||||||
|
.tm-cat-regulatory { color: var(--orange, #f0b429); }
|
||||||
|
.tm-cat-macro { color: var(--text-dim); }
|
||||||
|
|
||||||
|
.tm-empty { font-size: 12px; color: var(--text-muted); font-style: italic; padding: 8px 0; }
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import { fetchCatalysts, screenTickers, analyzeTickers } from '$lib/api.js';
|
import {
|
||||||
|
fetchCatalysts,
|
||||||
|
screenTickers,
|
||||||
|
analyzeTickers,
|
||||||
|
fetchSectorPulse,
|
||||||
|
fetchSectorDetail,
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import type { SectorPulse, SectorDetail } from '$lib/api/screener.js';
|
||||||
import { sorted } from '$lib/utils.js';
|
import { sorted } from '$lib/utils.js';
|
||||||
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
|
import type { ScreenerResult, AssetType, SidebarState } from '$lib/types.js';
|
||||||
|
|
||||||
@@ -31,6 +38,42 @@ class ScreenerStore {
|
|||||||
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
|
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Sector pulse (daily % change per sector via SPDR ETFs) ──────────────
|
||||||
|
sectorPulse = $state<SectorPulse | null>(null);
|
||||||
|
sectorPulseLoading = $state(true);
|
||||||
|
/** Selected sector — drives the sector drill-down panel only. */
|
||||||
|
sectorFilter = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Sector drill-down panel (top holdings screened + sector news)
|
||||||
|
sectorDetail = $state<SectorDetail | null>(null);
|
||||||
|
sectorDetailLoading = $state(false);
|
||||||
|
|
||||||
|
async loadSectorPulse(): Promise<void> {
|
||||||
|
this.sectorPulseLoading = true;
|
||||||
|
try {
|
||||||
|
this.sectorPulse = await fetchSectorPulse();
|
||||||
|
} catch {
|
||||||
|
this.sectorPulse = null;
|
||||||
|
} finally {
|
||||||
|
this.sectorPulseLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select a sector: filter the table and load the drill-down panel. */
|
||||||
|
async selectSector(sector: string | null): Promise<void> {
|
||||||
|
this.sectorFilter = sector;
|
||||||
|
this.sectorDetail = null;
|
||||||
|
if (!sector) return;
|
||||||
|
this.sectorDetailLoading = true;
|
||||||
|
try {
|
||||||
|
this.sectorDetail = await fetchSectorDetail(sector);
|
||||||
|
} catch {
|
||||||
|
this.sectorDetail = null;
|
||||||
|
} finally {
|
||||||
|
this.sectorDetailLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Actions ────────────────────────────────────────────────────────
|
// ── Actions ────────────────────────────────────────────────────────
|
||||||
async screen(): Promise<void> {
|
async screen(): Promise<void> {
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Plain-language advice line (personal-use layer).
|
||||||
|
*
|
||||||
|
* Translates the screener's signal + volatility markers into one sentence a
|
||||||
|
* human can act on. Deterministic and derived ONLY from data already on the
|
||||||
|
* row — it adds wording, never new judgment. Tones:
|
||||||
|
* buy → green "buy — stable growth"
|
||||||
|
* mindful → amber "buy, but expect dips"
|
||||||
|
* caution → orange "risky / expensive"
|
||||||
|
* wait → blue "no edge right now"
|
||||||
|
* skip → red "fundamentals don't support it"
|
||||||
|
* unknown → gray "not enough data"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AssetResult } from '$lib/types.js';
|
||||||
|
|
||||||
|
export type AdviceTone = 'buy' | 'mindful' | 'caution' | 'wait' | 'skip' | 'unknown';
|
||||||
|
|
||||||
|
export interface Advice {
|
||||||
|
text: string;
|
||||||
|
tone: AdviceTone;
|
||||||
|
detail: string; // tooltip — why this advice
|
||||||
|
/** True when the advice says something the signal pill doesn't already convey. */
|
||||||
|
addsInfo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse "1.85" / "-23.4%" / "—" out of a display metric. */
|
||||||
|
function num(v: string | number | null | undefined): number | null {
|
||||||
|
if (v == null || v === '—') return null;
|
||||||
|
const n = parseFloat(String(v).replace(/[%$,x+]/g, ''));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adviceFor(row: AssetResult): Advice {
|
||||||
|
const m = row.asset.displayMetrics ?? {};
|
||||||
|
const signal = row.signal ?? '';
|
||||||
|
const coverage = row.fundamental?.audit?.coverage;
|
||||||
|
|
||||||
|
// Not enough data → say so, don't fake confidence
|
||||||
|
if (coverage && coverage.active === 0) {
|
||||||
|
return {
|
||||||
|
text: "Can't judge — not enough data",
|
||||||
|
tone: 'unknown',
|
||||||
|
detail: 'No scoring factors had data for this asset. Treat any verdict as meaningless.',
|
||||||
|
addsInfo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volatility / drawdown markers (any one makes a buy "bumpy")
|
||||||
|
const beta = num(m['Beta']);
|
||||||
|
const fromHigh = num(m['From High']);
|
||||||
|
const chg52 = num(m['52W Chg']);
|
||||||
|
const style = String(m['Style'] ?? '');
|
||||||
|
const bumpy =
|
||||||
|
(beta != null && beta > 1.25) ||
|
||||||
|
(fromHigh != null && fromHigh <= -20) ||
|
||||||
|
(chg52 != null && chg52 <= -25) ||
|
||||||
|
style === 'High Growth' ||
|
||||||
|
style === 'Turnaround';
|
||||||
|
|
||||||
|
if (signal.includes('Strong Buy')) {
|
||||||
|
return bumpy
|
||||||
|
? {
|
||||||
|
text: 'Buy, but expect dips — long-term growth',
|
||||||
|
tone: 'mindful',
|
||||||
|
detail:
|
||||||
|
'Passes both the strict value gates and market-adjusted gates, but it moves sharply ' +
|
||||||
|
`(beta ${beta ?? '—'}, ${fromHigh ?? '—'}% off its high). Falls are likely along the way; ` +
|
||||||
|
'the fundamentals say the trend supports holding through them.',
|
||||||
|
addsInfo: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
text: 'Buy — stable growth',
|
||||||
|
tone: 'buy',
|
||||||
|
detail:
|
||||||
|
'Passes both the strict value gates and market-adjusted gates with calm price behavior. ' +
|
||||||
|
'The closest thing this screener has to a steady compounder.',
|
||||||
|
addsInfo: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.includes('Momentum')) {
|
||||||
|
return {
|
||||||
|
text: 'Be mindful — rising on momentum, can fall fast',
|
||||||
|
tone: 'mindful',
|
||||||
|
detail:
|
||||||
|
'Acceptable at today’s market prices but does not pass the strict value gates. ' +
|
||||||
|
'Fine while the market is kind; expect sharper falls when it isn’t.',
|
||||||
|
addsInfo: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.includes('Speculation')) {
|
||||||
|
return {
|
||||||
|
text: 'Caution — priced for perfection',
|
||||||
|
tone: 'caution',
|
||||||
|
detail:
|
||||||
|
'Only passes the loosened market-adjusted gates and fails fundamentals. ' +
|
||||||
|
'Buy only with money you can watch swing hard.',
|
||||||
|
addsInfo: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.includes('Neutral')) {
|
||||||
|
return {
|
||||||
|
text: 'Wait — no clear edge right now',
|
||||||
|
tone: 'wait',
|
||||||
|
detail:
|
||||||
|
'Neither clearly cheap nor clearly strong. Nothing here argues for buying today; ' +
|
||||||
|
'keep it on the watchlist and let the daily digest tell you if that changes.',
|
||||||
|
addsInfo: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal.includes('Avoid')) {
|
||||||
|
return {
|
||||||
|
text: 'Skip — fundamentals don’t support it',
|
||||||
|
tone: 'skip',
|
||||||
|
detail: 'Fails both the strict and the market-adjusted gates. The data says no.',
|
||||||
|
addsInfo: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: '—',
|
||||||
|
tone: 'unknown',
|
||||||
|
detail: 'No signal computed for this asset.',
|
||||||
|
addsInfo: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 💎 Quality dip: passes strict OR market-adjusted quality gates AND trades
|
||||||
|
* 10%+ below its 52-week high. A dip with a sound base — candidate to recover.
|
||||||
|
*/
|
||||||
|
export function isQualityDip(row: AssetResult): boolean {
|
||||||
|
const fromHigh = num(row.asset.displayMetrics?.['From High'] as string | undefined);
|
||||||
|
const quality = row.fundamental?.tier === 'PASS' || row.inflated?.tier === 'PASS';
|
||||||
|
return quality && fromHigh != null && fromHigh <= -10;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './sorting.js';
|
export * from './sorting.js';
|
||||||
export * from './verdicts.js';
|
export * from './verdicts.js';
|
||||||
export * from './formatting.js';
|
export * from './formatting.js';
|
||||||
|
export * from './advice.js';
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
import AssetTable from '$lib/components/screener/AssetTable.svelte';
|
||||||
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
|
import AnalysisSidebar from '$lib/components/screener/AnalysisSidebar.svelte';
|
||||||
import WatchlistPanel from '$lib/components/screener/WatchlistPanel.svelte';
|
import WatchlistPanel from '$lib/components/screener/WatchlistPanel.svelte';
|
||||||
|
import SectorPulse from '$lib/components/screener/SectorPulse.svelte';
|
||||||
|
import SectorPanel from '$lib/components/screener/SectorPanel.svelte';
|
||||||
|
|
||||||
const s = screenerStore;
|
const s = screenerStore;
|
||||||
|
|
||||||
@@ -19,11 +21,16 @@
|
|||||||
if (_booted) return;
|
if (_booted) return;
|
||||||
_booted = true;
|
_booted = true;
|
||||||
s.reloadCatalysts();
|
s.reloadCatalysts();
|
||||||
|
s.loadSectorPulse();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="screener-page">
|
<div class="screener-page">
|
||||||
|
|
||||||
|
<!-- ── Market pulse — page-level header band (sectors today) ──────── -->
|
||||||
|
<SectorPulse />
|
||||||
|
<SectorPanel />
|
||||||
|
|
||||||
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
|
<!-- ── Toolbar ────────────────────────────────────────────────────── -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-top">
|
<div class="toolbar-top">
|
||||||
|
|||||||
@@ -71,16 +71,17 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-wrap {
|
.login-wrap {
|
||||||
display: flex;
|
/* Grid centering — robust regardless of parent flex context */
|
||||||
align-items: center;
|
display: grid;
|
||||||
justify-content: center;
|
place-items: center;
|
||||||
min-height: 60vh;
|
width: 100%;
|
||||||
|
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: min(380px, 100%);
|
||||||
max-width: 380px;
|
margin-inline: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
@@ -91,12 +92,14 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
margin: -0.75rem 0 0;
|
margin: -0.75rem 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-banner {
|
.success-banner {
|
||||||
|
|||||||
@@ -77,16 +77,17 @@
|
|||||||
<style>
|
<style>
|
||||||
/* Auth page layout only — input styles come from global _forms.scss */
|
/* Auth page layout only — input styles come from global _forms.scss */
|
||||||
.login-wrap {
|
.login-wrap {
|
||||||
display: flex;
|
/* Grid centering — robust regardless of parent flex context */
|
||||||
align-items: center;
|
display: grid;
|
||||||
justify-content: center;
|
place-items: center;
|
||||||
min-height: 60vh;
|
width: 100%;
|
||||||
|
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: min(380px, 100%);
|
||||||
max-width: 380px;
|
margin-inline: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
@@ -97,12 +98,14 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
margin: -0.75rem 0 0;
|
margin: -0.75rem 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-switch {
|
.auth-switch {
|
||||||
|
|||||||
@@ -96,16 +96,17 @@
|
|||||||
<style>
|
<style>
|
||||||
/* Auth page layout only — input/select styles come from global _forms.scss */
|
/* Auth page layout only — input/select styles come from global _forms.scss */
|
||||||
.login-wrap {
|
.login-wrap {
|
||||||
display: flex;
|
/* Grid centering — robust regardless of parent flex context */
|
||||||
align-items: center;
|
display: grid;
|
||||||
justify-content: center;
|
place-items: center;
|
||||||
min-height: 60vh;
|
width: 100%;
|
||||||
|
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: min(380px, 100%);
|
||||||
max-width: 380px;
|
margin-inline: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
@@ -116,12 +117,14 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
margin: -0.75rem 0 0;
|
margin: -0.75rem 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field small {
|
.field small {
|
||||||
|
|||||||
@@ -100,16 +100,17 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-wrap {
|
.login-wrap {
|
||||||
display: flex;
|
/* Grid centering — robust regardless of parent flex context */
|
||||||
align-items: center;
|
display: grid;
|
||||||
justify-content: center;
|
place-items: center;
|
||||||
min-height: 60vh;
|
width: 100%;
|
||||||
|
min-height: calc(100vh - 180px); /* viewport minus nav + main padding */
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 100%;
|
width: min(380px, 100%);
|
||||||
max-width: 380px;
|
margin-inline: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
@@ -120,12 +121,14 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
margin: -0.75rem 0 0;
|
margin: -0.75rem 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-banner {
|
.success-banner {
|
||||||
|
|||||||
@@ -180,6 +180,54 @@
|
|||||||
&:hover { color: var(--red); }
|
&:hover { color: var(--red); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Plain-language advice line (personal-use layer) ──────────────────────
|
||||||
|
|
||||||
|
.advice-line {
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 3px;
|
||||||
|
cursor: help;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advice-buy { color: var(--green); }
|
||||||
|
.advice-mindful { color: var(--amber); }
|
||||||
|
.advice-caution { color: var(--orange); }
|
||||||
|
.advice-wait { color: var(--blue); }
|
||||||
|
.advice-skip { color: var(--red); }
|
||||||
|
.advice-unknown { color: var(--text-muted); font-style: italic; }
|
||||||
|
|
||||||
|
// Turnaround-watch filter toggle (STOCK section header)
|
||||||
|
.ta-filter-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { color: var(--green); border-color: var(--green); }
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--green);
|
||||||
|
border-color: var(--green);
|
||||||
|
background: rgba(74, 222, 128, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.qd:hover, &.qd.active { color: var(--blue); border-color: var(--blue); background: rgba(96, 165, 250, 0.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row td {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Column headers ────────────────────────────────────────────────────────
|
// ── Column headers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
.sort-th {
|
.sort-th {
|
||||||
|
|||||||
@@ -63,6 +63,39 @@ table {
|
|||||||
font-size: var(--fs-md);
|
font-size: var(--fs-md);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticker opens the company modal (profile + chart + news)
|
||||||
|
.ticker-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px dashed transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--blue);
|
||||||
|
border-bottom-color: var(--blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turnaround-watch badge: Turnaround style + improving score (candidate flag)
|
||||||
|
.ta-badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--green);
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
.num {
|
.num {
|
||||||
|
|||||||
Reference in New Issue
Block a user