feat(confluence): add racks, evaluations, and signal-history schema + repository (M22 slice 3)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user