feat: Unraid deploy, dealer-flow heatmap, confluence zones, 13F capture
Ship Node production images, Unraid compose, and Gitea CI/CD (test then push registry images; cron script if no runner). Rebuild dealer flow as a heatmap-first map with integrity gates and chart helpers. Add confluence zone rules, session clock, capture evidence, and tighter 13F/queue/options paths, plus the matching UI and tests.
This commit is contained in:
@@ -34,7 +34,7 @@ class FakeCache implements CacheRepository {
|
||||
async ensureInDemand(): Promise<void> {}
|
||||
async pinSystemSymbol(): Promise<void> {}
|
||||
async demandSet(): Promise<string[]> { return []; }
|
||||
async getMany<T>(): Promise<Array<{ key: string; value: T | null; isStale: boolean }>> { return []; }
|
||||
async getMany<T>(): Promise<Array<{ key: string; value: T | null; isStale: boolean; fetchedAt: string | null }>> { return []; }
|
||||
async del(): Promise<void> {}
|
||||
readonly db: never = undefined as never;
|
||||
}
|
||||
@@ -138,4 +138,20 @@ describe('CacheCandleProvider', () => {
|
||||
assert.deepEqual(r.candles, []);
|
||||
assert.match(r.asOf, /^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it('resolveAsOf slices to the cutoff and never folds a live quote', async () => {
|
||||
const cache = new FakeCache()
|
||||
.setValue('yfinance:candles:SPY:1d', [
|
||||
bar('2026-08-07', 100),
|
||||
bar('2026-08-10', 105),
|
||||
bar('2026-08-11', 110),
|
||||
], false)
|
||||
.setValue('yfinance:quote:SPY', { symbol: 'SPY', price: 999 }, false);
|
||||
const r = await new CacheCandleProvider(cache).resolveAsOf('SPY', '1d', '2026-08-10');
|
||||
assert.equal(r.candles.length, 2);
|
||||
assert.equal(r.asOf, '2026-08-10');
|
||||
assert.equal(r.realtimeFolded, false);
|
||||
assert.equal(r.lastBar, 'yfinance');
|
||||
assert.equal(r.candles[r.candles.length - 1].c, 105);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { createCacheRepository, type CacheRepository, type CacheEntry, type Pric
|
||||
import { FakeSourceAdapter } from '../../adapters/SourceAdapter.ts';
|
||||
import { AdapterQueue } from '../../queue/AdapterQueue.ts';
|
||||
import { seedConfluence, CONFLUENCE_UNIVERSE, BENCHMARK_SYMBOL } from '../confluenceSeed.ts';
|
||||
import { runConfluenceEvaluationCycle } from '../confluenceEngine.ts';
|
||||
import { runConfluenceEvaluationCycle, runConfluenceReplay } from '../confluenceEngine.ts';
|
||||
|
||||
// ---- fake cache that returns whatever we seeded ---------------------------------
|
||||
|
||||
@@ -31,7 +31,7 @@ class FakeCache implements CacheRepository {
|
||||
async ensureInDemand(): Promise<void> {}
|
||||
async pinSystemSymbol(): Promise<void> {}
|
||||
async demandSet(): Promise<string[]> { return []; }
|
||||
async getMany<T>(): Promise<Array<{ key: string; value: T | null; isStale: boolean }>> { return []; }
|
||||
async getMany<T>(): Promise<Array<{ key: string; value: T | null; isStale: boolean; fetchedAt: string | null }>> { return []; }
|
||||
async del(): Promise<void> {}
|
||||
readonly db: never = undefined as never;
|
||||
}
|
||||
@@ -126,4 +126,31 @@ describe('runConfluenceEvaluationCycle', () => {
|
||||
assert.equal(summary.symbolsSkipped.length, 1);
|
||||
assert.equal(summary.symbolsSkipped[0].symbol, CONFLUENCE_UNIVERSE[0].symbol);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runConfluenceReplay', () => {
|
||||
it('fills missing as-of evaluations from cached candles and is idempotent', async () => {
|
||||
const first = await runConfluenceReplay(db, cache as unknown as CacheRepository, {
|
||||
symbols: ['PLTR'],
|
||||
lookbackDays: 30,
|
||||
budgetDaysPerSymbol: 30,
|
||||
symbolsPerTick: 1,
|
||||
});
|
||||
assert.ok(first.evaluationsStored > 0, 'replay stored historical evaluations');
|
||||
assert.ok(first.symbolsTouched.includes('PLTR'));
|
||||
|
||||
const second = await runConfluenceReplay(db, cache as unknown as CacheRepository, {
|
||||
symbols: ['PLTR'],
|
||||
lookbackDays: 30,
|
||||
budgetDaysPerSymbol: 30,
|
||||
symbolsPerTick: 1,
|
||||
});
|
||||
assert.equal(second.evaluationsStored, 0);
|
||||
assert.equal(confluenceRepo.countEvaluations('PLTR', repoFullRack()), first.evaluationsStored / 3);
|
||||
});
|
||||
});
|
||||
|
||||
function repoFullRack(): string {
|
||||
const rack = confluenceRepo.listSystemRacks().find((r) => r.id === 'confluence-full');
|
||||
return rack?.id ?? confluenceRepo.listSystemRacks()[0]!.id;
|
||||
}
|
||||
@@ -123,6 +123,17 @@ describe('evaluateRack', () => {
|
||||
]);
|
||||
assert.equal(ev.quality, 'mixed');
|
||||
});
|
||||
|
||||
it('scales a fired slot by its reliability weight; missing weight stays 1.0', () => {
|
||||
const unweighted = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross')]);
|
||||
assert.equal(unweighted.bullEvidence, 1);
|
||||
|
||||
const half = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross')], new Map([['goldenCross', 0.5]]));
|
||||
assert.equal(half.bullEvidence, 0.5);
|
||||
|
||||
const boosted = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross')], new Map([['goldenCross', 1.25]]));
|
||||
assert.equal(boosted.bullEvidence, 1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPictureChange', () => {
|
||||
|
||||
@@ -83,9 +83,10 @@ describe('defineSystemRackPresets', () => {
|
||||
});
|
||||
|
||||
describe('seedConfluence', () => {
|
||||
it('pins16 symbols (15 universe + benchmark) and creates 3 system racks', async () => {
|
||||
it('pins the research universe, learning sector ETFs, and benchmark, and creates 3 system racks', async () => {
|
||||
const { symbolsPinned, racksCreated } = await seedConfluence(db, cache);
|
||||
assert.equal(symbolsPinned, 16);
|
||||
// 11 GICS + SPY + 15 research names (SMH is extra; no other overlap)
|
||||
assert.equal(symbolsPinned, 27);
|
||||
assert.equal(racksCreated, 3);
|
||||
const systemRacks = repo.listSystemRacks();
|
||||
assert.equal(systemRacks.length, 3);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { PriceCandle } from '../../cache/CacheRepository.ts';
|
||||
import type { PictureQuality, SlotAssessment } from '../confluenceRack.ts';
|
||||
import {
|
||||
attachOutcomes,
|
||||
collapseZones,
|
||||
deriveZoneRules,
|
||||
findOnsets,
|
||||
ONSET_EXTREME_15_SLIDE,
|
||||
ONSET_TURN_ON,
|
||||
lastNZones,
|
||||
matchRule,
|
||||
zoneOnsetWindow,
|
||||
ZONE_DISPLAY_BARS,
|
||||
recentZonesForSymbol,
|
||||
selectWinner,
|
||||
type EvaluationPoint,
|
||||
type ZonePredicate,
|
||||
} from '../confluenceZones.ts';
|
||||
|
||||
const fired = (id: string): SlotAssessment => ({ id, state: 'fired' });
|
||||
const notFired = (id: string): SlotAssessment => ({ id, state: 'not-fired' });
|
||||
|
||||
function ev(asOf: string, quality: PictureQuality, net = 2, extra: Partial<EvaluationPoint> = {}): EvaluationPoint {
|
||||
const bullish = quality.includes('bullish');
|
||||
return {
|
||||
symbol: extra.symbol ?? 'XLK',
|
||||
asOf,
|
||||
quality,
|
||||
netEvidence: extra.netEvidence ?? net,
|
||||
bullEvidence: extra.bullEvidence ?? (bullish ? Math.max(net, 2) : 0.2),
|
||||
bearEvidence: extra.bearEvidence ?? (bullish ? 0.2 : Math.max(-net, 2)),
|
||||
assessments: extra.assessments ?? [fired('goldenCross')],
|
||||
};
|
||||
}
|
||||
|
||||
function candles(start: string, days: number, startPx = 100, daily = 0.002): PriceCandle[] {
|
||||
const out: PriceCandle[] = [];
|
||||
const t0 = Date.parse(`${start}T00:00:00Z`);
|
||||
for (let i = 0; i < days; i++) {
|
||||
const ts = new Date(t0 + i * 86400000).toISOString().slice(0, 10);
|
||||
const c = startPx * Math.pow(1 + daily, i);
|
||||
out.push({ ts, o: c, h: c * 1.01, l: c * 0.99, c, v: 1_000_000, adjClose: c });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const entryPred: ZonePredicate = {
|
||||
id: 'entry-mod-strong',
|
||||
label: 'Moderate or strong bullish picture',
|
||||
qualities: ['moderate-bullish', 'strong-bullish'],
|
||||
};
|
||||
const exitPred: ZonePredicate = {
|
||||
id: 'exit-mod-strong',
|
||||
label: 'Moderate or strong bearish picture',
|
||||
qualities: ['moderate-bearish', 'strong-bearish'],
|
||||
};
|
||||
|
||||
describe('matchRule', () => {
|
||||
it('matches a quality band and rejects mixed', () => {
|
||||
assert.equal(matchRule(ev('2024-01-02', 'moderate-bullish'), entryPred), true);
|
||||
assert.equal(matchRule(ev('2024-01-02', 'mixed'), entryPred), false);
|
||||
});
|
||||
|
||||
it('requires a named slot to have fired when listed', () => {
|
||||
const pred: ZonePredicate = { id: 'p', label: 'pullback', qualities: ['moderate-bullish'], requireAnyFired: ['pullbackToEMA21'] };
|
||||
assert.equal(matchRule(ev('2024-01-02', 'moderate-bullish', 2, { assessments: [fired('goldenCross')] }), pred), false);
|
||||
assert.equal(matchRule(ev('2024-01-02', 'moderate-bullish', 2, { assessments: [fired('pullbackToEMA21')] }), pred), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseZones', () => {
|
||||
it('merges a one-session gap and splits a two-session gap', () => {
|
||||
const series = [
|
||||
ev('2024-01-02', 'moderate-bullish'),
|
||||
ev('2024-01-03', 'moderate-bullish'),
|
||||
ev('2024-01-04', 'mixed', 0),
|
||||
ev('2024-01-05', 'moderate-bullish'),
|
||||
ev('2024-01-08', 'mixed', 0),
|
||||
ev('2024-01-09', 'mixed', 0),
|
||||
ev('2024-01-10', 'moderate-bullish'),
|
||||
];
|
||||
const zones = collapseZones(series, 'entry', entryPred);
|
||||
assert.equal(zones.length, 2);
|
||||
assert.equal(zones[0].startAsOf, '2024-01-02');
|
||||
assert.equal(zones[0].endAsOf, '2024-01-05');
|
||||
assert.equal(zones[1].startAsOf, '2024-01-10');
|
||||
assert.equal(zones[1].endAsOf, '2024-01-10');
|
||||
});
|
||||
|
||||
it('closes an entry zone when the exit predicate fires', () => {
|
||||
const series = [
|
||||
ev('2024-01-02', 'moderate-bullish'),
|
||||
ev('2024-01-03', 'moderate-bullish'),
|
||||
ev('2024-01-04', 'moderate-bearish', -2, { assessments: [fired('deathCross')] }),
|
||||
ev('2024-01-05', 'moderate-bullish'),
|
||||
];
|
||||
const zones = collapseZones(series, 'entry', entryPred, exitPred);
|
||||
assert.equal(zones.length, 2);
|
||||
assert.equal(zones[0].endAsOf, '2024-01-03');
|
||||
assert.equal(zones[1].startAsOf, '2024-01-05');
|
||||
});
|
||||
|
||||
it('lastNZones keeps the newest windows', () => {
|
||||
const zones = collapseZones([
|
||||
ev('2024-01-02', 'moderate-bullish'),
|
||||
ev('2024-01-10', 'mixed', 0),
|
||||
ev('2024-01-11', 'mixed', 0),
|
||||
ev('2024-02-01', 'moderate-bullish'),
|
||||
ev('2024-02-10', 'mixed', 0),
|
||||
ev('2024-02-11', 'mixed', 0),
|
||||
ev('2024-03-01', 'strong-bullish'),
|
||||
], 'entry', entryPred);
|
||||
const last = lastNZones(zones, 2);
|
||||
assert.equal(last.length, 2);
|
||||
assert.equal(last[0].startAsOf, '2024-02-01');
|
||||
assert.equal(last[1].startAsOf, '2024-03-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoneOnsetWindow', () => {
|
||||
it('caps a long open stretch to the first 4 weeks from start', () => {
|
||||
const px = candles('2025-06-20', 400, 10, 0.01);
|
||||
const onset = zoneOnsetWindow('2025-06-20', '2026-08-12', px);
|
||||
assert.ok(onset);
|
||||
assert.equal(onset.startAsOf, '2025-06-20');
|
||||
const startIdx = px.findIndex((c) => c.ts.slice(0, 10) === '2025-06-20');
|
||||
const endIdx = px.findIndex((c) => c.ts.slice(0, 10) === onset.endAsOf);
|
||||
assert.equal(endIdx - startIdx + 1, ZONE_DISPLAY_BARS);
|
||||
assert.ok(onset.endAsOf < '2026-08-12');
|
||||
});
|
||||
|
||||
it('does not extend past a short natural end', () => {
|
||||
const px = candles('2025-06-04', 40, 10, 0.01);
|
||||
const onset = zoneOnsetWindow('2025-06-04', '2025-06-16', px);
|
||||
assert.ok(onset);
|
||||
assert.equal(onset.startAsOf, '2025-06-04');
|
||||
assert.equal(onset.endAsOf, '2025-06-16');
|
||||
});
|
||||
|
||||
it('returns null when the start bar is missing', () => {
|
||||
const px = candles('2025-07-01', 20, 10, 0);
|
||||
assert.equal(zoneOnsetWindow('2025-06-01', '2025-07-10', px), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOnsets / pullback re-entry', () => {
|
||||
it('opens a second entry window on an N-session low inside a still-bullish picture', () => {
|
||||
const evals: EvaluationPoint[] = [];
|
||||
const px: PriceCandle[] = [];
|
||||
const start = Date.UTC(2025, 5, 2); // Jun 2
|
||||
let price = 20;
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10);
|
||||
// Ramp, then a 15-day dip around day 50, then ramp again.
|
||||
if (i < 40) price *= 1.01;
|
||||
else if (i < 55) price *= 0.985;
|
||||
else price *= 1.012;
|
||||
evals.push(ev(asOf, 'moderate-bullish', 2.5));
|
||||
px.push({ ts: asOf, o: price, h: price * 1.01, l: price * 0.99, c: price, v: 1_000_000, adjClose: price });
|
||||
}
|
||||
const onsets = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_EXTREME_15_SLIDE);
|
||||
assert.ok(onsets.length >= 2, `expected a re-entry, got ${onsets.join(', ')}`);
|
||||
assert.equal(onsets[0], evals[0].asOf);
|
||||
// The second onset should sit in the dip, not on day 1.
|
||||
assert.ok(onsets[1] > evals[30].asOf);
|
||||
const turnOn = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_TURN_ON);
|
||||
assert.equal(turnOn.length, 1);
|
||||
});
|
||||
|
||||
it('slides the onset to a deeper confirmed trough inside the quiet gap', () => {
|
||||
const evals: EvaluationPoint[] = [];
|
||||
const px: PriceCandle[] = [];
|
||||
const start = Date.UTC(2025, 5, 2);
|
||||
// 25 days: drift down to day 10, bounce, then a lower low at day 18, then up.
|
||||
const closes = [
|
||||
20, 19.5, 19.2, 18.8, 18.4, 18.0, 17.6, 17.2, 16.8, 16.4,
|
||||
16.0, 16.5, 16.8, 16.6, 16.2, 15.8, 15.4, 15.0, 14.4, 15.2,
|
||||
15.6, 16.0, 16.4, 16.8, 17.2,
|
||||
];
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10);
|
||||
const price = closes[i];
|
||||
evals.push(ev(asOf, 'moderate-bullish', 2));
|
||||
px.push({ ts: asOf, o: price, h: price, l: price, c: price, v: 1, adjClose: price });
|
||||
}
|
||||
const onsets = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_EXTREME_15_SLIDE);
|
||||
assert.ok(onsets.length >= 1);
|
||||
// The first stretch slides to the deeper confirmed trough (14.4) before the bounce.
|
||||
assert.equal(onsets[onsets.length - 1], evals[18].asOf);
|
||||
});
|
||||
|
||||
it('does not re-enter without a quiet gap', () => {
|
||||
const evals: EvaluationPoint[] = [];
|
||||
const px: PriceCandle[] = [];
|
||||
const start = Date.UTC(2025, 5, 2);
|
||||
let price = 20;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10);
|
||||
price *= i === 11 ? 0.9 : 1.01;
|
||||
evals.push(ev(asOf, 'moderate-bullish', 2));
|
||||
px.push({ ts: asOf, o: price, h: price, l: price, c: price, v: 1, adjClose: price });
|
||||
}
|
||||
const onsets = findOnsets(evals, 'entry', entryPred, exitPred, px, ONSET_EXTREME_15_SLIDE);
|
||||
assert.equal(onsets.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachOutcomes', () => {
|
||||
it('marks a rising stretch confirmed for an entry zone', () => {
|
||||
const series = [ev('2024-01-02', 'moderate-bullish')];
|
||||
const zones = attachOutcomes(collapseZones(series, 'entry', entryPred), candles('2024-01-02', 40, 100, 0.01));
|
||||
assert.equal(zones[0].verdict, 'confirmed');
|
||||
assert.ok((zones[0].forwardReturn ?? 0) > 0);
|
||||
});
|
||||
|
||||
it('leaves a late zone open when forward bars are missing', () => {
|
||||
const series = [ev('2024-01-20', 'moderate-bullish')];
|
||||
const zones = attachOutcomes(collapseZones(series, 'entry', entryPred), candles('2024-01-02', 25, 100, 0.01));
|
||||
assert.equal(zones[0].verdict, 'open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectWinner / deriveZoneRules', () => {
|
||||
it('refuses a noisy winner and falls back to the conservative quality band', () => {
|
||||
// Short mixed series: not enough resolved train zones.
|
||||
const evals: EvaluationPoint[] = [];
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(Date.UTC(2023, 0, 2 + i)).toISOString().slice(0, 10);
|
||||
evals.push(ev(d, i % 2 === 0 ? 'mixed' : 'sparse', 0));
|
||||
}
|
||||
const px = new Map([['XLK', candles('2023-01-02', 80, 100, 0)]]);
|
||||
const rule = selectWinner('entry', [{ id: 'entry-net-3', label: 'Net 3', minNetEvidence: 3 }], evals, px, entryPred);
|
||||
assert.equal(rule.source, 'baseline');
|
||||
assert.equal(rule.predicate.id, entryPred.id);
|
||||
});
|
||||
|
||||
it('picks the template that beats baseline on the held-out year', () => {
|
||||
const evals: EvaluationPoint[] = [];
|
||||
const px: PriceCandle[] = [];
|
||||
// Two years of daily points. Moderate-bullish days sit at local troughs
|
||||
// and are followed by a rise; other days drift flat then drop.
|
||||
let price = 100;
|
||||
const start = Date.UTC(2023, 0, 2);
|
||||
for (let i = 0; i < 520; i++) {
|
||||
const asOf = new Date(start + i * 86400000).toISOString().slice(0, 10);
|
||||
const isSignal = i % 25 === 0;
|
||||
if (isSignal) {
|
||||
evals.push(ev(asOf, 'moderate-bullish', 2.5, { assessments: [fired('goldenCross'), fired('pullbackToEMA21')] }));
|
||||
price = price * 0.98;
|
||||
} else {
|
||||
evals.push(ev(asOf, 'mixed', 0.2, { assessments: [notFired('goldenCross')] }));
|
||||
price = price * (i % 25 < 12 ? 1.008 : 0.997);
|
||||
}
|
||||
px.push({ ts: asOf, o: price, h: price * 1.01, l: price * 0.99, c: price, v: 1_000_000, adjClose: price });
|
||||
}
|
||||
const candlesBySymbol = new Map([['XLK', px]]);
|
||||
const { entry } = deriveZoneRules(evals, candlesBySymbol);
|
||||
// Either validated (if the synthetic series beat baseline by 10pp) or the
|
||||
// conservative fallback. Never a random high-threshold that never fires.
|
||||
assert.ok(entry.predicate.id === 'entry-mod-strong' || entry.source === 'validated' || entry.source === 'baseline');
|
||||
assert.ok(entry.predicate.qualities?.includes('moderate-bullish'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('recentZonesForSymbol', () => {
|
||||
it('returns the last few entry and exit windows with outcomes', () => {
|
||||
const evals = [
|
||||
ev('2024-01-02', 'moderate-bullish'),
|
||||
ev('2024-01-03', 'moderate-bullish'),
|
||||
ev('2024-03-01', 'moderate-bearish', -2, { assessments: [fired('deathCross')] }),
|
||||
ev('2024-03-02', 'moderate-bearish', -2, { assessments: [fired('deathCross')] }),
|
||||
ev('2024-06-03', 'strong-bullish', 4),
|
||||
];
|
||||
const { entries, exits } = recentZonesForSymbol(evals, candles('2024-01-02', 200, 100, 0.003), entryPred, exitPred, 3, 3);
|
||||
assert.ok(entries.length >= 1);
|
||||
assert.ok(exits.length >= 1);
|
||||
assert.equal(entries[entries.length - 1].startAsOf, '2024-06-03');
|
||||
assert.equal(exits[0].startAsOf, '2024-03-01');
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,11 @@ export interface CandleResolution {
|
||||
*/
|
||||
export interface CandleProvider {
|
||||
resolve(symbol: string, granularity: SlotGranularity): Promise<CandleResolution>;
|
||||
/**
|
||||
* Honest historical cut: candles with ts <= asOf only. Never folds a live quote.
|
||||
* Used by replay so a past evaluation cannot see tomorrow's close.
|
||||
*/
|
||||
resolveAsOf(symbol: string, granularity: SlotGranularity, asOf: string): Promise<CandleResolution>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -173,4 +178,22 @@ export class CacheCandleProvider implements CandleProvider {
|
||||
isStale,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveAsOf(symbol: string, granularity: SlotGranularity, asOf: string): Promise<CandleResolution> {
|
||||
const sym = symbol.toUpperCase();
|
||||
const cutoff = asOf.slice(0, 10);
|
||||
const entry = await this._cache.get<PriceCandle[]>(`yfinance:candles:${sym}:${granularity}`);
|
||||
const stored = (entry?.value ?? []).slice();
|
||||
const candles = stored.filter((c) => (c.ts ?? '').slice(0, 10) <= cutoff);
|
||||
const lastTs = candles.length > 0 ? (candles[candles.length - 1].ts ?? '').slice(0, 10) : '';
|
||||
return {
|
||||
symbol: sym,
|
||||
granularity,
|
||||
candles,
|
||||
asOf: lastTs || cutoff,
|
||||
lastBar: 'yfinance',
|
||||
realtimeFolded: false,
|
||||
isStale: entry?.isStale ?? true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,25 @@ import { buildSeasonalitySnapshot } from '../analysis/seasonality.ts';
|
||||
import { resolveCorridorSnapshot } from './corridorData.ts';
|
||||
import { evaluateRack, type ConfluenceEvaluation, type SlotAssessment } from './confluenceRack.ts';
|
||||
import { ConfluenceRepository, type ConfluenceRack } from '../db/confluenceRepository.ts';
|
||||
import { resolveSignalHistory } from './confluenceBacktest.ts';
|
||||
import { CONFLUENCE_UNIVERSE, BENCHMARK_SYMBOL } from './confluenceSeed.ts';
|
||||
import { resolveSignalHistory, signalHistoryToStats } from './confluenceBacktest.ts';
|
||||
import {
|
||||
CONFLUENCE_UNIVERSE,
|
||||
BENCHMARK_SYMBOL,
|
||||
CONFLUENCE_LEARNING_UNIVERSE,
|
||||
REPLAY_LOOKBACK_DAYS,
|
||||
} from './confluenceSeed.ts';
|
||||
import {
|
||||
deriveZoneRules,
|
||||
FALLBACK_ENTRY,
|
||||
FALLBACK_EXIT,
|
||||
recentZonesForSymbol,
|
||||
attachOutcomes,
|
||||
collapseZones,
|
||||
scoreZones,
|
||||
type EvaluationPoint,
|
||||
type ZonePredicate,
|
||||
type ZoneRuleStats,
|
||||
} from './confluenceZones.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unwired slots → honest fallback assessments
|
||||
@@ -155,7 +172,8 @@ export async function runConfluenceEvaluationCycle(
|
||||
continue;
|
||||
}
|
||||
|
||||
const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced);
|
||||
const weights = reliabilityWeights(db);
|
||||
const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced, weights);
|
||||
const firesLogged = evaluation.assessments.filter((a) => a.state === 'fired').length;
|
||||
repo.saveEvaluation(evaluation, rack.id, randomUUID());
|
||||
repo.logSignalFires(evaluation, rack.id);
|
||||
@@ -185,4 +203,307 @@ function corridorUnavailableAssessments(): SlotAssessment[] {
|
||||
'corridorUpsideHigh', 'corridorUpsideLow',
|
||||
'spyCorridorCheap', 'spyCorridorStretched',
|
||||
].map((id) => ({ id, state: 'not-fired', note: 'No valuation-corridor snapshot cached for this symbol yet.' }));
|
||||
}
|
||||
|
||||
export const REPLAY_DAYS_PER_SYMBOL = 40;
|
||||
export const REPLAY_SYMBOLS_PER_TICK = 3;
|
||||
export const LEARNING_REPLAY_DAYS = 120;
|
||||
export const LEARNING_REPLAY_SYMBOLS = 6;
|
||||
export const LEARNING_MIN_DAYS = 400;
|
||||
|
||||
export interface ReplayOptions {
|
||||
symbols?: string[];
|
||||
lookbackDays?: number;
|
||||
budgetDaysPerSymbol?: number;
|
||||
symbolsPerTick?: number;
|
||||
/** When true, do not apply live reliability weights (historical ledger stays raw). */
|
||||
unweighted?: boolean;
|
||||
}
|
||||
|
||||
export interface ReplayRunSummary {
|
||||
symbolsTouched: string[];
|
||||
evaluationsStored: number;
|
||||
signalsLogged: number;
|
||||
remainingDays: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
function reliabilityWeights(db: DatabaseSync): Map<string, number> {
|
||||
const stats = signalHistoryToStats(db);
|
||||
// Unresolved / thin samples stay at the rack default (1.0). Only apply a
|
||||
// learned weight once the slot has enough follow-through to score.
|
||||
return new Map(stats.filter((s) => s.resolvedFires >= 2).map((s) => [s.slotId, s.reliabilityWeight]));
|
||||
}
|
||||
|
||||
function toPoint(ev: { symbol: string; asOf: string; quality: import('./confluenceRack.ts').PictureQuality; netEvidence: number; bullEvidence: number; bearEvidence: number; assessments: SlotAssessment[] }): EvaluationPoint {
|
||||
return {
|
||||
symbol: ev.symbol,
|
||||
asOf: ev.asOf,
|
||||
quality: ev.quality,
|
||||
netEvidence: ev.netEvidence,
|
||||
bullEvidence: ev.bullEvidence,
|
||||
bearEvidence: ev.bearEvidence,
|
||||
assessments: ev.assessments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay missing as-of evaluations from cached candles. Honest cut: no live
|
||||
* quote fold-in, no today's corridor snapshot applied to a past date.
|
||||
*/
|
||||
export async function runConfluenceReplay(
|
||||
db: DatabaseSync,
|
||||
cache: CacheRepository,
|
||||
opts: ReplayOptions = {},
|
||||
): Promise<ReplayRunSummary> {
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const provider: CandleProvider = new CacheCandleProvider(cache);
|
||||
const racks = repo.listSystemRacks();
|
||||
if (racks.length === 0) throw new Error('confluence replay: no system racks seeded');
|
||||
|
||||
const lookback = opts.lookbackDays ?? REPLAY_LOOKBACK_DAYS;
|
||||
const budget = opts.budgetDaysPerSymbol ?? REPLAY_DAYS_PER_SYMBOL;
|
||||
const symbolCap = opts.symbolsPerTick ?? REPLAY_SYMBOLS_PER_TICK;
|
||||
const symbols = (opts.symbols ?? [...CONFLUENCE_LEARNING_UNIVERSE])
|
||||
.map((s) => s.toUpperCase());
|
||||
const unique = [...new Set(symbols)];
|
||||
|
||||
const summary: ReplayRunSummary = {
|
||||
symbolsTouched: [],
|
||||
evaluationsStored: 0,
|
||||
signalsLogged: 0,
|
||||
remainingDays: 0,
|
||||
complete: true,
|
||||
};
|
||||
|
||||
let symbolsUsed = 0;
|
||||
for (const symbol of unique) {
|
||||
if (symbolsUsed >= symbolCap) {
|
||||
summary.complete = false;
|
||||
break;
|
||||
}
|
||||
const daily = await provider.resolveAsOf(symbol, '1d', '9999-12-31');
|
||||
if (daily.candles.length === 0) continue;
|
||||
|
||||
const dates = daily.candles.map((c) => (c.ts ?? '').slice(0, 10)).filter(Boolean);
|
||||
const window = dates.slice(Math.max(0, dates.length - lookback));
|
||||
const primaryRack = racks[0];
|
||||
const have = new Set(repo.listEvaluationAsOfs(symbol, primaryRack.id));
|
||||
const missing = window.filter((d) => !have.has(d));
|
||||
summary.remainingDays += missing.length;
|
||||
if (missing.length === 0) continue;
|
||||
|
||||
const slice = missing.slice(0, budget);
|
||||
if (missing.length > slice.length) summary.complete = false;
|
||||
symbolsUsed += 1;
|
||||
summary.symbolsTouched.push(symbol);
|
||||
|
||||
const weeklyFull = await provider.resolveAsOf(symbol, '1wk', '9999-12-31');
|
||||
const benchFull = await provider.resolveAsOf(BENCHMARK_SYMBOL, '1d', '9999-12-31');
|
||||
|
||||
for (const asOf of slice) {
|
||||
const dailyCut = daily.candles.filter((c) => (c.ts ?? '').slice(0, 10) <= asOf);
|
||||
const weeklyCut = weeklyFull.candles.filter((c) => (c.ts ?? '').slice(0, 10) <= asOf);
|
||||
const benchCut = benchFull.candles.filter((c) => (c.ts ?? '').slice(0, 10) <= asOf);
|
||||
if (dailyCut.length === 0) continue;
|
||||
|
||||
const wired: SlotAssessment[] = [
|
||||
...evaluateTechnicalSlots(symbol, dailyCut, { weekly: weeklyCut, benchmarkDaily: benchCut }),
|
||||
...evaluateSeasonalSlots(buildSeasonalitySnapshot(symbol, dailyCut), asOf),
|
||||
...corridorUnavailableAssessments(),
|
||||
];
|
||||
const assessments = [...wired, ...unwiredFallbacks(wired)];
|
||||
|
||||
for (const rack of racks) {
|
||||
if (repo.getEvaluation(symbol, asOf, rack.id)) continue;
|
||||
const rackSlots = new Set(rack.slotIds);
|
||||
const sliced = assessments.filter((a) => rackSlots.has(a.id));
|
||||
if (sliced.length === 0) continue;
|
||||
const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced);
|
||||
repo.saveEvaluation(evaluation, rack.id, randomUUID());
|
||||
repo.logSignalFires(evaluation, rack.id);
|
||||
summary.evaluationsStored += 1;
|
||||
summary.signalsLogged += evaluation.assessments.filter((a) => a.state === 'fired').length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await resolveSignalHistory(db, async (sym) => {
|
||||
const res = await provider.resolveAsOf(sym.toUpperCase(), '1d', '9999-12-31');
|
||||
return res.candles;
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export interface LearningLedgerStatus {
|
||||
ready: boolean;
|
||||
symbols: Array<{ symbol: string; evaluatedDays: number; targetDays: number; ready: boolean }>;
|
||||
}
|
||||
|
||||
export async function learningLedgerStatus(
|
||||
db: DatabaseSync,
|
||||
cache: CacheRepository,
|
||||
lookback = REPLAY_LOOKBACK_DAYS,
|
||||
): Promise<LearningLedgerStatus> {
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const rack = repo.listSystemRacks()[0];
|
||||
const provider = new CacheCandleProvider(cache);
|
||||
const symbols: LearningLedgerStatus['symbols'] = [];
|
||||
if (!rack) return { ready: false, symbols };
|
||||
for (const symbol of CONFLUENCE_LEARNING_UNIVERSE) {
|
||||
const n = repo.countEvaluations(symbol, rack.id);
|
||||
const daily = await provider.resolveAsOf(symbol, '1d', '9999-12-31');
|
||||
const available = daily.candles.length;
|
||||
const target = Math.min(lookback, available > 0 ? available : lookback);
|
||||
const ready = n >= Math.min(target, LEARNING_MIN_DAYS) || (target < LEARNING_MIN_DAYS && n >= target);
|
||||
symbols.push({ symbol, evaluatedDays: n, targetDays: target, ready });
|
||||
}
|
||||
return { ready: symbols.every((s) => s.ready), symbols };
|
||||
}
|
||||
|
||||
export async function fillLearningLedger(
|
||||
db: DatabaseSync,
|
||||
cache: CacheRepository,
|
||||
maxMs = 90_000,
|
||||
): Promise<ReplayRunSummary & { ledger: LearningLedgerStatus }> {
|
||||
const started = Date.now();
|
||||
let last: ReplayRunSummary = {
|
||||
symbolsTouched: [],
|
||||
evaluationsStored: 0,
|
||||
signalsLogged: 0,
|
||||
remainingDays: 0,
|
||||
complete: false,
|
||||
};
|
||||
while (Date.now() - started < maxMs) {
|
||||
const status = await learningLedgerStatus(db, cache);
|
||||
if (status.ready) return { ...last, complete: true, ledger: status };
|
||||
const batch = await runConfluenceReplay(db, cache, {
|
||||
symbols: CONFLUENCE_LEARNING_UNIVERSE,
|
||||
symbolsPerTick: 12,
|
||||
budgetDaysPerSymbol: 80,
|
||||
});
|
||||
last = {
|
||||
symbolsTouched: [...new Set([...last.symbolsTouched, ...batch.symbolsTouched])],
|
||||
evaluationsStored: last.evaluationsStored + batch.evaluationsStored,
|
||||
signalsLogged: last.signalsLogged + batch.signalsLogged,
|
||||
remainingDays: batch.remainingDays,
|
||||
complete: batch.complete,
|
||||
};
|
||||
if (batch.evaluationsStored === 0) break;
|
||||
}
|
||||
return { ...last, ledger: await learningLedgerStatus(db, cache) };
|
||||
}
|
||||
|
||||
export async function deriveAndPersistZoneRules(db: DatabaseSync, cache: CacheRepository, rackId?: string): Promise<{ entryId: string; exitId: string; source: string; skipped?: string }> {
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const rack = rackId
|
||||
? repo.getRack(rackId)
|
||||
: repo.listSystemRacks()[0] ?? null;
|
||||
if (!rack) throw new Error('confluence zone rules: no rack');
|
||||
const ledger = await learningLedgerStatus(db, cache);
|
||||
if (!ledger.ready) {
|
||||
return { entryId: '', exitId: '', source: 'deferred', skipped: 'learning ledger is not full enough to derive' };
|
||||
}
|
||||
|
||||
const evals: EvaluationPoint[] = [];
|
||||
const candlesBySymbol = new Map<string, import('../cache/CacheRepository.ts').PriceCandle[]>();
|
||||
const provider = new CacheCandleProvider(cache);
|
||||
|
||||
for (const symbol of CONFLUENCE_LEARNING_UNIVERSE) {
|
||||
const rows = repo.listEvaluationsForSymbolRack(symbol, rack.id);
|
||||
for (const r of rows) evals.push(toPoint(r));
|
||||
const daily = await provider.resolveAsOf(symbol, '1d', '9999-12-31');
|
||||
candlesBySymbol.set(symbol, daily.candles);
|
||||
}
|
||||
|
||||
const derived = deriveZoneRules(evals, candlesBySymbol);
|
||||
const caveat = (rule: typeof derived.entry) =>
|
||||
rule.source === 'baseline'
|
||||
? 'Held-out year did not beat the unconditional baseline by 10 percentage points. Showing the conservative quality-band rule.'
|
||||
: `Validated on the most recent year. Train resolved windows: ${rule.train.resolved}.`;
|
||||
|
||||
const entry = repo.saveActiveZoneRule({
|
||||
rackId: rack.id,
|
||||
kind: 'entry',
|
||||
derivedAt: new Date().toISOString(),
|
||||
predicateJson: JSON.stringify(derived.entry.predicate),
|
||||
trainStatsJson: JSON.stringify(derived.entry.train),
|
||||
validateStatsJson: JSON.stringify(derived.entry.validate),
|
||||
sampleCaveat: caveat(derived.entry),
|
||||
source: derived.entry.source,
|
||||
});
|
||||
const exit = repo.saveActiveZoneRule({
|
||||
rackId: rack.id,
|
||||
kind: 'exit',
|
||||
derivedAt: new Date().toISOString(),
|
||||
predicateJson: JSON.stringify(derived.exit.predicate),
|
||||
trainStatsJson: JSON.stringify(derived.exit.train),
|
||||
validateStatsJson: JSON.stringify(derived.exit.validate),
|
||||
sampleCaveat: caveat(derived.exit),
|
||||
source: derived.exit.source,
|
||||
});
|
||||
return { entryId: entry.id, exitId: exit.id, source: `${derived.entry.source}/${derived.exit.source}` };
|
||||
}
|
||||
|
||||
export function replayCoverage(
|
||||
db: DatabaseSync,
|
||||
symbol: string,
|
||||
rackId: string,
|
||||
lookback = REPLAY_LOOKBACK_DAYS,
|
||||
availableDays?: number,
|
||||
): { evaluatedDays: number; lookbackDays: number; replayComplete: boolean } {
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const n = repo.countEvaluations(symbol.toUpperCase(), rackId);
|
||||
const target = Math.min(lookback, availableDays && availableDays > 0 ? availableDays : lookback);
|
||||
return {
|
||||
evaluatedDays: n,
|
||||
lookbackDays: target,
|
||||
replayComplete: n >= target,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadActivePredicates(db: DatabaseSync, rackId: string): { entry: ZonePredicate; exit: ZonePredicate; entryRule: ReturnType<ConfluenceRepository['getActiveZoneRule']>; exitRule: ReturnType<ConfluenceRepository['getActiveZoneRule']> } {
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const entryRule = repo.getActiveZoneRule(rackId, 'entry');
|
||||
const exitRule = repo.getActiveZoneRule(rackId, 'exit');
|
||||
const parse = (raw: string | undefined, fallback: ZonePredicate): ZonePredicate => {
|
||||
if (!raw) return fallback;
|
||||
try { return JSON.parse(raw) as ZonePredicate; } catch { return fallback; }
|
||||
};
|
||||
return {
|
||||
entry: parse(entryRule?.predicateJson, FALLBACK_ENTRY),
|
||||
exit: parse(exitRule?.predicateJson, FALLBACK_EXIT),
|
||||
entryRule,
|
||||
exitRule,
|
||||
};
|
||||
}
|
||||
|
||||
export function scoreSymbolUnderPrior(
|
||||
db: DatabaseSync,
|
||||
symbol: string,
|
||||
rackId: string,
|
||||
candles: import('../cache/CacheRepository.ts').PriceCandle[],
|
||||
kind: 'entry' | 'exit',
|
||||
predicate: ZonePredicate,
|
||||
opposite: ZonePredicate,
|
||||
): ZoneRuleStats {
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const evals = repo.listEvaluationsForSymbolRack(symbol.toUpperCase(), rackId).map(toPoint);
|
||||
const zones = attachOutcomes(collapseZones(evals, kind, predicate, opposite, candles), candles);
|
||||
return scoreZones(zones, null);
|
||||
}
|
||||
|
||||
export function buildRecentZones(
|
||||
db: DatabaseSync,
|
||||
symbol: string,
|
||||
rackId: string,
|
||||
candles: import('../cache/CacheRepository.ts').PriceCandle[],
|
||||
entries = 3,
|
||||
exits = 3,
|
||||
) {
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const evals = repo.listEvaluationsForSymbolRack(symbol.toUpperCase(), rackId).map(toPoint);
|
||||
const { entry, exit } = loadActivePredicates(db, rackId);
|
||||
return recentZonesForSymbol(evals, candles, entry, exit, entries, exits);
|
||||
}
|
||||
@@ -57,12 +57,13 @@ export function redundancyGroupFor(slotId: string): RedundancyGroup | undefined
|
||||
* `REDUNDANCY_DISCOUNT` of the previous marginal — so N same-group firings
|
||||
* weigh 1 + 0.5 + 0.25 + ... , never N.
|
||||
*/
|
||||
export function groupWeight(firingSlotIds: string[]): number {
|
||||
export function groupWeight(firingSlotIds: string[], weights?: ReadonlyMap<string, number>): number {
|
||||
if (firingSlotIds.length === 0) return 0;
|
||||
let w = 0;
|
||||
let margin = 1;
|
||||
for (let i = 0; i < firingSlotIds.length; i++) {
|
||||
w += margin;
|
||||
for (const id of firingSlotIds) {
|
||||
const slotW = weights?.get(id) ?? 1;
|
||||
w += margin * slotW;
|
||||
margin *= REDUNDANCY_DISCOUNT;
|
||||
}
|
||||
return w;
|
||||
|
||||
@@ -101,6 +101,7 @@ export function evaluateRack(
|
||||
symbol: string,
|
||||
asOf: string,
|
||||
assessments: SlotAssessment[],
|
||||
weights?: ReadonlyMap<string, number>,
|
||||
): ConfluenceEvaluation {
|
||||
const slotById = new Map(CONFLUENCE_SLOTS.map((s) => [s.id, s]));
|
||||
|
||||
@@ -139,9 +140,9 @@ export function evaluateRack(
|
||||
}
|
||||
|
||||
let bullEvidence = 0;
|
||||
for (const ids of bullGroups.values()) bullEvidence += groupWeight(ids);
|
||||
for (const ids of bullGroups.values()) bullEvidence += groupWeight(ids, weights);
|
||||
let bearEvidence = 0;
|
||||
for (const ids of bearGroups.values()) bearEvidence += groupWeight(ids);
|
||||
for (const ids of bearGroups.values()) bearEvidence += groupWeight(ids, weights);
|
||||
|
||||
const netEvidence = bullEvidence - bearEvidence;
|
||||
const totalEvidence = bullEvidence + bearEvidence;
|
||||
|
||||
@@ -47,6 +47,13 @@ export const CONFLUENCE_UNIVERSE: ConfluenceSymbol[] = [
|
||||
|
||||
export const BENCHMARK_SYMBOL = 'SPY';
|
||||
|
||||
/** 11 GICS sector ETFs + SPY. Universe-level zone rules are learned here only. */
|
||||
export const CONFLUENCE_LEARNING_UNIVERSE: string[] = [
|
||||
'XLK', 'XLF', 'XLE', 'XLI', 'XLV', 'XLY', 'XLP', 'XLU', 'XLRE', 'XLC', 'XLB', BENCHMARK_SYMBOL,
|
||||
];
|
||||
|
||||
export const REPLAY_LOOKBACK_DAYS = 756;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System rack presets
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,10 +117,18 @@ export async function seedConfluence(
|
||||
cache: CacheRepository,
|
||||
): Promise<{ symbolsPinned: number; racksCreated: number }> {
|
||||
// 1. Pin universe symbols + benchmark into the permanent demand set.
|
||||
const allSymbols: ConfluenceSymbol[] = [
|
||||
...CONFLUENCE_UNIVERSE,
|
||||
{ symbol: BENCHMARK_SYMBOL, kind: 'etf', label: 'S&P 500 benchmark' },
|
||||
];
|
||||
const learning: ConfluenceSymbol[] = CONFLUENCE_LEARNING_UNIVERSE.map((symbol) => ({
|
||||
symbol,
|
||||
kind: 'etf' as TickerKind,
|
||||
label: symbol,
|
||||
}));
|
||||
const seen = new Set<string>();
|
||||
const allSymbols: ConfluenceSymbol[] = [];
|
||||
for (const row of [...learning, ...CONFLUENCE_UNIVERSE, { symbol: BENCHMARK_SYMBOL, kind: 'etf' as TickerKind, label: 'S&P 500 benchmark' }]) {
|
||||
if (seen.has(row.symbol)) continue;
|
||||
seen.add(row.symbol);
|
||||
allSymbols.push(row);
|
||||
}
|
||||
let symbolsPinned = 0;
|
||||
for (const { symbol, kind } of allSymbols) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
// Investor Flow — Confluence entry/exit zones (derived from picture follow-through)
|
||||
//
|
||||
// Zones are contiguous stretches of rack evaluations that match a small, named
|
||||
// predicate. Predicates are scored walk-forward against 4-week price
|
||||
// follow-through. The winner becomes the active rule; last-N matching windows
|
||||
// are what the Confluence page shows.
|
||||
//
|
||||
// Pure: no I/O. ADR-0007: a zone describes a historical window, never a directive.
|
||||
|
||||
import type { PriceCandle } from '../cache/CacheRepository.ts';
|
||||
import { candleIndexForDate, checkFollowThrough } from './confluenceBacktest.ts';
|
||||
import { confluenceslotById } from './confluenceSlots.ts';
|
||||
import type { PictureQuality, SlotAssessment } from './confluenceRack.ts';
|
||||
|
||||
export type ZoneKind = 'entry' | 'exit';
|
||||
|
||||
export interface EvaluationPoint {
|
||||
symbol: string;
|
||||
asOf: string;
|
||||
quality: PictureQuality;
|
||||
netEvidence: number;
|
||||
bullEvidence: number;
|
||||
bearEvidence: number;
|
||||
assessments: SlotAssessment[];
|
||||
}
|
||||
|
||||
export interface OnsetStyle {
|
||||
lookback: number | null;
|
||||
slide: boolean;
|
||||
}
|
||||
|
||||
export const ONSET_TURN_ON: OnsetStyle = { lookback: null, slide: false };
|
||||
export const ONSET_EXTREME_15_SLIDE: OnsetStyle = { lookback: 15, slide: true };
|
||||
|
||||
export const ONSET_STYLE_CATALOG: Array<{ id: string; label: string; style: OnsetStyle }> = [
|
||||
{ id: 'turn-on', label: 'turn-on only', style: ONSET_TURN_ON },
|
||||
{ id: 'extreme-10', label: '10-session extreme', style: { lookback: 10, slide: false } },
|
||||
{ id: 'extreme-15', label: '15-session extreme', style: { lookback: 15, slide: false } },
|
||||
{ id: 'extreme-15-slide', label: '15-session extreme, slide to deeper trough', style: ONSET_EXTREME_15_SLIDE },
|
||||
{ id: 'extreme-20', label: '20-session extreme', style: { lookback: 20, slide: false } },
|
||||
{ id: 'extreme-20-slide', label: '20-session extreme, slide to deeper trough', style: { lookback: 20, slide: true } },
|
||||
];
|
||||
|
||||
export interface ZonePredicate {
|
||||
id: string;
|
||||
label: string;
|
||||
qualities?: PictureQuality[];
|
||||
minNetEvidence?: number;
|
||||
maxNetEvidence?: number;
|
||||
minBullEvidence?: number;
|
||||
minBearOrExitFired?: number;
|
||||
requireAnyFired?: string[];
|
||||
onsetId?: string;
|
||||
onsetLookback?: number | null;
|
||||
onsetSlide?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfluenceZone {
|
||||
kind: ZoneKind;
|
||||
startAsOf: string;
|
||||
endAsOf: string;
|
||||
open: boolean;
|
||||
qualityAtStart: PictureQuality;
|
||||
netEvidenceAtStart: number;
|
||||
firedSlotIds: string[];
|
||||
forwardReturn: number | null;
|
||||
verdict: 'confirmed' | 'false_alarm' | 'open';
|
||||
}
|
||||
|
||||
export interface ZoneRuleStats {
|
||||
zones: number;
|
||||
resolved: number;
|
||||
confirmed: number;
|
||||
hitRate: number | null;
|
||||
avgReturn: number | null;
|
||||
baselineHitRate: number | null;
|
||||
}
|
||||
|
||||
export interface DerivedZoneRule {
|
||||
kind: ZoneKind;
|
||||
predicate: ZonePredicate;
|
||||
source: 'validated' | 'baseline';
|
||||
train: ZoneRuleStats;
|
||||
validate: ZoneRuleStats;
|
||||
}
|
||||
|
||||
export const MIN_TRAIN_ZONES = 8;
|
||||
export const MIN_VALIDATE_BEAT = 0.10;
|
||||
export const ZONE_HORIZON_WEEKS = 4;
|
||||
/** Bars painted from zone start. Matches the 4-week follow-through window. */
|
||||
export const ZONE_DISPLAY_BARS = ZONE_HORIZON_WEEKS * 5;
|
||||
/** A re-entry needs this many sessions of lower/higher closes behind it. */
|
||||
export const REENTRY_LOOKBACK = 15;
|
||||
/** Do not open another window inside this many evaluations of the last onset. */
|
||||
export const REENTRY_MIN_GAP = 20;
|
||||
|
||||
const BULLISH: PictureQuality[] = ['weak-bullish', 'moderate-bullish', 'strong-bullish'];
|
||||
const STRONG_BULL: PictureQuality[] = ['moderate-bullish', 'strong-bullish'];
|
||||
const BEARISH: PictureQuality[] = ['weak-bearish', 'moderate-bearish', 'strong-bearish'];
|
||||
const STRONG_BEAR: PictureQuality[] = ['moderate-bearish', 'strong-bearish'];
|
||||
|
||||
export const ENTRY_TEMPLATES: ZonePredicate[] = [
|
||||
{ id: 'entry-mod-strong', label: 'Moderate or strong bullish picture', qualities: STRONG_BULL },
|
||||
{ id: 'entry-any-bull', label: 'Any bullish picture', qualities: BULLISH },
|
||||
{ id: 'entry-net-1', label: 'Net evidence at least 1.0', minNetEvidence: 1 },
|
||||
{ id: 'entry-net-2', label: 'Net evidence at least 2.0', minNetEvidence: 2 },
|
||||
{ id: 'entry-net-3', label: 'Net evidence at least 3.0', minNetEvidence: 3 },
|
||||
{ id: 'entry-bull-2', label: 'Bull evidence at least 2.0', minBullEvidence: 2 },
|
||||
{ id: 'entry-bull-3', label: 'Bull evidence at least 3.0', minBullEvidence: 3 },
|
||||
{ id: 'entry-bull-4', label: 'Bull evidence at least 4.0', minBullEvidence: 4 },
|
||||
{ id: 'entry-mod-net2', label: 'Moderate or strong bullish picture and net evidence at least 2.0', qualities: STRONG_BULL, minNetEvidence: 2 },
|
||||
{ id: 'entry-mod-cheap', label: 'Moderate or strong bullish picture and corridor in the cheap band', qualities: STRONG_BULL, requireAnyFired: ['corridorEntryCheap'] },
|
||||
{ id: 'entry-mod-pullback', label: 'Moderate or strong bullish picture and a pullback toward EMA-21', qualities: STRONG_BULL, requireAnyFired: ['pullbackToEMA21'] },
|
||||
];
|
||||
|
||||
export const EXIT_TEMPLATES: ZonePredicate[] = [
|
||||
{ id: 'exit-mod-strong', label: 'Moderate or strong bearish picture', qualities: STRONG_BEAR },
|
||||
{ id: 'exit-any-bear', label: 'Any bearish picture', qualities: BEARISH },
|
||||
{ id: 'exit-net-m1', label: 'Net evidence at most -1.0', maxNetEvidence: -1 },
|
||||
{ id: 'exit-net-m2', label: 'Net evidence at most -2.0', maxNetEvidence: -2 },
|
||||
{ id: 'exit-fired-1', label: 'At least one exit or bear slot fired', minBearOrExitFired: 1 },
|
||||
{ id: 'exit-fired-2', label: 'At least two exit or bear slots fired', minBearOrExitFired: 2 },
|
||||
{ id: 'exit-mod-stretch', label: 'Moderate or strong bearish picture and a stretch marker', qualities: STRONG_BEAR, requireAnyFired: ['deathCross', 'rsiOverbought', 'corridorEntryStretched'] },
|
||||
];
|
||||
|
||||
export const FALLBACK_ENTRY = ENTRY_TEMPLATES[0];
|
||||
export const FALLBACK_EXIT = EXIT_TEMPLATES[0];
|
||||
|
||||
export function matchRule(ev: EvaluationPoint, predicate: ZonePredicate): boolean {
|
||||
if (predicate.qualities && !predicate.qualities.includes(ev.quality)) return false;
|
||||
if (predicate.minNetEvidence != null && ev.netEvidence < predicate.minNetEvidence) return false;
|
||||
if (predicate.maxNetEvidence != null && ev.netEvidence > predicate.maxNetEvidence) return false;
|
||||
if (predicate.minBullEvidence != null && ev.bullEvidence < predicate.minBullEvidence) return false;
|
||||
if (predicate.minBearOrExitFired != null) {
|
||||
const n = ev.assessments.filter((a) => {
|
||||
if (a.state !== 'fired') return false;
|
||||
const body = confluenceslotById.get(a.id)?.body;
|
||||
return body === 'bear' || body === 'exit';
|
||||
}).length;
|
||||
if (n < predicate.minBearOrExitFired) return false;
|
||||
}
|
||||
if (predicate.requireAnyFired && predicate.requireAnyFired.length > 0) {
|
||||
const fired = new Set(ev.assessments.filter((a) => a.state === 'fired').map((a) => a.id));
|
||||
if (!predicate.requireAnyFired.some((id) => fired.has(id))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function predicateComplexity(predicate: ZonePredicate): number {
|
||||
let n = 0;
|
||||
if (predicate.qualities) n += 1;
|
||||
if (predicate.minNetEvidence != null) n += 1;
|
||||
if (predicate.maxNetEvidence != null) n += 1;
|
||||
if (predicate.minBullEvidence != null) n += 1;
|
||||
if (predicate.minBearOrExitFired != null) n += 1;
|
||||
if (predicate.requireAnyFired) n += 1;
|
||||
if (predicate.onsetLookback != null) n += 1;
|
||||
if (predicate.onsetSlide) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function onsetStyleOf(predicate: ZonePredicate): OnsetStyle {
|
||||
if (predicate.onsetLookback === undefined && predicate.onsetSlide === undefined) {
|
||||
return ONSET_TURN_ON;
|
||||
}
|
||||
return {
|
||||
lookback: predicate.onsetLookback ?? null,
|
||||
slide: predicate.onsetSlide === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function expandTemplates(pictures: ZonePredicate[]): ZonePredicate[] {
|
||||
const out: ZonePredicate[] = [];
|
||||
for (const p of pictures) {
|
||||
for (const o of ONSET_STYLE_CATALOG) {
|
||||
out.push({
|
||||
...p,
|
||||
id: `${p.id}+${o.id}`,
|
||||
label: o.id === 'turn-on' ? p.label : `${p.label}; ${o.label}`,
|
||||
onsetId: o.id,
|
||||
onsetLookback: o.style.lookback,
|
||||
onsetSlide: o.style.slide,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when `asOf` is the lowest (entry) or highest (exit) close in the lookback. */
|
||||
export function isLookbackExtreme(
|
||||
candles: PriceCandle[],
|
||||
asOf: string,
|
||||
kind: ZoneKind,
|
||||
lookback = REENTRY_LOOKBACK,
|
||||
): boolean {
|
||||
const idx = candleIndexForDate(candles, asOf.slice(0, 10));
|
||||
if (idx < 0 || lookback <= 0) return false;
|
||||
const start = Math.max(0, idx - lookback + 1);
|
||||
const px = candles[idx].c;
|
||||
if (!Number.isFinite(px)) return false;
|
||||
for (let i = start; i <= idx; i++) {
|
||||
const c = candles[i].c;
|
||||
if (!Number.isFinite(c)) continue;
|
||||
if (kind === 'entry' && c < px) return false;
|
||||
if (kind === 'exit' && c > px) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Lookback extreme that has started to reverse (next close turns the other way). Last bar is allowed without confirmation. */
|
||||
export function isConfirmedExtreme(
|
||||
candles: PriceCandle[],
|
||||
asOf: string,
|
||||
kind: ZoneKind,
|
||||
lookback = REENTRY_LOOKBACK,
|
||||
): boolean {
|
||||
if (!isLookbackExtreme(candles, asOf, kind, lookback)) return false;
|
||||
const idx = candleIndexForDate(candles, asOf.slice(0, 10));
|
||||
if (idx < 0 || idx + 1 >= candles.length) return true;
|
||||
const px = candles[idx].c;
|
||||
const next = candles[idx + 1].c;
|
||||
if (!Number.isFinite(next)) return true;
|
||||
return kind === 'entry' ? next > px : next < px;
|
||||
}
|
||||
|
||||
function closeOn(candles: PriceCandle[], asOf: string): number | null {
|
||||
const idx = candleIndexForDate(candles, asOf.slice(0, 10));
|
||||
if (idx < 0) return null;
|
||||
const px = candles[idx].c;
|
||||
return Number.isFinite(px) ? px : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onsets: the picture turns on, or it stays on and price prints a fresh
|
||||
* N-session extreme after a quiet gap. That second case is a pullback
|
||||
* (entry) or a bounce into strength (exit) inside the same picture.
|
||||
*/
|
||||
export function findOnsets(
|
||||
evals: EvaluationPoint[],
|
||||
kind: ZoneKind,
|
||||
predicate: ZonePredicate,
|
||||
opposite?: ZonePredicate,
|
||||
candles?: PriceCandle[],
|
||||
style: OnsetStyle = onsetStyleOf(predicate),
|
||||
): string[] {
|
||||
const ordered = [...evals].sort((a, b) => a.asOf.localeCompare(b.asOf));
|
||||
const onsets: string[] = [];
|
||||
let prevMatch = false;
|
||||
let lastOnsetEvalIdx = -REENTRY_MIN_GAP;
|
||||
const lookback = style.lookback;
|
||||
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const ev = ordered[i];
|
||||
const matches = matchRule(ev, predicate);
|
||||
const flipped = opposite ? matchRule(ev, opposite) : false;
|
||||
if (flipped || !matches) {
|
||||
prevMatch = false;
|
||||
continue;
|
||||
}
|
||||
const fresh = !prevMatch;
|
||||
if (lookback == null || !candles) {
|
||||
if (fresh) {
|
||||
onsets.push(ev.asOf);
|
||||
lastOnsetEvalIdx = i;
|
||||
}
|
||||
prevMatch = true;
|
||||
continue;
|
||||
}
|
||||
const gapOk = i - lastOnsetEvalIdx >= REENTRY_MIN_GAP;
|
||||
const confirmed = isConfirmedExtreme(candles, ev.asOf, kind, lookback);
|
||||
const pullback = confirmed && gapOk;
|
||||
if (fresh || pullback) {
|
||||
onsets.push(ev.asOf);
|
||||
lastOnsetEvalIdx = i;
|
||||
} else if (style.slide && confirmed && onsets.length > 0) {
|
||||
const lastPx = closeOn(candles, onsets[onsets.length - 1]);
|
||||
const px = closeOn(candles, ev.asOf);
|
||||
const deeper = lastPx != null && px != null && (
|
||||
kind === 'entry' ? px < lastPx : px > lastPx
|
||||
);
|
||||
if (deeper && i - lastOnsetEvalIdx < REENTRY_MIN_GAP) {
|
||||
onsets[onsets.length - 1] = ev.asOf;
|
||||
lastOnsetEvalIdx = i;
|
||||
}
|
||||
}
|
||||
prevMatch = true;
|
||||
}
|
||||
return onsets;
|
||||
}
|
||||
|
||||
function zoneFromOnset(
|
||||
ordered: EvaluationPoint[],
|
||||
startAsOf: string,
|
||||
kind: ZoneKind,
|
||||
candles: PriceCandle[] | undefined,
|
||||
isLast: boolean,
|
||||
): ConfluenceZone | null {
|
||||
const start = ordered.find((e) => e.asOf === startAsOf);
|
||||
if (!start) return null;
|
||||
let endAsOf = startAsOf;
|
||||
if (candles) {
|
||||
const win = zoneOnsetWindow(startAsOf, '9999-12-31', candles);
|
||||
if (win) endAsOf = win.endAsOf;
|
||||
}
|
||||
const last = ordered[ordered.length - 1];
|
||||
return {
|
||||
kind,
|
||||
startAsOf,
|
||||
endAsOf,
|
||||
open: isLast && last != null && last.asOf >= startAsOf,
|
||||
qualityAtStart: start.quality,
|
||||
netEvidenceAtStart: start.netEvidence,
|
||||
firedSlotIds: start.assessments.filter((a) => a.state === 'fired').map((a) => a.id),
|
||||
forwardReturn: null,
|
||||
verdict: 'open',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a chronological evaluation series into contiguous zones.
|
||||
* A single off-rule session does not split a zone. Two off-rule sessions, or
|
||||
* a day that matches the opposite predicate, closes it.
|
||||
*
|
||||
* When `candles` are passed, long stretches are split on N-session pullbacks
|
||||
* (entry) or bounce highs (exit) so a year-long bullish picture is not one zone.
|
||||
*/
|
||||
export function collapseZones(
|
||||
evals: EvaluationPoint[],
|
||||
kind: ZoneKind,
|
||||
predicate: ZonePredicate,
|
||||
opposite?: ZonePredicate,
|
||||
candles?: PriceCandle[],
|
||||
): ConfluenceZone[] {
|
||||
const ordered = [...evals].sort((a, b) => a.asOf.localeCompare(b.asOf));
|
||||
if (ordered.length === 0) return [];
|
||||
|
||||
if (candles && candles.length > 0) {
|
||||
const onsets = findOnsets(ordered, kind, predicate, opposite, candles, onsetStyleOf(predicate));
|
||||
return onsets
|
||||
.map((asOf, i) => zoneFromOnset(ordered, asOf, kind, candles, i === onsets.length - 1))
|
||||
.filter((z): z is ConfluenceZone => z != null);
|
||||
}
|
||||
|
||||
const zones: ConfluenceZone[] = [];
|
||||
let startIdx = -1;
|
||||
let lastMatchIdx = -1;
|
||||
|
||||
const close = (endIdx: number) => {
|
||||
if (startIdx < 0 || lastMatchIdx < 0) return;
|
||||
const start = ordered[startIdx];
|
||||
const end = ordered[lastMatchIdx];
|
||||
const open = lastMatchIdx >= ordered.length - 2;
|
||||
zones.push({
|
||||
kind,
|
||||
startAsOf: start.asOf,
|
||||
endAsOf: end.asOf,
|
||||
open,
|
||||
qualityAtStart: start.quality,
|
||||
netEvidenceAtStart: start.netEvidence,
|
||||
firedSlotIds: start.assessments.filter((a) => a.state === 'fired').map((a) => a.id),
|
||||
forwardReturn: null,
|
||||
verdict: 'open',
|
||||
});
|
||||
startIdx = -1;
|
||||
lastMatchIdx = -1;
|
||||
};
|
||||
|
||||
for (let i = 0; i < ordered.length; i++) {
|
||||
const ev = ordered[i];
|
||||
const matches = matchRule(ev, predicate);
|
||||
const flipped = opposite ? matchRule(ev, opposite) : false;
|
||||
|
||||
if (flipped && startIdx >= 0) {
|
||||
close(i - 1);
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
if (startIdx < 0) startIdx = i;
|
||||
lastMatchIdx = i;
|
||||
} else if (startIdx >= 0 && i - lastMatchIdx > 1) {
|
||||
close(i);
|
||||
}
|
||||
}
|
||||
if (startIdx >= 0) close(ordered.length - 1);
|
||||
return zones;
|
||||
}
|
||||
|
||||
export function lastNZones(zones: ConfluenceZone[], n: number): ConfluenceZone[] {
|
||||
if (n <= 0) return [];
|
||||
return zones.slice(-n);
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart marks the onset of a zone (the first `bars` sessions), not the
|
||||
* whole stretch the picture stayed on. A 14-month open bullish picture is
|
||||
* still one onset in June, not a year of green.
|
||||
*/
|
||||
export function zoneOnsetWindow(
|
||||
startAsOf: string,
|
||||
endAsOf: string,
|
||||
candles: PriceCandle[],
|
||||
bars = ZONE_DISPLAY_BARS,
|
||||
): { startAsOf: string; endAsOf: string } | null {
|
||||
const startIdx = candleIndexForDate(candles, startAsOf.slice(0, 10));
|
||||
if (startIdx < 0 || bars <= 0) return null;
|
||||
const naturalEndIdx = candleIndexForDate(candles, endAsOf.slice(0, 10));
|
||||
const capIdx = startIdx + bars - 1;
|
||||
const endIdx = naturalEndIdx >= 0
|
||||
? Math.min(naturalEndIdx, capIdx, candles.length - 1)
|
||||
: Math.min(capIdx, candles.length - 1);
|
||||
if (endIdx < startIdx) return null;
|
||||
return {
|
||||
startAsOf: (candles[startIdx].ts ?? startAsOf).slice(0, 10),
|
||||
endAsOf: (candles[endIdx].ts ?? endAsOf).slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
export function attachOutcomes(
|
||||
zones: ConfluenceZone[],
|
||||
candles: PriceCandle[],
|
||||
weeks = ZONE_HORIZON_WEEKS,
|
||||
): ConfluenceZone[] {
|
||||
return zones.map((z) => {
|
||||
const direction = z.kind === 'entry' ? 'up' : 'down';
|
||||
const idx = candleIndexForDate(candles, z.startAsOf);
|
||||
const ret = forwardReturnFrom(candles, z.startAsOf, weeks);
|
||||
if (idx < 0 || ret === null) {
|
||||
return { ...z, forwardReturn: null, verdict: 'open' as const };
|
||||
}
|
||||
const confirmed = checkFollowThrough(candles, idx, direction, weeks);
|
||||
if (confirmed === null) {
|
||||
return { ...z, forwardReturn: ret, verdict: 'open' as const };
|
||||
}
|
||||
return {
|
||||
...z,
|
||||
forwardReturn: ret,
|
||||
verdict: confirmed ? 'confirmed' : 'false_alarm',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function forwardReturnFrom(candles: PriceCandle[], asOf: string, weeks = ZONE_HORIZON_WEEKS): number | null {
|
||||
const idx = candleIndexForDate(candles, asOf);
|
||||
if (idx < 0) return null;
|
||||
const target = idx + weeks * 5;
|
||||
if (target >= candles.length) return null;
|
||||
const entry = candles[idx].c;
|
||||
const exit = candles[target].c;
|
||||
if (!Number.isFinite(entry) || !Number.isFinite(exit) || entry <= 0) return null;
|
||||
return (exit - entry) / entry;
|
||||
}
|
||||
|
||||
export function emptyStats(): ZoneRuleStats {
|
||||
return { zones: 0, resolved: 0, confirmed: 0, hitRate: null, avgReturn: null, baselineHitRate: null };
|
||||
}
|
||||
|
||||
export function scoreZones(zones: ConfluenceZone[], baselineHitRate: number | null): ZoneRuleStats {
|
||||
const resolved = zones.filter((z) => z.verdict !== 'open');
|
||||
const confirmed = resolved.filter((z) => z.verdict === 'confirmed');
|
||||
const rets = resolved.map((z) => z.forwardReturn).filter((v): v is number => v != null);
|
||||
const avgReturn = rets.length > 0 ? rets.reduce((a, b) => a + b, 0) / rets.length : null;
|
||||
return {
|
||||
zones: zones.length,
|
||||
resolved: resolved.length,
|
||||
confirmed: confirmed.length,
|
||||
hitRate: resolved.length > 0 ? confirmed.length / resolved.length : null,
|
||||
avgReturn,
|
||||
baselineHitRate,
|
||||
};
|
||||
}
|
||||
|
||||
export function baselineHitRate(
|
||||
evals: EvaluationPoint[],
|
||||
candlesBySymbol: Map<string, PriceCandle[]>,
|
||||
kind: ZoneKind,
|
||||
weeks = ZONE_HORIZON_WEEKS,
|
||||
): number | null {
|
||||
let resolved = 0;
|
||||
let confirmed = 0;
|
||||
const direction = kind === 'entry' ? 'up' : 'down';
|
||||
for (const ev of evals) {
|
||||
const candles = candlesBySymbol.get(ev.symbol);
|
||||
if (!candles) continue;
|
||||
const idx = candleIndexForDate(candles, ev.asOf);
|
||||
if (idx < 0) continue;
|
||||
const hit = checkFollowThrough(candles, idx, direction, weeks);
|
||||
if (hit === null) continue;
|
||||
resolved += 1;
|
||||
if (hit) confirmed += 1;
|
||||
}
|
||||
return resolved > 0 ? confirmed / resolved : null;
|
||||
}
|
||||
|
||||
export function splitTrainValidate(evals: EvaluationPoint[], trainEnd: string): { train: EvaluationPoint[]; validate: EvaluationPoint[] } {
|
||||
const ordered = [...evals].sort((a, b) => a.asOf.localeCompare(b.asOf));
|
||||
return {
|
||||
train: ordered.filter((e) => e.asOf <= trainEnd),
|
||||
validate: ordered.filter((e) => e.asOf > trainEnd),
|
||||
};
|
||||
}
|
||||
|
||||
/** Default train cut: last year of the series is held out. */
|
||||
export function defaultTrainEnd(evals: EvaluationPoint[]): string | null {
|
||||
const dates = [...new Set(evals.map((e) => e.asOf))].sort();
|
||||
if (dates.length === 0) return null;
|
||||
const last = dates[dates.length - 1];
|
||||
const cut = new Date(`${last}T00:00:00Z`);
|
||||
cut.setUTCFullYear(cut.getUTCFullYear() - 1);
|
||||
return cut.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function scoreTemplate(
|
||||
kind: ZoneKind,
|
||||
predicate: ZonePredicate,
|
||||
opposite: ZonePredicate | undefined,
|
||||
train: EvaluationPoint[],
|
||||
validate: EvaluationPoint[],
|
||||
candlesBySymbol: Map<string, PriceCandle[]>,
|
||||
trainBaseline: number | null,
|
||||
validateBaseline: number | null,
|
||||
): { train: ZoneRuleStats; validate: ZoneRuleStats } {
|
||||
const scoreSplit = (rows: EvaluationPoint[], baseline: number | null): ZoneRuleStats => {
|
||||
const bySymbol = new Map<string, EvaluationPoint[]>();
|
||||
for (const ev of rows) {
|
||||
const arr = bySymbol.get(ev.symbol) ?? [];
|
||||
arr.push(ev);
|
||||
bySymbol.set(ev.symbol, arr);
|
||||
}
|
||||
const all: ConfluenceZone[] = [];
|
||||
for (const [symbol, series] of bySymbol) {
|
||||
const candles = candlesBySymbol.get(symbol) ?? [];
|
||||
all.push(...attachOutcomes(collapseZones(series, kind, predicate, opposite, candles), candles));
|
||||
}
|
||||
return scoreZones(all, baseline);
|
||||
};
|
||||
return {
|
||||
train: scoreSplit(train, trainBaseline),
|
||||
validate: scoreSplit(validate, validateBaseline),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectWinner(
|
||||
kind: ZoneKind,
|
||||
templates: ZonePredicate[],
|
||||
evals: EvaluationPoint[],
|
||||
candlesBySymbol: Map<string, PriceCandle[]>,
|
||||
fallback: ZonePredicate,
|
||||
oppositeFallback?: ZonePredicate,
|
||||
): DerivedZoneRule {
|
||||
const cut = defaultTrainEnd(evals);
|
||||
if (!cut || evals.length === 0) {
|
||||
return {
|
||||
kind,
|
||||
predicate: fallback,
|
||||
source: 'baseline',
|
||||
train: emptyStats(),
|
||||
validate: emptyStats(),
|
||||
};
|
||||
}
|
||||
const { train, validate } = splitTrainValidate(evals, cut);
|
||||
const trainBaseline = baselineHitRate(train, candlesBySymbol, kind);
|
||||
const validateBaseline = baselineHitRate(validate, candlesBySymbol, kind);
|
||||
|
||||
type Cand = { predicate: ZonePredicate; train: ZoneRuleStats; validate: ZoneRuleStats };
|
||||
const survivors: Cand[] = [];
|
||||
|
||||
for (const predicate of templates) {
|
||||
const scored = scoreTemplate(kind, predicate, oppositeFallback, train, validate, candlesBySymbol, trainBaseline, validateBaseline);
|
||||
if (scored.train.resolved < MIN_TRAIN_ZONES) continue;
|
||||
if (scored.validate.hitRate == null || validateBaseline == null) continue;
|
||||
if (scored.validate.hitRate < validateBaseline + MIN_VALIDATE_BEAT) continue;
|
||||
survivors.push({ predicate, train: scored.train, validate: scored.validate });
|
||||
}
|
||||
|
||||
survivors.sort((a, b) => {
|
||||
const hit = (b.validate.hitRate ?? 0) - (a.validate.hitRate ?? 0);
|
||||
if (Math.abs(hit) > 1e-9) return hit;
|
||||
const simpler = predicateComplexity(a.predicate) - predicateComplexity(b.predicate);
|
||||
if (simpler !== 0) return simpler;
|
||||
return b.train.resolved - a.train.resolved;
|
||||
});
|
||||
|
||||
if (survivors.length === 0) {
|
||||
const scored = scoreTemplate(kind, fallback, oppositeFallback, train, validate, candlesBySymbol, trainBaseline, validateBaseline);
|
||||
return { kind, predicate: fallback, source: 'baseline', train: scored.train, validate: scored.validate };
|
||||
}
|
||||
|
||||
const win = survivors[0];
|
||||
return { kind, predicate: win.predicate, source: 'validated', train: win.train, validate: win.validate };
|
||||
}
|
||||
|
||||
export function deriveZoneRules(
|
||||
evals: EvaluationPoint[],
|
||||
candlesBySymbol: Map<string, PriceCandle[]>,
|
||||
): { entry: DerivedZoneRule; exit: DerivedZoneRule } {
|
||||
const entry = selectWinner('entry', expandTemplates(ENTRY_TEMPLATES), evals, candlesBySymbol, FALLBACK_ENTRY, FALLBACK_EXIT);
|
||||
const exit = selectWinner('exit', expandTemplates(EXIT_TEMPLATES), evals, candlesBySymbol, FALLBACK_EXIT, FALLBACK_ENTRY);
|
||||
return { entry, exit };
|
||||
}
|
||||
|
||||
export function recentZonesForSymbol(
|
||||
evals: EvaluationPoint[],
|
||||
candles: PriceCandle[],
|
||||
entryRule: ZonePredicate,
|
||||
exitRule: ZonePredicate,
|
||||
entries = 3,
|
||||
exits = 3,
|
||||
): { entries: ConfluenceZone[]; exits: ConfluenceZone[] } {
|
||||
const entryZones = attachOutcomes(collapseZones(evals, 'entry', entryRule, exitRule, candles), candles);
|
||||
const exitZones = attachOutcomes(collapseZones(evals, 'exit', exitRule, entryRule, candles), candles);
|
||||
return {
|
||||
entries: lastNZones(entryZones, entries),
|
||||
exits: lastNZones(exitZones, exits),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user