91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
import { DatabaseConnection } from '../db/index';
|
|
import { QueryBuilder } from '../utils/QueryBuilder';
|
|
import type { ScoreResult, SignalSnapshotRow } from '../types';
|
|
|
|
/**
|
|
* Signal snapshot ledger (PRODUCT.md P0.1).
|
|
*
|
|
* Persists one row per ticker per day on every /api/screen call so the
|
|
* product builds a verifiable signal track record. This data cannot be
|
|
* backfilled — the backtest dashboard (Phase 10.5e), thesis review (10.6d),
|
|
* and calibration features all depend on it accumulating from day one.
|
|
*
|
|
* Recording is best-effort: failures are logged by the caller and must never
|
|
* fail the screen request itself.
|
|
*/
|
|
|
|
export interface SnapshotInput {
|
|
ticker: string;
|
|
assetType: string;
|
|
price: number | null;
|
|
signal: string;
|
|
fundamental: ScoreResult;
|
|
inflated: ScoreResult;
|
|
rateRegime?: string | null;
|
|
}
|
|
|
|
export class SignalSnapshotRepository {
|
|
constructor(private readonly db: DatabaseConnection) {}
|
|
|
|
/**
|
|
* Upsert today's snapshot for a batch of screened assets.
|
|
* Repeated screens on the same day keep the latest result.
|
|
*/
|
|
recordBatch(inputs: SnapshotInput[], date = SignalSnapshotRepository.today()): number {
|
|
let written = 0;
|
|
for (const input of inputs) {
|
|
this.record(input, date);
|
|
written++;
|
|
}
|
|
return written;
|
|
}
|
|
|
|
record(input: SnapshotInput, date = SignalSnapshotRepository.today()): void {
|
|
const { ticker, assetType, price, signal, fundamental, inflated, rateRegime } = input;
|
|
const coverage = fundamental.audit?.coverage ?? inflated.audit?.coverage ?? null;
|
|
const riskFlags = fundamental.audit?.riskFlags ?? inflated.audit?.riskFlags ?? null;
|
|
|
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.UPSERT', [
|
|
ticker.toUpperCase(),
|
|
date,
|
|
assetType,
|
|
price,
|
|
signal,
|
|
fundamental.tier,
|
|
fundamental.score,
|
|
fundamental.label,
|
|
inflated.tier,
|
|
inflated.score,
|
|
inflated.label,
|
|
coverage?.active ?? null,
|
|
coverage?.total ?? null,
|
|
riskFlags ? JSON.stringify(riskFlags) : null,
|
|
rateRegime ?? null,
|
|
new Date().toISOString(),
|
|
]);
|
|
this.db.run(qb);
|
|
}
|
|
|
|
/** Full history for one ticker, oldest first. */
|
|
history(ticker: string): SignalSnapshotRow[] {
|
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_TICKER', [ticker.toUpperCase()]);
|
|
return this.db.all<SignalSnapshotRow>(qb);
|
|
}
|
|
|
|
/** All snapshots for a given day (YYYY-MM-DD). */
|
|
byDate(date: string): SignalSnapshotRow[] {
|
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_DATE', [date]);
|
|
return this.db.all<SignalSnapshotRow>(qb);
|
|
}
|
|
|
|
/** Latest snapshot per ticker strictly before a date — for daily diffing. */
|
|
latestBefore(date: string): SignalSnapshotRow[] {
|
|
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_LATEST_BEFORE', [date]);
|
|
return this.db.all<SignalSnapshotRow>(qb);
|
|
}
|
|
|
|
private static today(): string {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
}
|