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
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user