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(); // 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); } }