phase-10.5: screener enhancements

This commit is contained in:
saikiranvella
2026-06-11 19:18:19 -04:00
parent bac00ab5d5
commit e953822bab
51 changed files with 3745 additions and 36 deletions
+49 -2
View File
@@ -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 P0P3) 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)
+39
View File
@@ -1,5 +1,38 @@
# PHASES.md # PHASES.md
---
## 📍 Roadmap Status & Realignment — June 2026
Cross-reference: **PRODUCT.md** (P0P3 priorities) and **FREE-DATA-STACK.md** ($0 data architecture). CLAUDE.md "Status Update" has the full shipped list.
### Done ahead of schedule (was "future", now shipped)
| Originally planned as | What actually shipped | Status |
|---|---|---|
| Phase 12 (news webhooks, ~$200/mo) | **Free-tier news pipeline**: EDGAR + PR-wire pollers → filter/dedupe/classify → SQLite; in-server scheduler + cron runner; `/api/news/*` | ✅ Free version shipped. Paid webhook spine = drop-in upgrade (same queue) |
| Phase 14 (real-time monitor + Discord) | **Daily change digest**: snapshot diff + catalyst join → Discord (forum-aware). EOD, not intraday | ✅ EOD version shipped. Real-time price feed still future |
| Phase 10.9 (dip opportunity monitor) | **💎 Quality dips filter**: quality-gate PASS + 10%+ off 52W high, in the STOCK table | ✅ v1 shipped. Dedicated daily monitor + dip attribution still future |
| Phase 10.5d tearsheet (partial) | **Ticker modal**: profile, 1D5Y chart w/ crosshair, analyst target bar, news | ✅ Covers chart/profile/targets/news. Peer comparison + what-ifs pending |
| 10.5e backtest (foundation) | **Signal snapshot ledger** + `/api/screen/history/:ticker` | ✅ Data accumulating; dashboard pending |
| (unplanned) | Market Pulse band, sector drill-down panel, advice layer, turnaround watch, data sentinel, verdict tiers, regime hysteresis | ✅ |
### Still at Phase-7 state (not touched this sprint)
**Portfolio**, **Market Calls**, **Safe Buys** pages — work as before, none of the new
intelligence (advice layer, snapshots, news) is wired into them yet.
### Realigned order of future work
1. **Finish Phase 10.5** — P/E+ROE+52W columns, P/E/ROE range filters, peer-comparison + what-if sections in the ticker modal (items listed in CLAUDE.md)
2. **Phase 10.6 — Portfolio integration** ← biggest gap now: wire signals/advice/snapshots/news into the Portfolio page ("you own this, verdict changed, here's why")
3. **Safe Buys upgrade (10.9 v2)** — rebuild the Safe Buys page on quality-dips + snapshot history + news attribution
4. **10.8a — earnings dates in the ticker modal** (Finnhub free tier, per FREE-DATA-STACK §1.5)
5. **10.5e — decision log + backtest dashboard** (once the ledger has ~3 months of data)
6. **Phase 11 — auth** (already partially present: JWT login/watchlist exist) → then paid upgrades: **Phase 12** webhook spine, **Phase 13** prompt caching, **Phase 14** real-time monitor
---
Complete roadmap for market-screener evolution from Phase 9 through Phase 16+. 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.
+43
View File
@@ -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
```
--- ---
+85
View File
@@ -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);
+67
View File
@@ -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);
}
+68
View File
@@ -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 */
+3
View File
@@ -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
View File
@@ -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;
+110
View File
@@ -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);
}
}
+128
View File
@@ -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);
}
}
+5
View File
@@ -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';
+165
View File
@@ -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');
}
}
+76
View File
@@ -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]));
}
}
+106
View File
@@ -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,
};
}
}
+50
View File
@@ -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;
}
}
+10
View File
@@ -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';
+90
View File
@@ -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,
};
}
}
+122
View File
@@ -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();
}
}
+43
View File
@@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}
}
+213 -1
View File
@@ -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 §25 — free-tier news pipeline) ───────────
export const NEWS_QUERIES = {
// INSERT OR IGNORE — url_hash PK is the first dedupe line (returns 0 changes on dup)
INSERT_ARTICLE: `
INSERT OR IGNORE INTO news_articles
(url_hash, title_hash, ticker_list, headline, body, source, catalyst, url, published_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
// Second dedupe line: same (normalized) title seen recently → syndicated copy
TITLE_SEEN_SINCE: `
SELECT 1 FROM news_articles
WHERE title_hash = ? AND published_at >= ?
LIMIT 1
`,
INSERT_CATALYST_LINK: `
INSERT OR IGNORE INTO ticker_catalysts (ticker, day, url_hash)
VALUES (?, ?, ?)
`,
// Per-ticker daily cap check (FREE-DATA-STACK §4.4)
COUNT_TICKER_DAY: `
SELECT COUNT(*) AS n FROM ticker_catalysts
WHERE ticker = ? AND day = ?
`,
// Stories for one ticker since a given day — what the UI reads (never Yahoo live)
SELECT_TICKER_NEWS: `
SELECT a.* FROM ticker_catalysts c
JOIN news_articles a ON a.url_hash = c.url_hash
WHERE c.ticker = ? AND c.day >= ?
ORDER BY a.published_at DESC
`,
SELECT_RECENT: `
SELECT * FROM news_articles
ORDER BY published_at DESC
LIMIT ?
`,
// Retention (FREE-DATA-STACK §5): purge bodies after 90d, drop unreferenced after 18mo
PURGE_BODIES_BEFORE: `
UPDATE news_articles SET body = NULL
WHERE body IS NOT NULL AND published_at < ?
`,
DELETE_UNREFERENCED_BEFORE: `
DELETE FROM news_articles
WHERE published_at < ?
AND url_hash NOT IN (SELECT url_hash FROM ticker_catalysts)
`,
}; };
// ── Signal Snapshot Queries (P0.1 — signal track record) ──────────────────── // ── 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) ────────────────────────
+3 -1
View File
@@ -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 ─────────────────────────────────────────────────
+9
View File
@@ -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;
+43
View File
@@ -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;
}
+191
View File
@@ -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'));
});
});
+129
View File
@@ -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');
}
+85
View File
@@ -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 &amp; B say &quot;hi&quot;</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');
});
});
+15
View File
@@ -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';
+93
View File
@@ -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 0100%. */
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 screeners 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>
+44 -1
View File
@@ -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;
+140
View File
@@ -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 todays market prices but does not pass the strict value gates. ' +
'Fine while the market is kind; expect sharper falls when it isnt.',
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 dont 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
View File
@@ -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';
+7
View File
@@ -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 {
+9 -6
View File
@@ -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 {
+9 -6
View File
@@ -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 {
+48
View File
@@ -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 {
+33
View File
@@ -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 {