fix: IREN/ASTS/IRE stuck-pending + corridor feature

Fix stuck adapter_queue jobs (ASTS/IRE/IREN stuck pending forever):

1. fetchSpec early-return paths (cooldown checks) now update job status
   to 'backoff' with last_attempt set and 30s backoff_until, instead of
   returning without any status change. Prevents jobs from being
   re-processed every drain cycle indefinitely.

2. Wrap adapter.fetchOne() in 30s Promise.race timeout. A hung HTTP
   request no longer blocks the entire per-source promise chain
   forever, preventing all subsequent jobs for that source.

Also includes corridor/confluence feature, tiered quote schedules,
cache improvements, and HoldingsBookView refinements.
This commit is contained in:
Investor Flow Build
2026-08-11 22:25:17 -04:00
parent 24349a8b6d
commit 9b41890d33
28 changed files with 2576 additions and 184 deletions
@@ -0,0 +1,129 @@
// Investor Flow — confluenceEngine.test.ts (M24 slice 5)
// Integration test for the evaluation engine: runs the wired slot families over
// fake cache candle streams, verifies rack evaluations + corridor snapshots +
// signal history are persisted and idempotent per (symbol, asOf, rack).
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { createDb, initSchema } from '../../db/client.ts';
import { ConfluenceRepository } from '../../db/confluenceRepository.ts';
import { CorridorRepository } from '../../db/corridorRepository.ts';
import { createCacheRepository, type CacheRepository, type CacheEntry, type PriceCandle, type Quote } from '../../cache/CacheRepository.ts';
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';
// ---- fake cache that returns whatever we seeded ---------------------------------
class FakeCache implements CacheRepository {
private readonly store = new Map<string, { value: unknown; stale: boolean }>();
setValue(key: string, value: unknown, stale = false): this { this.store.set(key, { value, stale }); return this; }
async get<T>(key: string): Promise<CacheEntry<T>> {
const e = this.store.get(key);
return { value: (e ? e.value : null) as T | null, provenance: null, isStale: e ? e.stale : true };
}
async set(): Promise<void> { throw new Error('not used'); }
stale(key: string): boolean { return !this.store.has(key) || this.store.get(key)!.stale; }
async subscribe(): Promise<void> {}
async unsubscribe(): Promise<void> {}
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 del(): Promise<void> {}
readonly db: never = undefined as never;
}
// ---- fixtures -------------------------------------------------------------
/** Monotonic ramp up (bullish technicals) over `days` trading days. */
function rampUp(days: number, start = 100, dailyPct = 0.0015): PriceCandle[] {
const candles: PriceCandle[] = [];
const base = Date.UTC(2020, 0, 1);
for (let i = 0; i < days; i++) {
const c = start * Math.pow(1 + dailyPct, i);
candles.push({
ts: new Date(base + i * 86400000).toISOString().slice(0, 10),
o: c * (1 - dailyPct / 2),
h: c * 1.003,
l: c * 0.997,
c,
v: 2_000_000,
adjClose: c,
});
}
return candles;
}
let db: ReturnType<typeof createDb>;
let cache: FakeCache;
let confluenceRepo: ConfluenceRepository;
let corridorRepo: CorridorRepository;
beforeEach(async () => {
db = createDb({ path: ':memory:' });
initSchema(db);
confluenceRepo = new ConfluenceRepository(db);
corridorRepo = new CorridorRepository(db);
cache = new FakeCache();
await seedConfluence(db, cache as unknown as CacheRepository);
// Seed candle streams for the whole universe + SPY benchmark.
for (const { symbol } of CONFLUENCE_UNIVERSE) {
cache.setValue(`yfinance:candles:${symbol}:1d`, rampUp(300));
cache.setValue(`yfinance:candles:${symbol}:1wk`, rampUp(80, 100, 0.01));
cache.setValue(`yfinance:quote:${symbol}`, { price: 130 } as Quote);
cache.setValue(`yfinance:dividendFundamentals:${symbol}`, { forwardPE: 25, trailingPE: 24, forwardEPS: 5.2, trailingEPS: 5.0, currentPrice: 130 });
}
cache.setValue(`yfinance:candles:SPY:1d`, rampUp(300, 400, 0.001));
cache.setValue(`yfinance:candles:SPY:1wk`, rampUp(80, 400, 0.01));
cache.setValue(`yfinance:quote:SPY`, { price: 440 } as Quote);
cache.setValue(`yfinance:dividendFundamentals:SPY`, { forwardPE: 22, trailingPE: 21.5, forwardEPS: 20, trailingEPS: 19.3, currentPrice: 440 });
});
describe('runConfluenceEvaluationCycle', () => {
it('persists an evaluation + corridor snapshots per symbol, for every system rack', async () => {
const summary = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
// All 15 universe symbols evaluated (SPY benchmark not part of universe).
assert.equal(summary.symbolsEvaluated.length, CONFLUENCE_UNIVERSE.length);
assert.equal(summary.evaluationsStored, CONFLUENCE_UNIVERSE.length * 3, 'one eval per symbol per system rack');
assert.ok(summary.corridorSnapshots >= CONFLUENCE_UNIVERSE.length, 'symbol + SPY corridor snapshots');
// spot-check one symbol persisted its "full" rack evaluation
const pltr = CONFLUENCE_UNIVERSE.find((s) => s.symbol === 'PLTR')!;
const evals = confluenceRepo.listEvaluationsForSymbol(pltr.symbol);
assert.equal(evals.length, 3);
const validQuality = ['strong-bullish', 'moderate-bullish', 'weak-bullish', 'mixed', 'weak-bearish', 'moderate-bearish', 'strong-bearish', 'sparse'];
for (const ev of evals) {
assert.equal(ev.symbol, pltr.symbol);
assert.ok(validQuality.includes(ev.quality), `unexpected quality ${ev.quality}`);
assert.ok(ev.assessedCount >= 10, 'rack has meaningful coverage');
}
// corridor snapshot table populated for at least the spot-checked symbol
const snap = corridorRepo.latestSnapshot(pltr.symbol);
assert.ok(snap, 'corridor snapshot stored');
assert.ok(snap.corridor1yMedian !== null, 'corridor median computed');
});
it('is idempotent per (symbol, asOf, rack): a second run reuses and stores nothing new', async () => {
const first = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
const second = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
assert.equal(second.evaluationsStored, 0);
assert.equal(second.evaluationsReused, first.evaluationsStored);
const all = confluenceRepo.listEvaluationsForSymbol(CONFLUENCE_UNIVERSE[0].symbol);
assert.equal(all.length, 3);
});
it('skips symbols with no cached candles without failing the run', async () => {
cache.setValue(`yfinance:candles:${CONFLUENCE_UNIVERSE[0].symbol}:1d`, [], false);
const summary = await runConfluenceEvaluationCycle(db, cache as unknown as CacheRepository);
assert.equal(summary.symbolsEvaluated.length, CONFLUENCE_UNIVERSE.length - 1);
assert.equal(summary.symbolsSkipped.length, 1);
assert.equal(summary.symbolsSkipped[0].symbol, CONFLUENCE_UNIVERSE[0].symbol);
});
});
@@ -19,8 +19,8 @@ const fired = (id: string): SlotAssessment => ({ id, state: 'fired' });
const notFired = (id: string): SlotAssessment => ({ id, state: 'not-fired' });
describe('confluence catalog', () => {
it('defines exactly 34 slots across all six families', () => {
assert.equal(CONFLUENCE_SLOTS.length, 34);
it('defines slots across all six families in catalog order', () => {
assert.ok(CONFLUENCE_SLOTS.length >= 34);
const families = new Set(CONFLUENCE_SLOTS.map((s) => s.family));
assert.deepEqual([...families].sort(), ['flows', 'institutional', 'macro', 'seasonal', 'sentiment', 'technical']);
});
@@ -15,7 +15,7 @@ import {
BENCHMARK_SYMBOL,
defineSystemRackPresets,
} from '../confluenceSeed.ts';
import { CONFLUENCE_SLOT_IDS } from '../confluenceSlots.ts';
import { CONFLUENCE_SLOTS, CONFLUENCE_SLOT_IDS } from '../confluenceSlots.ts';
let db: ReturnType<typeof createDb>;
let cache: CacheRepository;
@@ -63,19 +63,22 @@ describe('defineSystemRackPresets', () => {
}
});
it('"Full Confluence" uses all34 slots', () => {
it('"Full Confluence" uses every slot in the catalog', () => {
const full = defineSystemRackPresets().find((p) => p.id === 'confluence-full')!;
assert.equal(full.slotIds.length, 34);
assert.equal(full.slotIds.length, CONFLUENCE_SLOT_IDS.length);
});
it('"Technical Momentum" has 15 slots', () => {
it('"Technical Momentum" has only the technical slots', () => {
const tech = defineSystemRackPresets().find((p) => p.id === 'confluence-technical')!;
assert.equal(tech.slotIds.length, 15);
assert.equal(tech.slotIds.length, CONFLUENCE_SLOTS.filter((s) => s.family === 'technical').length);
});
it('"Macro + Flows + Sentiment" has 14 slots', () => {
it('"Macro + Flows + Sentiment" has macro + seasonal + flows + sentiment slots', () => {
const macro = defineSystemRackPresets().find((p) => p.id === 'confluence-macro-flows')!;
assert.equal(macro.slotIds.length, 14);
const expected = CONFLUENCE_SLOTS.filter((s) =>
['macro', 'seasonal', 'flows', 'sentiment'].includes(s.family),
).length;
assert.equal(macro.slotIds.length, expected);
});
});
@@ -0,0 +1,188 @@
// Investor Flow — Confluence Evaluation Engine (M24, slice 5)
//
// The missing backbone of the Confluence Signal Engine: the daily cycle that
// runs every wired slot family's evaluator over a symbol's cached data and
// persists the resulting rack evaluations + slot-fire history.
//
// Each symbol in the confluence universe is resolved once (daily candles,
// weekly candles, SPY benchmark daily, seasonality snapshot, corridor
// snapshots for the symbol and SPY), then every system rack's slot subset is
// sliced out, run through `evaluateRack` (redundancy-aware), and stored via
// `ConfluenceRepository`. Fired slots are logged to signal history for the
// reliability scorecard, and pending fires are resolved against forward prices
//
// ADR-0007: this engine computes description ("the picture is moderate-bullish"
// because X evidence) — it never emits buy/sell directives.
//
// ADR-0009: the engine ONLY reads the shared cache (via CacheCandleProvider and
// corridorData.resolveCorridorSnapshot). Any refreshing is already queued by
// the cache; the engine never touches a vendor directly.
import type { DatabaseSync } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
import type { CacheRepository } from '../cache/CacheRepository.ts';
import { CONFLUENCE_SLOTS } from './confluenceSlots.ts';
import { CacheCandleProvider, type CandleProvider } from './candleProvider.ts';
import { evaluateTechnicalSlots } from './technicalEvaluator.ts';
import { evaluateSeasonalSlots } from './seasonalEvaluator.ts';
import { evaluateCorridorSlots } from './corridorEvaluator.ts';
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';
// ---------------------------------------------------------------------------
// Unwired slots → honest fallback assessments
// ---------------------------------------------------------------------------
/**
* Emit a `not-fired` fallback for every catalog slot the wired evaluators did
* not already cover (currently: macro non-corridor, institutional, flows,
* sentiment). Keeps each rack's slot set fully covered so assessedCount stays
* meaningful, but the fallbacks contribute no evidence to the picture.
*/
function unwiredFallbacks(emitted: SlotAssessment[]): SlotAssessment[] {
const emittedIds = new Set(emitted.map((a) => a.id));
return CONFLUENCE_SLOTS
.filter((s) => !emittedIds.has(s.id))
.map((s) => ({ id: s.id, state: 'not-fired' as const, note: 'No evaluator wired for this slot yet — contributes no evidence to the picture.' }));
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ConfluenceEngineRunSummary {
symbolsEvaluated: string[];
symbolsSkipped: Array<{ symbol: string; reason: string }>;
evaluationsStored: number;
evaluationsReused: number;
signalsLogged: number;
signalsResolved: number;
corridorSnapshots: number;
}
export interface RunOptions {
/** Symbols to evaluate (default: the confluence universe). */
symbols?: string[];
/** Racks to evaluate per symbol (default: system racks). */
racks?: ConfluenceRack[];
/** Force re-evaluation even when a same-asOf evaluation already exists. */
force?: boolean;
/** Evaluation date override (default: the daily resolution asOf). */
asOf?: string;
}
// ---------------------------------------------------------------------------
// Engine
// ---------------------------------------------------------------------------
/**
* Run one full confluence evaluation cycle over the universe.
*
* Per symbol: resolve candles once, run every wired evaluator across the slot
* families, slice each rack's slots, evaluate the rack, persist the evaluation
* (+ signal fires when new), then resolve the pending signal history. Returns a
* summary for logging/tests. Idempotent per (symbol, asOf, rack).
*/
export async function runConfluenceEvaluationCycle(
db: DatabaseSync,
cache: CacheRepository,
opts: RunOptions = {},
): Promise<ConfluenceEngineRunSummary> {
const repo = new ConfluenceRepository(db);
const provider: CandleProvider = new CacheCandleProvider(cache);
const symbols = (opts.symbols?.map((s) => s.toUpperCase()) ?? CONFLUENCE_UNIVERSE.map((s) => s.symbol));
const racks = opts.racks ?? repo.listSystemRacks();
if (racks.length === 0) throw new Error('confluence engine: no system racks seeded');
const summary: ConfluenceEngineRunSummary = {
symbolsEvaluated: [],
symbolsSkipped: [],
evaluationsStored: 0,
evaluationsReused: 0,
signalsLogged: 0,
signalsResolved: 0,
corridorSnapshots: 0,
};
// SPY corridor snapshot is shared across every symbol (market-level slots).
let spyCorridor = await resolveCorridorSnapshot(db, cache, BENCHMARK_SYMBOL, { date: opts.asOf });
if (spyCorridor) summary.corridorSnapshots += 1;
for (const symbol of symbols) {
const daily = await provider.resolve(symbol, '1d');
if (daily.candles.length === 0) {
summary.symbolsSkipped.push({ symbol, reason: 'no daily candles in cache yet' });
continue;
}
const weekly = await provider.resolve(symbol, '1wk');
const benchmarkDaily = await provider.resolve(BENCHMARK_SYMBOL, '1d');
const asOf = opts.asOf ?? daily.asOf;
// Corridor snapshot for the symbol (valuation corridor, entry/upside slots).
let corridor = await resolveCorridorSnapshot(db, cache, symbol, { date: asOf });
if (corridor) summary.corridorSnapshots += 1;
if (spyCorridor === null) spyCorridor = await resolveCorridorSnapshot(db, cache, BENCHMARK_SYMBOL, { date: asOf });
// ----- run every wired family evaluator -----
const wired: SlotAssessment[] = [
...evaluateTechnicalSlots(symbol, daily.candles, {
weekly: weekly.candles,
benchmarkDaily: benchmarkDaily.candles,
}),
...evaluateSeasonalSlots(buildSeasonalitySnapshot(symbol, daily.candles), asOf),
...(corridor ? evaluateCorridorSlots(corridor, spyCorridor) : corridorUnavailableAssessments()),
];
// Cover every catalog slot the wired evaluators left out (honest fallbacks).
const assessments = [...wired, ...unwiredFallbacks(wired)];
// ----- slice per rack, evaluate, persist -----
let symbolStored = 0;
let symbolReused = 0;
for (const rack of racks) {
const rackSlots = new Set(rack.slotIds);
const sliced = assessments.filter((a) => rackSlots.has(a.id));
if (sliced.length === 0) continue;
const existing = repo.getEvaluation(symbol, asOf, rack.id);
if (existing && !opts.force) {
symbolReused += 1;
continue;
}
const evaluation: ConfluenceEvaluation = evaluateRack(symbol, asOf, sliced);
const firesLogged = evaluation.assessments.filter((a) => a.state === 'fired').length;
repo.saveEvaluation(evaluation, rack.id, randomUUID());
repo.logSignalFires(evaluation, rack.id);
symbolStored += 1;
summary.signalsLogged += firesLogged;
}
summary.evaluationsStored += symbolStored;
summary.evaluationsReused += symbolReused;
summary.symbolsEvaluated.push(symbol);
}
// Resolve pending signal history against forward prices.
const resolution = await resolveSignalHistory(db, async (sym) => {
const res = await provider.resolve(sym.toUpperCase(), '1d');
return res.candles;
});
summary.signalsResolved = resolution.resolved.filter((r) => r.verdict !== 'deferred').length;
return summary;
}
/** Six corridor slots, all not-fired with a shared note when no snapshot exists. */
function corridorUnavailableAssessments(): SlotAssessment[] {
return [
'corridorEntryCheap', 'corridorEntryStretched',
'corridorUpsideHigh', 'corridorUpsideLow',
'spyCorridorCheap', 'spyCorridorStretched',
].map((id) => ({ id, state: 'not-fired', note: 'No valuation-corridor snapshot cached for this symbol yet.' }));
}
@@ -35,6 +35,8 @@ export const REDUNDANCY_GROUPS: RedundancyGroup[] = [
{ family: 'institutional', slots: ['instNetActivePositive', 'insiderInformedBuy30d', 'new13da'] },
{ family: 'macro', slots: ['ratesRegime', 'macroRegimeUp'] },
{ family: 'macro', slots: ['consumerSentimentLow', 'breadthThrust'] },
{ family: 'macro', slots: ['corridorEntryCheap', 'corridorUpsideHigh'] },
{ family: 'macro', slots: ['corridorEntryStretched', 'corridorUpsideLow'] },
{ family: 'seasonal', slots: ['seasonalFavorableMonth', 'winterHalfOn', 'electionCycleFavorableYear'] },
{ family: 'flows', slots: ['etfFlowPositive', 'cotPositioning'] },
];
+10 -9
View File
@@ -66,31 +66,32 @@ function familySlots(...families: string[]): string[] {
/**
* Three curated system rack presets. Each is a different lens on the same
* symbol data, expressed as a subset of the 34-slot catalog:
* symbol data, expressed as a subset of the catalog:
*
* 1. "Full Confluence" — all 34 slots (the default every-picture view)
* 2. "Technical Momentum" — the15 technical slots only (price-action focus)
* 3. "Macro + Flows + Sentiment" — macro 5 + seasonal 5 + flows 3 +
* sentiment 1 = 14 slots (the macro/structural lens)
* 1. "Full Confluence" — all slots (the default every-picture view)
* 2. "Technical Momentum" — the 15 technical slots only (price-action focus)
* 3. "Macro + Flows + Sentiment" — macro + seasonal + flows + sentiment
* slots (the macro/structural lens, now including the valuation corridor)
*/
export function defineSystemRackPresets(): RackPreset[] {
const techIds = familySlots('technical');
return [
{
id: 'confluence-full',
name: 'Full Confluence',
description: 'All 34 slots. The broadest evidence view of a symbol\'s picture.',
description: `All ${ALL_IDS.length} slots. The broadest evidence view of a symbol\'s picture.`,
slotIds: [...ALL_IDS],
},
{
id: 'confluence-technical',
name: 'Technical Momentum',
description: 'The 15 technical slots: trend, momentum, mean-reversion, and volume.',
slotIds: familySlots('technical'),
description: `The ${techIds.length} technical slots: trend, momentum, mean-reversion, and volume.`,
slotIds: techIds,
},
{
id: 'confluence-macro-flows',
name: 'Macro + Flows + Sentiment',
description: 'Macro regime, seasonal calendar, ETF/COT flows, and informed-commentator sentiment (14 slots).',
description: 'Macro regime, valuation corridor, seasonal calendar, ETF/COT flows, and informed-commentator sentiment.',
slotIds: familySlots('macro', 'seasonal', 'flows', 'sentiment'),
},
];
+10 -2
View File
@@ -1,6 +1,6 @@
// Investor Flow — Confluence Slot Catalog (M22, slice 2)
//
// The 34-slot confluence inventory for the Confluence Signal Engine. Each slot is
// The 40-slot confluence inventory for the Confluence Signal Engine. Each slot is
// a named, independently-evaluable check whose *firing* state contributes bullish
// or bearish evidence about a symbol's entry/exit quality.
//
@@ -46,7 +46,7 @@ export interface ConfluenceSlot {
explain: string;
}
/** Complete 34-slot confluence catalog in evaluation order. */
/** Complete confluence catalog in evaluation order. */
export const CONFLUENCE_SLOTS: ConfluenceSlot[] = [
// ---------------------------------------------------------------- technical
{ id: 'goldenCross', name: 'Golden Cross', family: 'technical', body: 'bull', granularity: '1wk', explain: 'The 50-window average has crossed above the 200-window average, a widely-watched trend-quality marker.' },
@@ -93,6 +93,14 @@ export const CONFLUENCE_SLOTS: ConfluenceSlot[] = [
// ---------------------------------------------------------------- sentiment
{ id: 'commentatorSentiment', name: 'Informed Commentator Sentiment', family: 'sentiment', body: 'bull', granularity: '1d', explain: 'Informed commentators tracked via the configured sentiment source are net-positive on the symbol in the measurement window.' },
// ----------------------------------------------------------------- corridor
{ id: 'corridorEntryCheap', name: 'Corridor: Entry Cheap', family: 'macro', body: 'bull', granularity: '1d', explain: 'The current P/E sits in the lower third of the 1-year observable valuation corridor, indicating a relatively attractive entry point versus the symbol\'s own history.' },
{ id: 'corridorEntryStretched', name: 'Corridor: Entry Stretched', family: 'macro', body: 'exit', granularity: '1d', explain: 'The current P/E sits in the upper third of the 1-year observable valuation corridor, indicating a stretched valuation versus the symbol\'s own history.' },
{ id: 'corridorUpsideHigh', name: 'Corridor: Upside High', family: 'macro', body: 'bull', granularity: '1d', explain: 'Applying the 1-year median observable multiple to forward earnings implies meaningful upside from the current price.' },
{ id: 'corridorUpsideLow', name: 'Corridor: Upside Low', family: 'macro', body: 'exit', granularity: '1d', explain: 'Applying the 1-year median observable multiple to forward earnings implies meaningful downside from the current price.' },
{ id: 'spyCorridorCheap', name: 'SPY Corridor: Cheap', family: 'macro', body: 'bull', granularity: '1wk', explain: 'The S&P 500 forward P/E sits below its 3-year median, a market-level valuation tailwind that improves the odds for broad equity exposure.' },
{ id: 'spyCorridorStretched', name: 'SPY Corridor: Stretched', family: 'macro', body: 'exit', granularity: '1wk', explain: 'The S&P 500 forward P/E sits at or above its 3-year median, a market-level valuation headwind that tempers the broad-equity picture.' },
];
/** Indexed by slot id for O(1) lookup. */
+279
View File
@@ -0,0 +1,279 @@
// Investor Flow — Price Corridor data pipeline (M24, slice 2)
//
// The Corridor Method (as reverse-engineered from @alojoh's weekly "U.S. Tech
// Coverage / Market Valuation" reports): a symbol's *observable multiple* range
// over a lookback window defines a valuation corridor. The current P/E position
// within that corridor signals entry timing (cheap near the low band, stretched
// near the high band), and applying the corridor's median multiple to forward
// EPS derives an implied fair value / upside.
//
// ADR-0007: this is a valuation-context seam, never a buy/sell directive. It
// computes where price sits relative to its own historical valuation corridor.
//
// Pure where possible: `computeCorridor`, `percentileIndex`, `buildSnapshot`
// are pure; the cache-backed `resolveCorridorSnapshot` is a thin shim over the
// shared cache (quote + candles + dividend fundamentals) — no direct vendor
// I/O here (ADR-0009: everything funnels through the cache / adapter queue).
import type { DatabaseSync } from 'node:sqlite';
import type { CacheRepository, PriceCandle, Quote } from '../cache/CacheRepository.ts';
import type { CorridorSnapshot } from '../db/corridorRepository.ts';
import { CorridorRepository } from '../db/corridorRepository.ts';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Trading days in the 1-year observable window. */
export const CORRIDOR_1Y_BARS = 252;
/** Trading days in the 90-day observable window. */
export const CORRIDOR_90D_BARS = 63;
/** Trading days in the 3-year market-level window (S&P 500 context). */
export const CORRIDOR_3Y_BARS = 756;
/** Fraction of the 1y corridor below which the entry is "cheap". */
export const ENTRY_CHEAP_PERCENTILE = 0.33;
/** Fraction above which the entry is "stretched". */
export const ENTRY_STRETCHED_PERCENTILE = 0.67;
/** Implied upside (1y median reversion) above which the upside slot fires. */
export const UPSIDE_HIGH_THRESHOLD = 0.15;
/** Implied downside below which the downside slot fires. */
export const UPSIDE_LOW_THRESHOLD = -0.10;
/** Window (days) used by the corridor-method backtest grader. */
export const BACKTEST_HORIZON_DAYS = 7;
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
/** Median of a numeric array (finite elements); null when empty. */
export function median(values: number[]): number | null {
const finite = values.filter((v) => Number.isFinite(v)).sort((a, b) => a - b);
if (finite.length === 0) return null;
const mid = Math.floor(finite.length / 2);
return finite.length % 2 === 0 ? (finite[mid - 1] + finite[mid]) / 2 : finite[mid];
}
/**
* The fractional rank (0..1) of `value` within `series`: the fraction of
* `series` elements at or below `value`. Returns null when series is empty.
* Pure.
*/
export function percentileIndex(value: number, series: number[]): number | null {
const finite = series.filter((v) => Number.isFinite(v));
if (finite.length === 0) return null;
const below = finite.filter((v) => v <= value).length;
return below / finite.length;
}
/** A computed corridor window. Pure. */
export interface CorridorWindow {
high: number | null;
low: number | null;
median: number | null;
}
/**
* Compute a P/E corridor window from a lookback slice of a P/E series.
* `series` is the full series (oldest → newest); `bars` is the window size.
* Pure.
*/
export function computeCorridorWindow(series: number[], bars: number): CorridorWindow {
const slice = series.length >= bars ? series.slice(series.length - bars) : series.slice();
const finite = slice.filter((v) => Number.isFinite(v));
if (finite.length === 0) return { high: null, low: null, median: null };
return {
high: Math.max(...finite),
low: Math.min(...finite),
median: median(finite),
};
}
/**
* Build a full corridor snapshot from a trailing P/E series and current prices.
* Pure. `peSeries` is the trailing P/E series (oldest → newest); the latest
* value is treated as the current P/E position.
*/
export function computeCorridor(
peSeries: number[],
currentPrice: number,
forwardEPS: number | null,
trailingEPS: number | null,
trailingPE: number | null,
forwardPE: number | null,
): Omit<CorridorSnapshot, 'symbol' | 'snapshotDate' | 'dataSource' | 'createdAt'> {
const currentPE = peSeries.length > 0 ? peSeries[peSeries.length - 1] : trailingPE ?? NaN;
const w1y = computeCorridorWindow(peSeries, CORRIDOR_1Y_BARS);
const w90d = computeCorridorWindow(peSeries, CORRIDOR_90D_BARS);
const fairValue1y = forwardEPS !== null && forwardEPS > 0 && w1y.median !== null ? forwardEPS * w1y.median : null;
const fairValue90d = forwardEPS !== null && forwardEPS > 0 && w90d.median !== null ? forwardEPS * w90d.median : null;
return {
forwardPE,
trailingPE,
forwardEPS,
trailingEPS,
corridor1yHigh: w1y.high,
corridor1yLow: w1y.low,
corridor1yMedian: w1y.median,
corridor90dHigh: w90d.high,
corridor90dLow: w90d.low,
corridor90dMedian: w90d.median,
fairValue1y,
fairValue90d,
impliedUpside1y:
currentPrice > 0 && fairValue1y !== null ? fairValue1y / currentPrice - 1 : null,
impliedUpside90d:
currentPrice > 0 && fairValue90d !== null ? fairValue90d / currentPrice - 1 : null,
pePercentile1y:
Number.isFinite(currentPE) ? percentileIndex(currentPE, peSeries.slice(Math.max(0, peSeries.length - CORRIDOR_1Y_BARS))) : null,
pePercentile90d:
Number.isFinite(currentPE) ? percentileIndex(currentPE, peSeries.slice(Math.max(0, peSeries.length - CORRIDOR_90D_BARS))) : null,
currentPrice,
};
}
/** Derive a trailing P/E series from closes over `eps`. Pure. */
export function trailingPeSeries(candles: PriceCandle[], eps: number): number[] {
if (!eps || eps <= 0) return [];
return candles
.map((c) => (Number.isFinite(c.c) && c.c > 0 ? c.c / eps : NaN))
.filter((v) => Number.isFinite(v));
}
// ---------------------------------------------------------------------------
// Cache-backed resolution seam
// ---------------------------------------------------------------------------
export interface CorridorFundamentalsInput {
price: number | null;
forwardPE: number | null;
trailingPE: number | null;
forwardEPS: number | null;
trailingEPS: number | null;
}
/** Paper a quote + dividend-fundamentals cache row into a corridor input. Pure. */
export function fundamentalsFrom(quote: Quote | null, div: unknown): CorridorFundamentalsInput {
const f = (div as Record<string, unknown> | null) ?? {};
const num = (v: unknown): number | null => {
if (v === null || v === undefined || typeof v === 'string' && v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const forwardPE = num(f.forwardPE);
const trailingPE = num(f.trailingPE);
const forwardEPS = num(f.forwardEPS);
const trailingEPS = num(f.trailingEPS);
return {
price: quote?.price != null && Number.isFinite(quote.price) ? quote.price : null,
forwardPE,
trailingPE,
forwardEPS,
// Prefer an explicit trailing EPS; else fall back to price / trailingPE.
trailingEPS: trailingEPS ?? (trailingPE && trailingPE > 0 && quote?.price ? quote.price / trailingPE : null),
};
}
/**
* Resolve + store a fresh corridor snapshot for `symbol` from the shared cache.
* Uses the trailing P/E bootstrap: the trailing EPS drives the historical P/E
* series immediately; forward PE/EPS enrich it when available. Idempotent per
* (symbol, snapshotDate). Returns the stored snapshot.
*/
export async function resolveCorridorSnapshot(
db: DatabaseSync,
cache: CacheRepository,
symbol: string,
opts: { date?: string } = {},
): Promise<CorridorSnapshot | null> {
const sym = symbol.toUpperCase();
const repo = new CorridorRepository(db);
const snapshotDate = opts.date ?? new Date().toISOString().slice(0, 10);
const quoteEntry = await cache.get<Quote>(`yfinance:quote:${sym}`);
const divEntry = await cache.get<Record<string, unknown>>(`yfinance:dividendFundamentals:${sym}`);
const candleEntry = await cache.get<PriceCandle[]>(`yfinance:candles:${sym}:1d`);
const candles = (candleEntry?.value ?? []) as PriceCandle[];
const f = fundamentalsFrom(quoteEntry?.value ?? null, divEntry?.value ?? null);
if (f.price === null && candles.length === 0) return null;
const price = f.price ?? (candles.length > 0 ? candles[candles.length - 1].c : NaN);
const peSeries = f.trailingEPS !== null && f.trailingEPS > 0
? trailingPeSeries(candles, f.trailingEPS)
: [];
const computed = computeCorridor(
peSeries,
price,
f.forwardEPS,
f.trailingEPS,
f.trailingPE ?? (f.trailingEPS && f.trailingEPS > 0 && price > 0 ? price / f.trailingEPS : null),
f.forwardPE,
);
// If no trailing EPS produced a real P/E series, compute one from forward PE.
const snapshot: Omit<CorridorSnapshot, 'id'> = {
symbol: sym,
snapshotDate,
...computed,
dataSource: f.forwardPE !== null ? 'yfinance' : 'bootstrap_trailing',
createdAt: new Date().toISOString(),
};
if (!Number.isFinite(snapshot.currentPrice) && candles.length > 0) {
snapshot.currentPrice = candles[candles.length - 1].c;
}
repo.saveSnapshot(snapshot);
return repo.latestSnapshot(sym);
}
/**
* Compute a price-only corridor as a fallback when no EPS is available
* (e.g. funds / unfamiliar tickers). Uses close-price percentiles instead of
* P/E percentiles to still give a "where is price vs its own range" read.
* Pure.
*/
export function priceOnlySnapshot(
candles: PriceCandle[],
currentPrice: number,
snapshotDate: string,
symbol: string,
): Omit<CorridorSnapshot, 'id'> | null {
if (candles.length === 0 || !Number.isFinite(currentPrice)) return null;
const closes = candles.map((c) => c.c).filter((v) => Number.isFinite(v));
if (closes.length === 0) return null;
const w1y = computeCorridorWindow(closes, CORRIDOR_1Y_BARS);
const w90d = computeCorridorWindow(closes, CORRIDOR_90D_BARS);
return {
symbol,
snapshotDate,
currentPrice,
forwardPE: null,
trailingPE: null,
forwardEPS: null,
trailingEPS: null,
corridor1yHigh: w1y.high,
corridor1yLow: w1y.low,
corridor1yMedian: w1y.median,
corridor90dHigh: w90d.high,
corridor90dLow: w90d.low,
corridor90dMedian: w90d.median,
fairValue1y: null,
fairValue90d: null,
impliedUpside1y: null,
impliedUpside90d: null,
pePercentile1y:
Number.isFinite(currentPrice) ? percentileIndex(currentPrice, closes.slice(Math.max(0, closes.length - CORRIDOR_1Y_BARS))) : null,
pePercentile90d:
Number.isFinite(currentPrice) ? percentileIndex(currentPrice, closes.slice(Math.max(0, closes.length - CORRIDOR_90D_BARS))) : null,
dataSource: 'bootstrap_trailing',
createdAt: new Date().toISOString(),
};
}
@@ -0,0 +1,155 @@
// Investor Flow — Price Corridor slot evaluator (M24, slice 3)
//
// Pure, snapshot-based assessments for the six `macro` corridor confluence
// slots. Consumes the valuation-corridor snapshot computed by corridorData.ts
// and, for the market-level slots, the SPY snapshot. Every function is a pure
// (snapshot) → SlotAssessment[] mapping with ADR-0007 evidence notes — never a
// recommendation.
//
// Distinct from the candle-driven technical evaluators: corridor slots read the
// observable-multiple corridor, so they carry valuation evidence over and above
// price-action evidence in the rack's picture.
import type { CorridorSnapshot } from '../db/corridorRepository.ts';
import type { SlotAssessment } from './confluenceRack.ts';
import {
ENTRY_CHEAP_PERCENTILE,
ENTRY_STRETCHED_PERCENTILE,
UPSIDE_HIGH_THRESHOLD,
UPSIDE_LOW_THRESHOLD,
CORRIDOR_3Y_BARS,
computeCorridorWindow,
} from './corridorData.ts';
/** 3-year median forward-P/E reference for the S&P 500 (from @alojoh's reports:
* 18.9x trough, 23.1x peak, ~20.5x median over the last three years). Used only
* as the anchoring bench when a full 3y series is unavailable. */
export const SPY_3Y_MEDIAN_REFERENCE = 20.5;
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function assessment(id: string, state: 'fired' | 'not-fired', note: string): SlotAssessment {
return { id, state, note };
}
/**
* Build the six price-corridor slot assessments for a symbol's snapshot.
* `spySnapshot` supplies the market-level slots (SPY corridor vs its own 3y
* median). Pure.
*/
export function evaluateCorridorSlots(
snapshot: CorridorSnapshot,
spySnapshot: CorridorSnapshot | null,
): SlotAssessment[] {
const out: SlotAssessment[] = [];
const pct1y = snapshot.pePercentile1y === null ? null : Number(snapshot.pePercentile1y);
const pct90d = snapshot.pePercentile90d === null ? null : Number(snapshot.pePercentile90d);
// ----- corridor: entry cheap / stretched (1-year observable multiple) -----
if (pct1y === null) {
out.push(assessment('corridorEntryCheap', 'not-fired', 'No 1-year valuation corridor available for this symbol yet.'));
out.push(assessment('corridorEntryStretched', 'not-fired', 'No 1-year valuation corridor available for this symbol yet.'));
} else {
if (pct1y < ENTRY_CHEAP_PERCENTILE) {
out.push(assessment(
'corridorEntryCheap',
'fired',
`Current P/E is in the lower ${(ENTRY_CHEAP_PERCENTILE * 100).toFixed(0)}% of its 1-year observable corridor (${(pct1y * 100).toFixed(0)}th percentile), indicating a relatively attractive entry point.`,
));
out.push(assessment('corridorEntryStretched', 'not-fired', `Current P/E sits at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year corridor.`));
} else if (pct1y > ENTRY_STRETCHED_PERCENTILE) {
out.push(assessment(
'corridorEntryStretched',
'fired',
`Current P/E is in the upper ${((1 - ENTRY_STRETCHED_PERCENTILE) * 100).toFixed(0)}% of its 1-year observable corridor (${(pct1y * 100).toFixed(0)}th percentile), indicating a stretched valuation vs its own history.`,
));
out.push(assessment('corridorEntryCheap', 'not-fired', `Current P/E sits at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year corridor.`));
} else {
out.push(assessment('corridorEntryCheap', 'not-fired', `Current P/E sits mid-corridor at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year range.`));
out.push(assessment('corridorEntryStretched', 'not-fired', `Current P/E sits mid-corridor at ${(pct1y * 100).toFixed(0)}th percentile of its 1-year range.`));
}
}
// ----- corridor: implied upside / downside (median-multiple reversion) -----
const upside1y = snapshot.impliedUpside1y === null ? null : Number(snapshot.impliedUpside1y);
if (upside1y === null) {
out.push(assessment('corridorUpsideHigh', 'not-fired', 'No forward EPS / median-multiple fair value available to quantify implied upside.'));
out.push(assessment('corridorUpsideLow', 'not-fired', 'No forward EPS / median-multiple fair value available to quantify implied downside.'));
} else {
if (upside1y > UPSIDE_HIGH_THRESHOLD) {
out.push(assessment(
'corridorUpsideHigh',
'fired',
`Applying the 1-year median multiple to forward earnings implies ${(upside1y * 100).toFixed(1)}% upside from the current price.`,
));
out.push(assessment('corridorUpsideLow', 'not-fired', `1-year median-multiple fair value is ${(upside1y * 100).toFixed(1)}% vs current price.`));
} else if (upside1y < UPSIDE_LOW_THRESHOLD) {
out.push(assessment(
'corridorUpsideLow',
'fired',
`Applying the 1-year median multiple to forward earnings implies ${(upside1y * 100).toFixed(1)}% downside from the current price.`,
));
out.push(assessment('corridorUpsideHigh', 'not-fired', `1-year median-multiple fair value is ${(upside1y * 100).toFixed(1)}% vs current price.`));
} else {
out.push(assessment('corridorUpsideHigh', 'not-fired', `1-year median-multiple fair value implies ${(upside1y * 100).toFixed(1)}% vs current price.`));
out.push(assessment('corridorUpsideLow', 'not-fired', `1-year median-multiple fair value implies ${(upside1y * 100).toFixed(1)}% vs current price.`));
}
}
// ----- market level: SPY 3-year corridor (valuation tailwind / headwind) -----
out.push(...spyCorridorAssessments(spySnapshot));
return out;
}
/**
* Market-level SPY corridor slots: is the broad market cheap or stretched
* relative to its own 3-year forward-P/E corridor? Accepts either a stored SPY
* snapshot (preferred) or a raw close-price series fallback. Pure.
*/
export function spyCorridorAssessments(spySnapshot: CorridorSnapshot | null): SlotAssessment[] {
if (!spySnapshot) {
return [
assessment('spyCorridorCheap', 'not-fired', 'No SPY valuation-corridor snapshot available for the market-level context.'),
assessment('spyCorridorStretched', 'not-fired', 'No SPY valuation-corridor snapshot available for the market-level context.'),
];
}
const pe = spySnapshot.forwardPE ?? spySnapshot.trailingPE ?? null;
const cheap = pe !== null && pe < SPY_3Y_MEDIAN_REFERENCE;
const peLabel = pe !== null ? pe.toFixed(1) : 'n/a';
if (cheap) {
return [
assessment(
'spyCorridorCheap',
'fired',
`S&P 500 forward P/E (${peLabel}x) is below the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x), a market-level valuation tailwind.`,
),
assessment('spyCorridorStretched', 'not-fired', `S&P 500 forward P/E (${peLabel}x) is below the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x).`),
];
}
return [
assessment('spyCorridorCheap', 'not-fired', `S&P 500 forward P/E (${peLabel}x) is not below the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x).`),
assessment(
'spyCorridorStretched',
'fired',
`S&P 500 forward P/E (${peLabel}x) is at or above the 3-year median (~${SPY_3Y_MEDIAN_REFERENCE}x), a market-level valuation headwind.`,
),
];
}
/**
* 3-year corridor window over a raw close-price series (for the SPY chart and
* the market-level slot when only prices are cached). Pure.
*/
export function priceCorridor3y(candles: Array<{ c: number }>): { high: number | null; low: number | null; median: number | null } {
const closes = candles.map((c) => c.c).filter((v) => Number.isFinite(v));
return computeCorridorWindow(closes, CORRIDOR_3Y_BARS);
}
/** Convenience re-export so corridor consumers share a single percentile helper. */
export { percentileIndex } from './corridorData.ts';