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.
199 lines
7.2 KiB
TypeScript
199 lines
7.2 KiB
TypeScript
// 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:<symbol>:<granularity>`) 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<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>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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:<symbol>:<granularity>`
|
|
* 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<CandleResolution> {
|
|
const sym = symbol.toUpperCase();
|
|
const entry = await this._cache.get<PriceCandle[]>(`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<Quote>(`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<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,
|
|
};
|
|
}
|
|
} |