// Investor Flow — Candle Resolution Seam for confluence (M22, slice 8) // // CANDLE_PROVIDER: the single way confluence slot evaluators obtain a symbol's // price history. It resolves daily or weekly candles from the shared cache // (`yfinance:candles::`) and optionally folds the freshest // live quote into the series so a mid-session evaluation sees the current price // instead of only the last EOD close. // // Why a seam instead of calling `cache.get` inline: // • evaluators stay testable against fake candle streams, // • one place owns "what does confluence mean by candles" (sorted ascending, // quote fold-in rules, staleness), so a future realtime/replay source can // slot in without touching any slot logic. // // ADR-0007: this is a data seam. It resolves price history; it never emits a // directive. `asOf` on the resolution is the effective evaluation date. // // Pure where possible: `foldRealtimeBar` is a pure function; the cache-backed // provider is a thin shim over CacheRepository. import type { CacheRepository, PriceCandle, Quote } from '../cache/CacheRepository.ts'; import type { SlotGranularity } from './confluenceSlots.ts'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Where the last bar of the resolved series came from. */ export type BarProvenance = 'yfinance' | 'realtime'; /** A symbol's resolved candle series for one granularity. */ export interface CandleResolution { symbol: string; granularity: SlotGranularity; /** Candles sorted ascending by ts. May include a folded-in realtime bar. */ candles: PriceCandle[]; /** Effective evaluation date (YYYY-MM-DD) = last bar ts, or quote date when folded. */ asOf: string; /** Last-bar provenance: folded live quote vs stored EOD bar. */ lastBar: BarProvenance; /** True when the folded realtime bar was appended/updated (not a stored bar). */ realtimeFolded: boolean; /** True when the underlying cached series is absent or past its freshness window. */ isStale: boolean; } /** * Candle-resolution seam for confluence evaluators and the slot engine. * * `resolve` must return candles sorted ascending by ts. Implementations may be * cache-backed (CacheCandleProvider), precomputed fixtures (tests), or a future * realtime source — evaluators must not care which. */ export interface CandleProvider { resolve(symbol: string, granularity: SlotGranularity): Promise; /** * 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; } // --------------------------------------------------------------------------- // Pure helpers // --------------------------------------------------------------------------- /** * Fold the freshest live quote into a daily series. * * Rules: * • no quote / non-finite price ⇒ unchanged * • last bar is already `today` ⇒ its close is replaced with the live price * (O/L/H expanded to contain the print); keeps bar count stable * • last bar is before `today` ⇒ a new bar for `today` is appended with the * quote price (O = last close, L/H bracketing it, V = 0) * * Returns the new array plus whether anything was folded. Pure. */ export function foldRealtimeBar( candles: PriceCandle[], quote: Quote | null | undefined, todayIso: string, ): { candles: PriceCandle[]; folded: boolean } { if (!quote?.price || !Number.isFinite(quote.price) || candles.length === 0) { return { candles, folded: false }; } const last = candles[candles.length - 1]; const lastTs = (last.ts ?? '').slice(0, 10); const price = quote.price; if (lastTs === todayIso) { const updated: PriceCandle = { ts: last.ts, o: last.o, h: Math.max(last.h, price), l: Math.min(last.l, price), c: price, v: last.v, adjClose: last.adjClose, }; return { candles: [...candles.slice(0, -1), updated], folded: true }; } if (lastTs < todayIso) { const o = last.c; return { candles: [ ...candles, { ts: todayIso, o, h: Math.max(o, price), l: Math.min(o, price), c: price, v: 0 }, ], folded: true, }; } return { candles, folded: false }; } /** Current date as YYYY-MM-DD in US/Eastern (the market session's clock). */ export function todayIso(now: Date = new Date()): string { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(now); const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ''; return `${get('year')}-${get('month')}-${get('day')}`; } // --------------------------------------------------------------------------- // Cache-backed provider // --------------------------------------------------------------------------- /** * Cache-backed CandleProvider. Reads `yfinance:candles::` * from the shared cache; for daily granularity it folds the freshest quote in * when the quote is newer than the last stored bar. */ export class CacheCandleProvider implements CandleProvider { private readonly _cache: CacheRepository; constructor(cache: CacheRepository) { this._cache = cache; } async resolve(symbol: string, granularity: SlotGranularity): Promise { const sym = symbol.toUpperCase(); const entry = await this._cache.get(`yfinance:candles:${sym}:${granularity}`); const stored = (entry?.value ?? []).slice(); const isStale = entry?.isStale ?? true; let candles = stored; let realtimeFolded = false; let lastBar: BarProvenance = 'yfinance'; if (granularity === '1d') { const quoteEntry = await this._cache.get(`yfinance:quote:${sym}`); const quote = quoteEntry?.value; const fold = foldRealtimeBar(candles, quote, todayIso()); if (fold.folded && fold.candles.length > 0) { candles = fold.candles; realtimeFolded = true; lastBar = 'realtime'; } } const lastTs = candles.length > 0 ? (candles[candles.length - 1].ts ?? '').slice(0, 10) : ''; const asOf = lastTs || todayIso(); return { symbol: sym, granularity, candles, asOf, lastBar, realtimeFolded, isStale, }; } async resolveAsOf(symbol: string, granularity: SlotGranularity, asOf: string): Promise { const sym = symbol.toUpperCase(); const cutoff = asOf.slice(0, 10); const entry = await this._cache.get(`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, }; } }