feat(confluence): add slot backtest, follow-through resolver, and reliability scorecard (M22 slice 4)
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
// Investor Flow — confluenceBacktest.test.ts
|
||||||
|
// Pure follow-through math + resolvability over synthetic candles.
|
||||||
|
|
||||||
|
import { describe, it, test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
candleIndexForDate,
|
||||||
|
checkFollowThrough,
|
||||||
|
expectedDirection,
|
||||||
|
reliabilityWeightFrom,
|
||||||
|
runSlotBacktest,
|
||||||
|
resolveSignalHistory,
|
||||||
|
} from '../confluenceBacktest.ts';
|
||||||
|
import type { PriceCandle } from '../../cache/CacheRepository.ts';
|
||||||
|
|
||||||
|
const day = (offset: number): string => {
|
||||||
|
const d = new Date(Date.UTC(2025, 0, 5 + offset));
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
function candles(startOffset = 0, n = 120, step = 1): PriceCandle[] {
|
||||||
|
const out: PriceCandle[] = [];
|
||||||
|
let price = 100;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
price += step;
|
||||||
|
out.push({ ts: day(startOffset + i), o: price, h: price + 1, l: price - 1, c: price, v: 1e6, adjClose: price });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('expectedDirection', () => {
|
||||||
|
it('maps bull slots to up and exit/bear slots to down', () => {
|
||||||
|
assert.equal(expectedDirection('goldenCross'), 'up');
|
||||||
|
assert.equal(expectedDirection('deathCross'), 'down');
|
||||||
|
assert.equal(expectedDirection('insiderInformedSell'), 'down');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown slots', () => {
|
||||||
|
assert.equal(expectedDirection('notASlot'), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('candleIndexForDate', () => {
|
||||||
|
it('locates the exact candle ts-date', () => {
|
||||||
|
const cs = candles();
|
||||||
|
assert.equal(candleIndexForDate(cs, day(7)), 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when the date is absent', () => {
|
||||||
|
assert.equal(candleIndexForDate(candles(), '1999-01-01'), -1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkFollowThrough', () => {
|
||||||
|
it('resolves true for an uptrend with up direction', () => {
|
||||||
|
// Up movement: entry at idx 0 close=100, 20 bars later close=120 → +20%.
|
||||||
|
const cs = candles(0, 30, 1);
|
||||||
|
assert.equal(checkFollowThrough(cs, 0, 'up', 4, 0.5), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves false for an uptrend with down direction', () => {
|
||||||
|
const cs = candles(0, 30, 1);
|
||||||
|
assert.equal(checkFollowThrough(cs, 0, 'down', 4, 0.5), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not enough forward bars exist', () => {
|
||||||
|
const cs = candles(0, 10, 1); // only 10 bars; need 20 forward
|
||||||
|
assert.equal(checkFollowThrough(cs, 5, 'up', 4, 0.5), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null inside the dead band (flat outcome)', () => {
|
||||||
|
const cs = candles(0, 30, 0.001); // ~0.1% per bar, likely flat over 20
|
||||||
|
const r = checkFollowThrough(cs, 0, 'up', 4, 5);
|
||||||
|
assert.equal(r, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runSlotBacktest', () => {
|
||||||
|
it('computes per-slot hit rates from a fire corpus', () => {
|
||||||
|
const cs = candles(0, 60, 1);
|
||||||
|
const fires = [
|
||||||
|
{ symbol: 'PLTR', asOf: day(0), slotId: 'goldenCross' },
|
||||||
|
{ symbol: 'PLTR', asOf: day(5), slotId: 'goldenCross' },
|
||||||
|
{ symbol: 'PLTR', asOf: day(10), slotId: 'deathCross' },
|
||||||
|
{ symbol: 'PLTR', asOf: day(15), slotId: 'deathCross' },
|
||||||
|
];
|
||||||
|
const result = runSlotBacktest('PLTR', cs, fires, 4);
|
||||||
|
const gc = result.slots.find((s) => s.slotId === 'goldenCross')!;
|
||||||
|
const dc = result.slots.find((s) => s.slotId === 'deathCross')!;
|
||||||
|
|
||||||
|
// Both golden crosses resolve up-confirmed (both fire bars have 20 forward bars in a 60-bar series).
|
||||||
|
assert.equal(gc.fires, 2);
|
||||||
|
assert.equal(gc.confirmedFires, 2);
|
||||||
|
assert.equal(gc.hitRate, 1);
|
||||||
|
|
||||||
|
// Death crosses expect the price down; uptrend means both are false alarms.
|
||||||
|
assert.equal(dc.fires, 2);
|
||||||
|
assert.equal(dc.confirmedFires, 0);
|
||||||
|
assert.equal(dc.falseAlarms, 2);
|
||||||
|
assert.equal(dc.hitRate, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags thin samples in the caveat', () => {
|
||||||
|
const result = runSlotBacktest('PLTR', candles(), [
|
||||||
|
{ symbol: 'PLTR', asOf: day(0), slotId: 'goldenCross' },
|
||||||
|
], 4);
|
||||||
|
assert.ok(result.sampleSizeCaveat.includes('limited'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores fires for other symbols and unknown slots', () => {
|
||||||
|
const cs = candles();
|
||||||
|
const result = runSlotBacktest('PLTR', cs, [
|
||||||
|
{ symbol: 'NVDA', asOf: day(0), slotId: 'goldenCross' },
|
||||||
|
{ symbol: 'PLTR', asOf: day(0), slotId: 'bogus' },
|
||||||
|
], 4);
|
||||||
|
assert.equal(result.fires, 0);
|
||||||
|
assert.equal(result.slots.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes reliabilityWeight from sample', () => {
|
||||||
|
assert.equal(reliabilityWeightFrom(0, 0), 0.5);
|
||||||
|
assert.equal(reliabilityWeightFrom(10, 10), 1.25);
|
||||||
|
assert.equal(reliabilityWeightFrom(10, 4), 0.5);
|
||||||
|
assert.equal(reliabilityWeightFrom(3, 3), 0.75); // thin sample dampened
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveSignalHistory defers when forward bars are insufficient, resolves when available', async () => {
|
||||||
|
const { createDb, initSchema } = await import('../../db/client.ts');
|
||||||
|
const { ConfluenceRepository, rackFromSlots } = await import('../../db/confluenceRepository.ts');
|
||||||
|
const { evaluateRack } = await import('../confluenceRack.ts');
|
||||||
|
|
||||||
|
const db = createDb({ path: ':memory:' });
|
||||||
|
initSchema(db);
|
||||||
|
const repo = new ConfluenceRepository(db);
|
||||||
|
repo.saveRack(rackFromSlots('rt', 'Rack T', ['goldenCross'], { isSystem: true }));
|
||||||
|
|
||||||
|
// 15 fire events spread over dates with only 10 bars after the last → most deferred.
|
||||||
|
// Build a series of 22 candles (2 more than needed for a fire at bar 0 + 20).
|
||||||
|
const cs = candles(0, 22, 1);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const ev = evaluateRack('PLTR', day(i * 2), [
|
||||||
|
{ id: 'goldenCross', state: 'fired' as const },
|
||||||
|
{ id: 'relVolume', state: 'not-fired' as const },
|
||||||
|
]);
|
||||||
|
repo.saveEvaluation(ev, 'rt', `ev-${i}`);
|
||||||
|
repo.logSignalFires(ev, 'rt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const candleLookup = async (symbol: string) => (symbol === 'PLTR' ? cs : []);
|
||||||
|
|
||||||
|
// First pass: fires near the end may be deferred.
|
||||||
|
await resolveSignalHistory(db, candleLookup, 4);
|
||||||
|
|
||||||
|
const allSignals = db.prepare(
|
||||||
|
`SELECT id, price_resolved, verdict FROM confluence_signal_history ORDER BY fired_at`,
|
||||||
|
).all() as { id: string; price_resolved: number; verdict: string | null }[];
|
||||||
|
assert.equal(allSignals.length, 3);
|
||||||
|
assert.equal(allSignals[0].price_resolved, 1);
|
||||||
|
assert.equal(allSignals[0].verdict, 'real');
|
||||||
|
// Later fires (asOf day 4) have fewer than 20 forward bars → deferred.
|
||||||
|
assert.equal(allSignals[2].price_resolved, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// Investor Flow — Confluence Backtest + Signal Resolver (M22, slice 4)
|
||||||
|
//
|
||||||
|
// The closed loop: every slot fire is logged to confluence_signal_history, then
|
||||||
|
// resolved to 'real' | 'false_alarm' by N-week price follow-through aligned with
|
||||||
|
// the slot body (bull slots expect the price up, bear/exit slots expect it down).
|
||||||
|
//
|
||||||
|
// `runSlotBacktest` answers "when this slot fired, did price follow through N
|
||||||
|
// weeks later?" for a corpus of fire events — the raw material for a per-slot
|
||||||
|
// reliability scorecard that teaches the rack which confluences deserve weight.
|
||||||
|
//
|
||||||
|
// `resolveSignalHistory` walks pending `confluence_signal_history` rows and flips
|
||||||
|
// them to confirmed / false_alarm once enough forward bars exist. Mirrors the
|
||||||
|
// rotation_signals gamma two-stage, but for confluence slots.
|
||||||
|
//
|
||||||
|
// Pure where possible: candle math is pure; the resolver is a thin DB+data shim.
|
||||||
|
|
||||||
|
import type { PriceCandle } from '../cache/CacheRepository.ts';
|
||||||
|
import { confluenceslotById, type ConfluenceSlot } from './confluenceSlots.ts';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** One recorded slot fire at a date, as the backtest input unit. */
|
||||||
|
export interface ConfluenceFireEvent {
|
||||||
|
symbol: string;
|
||||||
|
/** YYYY-MM-DD the slot fired (as-of of the evaluation that fired it). */
|
||||||
|
asOf: string;
|
||||||
|
slotId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directional expectation of a slot body for follow-through resolution. */
|
||||||
|
export type FollowThrough = 'up' | 'down';
|
||||||
|
|
||||||
|
/** Per-slot follow-through stats across the backtest corpus. */
|
||||||
|
export interface SlotFollowThroughStats {
|
||||||
|
slotId: string;
|
||||||
|
name: string;
|
||||||
|
body: ConfluenceSlot['body'];
|
||||||
|
direction: FollowThrough;
|
||||||
|
fires: number;
|
||||||
|
/** Fires with enough forward bars to resolve. */
|
||||||
|
resolvedFires: number;
|
||||||
|
/** Resolved fires where price moved the expected way. */
|
||||||
|
confirmedFires: number;
|
||||||
|
/** resolved - confirmed (price moved the opposite way). */
|
||||||
|
falseAlarms: number;
|
||||||
|
/** confirmed / resolved over resolvedFires; null when none resolved. */
|
||||||
|
hitRate: number | null;
|
||||||
|
/** 0..1 multiplier a rack can apply to this slot's weight. */
|
||||||
|
reliabilityWeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole backtest summary (ADR-0007-safe: no directional advice). */
|
||||||
|
export interface ConfluenceBacktestResult {
|
||||||
|
symbol: string;
|
||||||
|
fires: number;
|
||||||
|
/** Calendar-day horizon used (weeks * 7). */
|
||||||
|
horizonDays: number;
|
||||||
|
slots: SlotFollowThroughStats[];
|
||||||
|
sampleSizeCaveat: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Expected follow-through direction for a slot: bull ⇒ up, bear/exit ⇒ down. */
|
||||||
|
export function expectedDirection(slotId: string): FollowThrough | null {
|
||||||
|
const slot = confluenceslotById.get(slotId);
|
||||||
|
if (!slot) return null;
|
||||||
|
return slot.body === 'bull' ? 'up' : 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The candle index whose ts-date is `asOf`, or -1. Candles sorted ascending. */
|
||||||
|
export function candleIndexForDate(candles: PriceCandle[], asOf: string): number {
|
||||||
|
for (let i = 0; i < candles.length; i++) {
|
||||||
|
const ts = (candles[i].ts ?? '').slice(0, 10);
|
||||||
|
if (ts === asOf) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure follow-through check: did price move the expected way `weeks` later?
|
||||||
|
* Uses the close `weeks*TRADING_DAYS_PER_WEEK` bars after the fire bar and
|
||||||
|
* permits a small dead-band so a whipsaw to flat is treated as unresolved
|
||||||
|
* rather than a confirmation or false alarm. Returns null when there is not
|
||||||
|
* enough forward data to resolve.
|
||||||
|
*/
|
||||||
|
export function checkFollowThrough(
|
||||||
|
candles: PriceCandle[],
|
||||||
|
fireIndex: number,
|
||||||
|
direction: FollowThrough,
|
||||||
|
weeks = 4,
|
||||||
|
deadBandPct = 0.5,
|
||||||
|
): boolean | null {
|
||||||
|
const step = weeks * 5;
|
||||||
|
const target = fireIndex + step;
|
||||||
|
if (target >= candles.length) return null;
|
||||||
|
|
||||||
|
const entry = candles[fireIndex].c;
|
||||||
|
const exit = candles[target].c;
|
||||||
|
if (!Number.isFinite(entry) || !Number.isFinite(exit) || entry <= 0) return null;
|
||||||
|
|
||||||
|
const retPct = ((exit - entry) / entry) * 100;
|
||||||
|
if (Math.abs(retPct) < deadBandPct) return null;
|
||||||
|
return direction === 'up' ? retPct > 0 : retPct < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backtest a corpus of fire events against candles: per-slot follow-through
|
||||||
|
* stats over `weeks`-week forward windows. Pure.
|
||||||
|
*/
|
||||||
|
export function runSlotBacktest(
|
||||||
|
symbol: string,
|
||||||
|
candles: PriceCandle[],
|
||||||
|
fires: ConfluenceFireEvent[],
|
||||||
|
weeks = 4,
|
||||||
|
): ConfluenceBacktestResult {
|
||||||
|
const hitsBySlot = new Map<string, { fires: number; resolved: number; confirmed: number }>();
|
||||||
|
|
||||||
|
for (const fire of fires) {
|
||||||
|
if (fire.symbol !== symbol) continue;
|
||||||
|
const dir = expectedDirection(fire.slotId);
|
||||||
|
if (dir === null) continue;
|
||||||
|
|
||||||
|
const row = hitsBySlot.get(fire.slotId) ?? { fires: 0, resolved: 0, confirmed: 0 };
|
||||||
|
row.fires += 1;
|
||||||
|
|
||||||
|
const idx = candleIndexForDate(candles, fire.asOf);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const confirmed = checkFollowThrough(candles, idx, dir, weeks);
|
||||||
|
if (confirmed !== null) {
|
||||||
|
row.resolved += 1;
|
||||||
|
if (confirmed) row.confirmed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hitsBySlot.set(fire.slotId, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots: SlotFollowThroughStats[] = [...hitsBySlot.entries()].map(([slotId, s]) => {
|
||||||
|
const slot = confluenceslotById.get(slotId);
|
||||||
|
const hitRate = s.resolved > 0 ? s.confirmed / s.resolved : null;
|
||||||
|
return {
|
||||||
|
slotId,
|
||||||
|
name: slot?.name ?? slotId,
|
||||||
|
body: slot?.body ?? 'bull',
|
||||||
|
direction: expectedDirection(slotId) ?? 'up',
|
||||||
|
fires: s.fires,
|
||||||
|
resolvedFires: s.resolved,
|
||||||
|
confirmedFires: s.confirmed,
|
||||||
|
falseAlarms: s.resolved - s.confirmed,
|
||||||
|
hitRate,
|
||||||
|
reliabilityWeight: reliabilityWeightFrom(s.resolved, s.confirmed),
|
||||||
|
};
|
||||||
|
}).sort((a, b) => (b.hitRate ?? 0) - (a.hitRate ?? 0));
|
||||||
|
|
||||||
|
const totalFires = slots.reduce((acc, s) => acc + s.fires, 0);
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
fires: totalFires,
|
||||||
|
horizonDays: weeks * 7,
|
||||||
|
slots,
|
||||||
|
sampleSizeCaveat:
|
||||||
|
totalFires < 30
|
||||||
|
? `Sample size of ${totalFires} slot-fires is limited. Reliability estimates may not generalize.`
|
||||||
|
: `Sample size: ${totalFires} slot-fires.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A defensible reliability multiplier in [0.5, 1.25] from a slot's resolved
|
||||||
|
* sample: 100% and ≥8 resolved ⇒ 1.25×; worse-than-coin-flip or <2 resolved ⇒
|
||||||
|
* 0.5×; otherwise linear interpolation across [50%, 75%] hit rate. The rack can
|
||||||
|
* multiply per-slot evidence by this to self-tune. Pure.
|
||||||
|
*/
|
||||||
|
export function reliabilityWeightFrom(resolvedFires: number, confirmedFires: number): number {
|
||||||
|
if (resolvedFires <= 0) return 0.5;
|
||||||
|
const rate = confirmedFires / resolvedFires;
|
||||||
|
if (resolvedFires >= 8 && rate >= 0.9) return 1.25;
|
||||||
|
if (rate <= 0.5 || resolvedFires < 2) return 0.5;
|
||||||
|
if (resolvedFires < 8) return 0.75; // thin sample: hold confidence down
|
||||||
|
return Math.min(1.25, 0.5 + (rate - 0.5) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Signal-history resolver (thin service shim over the repository)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { ConfluenceRepository, type ConfluenceSignalHistoryRow } from '../db/confluenceRepository.ts';
|
||||||
|
|
||||||
|
/** Result of resolving one pending signal. */
|
||||||
|
export interface SignalResolutionOutcome {
|
||||||
|
signalId: string;
|
||||||
|
symbol: string;
|
||||||
|
slotId: string;
|
||||||
|
verdict: 'real' | 'false_alarm' | 'deferred';
|
||||||
|
priceConfirmed: boolean | null;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSignalsResult {
|
||||||
|
resolved: SignalResolutionOutcome[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve pending `confluence_signal_history` rows using forward candle data.
|
||||||
|
*
|
||||||
|
* @param db database handle
|
||||||
|
* @param candleProvider async lookup of a symbol's daily candles (sorted)
|
||||||
|
* @param weeks follow-through window (default 4)
|
||||||
|
*/
|
||||||
|
export async function resolveSignalHistory(
|
||||||
|
db: DatabaseSync,
|
||||||
|
candleProvider: (symbol: string) => Promise<PriceCandle[]>,
|
||||||
|
weeks = 4,
|
||||||
|
): Promise<ResolvedSignalsResult> {
|
||||||
|
const repo = new ConfluenceRepository(db);
|
||||||
|
const pending = repo.listPendingSignals();
|
||||||
|
if (pending.length === 0) return { resolved: [] };
|
||||||
|
|
||||||
|
const resolved: SignalResolutionOutcome[] = [];
|
||||||
|
|
||||||
|
for (const signal of pending) {
|
||||||
|
if (signal.verdict !== null) continue; // already resolved (guard)
|
||||||
|
const dir = expectedDirection(signal.slotId);
|
||||||
|
|
||||||
|
const outcome = await resolveOneSignal(signal, repo, candleProvider, dir, weeks);
|
||||||
|
resolved.push(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOneSignal(
|
||||||
|
signal: ConfluenceSignalHistoryRow,
|
||||||
|
repo: ConfluenceRepository,
|
||||||
|
candleProvider: (symbol: string) => Promise<PriceCandle[]>,
|
||||||
|
direction: FollowThrough | null,
|
||||||
|
weeks: number,
|
||||||
|
): Promise<SignalResolutionOutcome> {
|
||||||
|
try {
|
||||||
|
const candles = await candleProvider(signal.symbol);
|
||||||
|
const idx = candleIndexForDate(candles, signal.firedAt);
|
||||||
|
if (direction !== null && idx >= 0) {
|
||||||
|
const confirmed = checkFollowThrough(candles, idx, direction, weeks);
|
||||||
|
if (confirmed !== null) {
|
||||||
|
repo.resolveSignal(signal.id, confirmed, confirmed ? 'real' : 'false_alarm');
|
||||||
|
return {
|
||||||
|
signalId: signal.id,
|
||||||
|
symbol: signal.symbol,
|
||||||
|
slotId: signal.slotId,
|
||||||
|
verdict: confirmed ? 'real' : 'false_alarm',
|
||||||
|
priceConfirmed: confirmed,
|
||||||
|
note: `follow-through ${confirmed ? 'confirmed' : 'failed'} over ${weeks} weeks`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { signalId: signal.id, symbol: signal.symbol, slotId: signal.slotId, verdict: 'deferred', priceConfirmed: null, note: 'not enough forward bars yet' };
|
||||||
|
}
|
||||||
|
return { signalId: signal.id, symbol: signal.symbol, slotId: signal.slotId, verdict: 'deferred', priceConfirmed: null, note: 'no fire bar or unknown slot' };
|
||||||
|
} catch (e) {
|
||||||
|
return { signalId: signal.id, symbol: signal.symbol, slotId: signal.slotId, verdict: 'deferred', priceConfirmed: null, note: `candle fetch error: ${String(e)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract per-slot stats from a live DB for the scorecard view. */
|
||||||
|
export function signalHistoryToStats(db: DatabaseSync, symbol?: string): SlotFollowThroughStats[] {
|
||||||
|
const repo = new ConfluenceRepository(db);
|
||||||
|
const rows = symbol ? repo.listSignalsForSymbol(symbol) : repo.listAllSignals();
|
||||||
|
|
||||||
|
const bySlot = new Map<string, { fires: number; resolved: number; confirmed: number }>();
|
||||||
|
for (const s of rows) {
|
||||||
|
const row = bySlot.get(s.slotId) ?? { fires: 0, resolved: 0, confirmed: 0 };
|
||||||
|
row.fires += 1;
|
||||||
|
if (s.priceResolved) {
|
||||||
|
row.resolved += 1;
|
||||||
|
if (s.priceConfirmed) row.confirmed += 1;
|
||||||
|
}
|
||||||
|
bySlot.set(s.slotId, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...bySlot.entries()].map(([slotId, s]) => {
|
||||||
|
const slot = confluenceslotById.get(slotId);
|
||||||
|
const hitRate = s.resolved > 0 ? s.confirmed / s.resolved : null;
|
||||||
|
return {
|
||||||
|
slotId,
|
||||||
|
name: slot?.name ?? slotId,
|
||||||
|
body: slot?.body ?? 'bull',
|
||||||
|
direction: expectedDirection(slotId) ?? 'up',
|
||||||
|
fires: s.fires,
|
||||||
|
resolvedFires: s.resolved,
|
||||||
|
confirmedFires: s.confirmed,
|
||||||
|
falseAlarms: s.resolved - s.confirmed,
|
||||||
|
hitRate,
|
||||||
|
reliabilityWeight: reliabilityWeightFrom(s.resolved, s.confirmed),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -141,6 +141,10 @@ function stmts(db: DatabaseSync) {
|
|||||||
`SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict
|
`SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict
|
||||||
FROM confluence_signal_history WHERE price_resolved = 0 ORDER BY fired_at`,
|
FROM confluence_signal_history WHERE price_resolved = 0 ORDER BY fired_at`,
|
||||||
),
|
),
|
||||||
|
selectAllSignals: db.prepare(
|
||||||
|
`SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict
|
||||||
|
FROM confluence_signal_history ORDER BY fired_at`,
|
||||||
|
),
|
||||||
selectSignalsForSymbol: db.prepare(
|
selectSignalsForSymbol: db.prepare(
|
||||||
`SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict
|
`SELECT id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict
|
||||||
FROM confluence_signal_history WHERE symbol = ? ORDER BY fired_at DESC`,
|
FROM confluence_signal_history WHERE symbol = ? ORDER BY fired_at DESC`,
|
||||||
@@ -324,6 +328,10 @@ export class ConfluenceRepository {
|
|||||||
return (stmts(this.db).selectPendingSignals.all() as Record<string, unknown>[]).map(mapSignal);
|
return (stmts(this.db).selectPendingSignals.all() as Record<string, unknown>[]).map(mapSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listAllSignals(): ConfluenceSignalHistoryRow[] {
|
||||||
|
return (stmts(this.db).selectAllSignals.all() as Record<string, unknown>[]).map(mapSignal);
|
||||||
|
}
|
||||||
|
|
||||||
listSignalsForSymbol(symbol: string): ConfluenceSignalHistoryRow[] {
|
listSignalsForSymbol(symbol: string): ConfluenceSignalHistoryRow[] {
|
||||||
return (stmts(this.db).selectSignalsForSymbol.all(symbol) as Record<string, unknown>[]).map(mapSignal);
|
return (stmts(this.db).selectSignalsForSymbol.all(symbol) as Record<string, unknown>[]).map(mapSignal);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user