phase-10.5: screener enhancements
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user