Restore continuous live tape history

This commit is contained in:
dirtydishes 2026-05-07 00:39:26 -04:00
parent d81b4c0cfb
commit 034d24f8ac
5 changed files with 483 additions and 117 deletions

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { getSubscriptionKey as getLiveSubscriptionKey } from "@islandflow/types";
import { import {
NAV_ITEMS, NAV_ITEMS,
appendHistoryTail, appendHistoryTail,
@ -10,10 +11,12 @@ import {
formatOptionContractLabel, formatOptionContractLabel,
flushPausableTapeData, flushPausableTapeData,
getAlertWindowAnchorTs, getAlertWindowAnchorTs,
getScopedLiveAutoHydrationChannels,
getLiveHistoryRetentionCap, getLiveHistoryRetentionCap,
getOptionTableSnapshot, getOptionTableSnapshot,
getLiveFeedStatus, getLiveFeedStatus,
getLiveManifest, getLiveManifest,
mergeNewestWithOverflow,
normalizeAlertSeverity, normalizeAlertSeverity,
nextFlowFilterPopoverState, nextFlowFilterPopoverState,
projectPausableTapeState, projectPausableTapeState,
@ -243,6 +246,36 @@ describe("live tape pausable helpers", () => {
}); });
describe("live tape history helpers", () => { describe("live tape history helpers", () => {
it("promotes hot-window overflow into the history tail", () => {
const currentHot = [makeItem("hot-3", 3, 300), makeItem("hot-2", 2, 200), makeItem("hot-1", 1, 100)];
const incoming = [makeItem("hot-4", 4, 400)];
const { kept, evicted } = mergeNewestWithOverflow(incoming, currentHot, 3);
const nextHistory = appendHistoryTail([], evicted, kept, 5000);
expect(kept.map((item) => item.trace_id)).toEqual(["hot-4", "hot-3", "hot-2"]);
expect(nextHistory.map((item) => item.trace_id)).toEqual(["hot-1"]);
});
it("keeps the combined tape continuous beyond the hot live window", () => {
let hot: Array<ReturnType<typeof makeItem>> = [];
let history: Array<ReturnType<typeof makeItem>> = [];
for (let seq = 1; seq <= 5; seq += 1) {
const { kept, evicted } = mergeNewestWithOverflow([makeItem(`row-${seq}`, seq, seq * 100)], hot, 2);
hot = kept;
history = appendHistoryTail(history, evicted, hot, 5000);
}
expect([...hot, ...history].map((item) => item.trace_id)).toEqual([
"row-5",
"row-4",
"row-3",
"row-2",
"row-1"
]);
});
it("appends older scoped rows behind the hot live head", () => { it("appends older scoped rows behind the hot live head", () => {
const liveHead = Array.from({ length: 100 }, (_, idx) => const liveHead = Array.from({ length: 100 }, (_, idx) =>
makeItem(`hot-${idx}`, 200 - idx, 2_000 - idx) makeItem(`hot-${idx}`, 200 - idx, 2_000 - idx)
@ -263,6 +296,16 @@ describe("live tape history helpers", () => {
expect(next.map((item) => item.trace_id)).toEqual(["older"]); expect(next.map((item) => item.trace_id)).toEqual(["older"]);
}); });
it("dedupes the seam between promoted overflow and fetched history", () => {
const currentHot = [makeItem("hot-3", 3, 300), makeItem("hot-2", 2, 200), makeItem("hot-1", 1, 100)];
const { kept, evicted } = mergeNewestWithOverflow([makeItem("hot-4", 4, 400)], currentHot, 3);
const promoted = appendHistoryTail([], evicted, kept, 5000);
const merged = appendHistoryTail(promoted, [makeItem("hot-1", 1, 100), makeItem("older", 0, 50)], kept, 5000);
expect(merged.map((item) => item.trace_id)).toEqual(["hot-1", "older"]);
expect(new Set([...kept, ...merged].map((item) => item.trace_id)).size).toBe(kept.length + merged.length);
});
it("trims the history tail to the soft cap", () => { it("trims the history tail to the soft cap", () => {
const current = [makeItem("existing", 4, 400)]; const current = [makeItem("existing", 4, 400)];
const older = [makeItem("older-1", 3, 300), makeItem("older-2", 2, 200)]; const older = [makeItem("older-1", 3, 300), makeItem("older-2", 2, 200)];
@ -287,6 +330,38 @@ describe("live tape history helpers", () => {
} as any) } as any)
).toBeGreaterThan(0); ).toBeGreaterThan(0);
}); });
it("keeps auto-hydrating scoped live history while next_before exists", () => {
const manifest = getLiveManifest(
"/tape",
"AAPL",
60000,
buildDefaultFlowFilters(),
{
underlying_ids: ["AAPL"],
option_contract_id: "AAPL-2025-01-17-200-C"
},
{ underlying_ids: ["AAPL"] }
);
const historyCursors = Object.fromEntries(
manifest.map((subscription) => [getLiveSubscriptionKey(subscription), { ts: 1, seq: 1 }])
);
expect(
getScopedLiveAutoHydrationChannels(true, "/tape", manifest, historyCursors, {})
).toEqual(["options", "equities"]);
expect(
getScopedLiveAutoHydrationChannels(true, "/tape", manifest, historyCursors, {
[getLiveSubscriptionKey(manifest.find((subscription) => subscription.channel === "options")!)]: true
})
).toEqual(["equities"]);
expect(
getScopedLiveAutoHydrationChannels(true, "/tape", manifest, {
...historyCursors,
[getLiveSubscriptionKey(manifest.find((subscription) => subscription.channel === "equities")!)]: null
}, {})
).toEqual(["options"]);
});
}); });
describe("options display formatters", () => { describe("options display formatters", () => {

View file

@ -368,15 +368,15 @@ const buildItemKey = (item: SortableItem): string | null => {
return null; return null;
}; };
const mergeNewest = <T extends SortableItem>( export const mergeNewestWithOverflow = <T extends SortableItem>(
incoming: T[], incoming: T[],
existing: T[], existing: T[],
limit = LIVE_HOT_WINDOW, limit = LIVE_HOT_WINDOW,
onTrim?: (evicted: number) => void onTrim?: (evicted: number) => void
): T[] => { ): { kept: T[]; evicted: T[] } => {
const combined = [...incoming, ...existing]; const combined = [...incoming, ...existing];
if (combined.length === 0) { if (combined.length === 0) {
return combined; return { kept: combined, evicted: [] };
} }
const seen = new Set<string>(); const seen = new Set<string>();
@ -402,12 +402,24 @@ const mergeNewest = <T extends SortableItem>(
}); });
const safeLimit = Math.max(1, Math.floor(limit)); const safeLimit = Math.max(1, Math.floor(limit));
const evicted = Math.max(0, deduped.length - safeLimit); const evicted = deduped.slice(safeLimit);
if (evicted > 0) { if (evicted.length > 0) {
onTrim?.(evicted); onTrim?.(evicted.length);
} }
return deduped.slice(0, safeLimit); return {
kept: deduped.slice(0, safeLimit),
evicted
};
};
const mergeNewest = <T extends SortableItem>(
incoming: T[],
existing: T[],
limit = LIVE_HOT_WINDOW,
onTrim?: (evicted: number) => void
): T[] => {
return mergeNewestWithOverflow(incoming, existing, limit, onTrim).kept;
}; };
const getTapeItemKey = (item: SortableItem): string => { const getTapeItemKey = (item: SortableItem): string => {
@ -520,25 +532,27 @@ export const appendHistoryTail = <T extends SortableItem>(
return current; return current;
} }
const seen = new Set<string>(); const seen = new Set<string>(liveHead.map((item) => getTapeItemKey(item)));
for (const item of liveHead) { const combined: T[] = [];
seen.add(getTapeItemKey(item));
}
for (const item of current) {
seen.add(getTapeItemKey(item));
}
const appended = [...current]; for (const item of [...current, ...incoming]) {
for (const item of incoming) {
const key = getTapeItemKey(item); const key = getTapeItemKey(item);
if (seen.has(key)) { if (seen.has(key)) {
continue; continue;
} }
seen.add(key); seen.add(key);
appended.push(item); combined.push(item);
} }
return cap > 0 ? appended.slice(0, cap) : appended; combined.sort((a, b) => {
const delta = extractSortTs(b) - extractSortTs(a);
if (delta !== 0) {
return delta;
}
return extractSortSeq(b) - extractSortSeq(a);
});
return cap > 0 ? combined.slice(0, cap) : combined;
}; };
export const getLiveHistoryRetentionCap = (subscription: LiveSubscription): number => { export const getLiveHistoryRetentionCap = (subscription: LiveSubscription): number => {
@ -551,6 +565,36 @@ export const getLiveHistoryRetentionCap = (subscription: LiveSubscription): numb
} }
}; };
export const getScopedLiveAutoHydrationChannels = (
enabled: boolean,
pathname: string,
manifest: LiveSubscription[],
historyCursors: Partial<Record<string, Cursor | null>>,
historyLoading: Partial<Record<string, boolean>>
): Array<Extract<LiveSubscription["channel"], "options" | "equities">> => {
if (!enabled || pathname !== "/tape") {
return [];
}
const channels: Array<Extract<LiveSubscription["channel"], "options" | "equities">> = [];
for (const subscription of manifest) {
const scoped =
(subscription.channel === "options" &&
(subscription.underlying_ids?.length || subscription.option_contract_id)) ||
(subscription.channel === "equities" && subscription.underlying_ids?.length);
if (!scoped) {
continue;
}
const key = getLiveSubscriptionKey(subscription);
if (historyCursors[key] && !historyLoading[key]) {
channels.push(subscription.channel);
}
}
return channels;
};
export const getLiveFeedStatus = ( export const getLiveFeedStatus = (
sourceStatus: WsStatus, sourceStatus: WsStatus,
freshestTs: number | null, freshestTs: number | null,
@ -2544,6 +2588,27 @@ const useLiveSession = (
const [inferredDarkHistory, setInferredDarkHistory] = useState<InferredDarkEvent[]>([]); const [inferredDarkHistory, setInferredDarkHistory] = useState<InferredDarkEvent[]>([]);
const [chartCandles, setChartCandles] = useState<EquityCandle[]>([]); const [chartCandles, setChartCandles] = useState<EquityCandle[]>([]);
const [chartOverlay, setChartOverlay] = useState<EquityPrint[]>([]); const [chartOverlay, setChartOverlay] = useState<EquityPrint[]>([]);
const optionsRef = useRef<OptionPrint[]>([]);
const nbboRef = useRef<OptionNBBO[]>([]);
const equitiesRef = useRef<EquityPrint[]>([]);
const equityQuotesRef = useRef<EquityQuote[]>([]);
const equityJoinsRef = useRef<EquityPrintJoin[]>([]);
const flowRef = useRef<FlowPacket[]>([]);
const smartMoneyRef = useRef<SmartMoneyEvent[]>([]);
const classifierHitsRef = useRef<ClassifierHitEvent[]>([]);
const alertsRef = useRef<AlertEvent[]>([]);
const inferredDarkRef = useRef<InferredDarkEvent[]>([]);
const chartCandlesRef = useRef<EquityCandle[]>([]);
const chartOverlayRef = useRef<EquityPrint[]>([]);
const optionsHistoryRef = useRef<OptionPrint[]>([]);
const nbboHistoryRef = useRef<OptionNBBO[]>([]);
const equitiesHistoryRef = useRef<EquityPrint[]>([]);
const equityJoinsHistoryRef = useRef<EquityPrintJoin[]>([]);
const flowHistoryRef = useRef<FlowPacket[]>([]);
const smartMoneyHistoryRef = useRef<SmartMoneyEvent[]>([]);
const classifierHitsHistoryRef = useRef<ClassifierHitEvent[]>([]);
const alertsHistoryRef = useRef<AlertEvent[]>([]);
const inferredDarkHistoryRef = useRef<InferredDarkEvent[]>([]);
const socketRef = useRef<WebSocket | null>(null); const socketRef = useRef<WebSocket | null>(null);
const reconnectRef = useRef<number | null>(null); const reconnectRef = useRef<number | null>(null);
const idleWatchdogRef = useRef<number | null>(null); const idleWatchdogRef = useRef<number | null>(null);
@ -2556,6 +2621,27 @@ const useLiveSession = (
[pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope] [pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope]
); );
const replaceArrayState = <T,>(
setter: Dispatch<SetStateAction<T[]>>,
ref: { current: T[] },
next: T[]
): void => {
ref.current = next;
setter(next);
};
const mergeHistoryState = <T extends SortableItem>(
setter: Dispatch<SetStateAction<T[]>>,
ref: { current: T[] },
incoming: T[],
liveHead: T[],
cap = LIVE_HISTORY_SOFT_CAP
): void => {
const next = appendHistoryTail(ref.current, incoming, liveHead, cap);
ref.current = next;
setter(next);
};
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
setStatus("disconnected"); setStatus("disconnected");
@ -2586,6 +2672,27 @@ const useLiveSession = (
setInferredDarkHistory([]); setInferredDarkHistory([]);
setChartCandles([]); setChartCandles([]);
setChartOverlay([]); setChartOverlay([]);
optionsRef.current = [];
nbboRef.current = [];
equitiesRef.current = [];
equityQuotesRef.current = [];
equityJoinsRef.current = [];
flowRef.current = [];
smartMoneyRef.current = [];
classifierHitsRef.current = [];
alertsRef.current = [];
inferredDarkRef.current = [];
chartCandlesRef.current = [];
chartOverlayRef.current = [];
optionsHistoryRef.current = [];
nbboHistoryRef.current = [];
equitiesHistoryRef.current = [];
equityJoinsHistoryRef.current = [];
flowHistoryRef.current = [];
smartMoneyHistoryRef.current = [];
classifierHitsHistoryRef.current = [];
alertsHistoryRef.current = [];
inferredDarkHistoryRef.current = [];
subscribedKeysRef.current = new Set(); subscribedKeysRef.current = new Set();
subscribedMapRef.current = new Map(); subscribedMapRef.current = new Map();
if (socketRef.current) { if (socketRef.current) {
@ -2642,62 +2749,112 @@ const useLiveSession = (
const updateAt = Date.now(); const updateAt = Date.now();
const mergeItems = <T extends SortableItem>( const mergeItems = <T extends SortableItem>(
setter: React.Dispatch<React.SetStateAction<T[]>>, setter: Dispatch<SetStateAction<T[]>>,
ref: { current: T[] },
nextItems: T[], nextItems: T[],
retentionLimit = LIVE_HOT_WINDOW retentionLimit = LIVE_HOT_WINDOW,
history?: {
setter: Dispatch<SetStateAction<T[]>>;
ref: { current: T[] };
cap?: number;
}
) => { ) => {
setter((prev) => if (message.op === "snapshot") {
message.op === "snapshot" const next = shouldRetainLiveSnapshotHistory(
? shouldRetainLiveSnapshotHistory(
subscription.channel, subscription.channel,
true, true,
nextItems.length, nextItems.length,
prev.length ref.current.length
)
? prev
: (nextItems as T[])
: mergeNewest(nextItems as T[], prev, retentionLimit, (evicted) =>
incrementRetentionMetric("hotWindowEvictions", evicted)
) )
? ref.current
: nextItems;
replaceArrayState(setter, ref, next);
return;
}
const { kept, evicted } = mergeNewestWithOverflow(
nextItems,
ref.current,
retentionLimit,
(evictedCount) => incrementRetentionMetric("hotWindowEvictions", evictedCount)
); );
replaceArrayState(setter, ref, kept);
if (history && evicted.length > 0) {
mergeHistoryState(history.setter, history.ref, evicted, kept, history.cap);
}
}; };
switch (subscription.channel) { switch (subscription.channel) {
case "options": case "options":
mergeItems(setOptions, items as OptionPrint[], LIVE_HOT_WINDOW_OPTIONS); mergeItems(setOptions, optionsRef, items as OptionPrint[], LIVE_HOT_WINDOW_OPTIONS, {
setter: setOptionsHistory,
ref: optionsHistoryRef,
cap: getLiveHistoryRetentionCap(subscription)
});
break; break;
case "nbbo": case "nbbo":
mergeItems(setNbbo, items as OptionNBBO[]); mergeItems(setNbbo, nbboRef, items as OptionNBBO[], LIVE_HOT_WINDOW, {
setter: setNbboHistory,
ref: nbboHistoryRef
});
break; break;
case "equities": case "equities":
mergeItems(setEquities, items as EquityPrint[]); mergeItems(setEquities, equitiesRef, items as EquityPrint[], LIVE_HOT_WINDOW, {
setter: setEquitiesHistory,
ref: equitiesHistoryRef,
cap: getLiveHistoryRetentionCap(subscription)
});
break; break;
case "equity-quotes": case "equity-quotes":
mergeItems(setEquityQuotes, items as EquityQuote[]); mergeItems(setEquityQuotes, equityQuotesRef, items as EquityQuote[]);
break; break;
case "equity-joins": case "equity-joins":
mergeItems(setEquityJoins, items as EquityPrintJoin[]); mergeItems(setEquityJoins, equityJoinsRef, items as EquityPrintJoin[], LIVE_HOT_WINDOW, {
setter: setEquityJoinsHistory,
ref: equityJoinsHistoryRef
});
break; break;
case "flow": case "flow":
mergeItems(setFlow, items as FlowPacket[]); mergeItems(setFlow, flowRef, items as FlowPacket[], LIVE_HOT_WINDOW, {
setter: setFlowHistory,
ref: flowHistoryRef
});
break; break;
case "smart-money": case "smart-money":
mergeItems(setSmartMoney, items as SmartMoneyEvent[]); mergeItems(setSmartMoney, smartMoneyRef, items as SmartMoneyEvent[], LIVE_HOT_WINDOW, {
setter: setSmartMoneyHistory,
ref: smartMoneyHistoryRef
});
break; break;
case "classifier-hits": case "classifier-hits":
mergeItems(setClassifierHits, items as ClassifierHitEvent[]); mergeItems(
setClassifierHits,
classifierHitsRef,
items as ClassifierHitEvent[],
LIVE_HOT_WINDOW,
{
setter: setClassifierHitsHistory,
ref: classifierHitsHistoryRef
}
);
break; break;
case "alerts": case "alerts":
mergeItems(setAlerts, items as AlertEvent[]); mergeItems(setAlerts, alertsRef, items as AlertEvent[], LIVE_HOT_WINDOW, {
setter: setAlertsHistory,
ref: alertsHistoryRef
});
break; break;
case "inferred-dark": case "inferred-dark":
mergeItems(setInferredDark, items as InferredDarkEvent[]); mergeItems(setInferredDark, inferredDarkRef, items as InferredDarkEvent[], LIVE_HOT_WINDOW, {
setter: setInferredDarkHistory,
ref: inferredDarkHistoryRef
});
break; break;
case "equity-candles": case "equity-candles":
mergeItems(setChartCandles, items as EquityCandle[]); mergeItems(setChartCandles, chartCandlesRef, items as EquityCandle[]);
break; break;
case "equity-overlay": case "equity-overlay":
mergeItems(setChartOverlay, items as EquityPrint[]); mergeItems(setChartOverlay, chartOverlayRef, items as EquityPrint[]);
break; break;
} }
@ -2839,10 +2996,14 @@ const useLiveSession = (
.filter((channel) => channel === "options" || channel === "equities") .filter((channel) => channel === "options" || channel === "equities")
); );
if (resetScopedChannels.has("options")) { if (resetScopedChannels.has("options")) {
optionsRef.current = [];
optionsHistoryRef.current = [];
setOptions([]); setOptions([]);
setOptionsHistory([]); setOptionsHistory([]);
} }
if (resetScopedChannels.has("equities")) { if (resetScopedChannels.has("equities")) {
equitiesRef.current = [];
equitiesHistoryRef.current = [];
setEquities([]); setEquities([]);
setEquitiesHistory([]); setEquitiesHistory([]);
} }
@ -2926,41 +3087,56 @@ const useLiveSession = (
const mergeOlder = <T extends SortableItem>( const mergeOlder = <T extends SortableItem>(
setter: Dispatch<SetStateAction<T[]>>, setter: Dispatch<SetStateAction<T[]>>,
ref: { current: T[] },
liveHead: T[], liveHead: T[],
cap = LIVE_HISTORY_SOFT_CAP cap = LIVE_HISTORY_SOFT_CAP
) => { ) => {
setter((prev) => appendHistoryTail(prev, older as T[], liveHead, cap)); mergeHistoryState(setter, ref, older as T[], liveHead, cap);
}; };
switch (subscription.channel) { switch (subscription.channel) {
case "options": case "options":
mergeOlder(setOptionsHistory, options, getLiveHistoryRetentionCap(subscription)); mergeOlder(
setOptionsHistory,
optionsHistoryRef,
optionsRef.current,
getLiveHistoryRetentionCap(subscription)
);
break; break;
case "nbbo": case "nbbo":
mergeOlder(setNbboHistory, nbbo); mergeOlder(setNbboHistory, nbboHistoryRef, nbboRef.current);
break; break;
case "equities": case "equities":
mergeOlder(setEquitiesHistory, equities, getLiveHistoryRetentionCap(subscription)); mergeOlder(
setEquitiesHistory,
equitiesHistoryRef,
equitiesRef.current,
getLiveHistoryRetentionCap(subscription)
);
break; break;
case "equity-quotes": case "equity-quotes":
break; break;
case "equity-joins": case "equity-joins":
mergeOlder(setEquityJoinsHistory, equityJoins); mergeOlder(setEquityJoinsHistory, equityJoinsHistoryRef, equityJoinsRef.current);
break; break;
case "flow": case "flow":
mergeOlder(setFlowHistory, flow); mergeOlder(setFlowHistory, flowHistoryRef, flowRef.current);
break; break;
case "smart-money": case "smart-money":
mergeOlder(setSmartMoneyHistory, smartMoney); mergeOlder(setSmartMoneyHistory, smartMoneyHistoryRef, smartMoneyRef.current);
break; break;
case "classifier-hits": case "classifier-hits":
mergeOlder(setClassifierHitsHistory, classifierHits); mergeOlder(
setClassifierHitsHistory,
classifierHitsHistoryRef,
classifierHitsRef.current
);
break; break;
case "alerts": case "alerts":
mergeOlder(setAlertsHistory, alerts); mergeOlder(setAlertsHistory, alertsHistoryRef, alertsRef.current);
break; break;
case "inferred-dark": case "inferred-dark":
mergeOlder(setInferredDarkHistory, inferredDark); mergeOlder(setInferredDarkHistory, inferredDarkHistoryRef, inferredDarkRef.current);
break; break;
} }
@ -2978,41 +3154,18 @@ const useLiveSession = (
setHistoryLoading((current) => ({ ...current, [key]: false })); setHistoryLoading((current) => ({ ...current, [key]: false }));
} }
}, },
[ [enabled, manifest, historyCursors, historyLoading]
enabled,
manifest,
historyCursors,
historyLoading,
options,
nbbo,
equities,
equityJoins,
flow,
smartMoney,
classifierHits,
alerts,
inferredDark
]
); );
useEffect(() => { useEffect(() => {
if (!enabled || pathname !== "/tape") { for (const channel of getScopedLiveAutoHydrationChannels(
return; enabled,
} pathname,
const scoped = manifest.filter( manifest,
(subscription) => historyCursors,
(subscription.channel === "options" && historyLoading
(subscription.underlying_ids?.length || subscription.option_contract_id)) || )) {
(subscription.channel === "equities" && subscription.underlying_ids?.length) void loadOlder(channel);
);
if (scoped.length === 0) {
return;
}
for (const subscription of scoped) {
const key = getLiveSubscriptionKey(subscription);
if (historyCursors[key] && !historyLoading[key]) {
void loadOlder(subscription.channel);
}
} }
}, [enabled, pathname, manifest, historyCursors, historyLoading, loadOlder]); }, [enabled, pathname, manifest, historyCursors, historyLoading, loadOlder]);

View file

@ -112,7 +112,7 @@ import {
} from "@islandflow/types"; } from "@islandflow/types";
import { createClient } from "redis"; import { createClient } from "redis";
import { z } from "zod"; import { z } from "zod";
import { LIVE_FEED_LOOKBACK_MS, LiveStateManager, shouldFanoutLiveEvent } from "./live"; import { LiveStateManager, shouldFanoutLiveEvent } from "./live";
const service = "api"; const service = "api";
const logger = createLogger({ service }); const logger = createLogger({ service });
@ -617,14 +617,12 @@ const parseLiveOptionPrintFilters = (url: URL): OptionPrintQueryFilters => {
return { return {
...storageFilters, ...storageFilters,
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"), underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
optionContractId: url.searchParams.get("option_contract_id") ?? undefined, optionContractId: url.searchParams.get("option_contract_id") ?? undefined
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
}; };
}; };
const parseLiveEquityPrintFilters = (url: URL): EquityPrintQueryFilters => ({ const parseLiveEquityPrintFilters = (url: URL): EquityPrintQueryFilters => ({
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"), underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids")
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
}); });
const matchesScopedOptionSubscription = ( const matchesScopedOptionSubscription = (

View file

@ -408,13 +408,7 @@ export class LiveStateManager {
const config = this.generic[channel]; const config = this.generic[channel];
if (this.redis?.isOpen) { if (this.redis?.isOpen) {
const payloads = await this.redis.lRange(config.redisKey, 0, config.limit - 1); const payloads = await this.redis.lRange(config.redisKey, 0, config.limit - 1);
const cached = normalizeGenericItems( const cached = normalizeGenericItems(channel, parseJsonList(payloads, config.parse), config);
channel,
parseJsonList(payloads, config.parse).filter((item) =>
isWithinLiveFeedLookback(channel, item)
),
config
);
if (cached.length > 0) { if (cached.length > 0) {
this.genericItems.set(channel, cached); this.genericItems.set(channel, cached);
this.stats.genericHydrateFromRedis += 1; this.stats.genericHydrateFromRedis += 1;
@ -434,9 +428,7 @@ export class LiveStateManager {
const fresh = normalizeGenericItems( const fresh = normalizeGenericItems(
channel, channel,
(await config.fetchRecent(this.clickhouse, config.limit)).filter((item) => await config.fetchRecent(this.clickhouse, config.limit),
isWithinLiveFeedLookback(channel, item)
),
config config
); );
this.stats.genericHydrateFromClickHouse += 1; this.stats.genericHydrateFromClickHouse += 1;
@ -467,8 +459,7 @@ export class LiveStateManager {
optionTypes: subscription.filters?.optionTypes, optionTypes: subscription.filters?.optionTypes,
minNotional: subscription.filters?.minNotional, minNotional: subscription.filters?.minNotional,
underlyingIds: subscription.underlying_ids, underlyingIds: subscription.underlying_ids,
optionContractId: subscription.option_contract_id, optionContractId: subscription.option_contract_id
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
}; };
const items = await fetchRecentOptionPrints( const items = await fetchRecentOptionPrints(
this.clickhouse, this.clickhouse,
@ -487,7 +478,6 @@ export class LiveStateManager {
const config = this.generic.options; const config = this.generic.options;
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
const items = (this.genericItems.get("options") ?? []).filter((item) => const items = (this.genericItems.get("options") ?? []).filter((item) =>
isWithinLiveFeedLookback("options", item) &&
matchesOptionPrintFilters(item, subscription.filters) matchesOptionPrintFilters(item, subscription.filters)
).slice(0, limit); ).slice(0, limit);
return { return {
@ -501,7 +491,6 @@ export class LiveStateManager {
const config = this.generic.flow; const config = this.generic.flow;
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
const items = (this.genericItems.get("flow") ?? []).filter((item) => const items = (this.genericItems.get("flow") ?? []).filter((item) =>
isWithinLiveFeedLookback("flow", item) &&
matchesFlowPacketFilters(item, subscription.filters) matchesFlowPacketFilters(item, subscription.filters)
).slice(0, limit); ).slice(0, limit);
return { return {
@ -516,8 +505,7 @@ export class LiveStateManager {
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
if (subscription.underlying_ids?.length) { if (subscription.underlying_ids?.length) {
const filters: EquityPrintQueryFilters = { const filters: EquityPrintQueryFilters = {
underlyingIds: subscription.underlying_ids, underlyingIds: subscription.underlying_ids
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
}; };
const items = await fetchRecentEquityPrints(this.clickhouse, limit, filters); const items = await fetchRecentEquityPrints(this.clickhouse, limit, filters);
return { return {
@ -527,9 +515,7 @@ export class LiveStateManager {
next_before: nextBeforeForItems(items, config.cursor) next_before: nextBeforeForItems(items, config.cursor)
}; };
} }
const items = (this.genericItems.get("equities") ?? []).filter((item) => const items = (this.genericItems.get("equities") ?? []).slice(0, limit);
isWithinLiveFeedLookback("equities", item)
).slice(0, limit);
return { return {
subscription, subscription,
items, items,
@ -568,9 +554,7 @@ export class LiveStateManager {
default: { default: {
const config = this.generic[subscription.channel]; const config = this.generic[subscription.channel];
const limit = snapshotLimitFor(subscription, config.limit); const limit = snapshotLimitFor(subscription, config.limit);
const items = (this.genericItems.get(subscription.channel) ?? []).filter((item) => const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit);
isWithinLiveFeedLookback(subscription.channel, item)
).slice(0, limit);
return { return {
subscription, subscription,
items, items,

View file

@ -7,15 +7,17 @@ import {
shouldFanoutLiveEvent shouldFanoutLiveEvent
} from "../src/live"; } from "../src/live";
const makeClickHouse = (): ClickHouseClient => const makeClickHouse = (
queryResolver?: (query: string) => unknown[]
): ClickHouseClient =>
({ ({
exec: async () => {}, exec: async () => {},
insert: async () => {}, insert: async () => {},
ping: async () => ({ success: true }), ping: async () => ({ success: true }),
close: async () => {}, close: async () => {},
query: async () => ({ query: async ({ query }: { query: string }) => ({
async json<T>() { async json<T>() {
return [] as T; return (queryResolver?.(query) ?? []) as T;
} }
}) })
}) as ClickHouseClient; }) as ClickHouseClient;
@ -408,6 +410,160 @@ describe("LiveStateManager", () => {
]); ]);
}); });
it("seeds scoped option snapshots from clickhouse rows older than 24h", async () => {
const now = Date.now();
const staleTs = now - 25 * 60 * 60 * 1000;
const manager = new LiveStateManager(
makeClickHouse((query) =>
query.includes("FROM option_prints")
? [
{
source_ts: staleTs,
ingest_ts: staleTs + 1,
seq: 1,
trace_id: "opt-ancient",
ts: staleTs,
option_contract_id: "AAPL-2025-01-17-200-C",
underlying_id: "AAPL",
price: 1,
size: 10,
exchange: "X",
signal_pass: true
}
]
: []
),
null
);
const snapshot = await manager.getSnapshot({
channel: "options",
underlying_ids: ["AAPL"],
option_contract_id: "AAPL-2025-01-17-200-C"
});
expect((snapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
"opt-ancient"
]);
expect(snapshot.next_before).toEqual({ ts: staleTs, seq: 1 });
expect(isLiveItemFresh("options", snapshot.items[0], now)).toBe(false);
});
it("seeds scoped equity snapshots from clickhouse rows older than 24h", async () => {
const now = Date.now();
const staleTs = now - 25 * 60 * 60 * 1000;
const manager = new LiveStateManager(
makeClickHouse((query) =>
query.includes("FROM equity_prints")
? [
{
source_ts: staleTs,
ingest_ts: staleTs + 1,
seq: 1,
trace_id: "eq-ancient",
ts: staleTs,
underlying_id: "AAPL",
price: 100,
size: 10,
exchange: "X",
offExchangeFlag: false
}
]
: []
),
null
);
const snapshot = await manager.getSnapshot({
channel: "equities",
underlying_ids: ["AAPL"]
});
expect((snapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
"eq-ancient"
]);
expect(snapshot.next_before).toEqual({ ts: staleTs, seq: 1 });
expect(isLiveItemFresh("equities", snapshot.items[0], now)).toBe(false);
});
it("hydrates retained rows older than 24h into generic live snapshots and keeps them stale", async () => {
const redis = makeRedis();
const now = Date.now();
const staleTs = now - 25 * 60 * 60 * 1000;
await redis.lPush(
"live:options",
JSON.stringify({
source_ts: staleTs,
ingest_ts: staleTs + 1,
seq: 1,
trace_id: "opt-retained",
ts: staleTs,
option_contract_id: "AAPL-2025-01-17-200-C",
underlying_id: "AAPL",
price: 1,
size: 10,
exchange: "X",
signal_pass: true
})
);
await redis.hSet("live:cursors", "options", JSON.stringify({ ts: staleTs, seq: 1 }));
await redis.lPush(
"live:equities",
JSON.stringify({
source_ts: staleTs,
ingest_ts: staleTs + 1,
seq: 2,
trace_id: "eq-retained",
ts: staleTs,
underlying_id: "AAPL",
price: 100,
size: 10,
exchange: "X",
offExchangeFlag: false
})
);
await redis.hSet("live:cursors", "equities", JSON.stringify({ ts: staleTs, seq: 2 }));
await redis.lPush(
"live:flow",
JSON.stringify({
source_ts: staleTs,
ingest_ts: staleTs + 1,
seq: 3,
trace_id: "flow-retained",
id: "flow-retained",
members: ["opt-retained"],
features: {},
join_quality: {}
})
);
await redis.hSet("live:cursors", "flow", JSON.stringify({ ts: staleTs, seq: 3 }));
const manager = new LiveStateManager(makeClickHouse(), redis as never);
await manager.hydrate();
const [optionsSnapshot, equitiesSnapshot, flowSnapshot] = await Promise.all([
manager.getSnapshot({ channel: "options" }),
manager.getSnapshot({ channel: "equities" }),
manager.getSnapshot({ channel: "flow" })
]);
expect((optionsSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
"opt-retained"
]);
expect((equitiesSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
"eq-retained"
]);
expect((flowSnapshot.items as Array<{ id: string }>).map((item) => item.id)).toEqual([
"flow-retained"
]);
expect(isLiveItemFresh("options", optionsSnapshot.items[0], now)).toBe(false);
expect(isLiveItemFresh("equities", equitiesSnapshot.items[0], now)).toBe(false);
expect(isLiveItemFresh("flow", flowSnapshot.items[0], now)).toBe(false);
});
it("keeps only the newest NBBO quote per contract across hydrate and ingest", async () => { it("keeps only the newest NBBO quote per contract across hydrate and ingest", async () => {
const redis = makeRedis(); const redis = makeRedis();
const now = Date.now(); const now = Date.now();