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);
}
}
+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';