Merge pull request #30 from dirtydishes/live-clickhouse-scrollgate
Implement live tape scroll-gated history
This commit is contained in:
commit
62244ce048
6 changed files with 535 additions and 53 deletions
|
|
@ -62,13 +62,25 @@ const parseBoundedInt = (
|
||||||
return Math.max(min, Math.min(max, Math.floor(parsed)));
|
return Math.max(min, Math.min(max, Math.floor(parsed)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIVE_HOT_WINDOW = parseBoundedInt(process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW, 2000, 100, 100000);
|
const LIVE_HOT_WINDOW = parseBoundedInt(process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW, 100, 1, 100000);
|
||||||
const LIVE_HOT_WINDOW_OPTIONS = parseBoundedInt(
|
const LIVE_HOT_WINDOW_OPTIONS = parseBoundedInt(
|
||||||
process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS,
|
process.env.NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS,
|
||||||
25000,
|
|
||||||
100,
|
100,
|
||||||
|
1,
|
||||||
100000
|
100000
|
||||||
);
|
);
|
||||||
|
const LIVE_HISTORY_SOFT_CAP = parseBoundedInt(
|
||||||
|
process.env.NEXT_PUBLIC_LIVE_HISTORY_SOFT_CAP,
|
||||||
|
5000,
|
||||||
|
100,
|
||||||
|
50000
|
||||||
|
);
|
||||||
|
const LIVE_HISTORY_BATCH = parseBoundedInt(
|
||||||
|
process.env.NEXT_PUBLIC_LIVE_HISTORY_BATCH,
|
||||||
|
500,
|
||||||
|
1,
|
||||||
|
1000
|
||||||
|
);
|
||||||
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;
|
||||||
|
|
@ -409,6 +421,16 @@ type PausableTapeData<T> = {
|
||||||
dropped: number;
|
dropped: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LiveHistoryBuffer<T> = {
|
||||||
|
liveHead: T[];
|
||||||
|
queuedLive: T[];
|
||||||
|
historyTail: T[];
|
||||||
|
nextBefore: Cursor | null;
|
||||||
|
historyLoading: boolean;
|
||||||
|
historyExhausted: boolean;
|
||||||
|
autoHydrating: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const reducePausableTapeData = <T extends SortableItem>(
|
export const reducePausableTapeData = <T extends SortableItem>(
|
||||||
current: PausableTapeData<T>,
|
current: PausableTapeData<T>,
|
||||||
incoming: T[],
|
incoming: T[],
|
||||||
|
|
@ -488,6 +510,37 @@ const EMPTY_PAUSABLE_TAPE = {
|
||||||
dropped: 0
|
dropped: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appendHistoryTail = <T extends SortableItem>(
|
||||||
|
current: T[],
|
||||||
|
incoming: T[],
|
||||||
|
liveHead: T[],
|
||||||
|
cap = LIVE_HISTORY_SOFT_CAP
|
||||||
|
): T[] => {
|
||||||
|
if (incoming.length === 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of liveHead) {
|
||||||
|
seen.add(getTapeItemKey(item));
|
||||||
|
}
|
||||||
|
for (const item of current) {
|
||||||
|
seen.add(getTapeItemKey(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
const appended = [...current];
|
||||||
|
for (const item of incoming) {
|
||||||
|
const key = getTapeItemKey(item);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
appended.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cap > 0 ? appended.slice(0, cap) : appended;
|
||||||
|
};
|
||||||
|
|
||||||
export const getLiveFeedStatus = (
|
export const getLiveFeedStatus = (
|
||||||
sourceStatus: WsStatus,
|
sourceStatus: WsStatus,
|
||||||
freshestTs: number | null,
|
freshestTs: number | null,
|
||||||
|
|
@ -1315,6 +1368,40 @@ const useScrollAnchor = (
|
||||||
return { capture, apply };
|
return { capture, apply };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useBottomHistoryGate = (
|
||||||
|
listRef: React.RefObject<HTMLDivElement>,
|
||||||
|
enabled: boolean,
|
||||||
|
onLoadOlder: () => void
|
||||||
|
): void => {
|
||||||
|
const loadRef = useRef(onLoadOlder);
|
||||||
|
useEffect(() => {
|
||||||
|
loadRef.current = onLoadOlder;
|
||||||
|
}, [onLoadOlder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = listRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maybeLoad = () => {
|
||||||
|
const threshold = Math.max(240, element.clientHeight * 0.5);
|
||||||
|
if (element.scrollTop + element.clientHeight >= element.scrollHeight - threshold) {
|
||||||
|
loadRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
maybeLoad();
|
||||||
|
element.addEventListener("scroll", maybeLoad);
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("scroll", maybeLoad);
|
||||||
|
};
|
||||||
|
}, [enabled, listRef]);
|
||||||
|
};
|
||||||
|
|
||||||
type VirtualListResult<T> = {
|
type VirtualListResult<T> = {
|
||||||
visibleItems: T[];
|
visibleItems: T[];
|
||||||
topSpacerHeight: number;
|
topSpacerHeight: number;
|
||||||
|
|
@ -1886,6 +1973,7 @@ type PausableTapeViewConfig<T extends SortableItem & { seq: number }> = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sourceStatus: WsStatus;
|
sourceStatus: WsStatus;
|
||||||
sourceItems: T[];
|
sourceItems: T[];
|
||||||
|
historyTail?: T[];
|
||||||
lastUpdate: number | null;
|
lastUpdate: number | null;
|
||||||
freshnessMs: number;
|
freshnessMs: number;
|
||||||
onNewItems?: (count: number) => void;
|
onNewItems?: (count: number) => void;
|
||||||
|
|
@ -2011,10 +2099,14 @@ const usePausableTapeView = <T extends SortableItem & { seq: number }>(
|
||||||
)
|
)
|
||||||
: "disconnected";
|
: "disconnected";
|
||||||
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
|
const projected = projectPausableTapeState(data.visible, status, config.lastUpdate);
|
||||||
|
const items = useMemo(
|
||||||
|
() => [...projected.items, ...(config.historyTail ?? [])],
|
||||||
|
[projected.items, config.historyTail]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
items: projected.items,
|
items,
|
||||||
lastUpdate: projected.lastUpdate,
|
lastUpdate: projected.lastUpdate,
|
||||||
replayTime: null,
|
replayTime: null,
|
||||||
replayComplete: false,
|
replayComplete: false,
|
||||||
|
|
@ -2269,6 +2361,15 @@ type LiveSessionState = {
|
||||||
historyLoading: Partial<Record<string, boolean>>;
|
historyLoading: Partial<Record<string, boolean>>;
|
||||||
historyErrors: Partial<Record<string, string | null>>;
|
historyErrors: Partial<Record<string, string | null>>;
|
||||||
loadOlder: (channel: LiveSubscription["channel"]) => Promise<void>;
|
loadOlder: (channel: LiveSubscription["channel"]) => Promise<void>;
|
||||||
|
optionsHistory: OptionPrint[];
|
||||||
|
nbboHistory: OptionNBBO[];
|
||||||
|
equitiesHistory: EquityPrint[];
|
||||||
|
equityJoinsHistory: EquityPrintJoin[];
|
||||||
|
flowHistory: FlowPacket[];
|
||||||
|
smartMoneyHistory: SmartMoneyEvent[];
|
||||||
|
classifierHitsHistory: ClassifierHitEvent[];
|
||||||
|
alertsHistory: AlertEvent[];
|
||||||
|
inferredDarkHistory: InferredDarkEvent[];
|
||||||
options: OptionPrint[];
|
options: OptionPrint[];
|
||||||
nbbo: OptionNBBO[];
|
nbbo: OptionNBBO[];
|
||||||
equities: EquityPrint[];
|
equities: EquityPrint[];
|
||||||
|
|
@ -2360,14 +2461,23 @@ export const getLiveManifest = (
|
||||||
];
|
];
|
||||||
|
|
||||||
if (pathname === "/tape") {
|
if (pathname === "/tape") {
|
||||||
return dedupeLiveSubscriptions([
|
const optionsSub: Extract<LiveSubscription, { channel: "options" }> = {
|
||||||
...baselineSubs,
|
channel: "options",
|
||||||
{ channel: "nbbo" },
|
filters: flowFilters,
|
||||||
{ channel: "equities", ...equityScope },
|
...optionScope,
|
||||||
{ channel: "flow", filters: flowFilters },
|
snapshot_limit: LIVE_HOT_WINDOW_OPTIONS
|
||||||
{ channel: "smart-money" },
|
};
|
||||||
{ channel: "classifier-hits" }
|
const tapeSubs: LiveSubscription[] = [
|
||||||
]);
|
optionsSub,
|
||||||
|
{ channel: "nbbo", snapshot_limit: LIVE_HOT_WINDOW },
|
||||||
|
{ channel: "equities", ...equityScope, snapshot_limit: LIVE_HOT_WINDOW },
|
||||||
|
{ channel: "flow", filters: flowFilters, snapshot_limit: LIVE_HOT_WINDOW },
|
||||||
|
{ channel: "smart-money", snapshot_limit: LIVE_HOT_WINDOW },
|
||||||
|
{ channel: "classifier-hits", snapshot_limit: LIVE_HOT_WINDOW },
|
||||||
|
{ channel: "alerts", snapshot_limit: LIVE_HOT_WINDOW },
|
||||||
|
{ channel: "inferred-dark", snapshot_limit: LIVE_HOT_WINDOW }
|
||||||
|
];
|
||||||
|
return dedupeLiveSubscriptions(tapeSubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
return dedupeLiveSubscriptions([
|
return dedupeLiveSubscriptions([
|
||||||
|
|
@ -2410,6 +2520,15 @@ const useLiveSession = (
|
||||||
const [classifierHits, setClassifierHits] = useState<ClassifierHitEvent[]>([]);
|
const [classifierHits, setClassifierHits] = useState<ClassifierHitEvent[]>([]);
|
||||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||||
const [inferredDark, setInferredDark] = useState<InferredDarkEvent[]>([]);
|
const [inferredDark, setInferredDark] = useState<InferredDarkEvent[]>([]);
|
||||||
|
const [optionsHistory, setOptionsHistory] = useState<OptionPrint[]>([]);
|
||||||
|
const [nbboHistory, setNbboHistory] = useState<OptionNBBO[]>([]);
|
||||||
|
const [equitiesHistory, setEquitiesHistory] = useState<EquityPrint[]>([]);
|
||||||
|
const [equityJoinsHistory, setEquityJoinsHistory] = useState<EquityPrintJoin[]>([]);
|
||||||
|
const [flowHistory, setFlowHistory] = useState<FlowPacket[]>([]);
|
||||||
|
const [smartMoneyHistory, setSmartMoneyHistory] = useState<SmartMoneyEvent[]>([]);
|
||||||
|
const [classifierHitsHistory, setClassifierHitsHistory] = useState<ClassifierHitEvent[]>([]);
|
||||||
|
const [alertsHistory, setAlertsHistory] = useState<AlertEvent[]>([]);
|
||||||
|
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 socketRef = useRef<WebSocket | null>(null);
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
|
@ -2443,6 +2562,15 @@ const useLiveSession = (
|
||||||
setClassifierHits([]);
|
setClassifierHits([]);
|
||||||
setAlerts([]);
|
setAlerts([]);
|
||||||
setInferredDark([]);
|
setInferredDark([]);
|
||||||
|
setOptionsHistory([]);
|
||||||
|
setNbboHistory([]);
|
||||||
|
setEquitiesHistory([]);
|
||||||
|
setEquityJoinsHistory([]);
|
||||||
|
setFlowHistory([]);
|
||||||
|
setSmartMoneyHistory([]);
|
||||||
|
setClassifierHitsHistory([]);
|
||||||
|
setAlertsHistory([]);
|
||||||
|
setInferredDarkHistory([]);
|
||||||
setChartCandles([]);
|
setChartCandles([]);
|
||||||
setChartOverlay([]);
|
setChartOverlay([]);
|
||||||
subscribedKeysRef.current = new Set();
|
subscribedKeysRef.current = new Set();
|
||||||
|
|
@ -2699,9 +2827,11 @@ const useLiveSession = (
|
||||||
);
|
);
|
||||||
if (resetScopedChannels.has("options")) {
|
if (resetScopedChannels.has("options")) {
|
||||||
setOptions([]);
|
setOptions([]);
|
||||||
|
setOptionsHistory([]);
|
||||||
}
|
}
|
||||||
if (resetScopedChannels.has("equities")) {
|
if (resetScopedChannels.has("equities")) {
|
||||||
setEquities([]);
|
setEquities([]);
|
||||||
|
setEquitiesHistory([]);
|
||||||
}
|
}
|
||||||
if (resetScopedChannels.size > 0) {
|
if (resetScopedChannels.size > 0) {
|
||||||
setHistoryCursors((current) => {
|
setHistoryCursors((current) => {
|
||||||
|
|
@ -2765,7 +2895,7 @@ const useLiveSession = (
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
before_ts: String(cursor.ts),
|
before_ts: String(cursor.ts),
|
||||||
before_seq: String(cursor.seq),
|
before_seq: String(cursor.seq),
|
||||||
limit: String(subscription.channel === "options" ? 500 : 200)
|
limit: String(subscription.channel === "options" ? LIVE_HISTORY_BATCH : 200)
|
||||||
});
|
});
|
||||||
if (subscription.channel === "options" || subscription.channel === "flow") {
|
if (subscription.channel === "options" || subscription.channel === "flow") {
|
||||||
appendOptionFlowFilters(params, subscription.filters);
|
appendOptionFlowFilters(params, subscription.filters);
|
||||||
|
|
@ -2783,45 +2913,49 @@ const useLiveSession = (
|
||||||
|
|
||||||
const mergeOlder = <T extends SortableItem>(
|
const mergeOlder = <T extends SortableItem>(
|
||||||
setter: Dispatch<SetStateAction<T[]>>,
|
setter: Dispatch<SetStateAction<T[]>>,
|
||||||
limit: number
|
liveHead: T[],
|
||||||
|
cap = LIVE_HISTORY_SOFT_CAP
|
||||||
) => {
|
) => {
|
||||||
setter((prev) =>
|
setter((prev) => appendHistoryTail(prev, older as T[], liveHead, cap));
|
||||||
mergeNewest(older as T[], prev, limit, (evicted) =>
|
|
||||||
incrementRetentionMetric("hotWindowEvictions", evicted)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (subscription.channel) {
|
switch (subscription.channel) {
|
||||||
case "options":
|
case "options":
|
||||||
mergeOlder(setOptions, LIVE_HOT_WINDOW_OPTIONS);
|
mergeOlder(
|
||||||
|
setOptionsHistory,
|
||||||
|
options,
|
||||||
|
subscription.underlying_ids?.length || subscription.option_contract_id ? 0 : LIVE_HISTORY_SOFT_CAP
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "nbbo":
|
case "nbbo":
|
||||||
mergeOlder(setNbbo, LIVE_HOT_WINDOW);
|
mergeOlder(setNbboHistory, nbbo);
|
||||||
break;
|
break;
|
||||||
case "equities":
|
case "equities":
|
||||||
mergeOlder(setEquities, LIVE_HOT_WINDOW);
|
mergeOlder(
|
||||||
|
setEquitiesHistory,
|
||||||
|
equities,
|
||||||
|
subscription.underlying_ids?.length ? 0 : LIVE_HISTORY_SOFT_CAP
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case "equity-quotes":
|
case "equity-quotes":
|
||||||
mergeOlder(setEquityQuotes, LIVE_HOT_WINDOW);
|
|
||||||
break;
|
break;
|
||||||
case "equity-joins":
|
case "equity-joins":
|
||||||
mergeOlder(setEquityJoins, LIVE_HOT_WINDOW);
|
mergeOlder(setEquityJoinsHistory, equityJoins);
|
||||||
break;
|
break;
|
||||||
case "flow":
|
case "flow":
|
||||||
mergeOlder(setFlow, LIVE_HOT_WINDOW);
|
mergeOlder(setFlowHistory, flow);
|
||||||
break;
|
break;
|
||||||
case "smart-money":
|
case "smart-money":
|
||||||
mergeOlder(setSmartMoney, LIVE_HOT_WINDOW);
|
mergeOlder(setSmartMoneyHistory, smartMoney);
|
||||||
break;
|
break;
|
||||||
case "classifier-hits":
|
case "classifier-hits":
|
||||||
mergeOlder(setClassifierHits, LIVE_HOT_WINDOW);
|
mergeOlder(setClassifierHitsHistory, classifierHits);
|
||||||
break;
|
break;
|
||||||
case "alerts":
|
case "alerts":
|
||||||
mergeOlder(setAlerts, LIVE_HOT_WINDOW);
|
mergeOlder(setAlertsHistory, alerts);
|
||||||
break;
|
break;
|
||||||
case "inferred-dark":
|
case "inferred-dark":
|
||||||
mergeOlder(setInferredDark, LIVE_HOT_WINDOW);
|
mergeOlder(setInferredDarkHistory, inferredDark);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2839,9 +2973,44 @@ 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(() => {
|
||||||
|
if (!enabled || pathname !== "/tape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scoped = manifest.filter(
|
||||||
|
(subscription) =>
|
||||||
|
(subscription.channel === "options" &&
|
||||||
|
(subscription.underlying_ids?.length || subscription.option_contract_id)) ||
|
||||||
|
(subscription.channel === "equities" && subscription.underlying_ids?.length)
|
||||||
|
);
|
||||||
|
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]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
connectedAt,
|
connectedAt,
|
||||||
|
|
@ -2852,6 +3021,15 @@ const useLiveSession = (
|
||||||
historyLoading,
|
historyLoading,
|
||||||
historyErrors,
|
historyErrors,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
|
optionsHistory,
|
||||||
|
nbboHistory,
|
||||||
|
equitiesHistory,
|
||||||
|
equityJoinsHistory,
|
||||||
|
flowHistory,
|
||||||
|
smartMoneyHistory,
|
||||||
|
classifierHitsHistory,
|
||||||
|
alertsHistory,
|
||||||
|
inferredDarkHistory,
|
||||||
options,
|
options,
|
||||||
nbbo,
|
nbbo,
|
||||||
equities,
|
equities,
|
||||||
|
|
@ -4435,6 +4613,7 @@ const useTerminalState = () => {
|
||||||
enabled: mode === "live",
|
enabled: mode === "live",
|
||||||
sourceStatus: liveSession.status,
|
sourceStatus: liveSession.status,
|
||||||
sourceItems: liveSession.options,
|
sourceItems: liveSession.options,
|
||||||
|
historyTail: liveSession.optionsHistory,
|
||||||
lastUpdate: liveSession.lastUpdate,
|
lastUpdate: liveSession.lastUpdate,
|
||||||
freshnessMs: LIVE_OPTIONS_STALE_MS,
|
freshnessMs: LIVE_OPTIONS_STALE_MS,
|
||||||
retentionLimit: LIVE_HOT_WINDOW_OPTIONS,
|
retentionLimit: LIVE_HOT_WINDOW_OPTIONS,
|
||||||
|
|
@ -4447,6 +4626,7 @@ const useTerminalState = () => {
|
||||||
enabled: mode === "live",
|
enabled: mode === "live",
|
||||||
sourceStatus: liveSession.status,
|
sourceStatus: liveSession.status,
|
||||||
sourceItems: liveSession.equities,
|
sourceItems: liveSession.equities,
|
||||||
|
historyTail: liveSession.equitiesHistory,
|
||||||
lastUpdate: liveSession.lastUpdate,
|
lastUpdate: liveSession.lastUpdate,
|
||||||
freshnessMs: LIVE_EQUITIES_STALE_MS,
|
freshnessMs: LIVE_EQUITIES_STALE_MS,
|
||||||
captureScroll: equitiesAnchor.capture,
|
captureScroll: equitiesAnchor.capture,
|
||||||
|
|
@ -4458,6 +4638,7 @@ const useTerminalState = () => {
|
||||||
enabled: mode === "live",
|
enabled: mode === "live",
|
||||||
sourceStatus: liveSession.status,
|
sourceStatus: liveSession.status,
|
||||||
sourceItems: liveSession.flow,
|
sourceItems: liveSession.flow,
|
||||||
|
historyTail: liveSession.flowHistory,
|
||||||
lastUpdate: liveSession.lastUpdate,
|
lastUpdate: liveSession.lastUpdate,
|
||||||
freshnessMs: LIVE_FLOW_STALE_MS,
|
freshnessMs: LIVE_FLOW_STALE_MS,
|
||||||
captureScroll: flowAnchor.capture,
|
captureScroll: flowAnchor.capture,
|
||||||
|
|
@ -4469,26 +4650,26 @@ const useTerminalState = () => {
|
||||||
|
|
||||||
const optionsFeed = mode === "live" ? liveOptions : options;
|
const optionsFeed = mode === "live" ? liveOptions : options;
|
||||||
const nbboFeed =
|
const nbboFeed =
|
||||||
mode === "live" ? toStaticTapeState(liveSession.status, liveSession.nbbo, liveSession.lastUpdate) : nbbo;
|
mode === "live" ? toStaticTapeState(liveSession.status, [...liveSession.nbbo, ...liveSession.nbboHistory], liveSession.lastUpdate) : nbbo;
|
||||||
const equitiesFeed = mode === "live" ? liveEquities : equities;
|
const equitiesFeed = mode === "live" ? liveEquities : equities;
|
||||||
const equityJoinsFeed =
|
const equityJoinsFeed =
|
||||||
mode === "live"
|
mode === "live"
|
||||||
? toStaticTapeState(liveSession.status, liveSession.equityJoins, liveSession.lastUpdate)
|
? toStaticTapeState(liveSession.status, [...liveSession.equityJoins, ...liveSession.equityJoinsHistory], liveSession.lastUpdate)
|
||||||
: equityJoins;
|
: equityJoins;
|
||||||
const flowFeed = mode === "live" ? liveFlow : flow;
|
const flowFeed = mode === "live" ? liveFlow : flow;
|
||||||
const alertsFeed =
|
const alertsFeed =
|
||||||
mode === "live" ? toStaticTapeState(liveSession.status, liveSession.alerts, liveSession.lastUpdate) : alerts;
|
mode === "live" ? toStaticTapeState(liveSession.status, [...liveSession.alerts, ...liveSession.alertsHistory], liveSession.lastUpdate) : alerts;
|
||||||
const classifierHitsFeed =
|
const classifierHitsFeed =
|
||||||
mode === "live"
|
mode === "live"
|
||||||
? toStaticTapeState(liveSession.status, liveSession.classifierHits, liveSession.lastUpdate)
|
? toStaticTapeState(liveSession.status, [...liveSession.classifierHits, ...liveSession.classifierHitsHistory], liveSession.lastUpdate)
|
||||||
: classifierHits;
|
: classifierHits;
|
||||||
const smartMoneyFeed =
|
const smartMoneyFeed =
|
||||||
mode === "live"
|
mode === "live"
|
||||||
? toStaticTapeState(liveSession.status, liveSession.smartMoney, liveSession.lastUpdate)
|
? toStaticTapeState(liveSession.status, [...liveSession.smartMoney, ...liveSession.smartMoneyHistory], liveSession.lastUpdate)
|
||||||
: smartMoney;
|
: smartMoney;
|
||||||
const inferredDarkFeed =
|
const inferredDarkFeed =
|
||||||
mode === "live"
|
mode === "live"
|
||||||
? toStaticTapeState(liveSession.status, liveSession.inferredDark, liveSession.lastUpdate)
|
? toStaticTapeState(liveSession.status, [...liveSession.inferredDark, ...liveSession.inferredDarkHistory], liveSession.lastUpdate)
|
||||||
: inferredDark;
|
: inferredDark;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|
@ -4575,6 +4756,11 @@ const useTerminalState = () => {
|
||||||
const [pinnedEquityJoinMap, setPinnedEquityJoinMap] = useState<
|
const [pinnedEquityJoinMap, setPinnedEquityJoinMap] = useState<
|
||||||
Map<string, PinnedEntry<EquityPrintJoin>>
|
Map<string, PinnedEntry<EquityPrintJoin>>
|
||||||
>(() => new Map());
|
>(() => new Map());
|
||||||
|
const [optionSupportSmartMoney, setOptionSupportSmartMoney] = useState<SmartMoneyEvent[]>([]);
|
||||||
|
const [optionSupportClassifierHits, setOptionSupportClassifierHits] = useState<ClassifierHitEvent[]>([]);
|
||||||
|
const [historicalNbboByTraceId, setHistoricalNbboByTraceId] = useState<Map<string, OptionNBBO | null>>(
|
||||||
|
() => new Map()
|
||||||
|
);
|
||||||
|
|
||||||
const resolvedOptionPrintMap = useMemo(() => {
|
const resolvedOptionPrintMap = useMemo(() => {
|
||||||
const merged = new Map<string, OptionPrint>();
|
const merged = new Map<string, OptionPrint>();
|
||||||
|
|
@ -4809,7 +4995,7 @@ const useTerminalState = () => {
|
||||||
|
|
||||||
const classifierHitsByPacketId = useMemo(() => {
|
const classifierHitsByPacketId = useMemo(() => {
|
||||||
const map = new Map<string, ClassifierHitEvent[]>();
|
const map = new Map<string, ClassifierHitEvent[]>();
|
||||||
for (const hit of classifierHitsFeed.items) {
|
for (const hit of [...classifierHitsFeed.items, ...optionSupportClassifierHits]) {
|
||||||
const packetId = extractPacketIdFromClassifierHitTrace(hit.trace_id);
|
const packetId = extractPacketIdFromClassifierHitTrace(hit.trace_id);
|
||||||
if (!packetId) {
|
if (!packetId) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -4817,11 +5003,11 @@ const useTerminalState = () => {
|
||||||
map.set(packetId, [...(map.get(packetId) ?? []), hit]);
|
map.set(packetId, [...(map.get(packetId) ?? []), hit]);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [classifierHitsFeed.items, extractPacketIdFromClassifierHitTrace]);
|
}, [classifierHitsFeed.items, optionSupportClassifierHits, extractPacketIdFromClassifierHitTrace]);
|
||||||
|
|
||||||
const smartMoneyByPacketId = useMemo(() => {
|
const smartMoneyByPacketId = useMemo(() => {
|
||||||
const map = new Map<string, SmartMoneyEvent>();
|
const map = new Map<string, SmartMoneyEvent>();
|
||||||
for (const event of smartMoneyFeed.items) {
|
for (const event of [...smartMoneyFeed.items, ...optionSupportSmartMoney]) {
|
||||||
for (const packetId of event.packet_ids) {
|
for (const packetId of event.packet_ids) {
|
||||||
const existing = map.get(packetId);
|
const existing = map.get(packetId);
|
||||||
if (!existing || event.source_ts > existing.source_ts || event.seq > existing.seq) {
|
if (!existing || event.source_ts > existing.source_ts || event.seq > existing.seq) {
|
||||||
|
|
@ -4830,17 +5016,17 @@ const useTerminalState = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [smartMoneyFeed.items]);
|
}, [smartMoneyFeed.items, optionSupportSmartMoney]);
|
||||||
|
|
||||||
const packetIdByOptionTraceId = useMemo(() => {
|
const packetIdByOptionTraceId = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const packet of flowFeed.items) {
|
for (const packet of resolvedFlowPacketMap.values()) {
|
||||||
for (const member of packet.members) {
|
for (const member of packet.members) {
|
||||||
map.set(member, packet.id);
|
map.set(member, packet.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [flowFeed.items]);
|
}, [resolvedFlowPacketMap]);
|
||||||
|
|
||||||
const classifierDecorByOptionTraceId = useMemo(() => {
|
const classifierDecorByOptionTraceId = useMemo(() => {
|
||||||
const map = new Map<string, ClassifierDecor>();
|
const map = new Map<string, ClassifierDecor>();
|
||||||
|
|
@ -4858,6 +5044,111 @@ const useTerminalState = () => {
|
||||||
return map;
|
return map;
|
||||||
}, [classifierHitsByPacketId, packetIdByOptionTraceId, smartMoneyByPacketId]);
|
}, [classifierHitsByPacketId, packetIdByOptionTraceId, smartMoneyByPacketId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "live" || optionsFeed.items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const traceIds: string[] = [];
|
||||||
|
const nbboContext: Array<{ trace_id: string; option_contract_id: string; ts: number }> = [];
|
||||||
|
for (const print of optionsFeed.items.slice(0, 1000)) {
|
||||||
|
if (!print.trace_id || classifierDecorByOptionTraceId.has(print.trace_id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!packetIdByOptionTraceId.has(print.trace_id)) {
|
||||||
|
traceIds.push(print.trace_id);
|
||||||
|
}
|
||||||
|
const missingPreservedNbbo =
|
||||||
|
typeof print.execution_nbbo_side !== "string" &&
|
||||||
|
typeof print.nbbo_side !== "string" &&
|
||||||
|
!historicalNbboByTraceId.has(print.trace_id);
|
||||||
|
if (missingPreservedNbbo) {
|
||||||
|
nbboContext.push({
|
||||||
|
trace_id: print.trace_id,
|
||||||
|
option_contract_id: print.option_contract_id,
|
||||||
|
ts: print.ts
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (traceIds.length >= 250 && nbboContext.length >= 250) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueTraceIds = Array.from(new Set(traceIds)).slice(0, 250);
|
||||||
|
const uniqueNbboContext = Array.from(
|
||||||
|
new Map(nbboContext.map((item) => [item.trace_id, item])).values()
|
||||||
|
).slice(0, 250);
|
||||||
|
if (uniqueTraceIds.length === 0 && uniqueNbboContext.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void fetch(buildApiUrl("/lookup/options-support"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
trace_ids: uniqueTraceIds,
|
||||||
|
nbbo_context: uniqueNbboContext
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorDetail(response));
|
||||||
|
}
|
||||||
|
return response.json() as Promise<{
|
||||||
|
packets?: FlowPacket[];
|
||||||
|
smart_money?: SmartMoneyEvent[];
|
||||||
|
classifier_hits?: ClassifierHitEvent[];
|
||||||
|
nbbo_by_trace_id?: Record<string, OptionNBBO | null>;
|
||||||
|
}>;
|
||||||
|
})
|
||||||
|
.then((payload) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const packetMap = new Map<string, FlowPacket>();
|
||||||
|
for (const packet of payload.packets ?? []) {
|
||||||
|
packetMap.set(packet.id, packet);
|
||||||
|
}
|
||||||
|
if (packetMap.size > 0) {
|
||||||
|
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, packetMap, now));
|
||||||
|
}
|
||||||
|
if (payload.smart_money?.length) {
|
||||||
|
setOptionSupportSmartMoney((prev) =>
|
||||||
|
mergeNewest(payload.smart_money ?? [], prev, PINNED_EVIDENCE_MAX_ITEMS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.classifier_hits?.length) {
|
||||||
|
setOptionSupportClassifierHits((prev) =>
|
||||||
|
mergeNewest(payload.classifier_hits ?? [], prev, PINNED_EVIDENCE_MAX_ITEMS)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.nbbo_by_trace_id) {
|
||||||
|
setHistoricalNbboByTraceId((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const [traceId, quote] of Object.entries(payload.nbbo_by_trace_id ?? {})) {
|
||||||
|
next.set(traceId, quote);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("Failed to hydrate option row support", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
mode,
|
||||||
|
optionsFeed.items,
|
||||||
|
classifierDecorByOptionTraceId,
|
||||||
|
packetIdByOptionTraceId,
|
||||||
|
historicalNbboByTraceId
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedClassifierPacketId = useMemo(() => {
|
const selectedClassifierPacketId = useMemo(() => {
|
||||||
if (!selectedClassifierHit) {
|
if (!selectedClassifierHit) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -5456,6 +5747,7 @@ const useTerminalState = () => {
|
||||||
tickerSet,
|
tickerSet,
|
||||||
chartTicker,
|
chartTicker,
|
||||||
nbboMap,
|
nbboMap,
|
||||||
|
historicalNbboByTraceId,
|
||||||
optionPrintMap: resolvedOptionPrintMap,
|
optionPrintMap: resolvedOptionPrintMap,
|
||||||
equityPrintMap,
|
equityPrintMap,
|
||||||
equityJoinMap: resolvedEquityJoinMap,
|
equityJoinMap: resolvedEquityJoinMap,
|
||||||
|
|
@ -5808,6 +6100,9 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
||||||
const state = useTerminal();
|
const state = useTerminal();
|
||||||
const items = limit ? state.filteredOptions.slice(0, limit) : state.filteredOptions;
|
const items = limit ? state.filteredOptions.slice(0, limit) : state.filteredOptions;
|
||||||
const virtual = useVirtualList(items, state.optionsScroll.listRef, !limit, 36);
|
const virtual = useVirtualList(items, state.optionsScroll.listRef, !limit, 36);
|
||||||
|
useBottomHistoryGate(state.optionsScroll.listRef, state.mode === "live" && !limit, () =>
|
||||||
|
void state.liveSession.loadOlder("options")
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pane
|
<Pane
|
||||||
|
|
@ -5868,7 +6163,7 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
||||||
const contractId = normalizeContractId(print.option_contract_id);
|
const contractId = normalizeContractId(print.option_contract_id);
|
||||||
const parsed = parseOptionContractId(contractId);
|
const parsed = parseOptionContractId(contractId);
|
||||||
const contractDisplay = formatOptionContractLabel(contractId);
|
const contractDisplay = formatOptionContractLabel(contractId);
|
||||||
const quote = state.nbboMap.get(contractId);
|
const quote = state.historicalNbboByTraceId.get(print.trace_id) ?? state.nbboMap.get(contractId);
|
||||||
const hasPreservedNbbo = typeof print.execution_nbbo_side === "string";
|
const hasPreservedNbbo = typeof print.execution_nbbo_side === "string";
|
||||||
const nbboSide =
|
const nbboSide =
|
||||||
print.execution_nbbo_side ??
|
print.execution_nbbo_side ??
|
||||||
|
|
@ -5982,6 +6277,9 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => {
|
||||||
const state = useTerminal();
|
const state = useTerminal();
|
||||||
const items = limit ? state.filteredEquities.slice(0, limit) : state.filteredEquities;
|
const items = limit ? state.filteredEquities.slice(0, limit) : state.filteredEquities;
|
||||||
const virtual = useVirtualList(items, state.equitiesScroll.listRef, !limit, 36);
|
const virtual = useVirtualList(items, state.equitiesScroll.listRef, !limit, 36);
|
||||||
|
useBottomHistoryGate(state.equitiesScroll.listRef, state.mode === "live" && !limit, () =>
|
||||||
|
void state.liveSession.loadOlder("equities")
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pane
|
<Pane
|
||||||
|
|
@ -6077,6 +6375,9 @@ const FlowPane = ({ limit, title = "Flow" }: FlowPaneProps) => {
|
||||||
const state = useTerminal();
|
const state = useTerminal();
|
||||||
const items = limit ? state.filteredFlow.slice(0, limit) : state.filteredFlow;
|
const items = limit ? state.filteredFlow.slice(0, limit) : state.filteredFlow;
|
||||||
const virtual = useVirtualList(items, state.flowScroll.listRef, !limit, 44);
|
const virtual = useVirtualList(items, state.flowScroll.listRef, !limit, 44);
|
||||||
|
useBottomHistoryGate(state.flowScroll.listRef, state.mode === "live" && !limit, () =>
|
||||||
|
void state.liveSession.loadOlder("flow")
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pane
|
<Pane
|
||||||
|
|
@ -6216,6 +6517,9 @@ const AlertsPane = ({ limit, withStrip = false, className }: AlertsPaneProps) =>
|
||||||
const state = useTerminal();
|
const state = useTerminal();
|
||||||
const items = limit ? state.filteredAlerts.slice(0, limit) : state.filteredAlerts;
|
const items = limit ? state.filteredAlerts.slice(0, limit) : state.filteredAlerts;
|
||||||
const virtual = useVirtualList(items, state.alertsScroll.listRef, !limit, 46);
|
const virtual = useVirtualList(items, state.alertsScroll.listRef, !limit, 46);
|
||||||
|
useBottomHistoryGate(state.alertsScroll.listRef, state.mode === "live" && !limit, () =>
|
||||||
|
void state.liveSession.loadOlder("alerts")
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pane
|
<Pane
|
||||||
|
|
@ -6312,6 +6616,10 @@ type ClassifierPaneProps = {
|
||||||
|
|
||||||
const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => {
|
const ClassifierPane = ({ limit, className }: ClassifierPaneProps) => {
|
||||||
const state = useTerminal();
|
const state = useTerminal();
|
||||||
|
useBottomHistoryGate(state.classifierScroll.listRef, state.mode === "live" && !limit, () => {
|
||||||
|
void state.liveSession.loadOlder("smart-money");
|
||||||
|
void state.liveSession.loadOlder("classifier-hits");
|
||||||
|
});
|
||||||
const smartMoneyItems = limit ? state.filteredSmartMoneyEvents.slice(0, limit) : state.filteredSmartMoneyEvents;
|
const smartMoneyItems = limit ? state.filteredSmartMoneyEvents.slice(0, limit) : state.filteredSmartMoneyEvents;
|
||||||
const legacyItems =
|
const legacyItems =
|
||||||
smartMoneyItems.length === 0
|
smartMoneyItems.length === 0
|
||||||
|
|
@ -6438,6 +6746,9 @@ const DarkPane = ({ limit, className }: DarkPaneProps) => {
|
||||||
const state = useTerminal();
|
const state = useTerminal();
|
||||||
const items = limit ? state.filteredInferredDark.slice(0, limit) : state.filteredInferredDark;
|
const items = limit ? state.filteredInferredDark.slice(0, limit) : state.filteredInferredDark;
|
||||||
const virtual = useVirtualList(items, state.darkScroll.listRef, !limit, 44);
|
const virtual = useVirtualList(items, state.darkScroll.listRef, !limit, 44);
|
||||||
|
useBottomHistoryGate(state.darkScroll.listRef, state.mode === "live" && !limit, () =>
|
||||||
|
void state.liveSession.loadOlder("inferred-dark")
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pane
|
<Pane
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
## Docker-internal service URLs (do not use 127.0.0.1/localhost here).
|
||||||
|
## Containers must reach each other via Compose service names.
|
||||||
NATS_URL=nats://nats:4222
|
NATS_URL=nats://nats:4222
|
||||||
CLICKHOUSE_URL=http://clickhouse:8123
|
CLICKHOUSE_URL=http://clickhouse:8123
|
||||||
CLICKHOUSE_DATABASE=default
|
CLICKHOUSE_DATABASE=default
|
||||||
|
|
|
||||||
|
|
@ -1560,6 +1560,104 @@ export const fetchFlowPacketById = async (
|
||||||
return record ? FlowPacketSchema.parse(fromFlowPacketRecord(record)) : null;
|
return record ? FlowPacketSchema.parse(fromFlowPacketRecord(record)) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchFlowPacketsByMemberTraceIds = async (
|
||||||
|
client: ClickHouseClient,
|
||||||
|
traceIds: string[]
|
||||||
|
): Promise<FlowPacket[]> => {
|
||||||
|
const ids = Array.from(new Set(traceIds.map((id) => id.trim()).filter(Boolean)));
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberPredicates = ids.map((id) => `has(members, ${quoteString(id)})`);
|
||||||
|
const result = await client.query({
|
||||||
|
query: `SELECT * FROM ${FLOW_PACKETS_TABLE} WHERE ${memberPredicates.join(" OR ")} ORDER BY source_ts DESC, seq DESC LIMIT ${clampLookupLimit(ids.length * 4)}`,
|
||||||
|
format: "JSONEachRow"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await result.json<unknown[]>();
|
||||||
|
const records = rows
|
||||||
|
.map(normalizeFlowPacketRow)
|
||||||
|
.filter((record): record is FlowPacketRecord => record !== null);
|
||||||
|
return FlowPacketSchema.array().parse(records.map(fromFlowPacketRecord));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSmartMoneyEventsByPacketIds = async (
|
||||||
|
client: ClickHouseClient,
|
||||||
|
packetIds: string[]
|
||||||
|
): Promise<SmartMoneyEvent[]> => {
|
||||||
|
const ids = Array.from(new Set(packetIds.map((id) => id.trim()).filter(Boolean)));
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const packetPredicates = ids.map((id) => `has(packet_ids, ${quoteString(id)})`);
|
||||||
|
const result = await client.query({
|
||||||
|
query: `SELECT * FROM ${SMART_MONEY_EVENTS_TABLE} WHERE ${packetPredicates.join(" OR ")} ORDER BY source_ts DESC, seq DESC LIMIT ${clampLookupLimit(ids.length * 4)}`,
|
||||||
|
format: "JSONEachRow"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await result.json<unknown[]>();
|
||||||
|
const records = rows
|
||||||
|
.map(normalizeSmartMoneyEventRow)
|
||||||
|
.filter((record): record is SmartMoneyEventRecord => record !== null);
|
||||||
|
return SmartMoneyEventSchema.array().parse(records.map(fromSmartMoneyEventRecord));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchClassifierHitsByPacketIds = async (
|
||||||
|
client: ClickHouseClient,
|
||||||
|
packetIds: string[]
|
||||||
|
): Promise<ClassifierHitEvent[]> => {
|
||||||
|
const ids = Array.from(new Set(packetIds.map((id) => id.trim()).filter(Boolean)));
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracePredicates = ids.map((id) => `position(trace_id, ${quoteString(id)}) > 0`);
|
||||||
|
const result = await client.query({
|
||||||
|
query: `SELECT * FROM ${CLASSIFIER_HITS_TABLE} WHERE ${tracePredicates.join(" OR ")} ORDER BY source_ts DESC, seq DESC LIMIT ${clampLookupLimit(ids.length * 4)}`,
|
||||||
|
format: "JSONEachRow"
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await result.json<unknown[]>();
|
||||||
|
const records = rows
|
||||||
|
.map(normalizeClassifierHitRow)
|
||||||
|
.filter((record): record is ClassifierHitRecord => record !== null);
|
||||||
|
return ClassifierHitEventSchema.array().parse(records.map(fromClassifierHitRecord));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchNearestOptionNBBOForPrints = async (
|
||||||
|
client: ClickHouseClient,
|
||||||
|
inputs: Array<{ trace_id: string; option_contract_id: string; ts: number }>
|
||||||
|
): Promise<Record<string, OptionNBBO | null>> => {
|
||||||
|
const normalized = inputs
|
||||||
|
.map((item) => ({
|
||||||
|
trace_id: item.trace_id.trim(),
|
||||||
|
option_contract_id: item.option_contract_id.trim(),
|
||||||
|
ts: clampCursor(item.ts)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.trace_id && item.option_contract_id);
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const byTraceId: Record<string, OptionNBBO | null> = Object.fromEntries(
|
||||||
|
normalized.map((item) => [item.trace_id, null])
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
normalized.map(async (item) => {
|
||||||
|
const result = await client.query({
|
||||||
|
query: `SELECT * FROM ${OPTION_NBBO_TABLE} WHERE option_contract_id = ${quoteString(item.option_contract_id)} AND ts <= ${item.ts} ORDER BY ts DESC, seq DESC LIMIT 1`,
|
||||||
|
format: "JSONEachRow"
|
||||||
|
});
|
||||||
|
const rows = await result.json<unknown[]>();
|
||||||
|
const quote = OptionNBBOSchema.array().parse(rows.map(normalizeOptionNbboRow))[0] ?? null;
|
||||||
|
byTraceId[item.trace_id] = quote;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return byTraceId;
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchOptionPrintsByTraceIds = async (
|
export const fetchOptionPrintsByTraceIds = async (
|
||||||
client: ClickHouseClient,
|
client: ClickHouseClient,
|
||||||
traceIds: string[]
|
traceIds: string[]
|
||||||
|
|
|
||||||
|
|
@ -60,21 +60,26 @@ export const LiveSubscriptionSchema = z.discriminatedUnion("channel", [
|
||||||
channel: z.literal("options"),
|
channel: z.literal("options"),
|
||||||
filters: OptionFlowFiltersSchema.optional(),
|
filters: OptionFlowFiltersSchema.optional(),
|
||||||
underlying_ids: z.array(z.string().min(1)).optional(),
|
underlying_ids: z.array(z.string().min(1)).optional(),
|
||||||
option_contract_id: z.string().min(1).optional()
|
option_contract_id: z.string().min(1).optional(),
|
||||||
|
snapshot_limit: z.number().int().positive().optional()
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
channel: z.literal("flow"),
|
channel: z.literal("flow"),
|
||||||
filters: OptionFlowFiltersSchema.optional()
|
filters: OptionFlowFiltersSchema.optional(),
|
||||||
|
snapshot_limit: z.number().int().positive().optional()
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
channel: z.literal("smart-money")
|
channel: z.literal("smart-money"),
|
||||||
|
snapshot_limit: z.number().int().positive().optional()
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
channel: z.enum(["nbbo", "equity-quotes", "equity-joins", "classifier-hits", "alerts", "inferred-dark"])
|
channel: z.enum(["nbbo", "equity-quotes", "equity-joins", "classifier-hits", "alerts", "inferred-dark"]),
|
||||||
|
snapshot_limit: z.number().int().positive().optional()
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
channel: z.literal("equities"),
|
channel: z.literal("equities"),
|
||||||
underlying_ids: z.array(z.string().min(1)).optional()
|
underlying_ids: z.array(z.string().min(1)).optional(),
|
||||||
|
snapshot_limit: z.number().int().positive().optional()
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
channel: z.literal("equity-candles"),
|
channel: z.literal("equity-candles"),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import {
|
||||||
fetchSmartMoneyEventsBefore,
|
fetchSmartMoneyEventsBefore,
|
||||||
fetchFlowPacketsAfter,
|
fetchFlowPacketsAfter,
|
||||||
fetchFlowPacketById,
|
fetchFlowPacketById,
|
||||||
|
fetchFlowPacketsByMemberTraceIds,
|
||||||
fetchFlowPacketsBefore,
|
fetchFlowPacketsBefore,
|
||||||
fetchRecentAlerts,
|
fetchRecentAlerts,
|
||||||
fetchRecentClassifierHits,
|
fetchRecentClassifierHits,
|
||||||
|
|
@ -76,6 +77,9 @@ import {
|
||||||
fetchOptionPrintsBefore,
|
fetchOptionPrintsBefore,
|
||||||
fetchOptionPrintsAfter,
|
fetchOptionPrintsAfter,
|
||||||
fetchOptionPrintsByTraceIds,
|
fetchOptionPrintsByTraceIds,
|
||||||
|
fetchNearestOptionNBBOForPrints,
|
||||||
|
fetchSmartMoneyEventsByPacketIds,
|
||||||
|
fetchClassifierHitsByPacketIds,
|
||||||
fetchRecentOptionPrints
|
fetchRecentOptionPrints
|
||||||
} from "@islandflow/storage";
|
} from "@islandflow/storage";
|
||||||
import type { EquityPrintQueryFilters, OptionPrintQueryFilters } from "@islandflow/storage";
|
import type { EquityPrintQueryFilters, OptionPrintQueryFilters } from "@islandflow/storage";
|
||||||
|
|
@ -303,6 +307,28 @@ const jsonResponse = (body: unknown, status = 200): Response => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readJsonBody = async (req: Request): Promise<unknown> => {
|
||||||
|
const text = await req.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return JSON.parse(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionsSupportLookupSchema = z.object({
|
||||||
|
trace_ids: z.array(z.string().min(1)).default([]),
|
||||||
|
nbbo_context: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
trace_id: z.string().min(1),
|
||||||
|
option_contract_id: z.string().min(1),
|
||||||
|
ts: z.number().int().nonnegative()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
});
|
||||||
|
|
||||||
const parseLimit = (value: string | null): number => {
|
const parseLimit = (value: string | null): number => {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return env.REST_DEFAULT_LIMIT;
|
return env.REST_DEFAULT_LIMIT;
|
||||||
|
|
@ -1608,6 +1634,33 @@ const run = async () => {
|
||||||
return jsonResponse({ data });
|
return jsonResponse({ data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/lookup/options-support") {
|
||||||
|
try {
|
||||||
|
const body = optionsSupportLookupSchema.parse(await readJsonBody(req));
|
||||||
|
const packets = await fetchFlowPacketsByMemberTraceIds(clickhouse, body.trace_ids);
|
||||||
|
const packetIds = packets.map((packet) => packet.id);
|
||||||
|
const [smartMoney, classifierHits, nbboByTraceId] = await Promise.all([
|
||||||
|
fetchSmartMoneyEventsByPacketIds(clickhouse, packetIds),
|
||||||
|
fetchClassifierHitsByPacketIds(clickhouse, packetIds),
|
||||||
|
fetchNearestOptionNBBOForPrints(clickhouse, body.nbbo_context)
|
||||||
|
]);
|
||||||
|
return jsonResponse({
|
||||||
|
packets,
|
||||||
|
smart_money: smartMoney,
|
||||||
|
classifier_hits: classifierHits,
|
||||||
|
nbbo_by_trace_id: nbboByTraceId
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: "invalid options support lookup",
|
||||||
|
detail: error instanceof Error ? error.message : String(error)
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/equity-joins/by-id") {
|
if (req.method === "GET" && url.pathname === "/equity-joins/by-id") {
|
||||||
const ids = url.searchParams.getAll("id");
|
const ids = url.searchParams.getAll("id");
|
||||||
const data = await fetchEquityPrintJoinsByIds(clickhouse, ids);
|
const data = await fetchEquityPrintJoinsByIds(clickhouse, ids);
|
||||||
|
|
|
||||||
|
|
@ -327,6 +327,14 @@ const nextBeforeForItems = <T>(items: T[], cursorOf: (item: T) => Cursor): Curso
|
||||||
return last ? cursorOf(last) : null;
|
return last ? cursorOf(last) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const snapshotLimitFor = (subscription: LiveSubscription, configuredLimit: number): number => {
|
||||||
|
const requested = "snapshot_limit" in subscription ? subscription.snapshot_limit : undefined;
|
||||||
|
if (!requested) {
|
||||||
|
return configuredLimit;
|
||||||
|
}
|
||||||
|
return Math.max(1, Math.min(configuredLimit, Math.floor(requested)));
|
||||||
|
};
|
||||||
|
|
||||||
const candleRedisKey = (underlyingId: string, intervalMs: number): string =>
|
const candleRedisKey = (underlyingId: string, intervalMs: number): string =>
|
||||||
`live:equity-candles:${underlyingId}:${intervalMs}`;
|
`live:equity-candles:${underlyingId}:${intervalMs}`;
|
||||||
|
|
||||||
|
|
@ -448,6 +456,7 @@ export class LiveStateManager {
|
||||||
const scoped =
|
const scoped =
|
||||||
Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
|
Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
|
||||||
if (subscription.filters?.view === "raw" || scoped) {
|
if (subscription.filters?.view === "raw" || scoped) {
|
||||||
|
const limit = snapshotLimitFor(subscription, this.generic.options.limit);
|
||||||
const storageFilters: OptionPrintQueryFilters = {
|
const storageFilters: OptionPrintQueryFilters = {
|
||||||
view: subscription.filters?.view ?? "signal",
|
view: subscription.filters?.view ?? "signal",
|
||||||
security:
|
security:
|
||||||
|
|
@ -463,7 +472,7 @@ export class LiveStateManager {
|
||||||
};
|
};
|
||||||
const items = await fetchRecentOptionPrints(
|
const items = await fetchRecentOptionPrints(
|
||||||
this.clickhouse,
|
this.clickhouse,
|
||||||
this.generic.options.limit,
|
limit,
|
||||||
undefined,
|
undefined,
|
||||||
storageFilters
|
storageFilters
|
||||||
);
|
);
|
||||||
|
|
@ -476,10 +485,11 @@ export class LiveStateManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = this.generic.options;
|
const config = this.generic.options;
|
||||||
|
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) &&
|
isWithinLiveFeedLookback("options", item) &&
|
||||||
matchesOptionPrintFilters(item, subscription.filters)
|
matchesOptionPrintFilters(item, subscription.filters)
|
||||||
);
|
).slice(0, limit);
|
||||||
return {
|
return {
|
||||||
subscription,
|
subscription,
|
||||||
items,
|
items,
|
||||||
|
|
@ -489,10 +499,11 @@ export class LiveStateManager {
|
||||||
}
|
}
|
||||||
case "flow": {
|
case "flow": {
|
||||||
const config = this.generic.flow;
|
const config = this.generic.flow;
|
||||||
|
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) &&
|
isWithinLiveFeedLookback("flow", item) &&
|
||||||
matchesFlowPacketFilters(item, subscription.filters)
|
matchesFlowPacketFilters(item, subscription.filters)
|
||||||
);
|
).slice(0, limit);
|
||||||
return {
|
return {
|
||||||
subscription,
|
subscription,
|
||||||
items,
|
items,
|
||||||
|
|
@ -502,12 +513,13 @@ export class LiveStateManager {
|
||||||
}
|
}
|
||||||
case "equities": {
|
case "equities": {
|
||||||
const config = this.generic.equities;
|
const config = this.generic.equities;
|
||||||
|
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
|
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||||
};
|
};
|
||||||
const items = await fetchRecentEquityPrints(this.clickhouse, config.limit, filters);
|
const items = await fetchRecentEquityPrints(this.clickhouse, limit, filters);
|
||||||
return {
|
return {
|
||||||
subscription,
|
subscription,
|
||||||
items,
|
items,
|
||||||
|
|
@ -517,7 +529,7 @@ export class LiveStateManager {
|
||||||
}
|
}
|
||||||
const items = (this.genericItems.get("equities") ?? []).filter((item) =>
|
const items = (this.genericItems.get("equities") ?? []).filter((item) =>
|
||||||
isWithinLiveFeedLookback("equities", item)
|
isWithinLiveFeedLookback("equities", item)
|
||||||
);
|
).slice(0, limit);
|
||||||
return {
|
return {
|
||||||
subscription,
|
subscription,
|
||||||
items,
|
items,
|
||||||
|
|
@ -555,9 +567,10 @@ export class LiveStateManager {
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
const config = this.generic[subscription.channel];
|
const config = this.generic[subscription.channel];
|
||||||
|
const limit = snapshotLimitFor(subscription, config.limit);
|
||||||
const items = (this.genericItems.get(subscription.channel) ?? []).filter((item) =>
|
const items = (this.genericItems.get(subscription.channel) ?? []).filter((item) =>
|
||||||
isWithinLiveFeedLookback(subscription.channel, item)
|
isWithinLiveFeedLookback(subscription.channel, item)
|
||||||
);
|
).slice(0, limit);
|
||||||
return {
|
return {
|
||||||
subscription,
|
subscription,
|
||||||
items,
|
items,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue