feat(confluence): add racks, evaluations, and signal-history schema + repository (M22 slice 3)

This commit is contained in:
Investor Flow Build
2026-08-10 15:13:08 -04:00
parent 49bcfc3d3a
commit 93593f3ecf
5 changed files with 588 additions and 0 deletions
@@ -0,0 +1,136 @@
// Investor Flow — confluenceRepository.test.ts
// Storage round-trip tests for the confluence racks / evaluations / signal history.
import { describe, it, beforeEach, test } from 'node:test';
import assert from 'node:assert/strict';
import { createDb, initSchema } from '../client.ts';
import { ConfluenceRepository, rackFromSlots } from '../confluenceRepository.ts';
import { evaluateRack, type SlotAssessment } from '../../confluence/confluenceRack.ts';
const fired = (id: string): SlotAssessment => ({ id, state: 'fired' });
const notFired = (id: string): SlotAssessment => ({ id, state: 'not-fired' });
let repo: ConfluenceRepository;
let db;
beforeEach(() => {
const d = createDb({ path: ':memory:' });
initSchema(d);
repo = new ConfluenceRepository(d);
db = d;
});
describe('ConfluenceRepository (racks)', () => {
it('persists and retrieves a system rack with slot ids', () => {
const rack = rackFromSlots('core_trend', 'Core Trend', ['goldenCross', 'relVolume'], { isSystem: true });
repo.saveRack(rack);
const got = repo.getRack('core_trend');
assert.ok(got);
assert.equal(got.name, 'Core Trend');
assert.deepEqual(got.slotIds, ['goldenCross', 'relVolume']);
assert.equal(got.isSystem, true);
assert.equal(got.ownerId, null);
});
it('lists system racks distinctly from user racks', () => {
repo.saveRack(rackFromSlots('sys1', 'System A', ['goldenCross'], { isSystem: true }));
repo.saveRack(rackFromSlots('usr1', 'Mine', ['relVolume'], { ownerId: 'u1' }));
assert.equal(repo.listSystemRacks().length, 1);
assert.equal(repo.listUserRacks('u1').length, 1);
assert.equal(repo.listAllRacks().length, 2);
});
it('updates rack slots and deletes racks', () => {
repo.saveRack(rackFromSlots('r1', 'Rack 1', ['goldenCross'], { ownerId: 'u1' }));
repo.updateRackSlots('r1', ['goldenCross', 'relVolume', 'cotPositioning']);
assert.deepEqual(repo.getRack('r1')!.slotIds, ['goldenCross', 'relVolume', 'cotPositioning']);
repo.deleteRack('r1');
assert.equal(repo.getRack('r1'), null);
});
it('looks up racks by owner id + name', () => {
repo.saveRack(rackFromSlots('a1', 'Alpha', ['goldenCross'], { ownerId: 'u1' }));
assert.equal(repo.getRackByOwnerAndName('u1', 'Alpha')!.id, 'a1');
assert.equal(repo.getRackByOwnerAndName('u1', 'nope'), null);
assert.equal(repo.getRackByOwnerAndName(null, 'Alpha'), null);
});
});
describe('ConfluenceRepository (evaluations)', () => {
it('round-trips a full evaluation', () => {
const rack = rackFromSlots('rt', 'Rack T', ['goldenCross', 'relVolume'], { isSystem: true });
repo.saveRack(rack);
const ev = evaluateRack('PLTR', '2026-02-02', [fired('goldenCross'), fired('relVolume'), notFired('cotPositioning')]);
repo.saveEvaluation(ev, 'rt', 'ev-1');
const got = repo.getEvaluation('PLTR', '2026-02-02', 'rt');
assert.ok(got);
assert.equal(got.quality, ev.quality);
assert.equal(got.bullEvidence, 2); // two distinct groups, no redundancy overlap
assert.equal(got.assessments.length, 3);
assert.equal(got.assessments[0].state, 'fired');
});
it('returns latest evaluation per rack by as_of', () => {
const rack = rackFromSlots('rt', 'Rack T', ['goldenCross'], { isSystem: true });
repo.saveRack(rack);
repo.saveEvaluation(evaluateRack('PLTR', '2026-02-01', [fired('goldenCross')]), 'rt', 'e1');
repo.saveEvaluation(evaluateRack('PLTR', '2026-02-02', [notFired('goldenCross')]), 'rt', 'e2');
const latest = repo.latestEvaluationForRack('rt');
assert.equal(latest!.asOf, '2026-02-02');
assert.equal(repo.listEvaluationsForSymbol('PLTR').length, 2);
});
it('dedupes a (symbol, as_of, rack) evaluation through unique constraint', () => {
const rack = rackFromSlots('rt', 'Rack T', ['goldenCross'], { isSystem: true });
repo.saveRack(rack);
repo.saveEvaluation(evaluateRack('NVDA', '2026-02-01', [fired('goldenCross')]), 'rt', 'n1');
assert.throws(() => repo.saveEvaluation(evaluateRack('NVDA', '2026-02-01', [notFired('goldenCross')]), 'rt', 'n2'));
});
});
describe('ConfluenceRepository (signal history)', () => {
it('logs fired slot signals and lists them for a symbol', () => {
const rack = rackFromSlots('rt', 'Rack T', ['goldenCross', 'relVolume', 'deathCross'], { isSystem: true });
repo.saveRack(rack);
const ev = evaluateRack('PLTR', '2026-02-02', [fired('goldenCross'), fired('deathCross'), notFired('relVolume')]);
repo.saveEvaluation(ev, 'rt', 'ev-1');
repo.logSignalFires(ev, 'rt');
const signals = repo.listSignalsForSymbol('PLTR');
assert.equal(signals.length, 2); // goldenCross + deathCross fired, relVolume did not
assert.deepEqual(signals.map((s) => s.slotId).sort(), ['deathCross', 'goldenCross']);
assert.equal(signals[0].priceResolved, false);
});
it('resolves a pending signal with verdict', () => {
const rack = rackFromSlots('rt', 'Rack T', ['goldenCross'], { isSystem: true });
repo.saveRack(rack);
const ev = evaluateRack('PLTR', '2026-02-02', [fired('goldenCross')]);
repo.saveEvaluation(ev, 'rt', 'ev-1');
repo.logSignalFires(ev, 'rt');
const pending = repo.listPendingSignals();
assert.equal(pending.length, 1);
repo.resolveSignal(pending[0].id, true, 'real');
const resolved = repo.listSignalsForSymbol('PLTR');
assert.equal(resolved[0].priceResolved, true);
assert.equal(resolved[0].priceConfirmed, true);
assert.equal(resolved[0].verdict, 'real');
});
});
// Ensure freshDb-style sanity through the migration path isn't broken by our tables.
test('confluence tables survive initSchema re-run (idempotent)', () => {
const d = createDb({ path: ':memory:' });
initSchema(d);
assert.doesNotThrow(() => initSchema(d));
});
@@ -19,6 +19,7 @@ const EXPECTED_TABLES = [
'screener_filters', 'reports', 'alerts', 'trusted_accounts',
'saved_posts', 'trade_key_levels',
'llm_summaries', 'rotation_signals', 'rotation_rank_snapshots', 'llm_providers', 'llm_dispatch_audit',
'confluence_racks', 'confluence_evaluations', 'confluence_signal_history',
];
test('schema creates all locked tables', () => {
+42
View File
@@ -212,6 +212,48 @@ function runMigrations(db: DatabaseSync): void {
sent_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_notification_outbox_pending ON notification_outbox(status, created_at)`,
// Confluence Signal Engine (M22) — racks, evaluations, signal history.
`CREATE TABLE IF NOT EXISTS confluence_racks (
id TEXT PRIMARY KEY,
owner_id TEXT,
name TEXT NOT NULL,
slot_ids_json TEXT NOT NULL,
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_confluence_racks_owner ON confluence_racks(owner_id)`,
`CREATE TABLE IF NOT EXISTS confluence_evaluations (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
as_of TEXT NOT NULL,
rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE,
assessments_json TEXT NOT NULL,
bull_evidence REAL NOT NULL,
bear_evidence REAL NOT NULL,
bull_count INTEGER NOT NULL,
bear_count INTEGER NOT NULL,
assessed_count INTEGER NOT NULL,
net_evidence REAL NOT NULL,
total_evidence REAL NOT NULL,
quality TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(symbol, as_of, rack_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_confluence_evals_symbol_asof ON confluence_evaluations(symbol, as_of DESC)`,
`CREATE TABLE IF NOT EXISTS confluence_signal_history (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
slot_id TEXT NOT NULL,
rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE,
fired_at TEXT NOT NULL,
quality_at_fire TEXT NOT NULL,
price_resolved INTEGER NOT NULL DEFAULT 0,
price_confirmed INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT,
verdict TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_confluence_signal_symbol_slot ON confluence_signal_history(symbol, slot_id)`,
];
for (const sql of migrations) {
try { db.exec(sql); } catch { /* column already exists */ }
+354
View File
@@ -0,0 +1,354 @@
// Investor Flow — Confluence Repository (M22, slice 3)
//
// Thin data-access layer over the three confluence tables:
// confluence_racks — named rack definitions (system + user)
// confluence_evaluations — per-symbol, per-as-of rack evaluations
// confluence_signal_history — closed-loop slot-fire log w/ verdict resolution
//
// Everything is stored serialized (assessments_json / slot_ids_json) so the
// redundancy-aware rack logic stays in confluenceRack.ts (pure) and this file
// only handles durable storage. No business rules live here.
import type { DatabaseSync } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
import type { ConfluenceEvaluation, SlotAssessment } from '../confluence/confluenceRack.ts';
import type { PictureQuality } from '../confluence/confluenceRack.ts';
import type { ConfluenceSlot } from '../confluence/confluenceSlots.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A named rack definition (system preset or user-created). */
export interface ConfluenceRack {
id: string;
ownerId: string | null;
name: string;
slotIds: string[];
description: string | null;
isSystem: boolean;
createdAt: string;
}
/** A stored rack evaluation for one symbol on one as-of date. */
export interface StoredConfluenceEvaluation {
symbol: string;
asOf: string;
rackId: string;
quality: PictureQuality;
bullEvidence: number;
bearEvidence: number;
bullCount: number;
bearCount: number;
assessedCount: number;
netEvidence: number;
totalEvidence: number;
assessments: SlotAssessment[];
createdAt: string;
}
/** A slot-fire logged from an evaluation, awaiting verdict resolution. */
export interface ConfluenceSignalHistoryRow {
id: string;
symbol: string;
slotId: string;
rackId: string;
firedAt: string;
qualityAtFire: PictureQuality;
priceResolved: boolean;
priceConfirmed: boolean;
resolvedAt: string | null;
verdict: 'real' | 'false_alarm' | null;
}
// ---------------------------------------------------------------------------
// Serialization helpers
// ---------------------------------------------------------------------------
const parseJsonArray = (raw: string): string[] => {
try {
const v = JSON.parse(raw) as unknown;
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
};
// ---------------------------------------------------------------------------
// Prepared statements (lazy, one per method)
// ---------------------------------------------------------------------------
function stmts(db: DatabaseSync) {
return {
// --- confluence_racks ---
insertRack: db.prepare(
`INSERT INTO confluence_racks (id, owner_id, name, slot_ids_json, description, is_system, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
),
selectRack: db.prepare(
`SELECT id, owner_id, name, slot_ids_json, description, is_system, created_at
FROM confluence_racks WHERE id = ?`,
),
selectRackByOwnerAndName: db.prepare(
`SELECT id, owner_id, name, slot_ids_json, description, is_system, created_at
FROM confluence_racks WHERE owner_id = ? AND name = ?`,
),
selectSystemRacks: db.prepare(
`SELECT id, owner_id, name, slot_ids_json, description, is_system, created_at
FROM confluence_racks WHERE is_system = 1 ORDER BY name`,
),
selectUserRacks: db.prepare(
`SELECT id, owner_id, name, slot_ids_json, description, is_system, created_at
FROM confluence_racks WHERE owner_id = ? ORDER BY created_at DESC`,
),
selectAllRacks: db.prepare(`SELECT id, owner_id, name, slot_ids_json, description, is_system, created_at FROM confluence_racks`),
updateRackSlots: db.prepare(`UPDATE confluence_racks SET slot_ids_json = ?, name = ?, description = ? WHERE id = ?`),
deleteRack: db.prepare(`DELETE FROM confluence_racks WHERE id = ?`),
// --- confluence_evaluations ---
insertEvaluation: db.prepare(
`INSERT INTO confluence_evaluations
(id, symbol, as_of, rack_id, assessments_json, bull_evidence, bear_evidence,
bull_count, bear_count, assessed_count, net_evidence, total_evidence, quality, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
),
selectEvaluation: db.prepare(
`SELECT id, symbol, as_of, rack_id, assessments_json, bull_evidence, bear_evidence,
bull_count, bear_count, assessed_count, net_evidence, total_evidence, quality, created_at
FROM confluence_evaluations WHERE symbol = ? AND as_of = ? AND rack_id = ?`,
),
selectEvaluationsForSymbol: db.prepare(
`SELECT id, symbol, as_of, rack_id, assessments_json, bull_evidence, bear_evidence,
bull_count, bear_count, assessed_count, net_evidence, total_evidence, quality, created_at
FROM confluence_evaluations WHERE symbol = ? ORDER BY as_of DESC`,
),
latestEvaluationForRack: db.prepare(
`SELECT id, symbol, as_of, rack_id, assessments_json, bull_evidence, bear_evidence,
bull_count, bear_count, assessed_count, net_evidence, total_evidence, quality, created_at
FROM confluence_evaluations
WHERE rack_id = ?
ORDER BY as_of DESC, created_at DESC
LIMIT 1`,
),
// --- confluence_signal_history ---
insertSignal: db.prepare(
`INSERT INTO confluence_signal_history
(id, symbol, slot_id, rack_id, fired_at, quality_at_fire, price_resolved, price_confirmed, resolved_at, verdict)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, NULL, NULL)`,
),
selectPendingSignals: 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 price_resolved = 0 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`,
),
resolveSignal: db.prepare(
`UPDATE confluence_signal_history
SET price_resolved = 1, price_confirmed = ?, resolved_at = ?, verdict = ?
WHERE id = ?`,
),
};
}
function mapRack(row: Record<string, unknown>): ConfluenceRack {
return {
id: row.id as string,
ownerId: (row.owner_id as string | null) ?? null,
name: row.name as string,
slotIds: parseJsonArray(row.slot_ids_json as string),
description: (row.description as string | null) ?? null,
isSystem: Number(row.is_system) === 1,
createdAt: row.created_at as string,
};
}
function mapEvaluation(row: Record<string, unknown>): StoredConfluenceEvaluation {
const assessments = (() => {
try {
const v = JSON.parse(row.assessments_json as string) as unknown;
return Array.isArray(v) ? (v as SlotAssessment[]) : [];
} catch {
return [];
}
})();
return {
symbol: row.symbol as string,
asOf: row.as_of as string,
rackId: row.rack_id as string,
quality: row.quality as PictureQuality,
bullEvidence: Number(row.bull_evidence),
bearEvidence: Number(row.bear_evidence),
bullCount: Number(row.bull_count),
bearCount: Number(row.bear_count),
assessedCount: Number(row.assessed_count),
netEvidence: Number(row.net_evidence),
totalEvidence: Number(row.total_evidence),
assessments,
createdAt: row.created_at as string,
};
}
function mapSignal(row: Record<string, unknown>): ConfluenceSignalHistoryRow {
return {
id: row.id as string,
symbol: row.symbol as string,
slotId: row.slot_id as string,
rackId: row.rack_id as string,
firedAt: row.fired_at as string,
qualityAtFire: row.quality_at_fire as PictureQuality,
priceResolved: Number(row.price_resolved) === 1,
priceConfirmed: Number(row.price_confirmed) === 1,
resolvedAt: (row.resolved_at as string | null) ?? null,
verdict: (row.verdict as 'real' | 'false_alarm' | null) ?? null,
};
}
// ---------------------------------------------------------------------------
// Repository
// ---------------------------------------------------------------------------
export class ConfluenceRepository {
private readonly db: DatabaseSync;
constructor(db: DatabaseSync) {
this.db = db;
}
// --------------------------------------------------------------- racks
saveRack(rack: ConfluenceRack): void {
stmts(this.db).insertRack.run(
rack.id,
rack.ownerId,
rack.name,
JSON.stringify(rack.slotIds),
rack.description,
rack.isSystem ? 1 : 0,
rack.createdAt,
);
}
getRack(id: string): ConfluenceRack | null {
const row = stmts(this.db).selectRack.get(id) as Record<string, unknown> | undefined;
return row ? mapRack(row) : null;
}
getRackByOwnerAndName(ownerId: string | null, name: string): ConfluenceRack | null {
const row = stmts(this.db).selectRackByOwnerAndName.get(ownerId, name) as Record<string, unknown> | undefined;
return row ? mapRack(row) : null;
}
listSystemRacks(): ConfluenceRack[] {
return (stmts(this.db).selectSystemRacks.all() as Record<string, unknown>[]).map(mapRack);
}
listUserRacks(ownerId: string): ConfluenceRack[] {
return (stmts(this.db).selectUserRacks.all(ownerId) as Record<string, unknown>[]).map(mapRack);
}
listAllRacks(): ConfluenceRack[] {
return (stmts(this.db).selectAllRacks.all() as Record<string, unknown>[]).map(mapRack);
}
updateRackSlots(id: string, slotIds: string[], name?: string, description?: string | null): void {
const existing = this.getRack(id);
if (!existing) throw new Error(`confluence rack not found: ${id}`);
stmts(this.db).updateRackSlots.run(
JSON.stringify(slotIds),
name ?? existing.name,
description === undefined ? existing.description : description,
id,
);
}
deleteRack(id: string): void {
stmts(this.db).deleteRack.run(id);
}
// ---------------------------------------------------------- evaluations
saveEvaluation(ev: ConfluenceEvaluation, rackId: string, id: string): void {
stmts(this.db).insertEvaluation.run(
id,
ev.symbol,
ev.asOf,
rackId,
JSON.stringify(ev.assessments),
ev.bullEvidence,
ev.bearEvidence,
ev.bullCount,
ev.bearCount,
ev.assessedCount,
ev.netEvidence,
ev.totalEvidence,
ev.quality,
new Date().toISOString(),
);
}
getEvaluation(symbol: string, asOf: string, rackId: string): StoredConfluenceEvaluation | null {
const row = stmts(this.db).selectEvaluation.get(symbol, asOf, rackId) as Record<string, unknown> | undefined;
return row ? mapEvaluation(row) : null;
}
listEvaluationsForSymbol(symbol: string): StoredConfluenceEvaluation[] {
return (stmts(this.db).selectEvaluationsForSymbol.all(symbol) as Record<string, unknown>[]).map(mapEvaluation);
}
latestEvaluationForRack(rackId: string): StoredConfluenceEvaluation | null {
const row = stmts(this.db).latestEvaluationForRack.get(rackId) as Record<string, unknown> | undefined;
return row ? mapEvaluation(row) : null;
}
// --------------------------------------------------------------- signals
logSignalFires(ev: ConfluenceEvaluation, rackId: string): void {
const insert = stmts(this.db).insertSignal;
for (const a of ev.assessments) {
if (a.state !== 'fired') continue;
insert.run(
randomUUID(),
ev.symbol,
a.id,
rackId,
ev.asOf,
ev.quality,
);
}
}
listPendingSignals(): ConfluenceSignalHistoryRow[] {
return (stmts(this.db).selectPendingSignals.all() as Record<string, unknown>[]).map(mapSignal);
}
listSignalsForSymbol(symbol: string): ConfluenceSignalHistoryRow[] {
return (stmts(this.db).selectSignalsForSymbol.all(symbol) as Record<string, unknown>[]).map(mapSignal);
}
resolveSignal(id: string, confirmed: boolean, verdict: 'real' | 'false_alarm'): void {
stmts(this.db).resolveSignal.run(confirmed ? 1 : 0, new Date().toISOString(), verdict, id);
}
}
/** Build a rack from the catalog subset; validates slot ids exist. */
export function rackFromSlots(
id: string,
name: string,
slotIds: string[],
opts: { ownerId?: string | null; description?: string | null; isSystem?: boolean; createdAt?: string } = {},
): ConfluenceRack {
return {
id,
ownerId: opts.ownerId ?? null,
name,
slotIds: [...slotIds],
description: opts.description ?? null,
isSystem: opts.isSystem ?? false,
createdAt: opts.createdAt ?? new Date().toISOString(),
};
}
export type { ConfluenceEvaluation, SlotAssessment, PictureQuality, ConfluenceSlot }; // re-export for consumers
+55
View File
@@ -962,3 +962,58 @@ CREATE TABLE IF NOT EXISTS stock_float (
UNIQUE(symbol, as_of)
);
CREATE INDEX IF NOT EXISTS idx_stock_float_symbol ON stock_float(symbol);
-- ===== Section 2.?? — Confluence Signal Engine (M22). =====
-- Named rack definitions: a rack is a curated subset of the 34-slot catalog
-- evaluated together for a symbol. System presets have owner_id NULL.
CREATE TABLE IF NOT EXISTS confluence_racks (
id TEXT PRIMARY KEY, -- 'core_trend' | user-defined key
owner_id TEXT, -- NULL for system presets
name TEXT NOT NULL,
slot_ids_json TEXT NOT NULL, -- JSON array of slot ids
description TEXT,
is_system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_confluence_racks_owner ON confluence_racks(owner_id);
-- Per-symbol, per-as-of rack evaluation: the redundancy-discounted evidence
-- totals and the resulting PictureQuality label. assessments_json stores the
-- full per-slot states so the picture can be re-rendered and diffed without
-- recomputation.
CREATE TABLE IF NOT EXISTS confluence_evaluations (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
as_of TEXT NOT NULL, -- YYYY-MM-DD evaluation date
rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE,
assessments_json TEXT NOT NULL, -- SlotAssessment[] JSON
bull_evidence REAL NOT NULL,
bear_evidence REAL NOT NULL,
bull_count INTEGER NOT NULL,
bear_count INTEGER NOT NULL,
assessed_count INTEGER NOT NULL,
net_evidence REAL NOT NULL,
total_evidence REAL NOT NULL,
quality TEXT NOT NULL, -- PictureQuality label
created_at TEXT NOT NULL,
UNIQUE(symbol, as_of, rack_id)
);
CREATE INDEX IF NOT EXISTS idx_confluence_evals_symbol_asof ON confluence_evaluations(symbol, as_of DESC);
-- Closed-loop signal history: every slot fire (from an evaluation) is logged
-- here and later resolved to 'real' | 'false_alarm' by N=4-week price
-- follow-through, mirroring rotation_signals. The per-slot reliability
-- scorecard derives from this table.
CREATE TABLE IF NOT EXISTS confluence_signal_history (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
slot_id TEXT NOT NULL,
rack_id TEXT NOT NULL REFERENCES confluence_racks(id) ON DELETE CASCADE,
fired_at TEXT NOT NULL, -- as-of date of the firing evaluation
quality_at_fire TEXT NOT NULL, -- PictureQuality when it fired
price_resolved INTEGER NOT NULL DEFAULT 0, -- follow-through window elapsed
price_confirmed INTEGER NOT NULL DEFAULT 0, -- follow-through aligned with slot body
resolved_at TEXT,
verdict TEXT -- real|false_alarm
);
CREATE INDEX IF NOT EXISTS idx_confluence_signal_symbol_slot ON confluence_signal_history(symbol, slot_id);