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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user