phase-10.5: screener enhancements

This commit is contained in:
Kazuma
2026-06-11 19:18:19 -04:00
parent f0c794f0c0
commit bf2a85b5c4
51 changed files with 3745 additions and 36 deletions
+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);
}
}