diff --git a/app/server/src/db/__tests__/confluenceRepository.test.ts b/app/server/src/db/__tests__/confluenceRepository.test.ts new file mode 100644 index 0000000..1fe0cde --- /dev/null +++ b/app/server/src/db/__tests__/confluenceRepository.test.ts @@ -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)); +}); \ No newline at end of file diff --git a/app/server/src/db/__tests__/schema.test.ts b/app/server/src/db/__tests__/schema.test.ts index 420a1b2..9b84970 100644 --- a/app/server/src/db/__tests__/schema.test.ts +++ b/app/server/src/db/__tests__/schema.test.ts @@ -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', () => { diff --git a/app/server/src/db/client.ts b/app/server/src/db/client.ts index d5ff6a2..90aca5c 100644 --- a/app/server/src/db/client.ts +++ b/app/server/src/db/client.ts @@ -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 */ } diff --git a/app/server/src/db/confluenceRepository.ts b/app/server/src/db/confluenceRepository.ts new file mode 100644 index 0000000..c8c842e --- /dev/null +++ b/app/server/src/db/confluenceRepository.ts @@ -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): 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): 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): 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 | 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 | undefined; + return row ? mapRack(row) : null; + } + + listSystemRacks(): ConfluenceRack[] { + return (stmts(this.db).selectSystemRacks.all() as Record[]).map(mapRack); + } + + listUserRacks(ownerId: string): ConfluenceRack[] { + return (stmts(this.db).selectUserRacks.all(ownerId) as Record[]).map(mapRack); + } + + listAllRacks(): ConfluenceRack[] { + return (stmts(this.db).selectAllRacks.all() as Record[]).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 | undefined; + return row ? mapEvaluation(row) : null; + } + + listEvaluationsForSymbol(symbol: string): StoredConfluenceEvaluation[] { + return (stmts(this.db).selectEvaluationsForSymbol.all(symbol) as Record[]).map(mapEvaluation); + } + + latestEvaluationForRack(rackId: string): StoredConfluenceEvaluation | null { + const row = stmts(this.db).latestEvaluationForRack.get(rackId) as Record | 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[]).map(mapSignal); + } + + listSignalsForSymbol(symbol: string): ConfluenceSignalHistoryRow[] { + return (stmts(this.db).selectSignalsForSymbol.all(symbol) as Record[]).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 \ No newline at end of file diff --git a/app/server/src/db/schema.sql b/app/server/src/db/schema.sql index a207c92..f78ae82 100644 --- a/app/server/src/db/schema.sql +++ b/app/server/src/db/schema.sql @@ -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);