Retain live history and warn on silent equities feeds

- Keep pausable live snapshots visible while stale
- Surface a connected-but-silent equities warning
- Add coverage for history retention and warning timing
This commit is contained in:
dirtydishes 2026-04-28 23:19:14 -04:00
parent 89aaf63d34
commit da942079f3
2 changed files with 161 additions and 15 deletions

View file

@ -5,7 +5,10 @@ import {
flushPausableTapeData, flushPausableTapeData,
getLiveFeedStatus, getLiveFeedStatus,
nextFlowFilterPopoverState, nextFlowFilterPopoverState,
projectPausableTapeState,
reducePausableTapeData, reducePausableTapeData,
shouldRetainLiveSnapshotHistory,
shouldShowEquitiesSilentFeedWarning,
toggleFilterValue toggleFilterValue
} from "./terminal"; } from "./terminal";
@ -53,6 +56,55 @@ describe("live tape pausable helpers", () => {
expect(getLiveFeedStatus("connected", 1000, 500, 1601)).toBe("stale"); expect(getLiveFeedStatus("connected", 1000, 500, 1601)).toBe("stale");
expect(getLiveFeedStatus("disconnected", 1000, 500, 1601)).toBe("disconnected"); expect(getLiveFeedStatus("disconnected", 1000, 500, 1601)).toBe("disconnected");
}); });
it("keeps visible history even when live status is stale", () => {
const projected = projectPausableTapeState([makeItem("stale", 7, 1000)], "stale", 2000);
expect(projected.items.map((item) => item.trace_id)).toEqual(["stale"]);
expect(projected.lastUpdate).toBeNull();
});
it("flags connected equities feeds that stay silent past threshold", () => {
expect(
shouldShowEquitiesSilentFeedWarning({
wsStatus: "connected",
equitiesSubscribed: true,
connectedAt: 1_000,
lastEquitiesEventAt: null,
now: 20_000,
thresholdMs: 25_000
})
).toBe(false);
expect(
shouldShowEquitiesSilentFeedWarning({
wsStatus: "connected",
equitiesSubscribed: true,
connectedAt: 1_000,
lastEquitiesEventAt: null,
now: 27_000,
thresholdMs: 25_000
})
).toBe(true);
expect(
shouldShowEquitiesSilentFeedWarning({
wsStatus: "connected",
equitiesSubscribed: true,
connectedAt: 1_000,
lastEquitiesEventAt: 20_000,
now: 40_000,
thresholdMs: 25_000
})
).toBe(false);
});
it("retains live history when freshness-gated snapshots are empty", () => {
expect(shouldRetainLiveSnapshotHistory("options", true, 0, 3)).toBe(true);
expect(shouldRetainLiveSnapshotHistory("equities", true, 0, 2)).toBe(true);
expect(shouldRetainLiveSnapshotHistory("alerts", true, 0, 3)).toBe(false);
expect(shouldRetainLiveSnapshotHistory("options", true, 1, 3)).toBe(false);
expect(shouldRetainLiveSnapshotHistory("options", false, 0, 3)).toBe(false);
});
}); });
describe("flow filter popup helpers", () => { describe("flow filter popup helpers", () => {

View file

@ -60,6 +60,12 @@ const LIVE_HOT_WINDOW = parseBoundedInt(process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW,
const LIVE_OPTIONS_STALE_MS = 15_000; const LIVE_OPTIONS_STALE_MS = 15_000;
const LIVE_NBBO_STALE_MS = 15_000; const LIVE_NBBO_STALE_MS = 15_000;
const LIVE_EQUITIES_STALE_MS = 15_000; const LIVE_EQUITIES_STALE_MS = 15_000;
const LIVE_EQUITIES_SILENT_WARNING_MS = parseBoundedInt(
process.env.NEXT_PUBLIC_LIVE_EQUITIES_SILENT_WARNING_MS,
25_000,
5_000,
5 * 60 * 1000
);
const LIVE_FLOW_STALE_MS = 30_000; const LIVE_FLOW_STALE_MS = 30_000;
const PINNED_EVIDENCE_TTL_MS = parseBoundedInt( const PINNED_EVIDENCE_TTL_MS = parseBoundedInt(
process.env.NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS, process.env.NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS,
@ -775,13 +781,6 @@ export const countActiveFlowFilterGroups = (filters: OptionFlowFilters): number
const isFreshLiveItem = (ts: number, thresholdMs: number, now = Date.now()): boolean => now - ts <= thresholdMs; const isFreshLiveItem = (ts: number, thresholdMs: number, now = Date.now()): boolean => now - ts <= thresholdMs;
const filterFreshLiveItems = <T extends SortableItem>(
items: T[],
thresholdMs: number,
getItemTs: (item: T) => number = extractSortTs,
now = Date.now()
): T[] => items.filter((item) => isFreshLiveItem(getItemTs(item), thresholdMs, now));
export const toggleFilterValue = <T extends string>( export const toggleFilterValue = <T extends string>(
values: T[] | undefined, values: T[] | undefined,
value: T, value: T,
@ -803,6 +802,60 @@ export const nextFlowFilterPopoverState = (
return action === "toggle" ? !current : false; return action === "toggle" ? !current : false;
}; };
export const projectPausableTapeState = <T extends SortableItem>(
visible: T[],
status: WsStatus,
lastUpdate: number | null
): { items: T[]; lastUpdate: number | null } => ({
items: visible,
lastUpdate: status === "stale" ? null : lastUpdate
});
type EquitiesSilentFeedWarningInput = {
wsStatus: WsStatus;
equitiesSubscribed: boolean;
connectedAt: number | null;
lastEquitiesEventAt: number | null;
now?: number;
thresholdMs?: number;
};
export const shouldShowEquitiesSilentFeedWarning = ({
wsStatus,
equitiesSubscribed,
connectedAt,
lastEquitiesEventAt,
now = Date.now(),
thresholdMs = LIVE_EQUITIES_SILENT_WARNING_MS
}: EquitiesSilentFeedWarningInput): boolean => {
if (wsStatus !== "connected" || !equitiesSubscribed) {
return false;
}
const baselineTs = lastEquitiesEventAt ?? connectedAt;
if (baselineTs === null) {
return false;
}
return now - baselineTs >= thresholdMs;
};
const LIVE_SNAPSHOT_HISTORY_CHANNELS = new Set<LiveSubscription["channel"]>([
"options",
"nbbo",
"equities",
"flow"
]);
export const shouldRetainLiveSnapshotHistory = (
channel: LiveSubscription["channel"],
isSnapshot: boolean,
snapshotItemCount: number,
currentItemCount: number
): boolean =>
isSnapshot &&
snapshotItemCount === 0 &&
currentItemCount > 0 &&
LIVE_SNAPSHOT_HISTORY_CHANNELS.has(channel);
const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined): NbboSide | null => { const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined): NbboSide | null => {
if (!quote || !Number.isFinite(price)) { if (!quote || !Number.isFinite(price)) {
return null; return null;
@ -1635,15 +1688,12 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
const status = config.enabled const status = config.enabled
? getLiveFeedStatus(config.sourceStatus, freshestTs, config.freshnessMs, clock) ? getLiveFeedStatus(config.sourceStatus, freshestTs, config.freshnessMs, clock)
: "disconnected"; : "disconnected";
const items = const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
status === "stale"
? []
: filterFreshLiveItems(data.visible, config.freshnessMs, getItemTs, clock);
return { return {
status, status,
items, items: projected.items,
lastUpdate: status === "stale" ? null : config.lastUpdate, lastUpdate: projected.lastUpdate,
replayTime: null, replayTime: null,
replayComplete: false, replayComplete: false,
paused, paused,
@ -1889,7 +1939,9 @@ const useFlowStream = (
type LiveSessionState = { type LiveSessionState = {
status: WsStatus; status: WsStatus;
connectedAt: number | null;
lastUpdate: number | null; lastUpdate: number | null;
lastEventByChannel: Partial<Record<LiveSubscription["channel"], number>>;
options: OptionPrint[]; options: OptionPrint[];
nbbo: OptionNBBO[]; nbbo: OptionNBBO[];
equities: EquityPrint[]; equities: EquityPrint[];
@ -1952,7 +2004,11 @@ const useLiveSession = (
flowFilters: OptionFlowFilters flowFilters: OptionFlowFilters
): LiveSessionState => { ): LiveSessionState => {
const [status, setStatus] = useState<WsStatus>(enabled ? "connecting" : "disconnected"); const [status, setStatus] = useState<WsStatus>(enabled ? "connecting" : "disconnected");
const [connectedAt, setConnectedAt] = useState<number | null>(null);
const [lastUpdate, setLastUpdate] = useState<number | null>(null); const [lastUpdate, setLastUpdate] = useState<number | null>(null);
const [lastEventByChannel, setLastEventByChannel] = useState<
Partial<Record<LiveSubscription["channel"], number>>
>({});
const [options, setOptions] = useState<OptionPrint[]>([]); const [options, setOptions] = useState<OptionPrint[]>([]);
const [nbbo, setNbbo] = useState<OptionNBBO[]>([]); const [nbbo, setNbbo] = useState<OptionNBBO[]>([]);
const [equities, setEquities] = useState<EquityPrint[]>([]); const [equities, setEquities] = useState<EquityPrint[]>([]);
@ -1975,7 +2031,9 @@ const useLiveSession = (
useEffect(() => { useEffect(() => {
if (!enabled) { if (!enabled) {
setStatus("disconnected"); setStatus("disconnected");
setConnectedAt(null);
setLastUpdate(null); setLastUpdate(null);
setLastEventByChannel({});
setOptions([]); setOptions([]);
setNbbo([]); setNbbo([]);
setEquities([]); setEquities([]);
@ -2040,7 +2098,14 @@ const useLiveSession = (
) => { ) => {
setter((prev) => setter((prev) =>
message.op === "snapshot" message.op === "snapshot"
? (nextItems as T[]) ? shouldRetainLiveSnapshotHistory(
subscription.channel,
true,
nextItems.length,
prev.length
)
? prev
: (nextItems as T[])
: mergeNewest(nextItems as T[], prev, LIVE_HOT_WINDOW, (evicted) => : mergeNewest(nextItems as T[], prev, LIVE_HOT_WINDOW, (evicted) =>
incrementRetentionMetric("hotWindowEvictions", evicted) incrementRetentionMetric("hotWindowEvictions", evicted)
) )
@ -2080,6 +2145,13 @@ const useLiveSession = (
break; break;
} }
if (items.length > 0) {
setLastEventByChannel((current) => ({
...current,
[subscription.channel]: updateAt
}));
}
setLastUpdate(updateAt); setLastUpdate(updateAt);
}; };
@ -2096,6 +2168,7 @@ const useLiveSession = (
return; return;
} }
setStatus("connected"); setStatus("connected");
setConnectedAt(Date.now());
syncSubscriptions(socket); syncSubscriptions(socket);
}; };
@ -2116,6 +2189,7 @@ const useLiveSession = (
return; return;
} }
setStatus("disconnected"); setStatus("disconnected");
setConnectedAt(null);
subscribedKeysRef.current = new Set(); subscribedKeysRef.current = new Set();
subscribedMapRef.current = new Map(); subscribedMapRef.current = new Map();
reconnectRef.current = window.setTimeout(connect, 1000); reconnectRef.current = window.setTimeout(connect, 1000);
@ -2126,6 +2200,7 @@ const useLiveSession = (
return; return;
} }
setStatus("disconnected"); setStatus("disconnected");
setConnectedAt(null);
socket.close(); socket.close();
}; };
}; };
@ -2172,7 +2247,9 @@ const useLiveSession = (
return { return {
status, status,
connectedAt,
lastUpdate, lastUpdate,
lastEventByChannel,
options, options,
nbbo, nbbo,
equities, equities,
@ -3401,6 +3478,13 @@ const useTerminalState = () => {
chartIntervalMs, chartIntervalMs,
flowFilters flowFilters
); );
const equitiesLiveSubscriptionActive = useMemo(
() =>
getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters).some(
(sub) => sub.channel === "equities"
),
[pathname, chartTicker, chartIntervalMs, flowFilters]
);
const handleReplaySource = useCallback((value: string | null) => { const handleReplaySource = useCallback((value: string | null) => {
setReplaySource(value); setReplaySource(value);
@ -4038,6 +4122,13 @@ const useTerminalState = () => {
return equitiesFeed.items.filter((print) => matchesTicker(print.underlying_id)); return equitiesFeed.items.filter((print) => matchesTicker(print.underlying_id));
}, [equitiesFeed.items, matchesTicker, tickerSet]); }, [equitiesFeed.items, matchesTicker, tickerSet]);
const equitiesSilentWarning = shouldShowEquitiesSilentFeedWarning({
wsStatus: liveSession.status,
equitiesSubscribed: mode === "live" && equitiesLiveSubscriptionActive,
connectedAt: liveSession.connectedAt,
lastEquitiesEventAt: liveSession.lastEventByChannel.equities ?? null
});
const filteredInferredDark = useMemo(() => { const filteredInferredDark = useMemo(() => {
if (tickerSet.size === 0) { if (tickerSet.size === 0) {
return inferredDarkFeed.items; return inferredDarkFeed.items;
@ -4390,6 +4481,7 @@ const useTerminalState = () => {
selectedClassifierEvidence, selectedClassifierEvidence,
filteredOptions, filteredOptions,
filteredEquities, filteredEquities,
equitiesSilentWarning,
filteredInferredDark, filteredInferredDark,
filteredFlow, filteredFlow,
filteredAlerts, filteredAlerts,
@ -4906,7 +4998,9 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => {
{state.tickerSet.size > 0 {state.tickerSet.size > 0
? "No equity prints match the current filter." ? "No equity prints match the current filter."
: state.mode === "live" : state.mode === "live"
? state.equities.status === "stale" ? state.equitiesSilentWarning
? "Connected but no equity prints received. Check ingest-equities."
: state.equities.status === "stale"
? "Live feed behind. Waiting for fresh equity prints." ? "Live feed behind. Waiting for fresh equity prints."
: "No equity prints yet. Start ingest-equities." : "No equity prints yet. Start ingest-equities."
: "Replay queue empty. Ensure ClickHouse has data."} : "Replay queue empty. Ensure ClickHouse has data."}