111 lines
3.8 KiB
TypeScript
111 lines
3.8 KiB
TypeScript
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);
|
|
}
|
|
}
|