feat(confluence): add slot backtest, follow-through resolver, and reliability scorecard (M22 slice 4)

This commit is contained in:
Investor Flow Build
2026-08-10 15:18:21 -04:00
parent 93593f3ecf
commit 358286ce18
3 changed files with 472 additions and 0 deletions
@@ -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
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(
`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`,
@@ -324,6 +328,10 @@ export class ConfluenceRepository {
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[] {
return (stmts(this.db).selectSignalsForSymbol.all(symbol) as Record<string, unknown>[]).map(mapSignal);
}