Implement scoped live 24h feed visibility

This commit is contained in:
dirtydishes 2026-05-04 05:52:38 -04:00
parent f28c8e641f
commit 48b0d980a6
11 changed files with 547 additions and 49 deletions

View file

@ -384,6 +384,59 @@ input {
color: #ffd89a;
}
.instrument-focus-chip {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
max-width: min(360px, 32vw);
padding: 6px 8px 6px 10px;
border: 1px solid rgba(255, 216, 154, 0.34);
border-radius: 8px;
background: rgba(245, 166, 35, 0.08);
color: #ffe2aa;
font-family: var(--font-mono), monospace;
font-size: 0.72rem;
font-weight: 700;
}
.instrument-focus-chip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.instrument-focus-chip button,
.instrument-cell-button {
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.instrument-focus-chip button {
padding: 4px 6px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.62rem;
}
.instrument-cell-button {
padding: 0;
text-align: inherit;
text-decoration: underline;
text-decoration-color: rgba(255, 216, 154, 0.36);
text-underline-offset: 3px;
}
.instrument-cell-button:hover,
.instrument-cell-button:focus-visible {
color: #ffd89a;
outline: none;
}
.pause-button {
padding: 7px 10px;
font-size: 0.66rem;

View file

@ -73,6 +73,32 @@ describe("live manifest", () => {
expect(optionsSubscription?.filters).toBe(filters);
});
it("includes scoped option and equity subscriptions", () => {
const manifest = getLiveManifest(
"/tape",
"AAPL",
60000,
buildDefaultFlowFilters(),
{
underlying_ids: ["AAPL"],
option_contract_id: "AAPL-2025-01-17-200-C"
},
{ underlying_ids: ["AAPL"] }
);
const optionsSubscription = manifest.find(
(subscription): subscription is Extract<(typeof manifest)[number], { channel: "options" }> =>
subscription.channel === "options"
);
const equitiesSubscription = manifest.find(
(subscription): subscription is Extract<(typeof manifest)[number], { channel: "equities" }> =>
subscription.channel === "equities"
);
expect(optionsSubscription?.underlying_ids).toEqual(["AAPL"]);
expect(optionsSubscription?.option_contract_id).toBe("AAPL-2025-01-17-200-C");
expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]);
});
});
describe("live tape pausable helpers", () => {

View file

@ -13,6 +13,7 @@ import {
useState,
type CSSProperties,
type Dispatch,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type SetStateAction
} from "react";
@ -124,6 +125,11 @@ type ChartCandle = {
close: number;
};
type SelectedInstrument =
| null
| { kind: "equity"; underlyingId: string }
| { kind: "option-contract"; contractId: string; underlyingId: string };
const formatIntervalLabel = (intervalMs: number): string => {
const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs);
if (match) {
@ -2247,6 +2253,15 @@ const appendOptionFlowFilters = (params: URLSearchParams, filters: OptionFlowFil
}
};
const appendLiveScopeParams = (params: URLSearchParams, subscription: LiveSubscription): void => {
if ((subscription.channel === "options" || subscription.channel === "equities") && subscription.underlying_ids?.length) {
params.set("underlying_ids", subscription.underlying_ids.join(","));
}
if (subscription.channel === "options" && subscription.option_contract_id) {
params.set("option_contract_id", subscription.option_contract_id);
}
};
const dedupeLiveSubscriptions = (subscriptions: LiveSubscription[]): LiveSubscription[] => {
const seen = new Set<string>();
return subscriptions.filter((subscription) => {
@ -2263,9 +2278,11 @@ export const getLiveManifest = (
pathname: string,
chartTicker: string,
chartIntervalMs: number,
flowFilters: OptionFlowFilters
flowFilters: OptionFlowFilters,
optionScope?: Pick<Extract<LiveSubscription, { channel: "options" }>, "underlying_ids" | "option_contract_id">,
equityScope?: Pick<Extract<LiveSubscription, { channel: "equities" }>, "underlying_ids">
): LiveSubscription[] => {
const baselineSubs: LiveSubscription[] = [{ channel: "options", filters: flowFilters }];
const baselineSubs: LiveSubscription[] = [{ channel: "options", filters: flowFilters, ...optionScope }];
const chartSubs: LiveSubscription[] = [
{ channel: "equity-candles", underlying_id: chartTicker, interval_ms: chartIntervalMs },
{ channel: "equity-overlay", underlying_id: chartTicker }
@ -2274,9 +2291,9 @@ export const getLiveManifest = (
if (pathname === "/tape") {
return dedupeLiveSubscriptions([
...baselineSubs,
{ channel: "options", filters: flowFilters },
{ channel: "options", filters: flowFilters, ...optionScope },
{ channel: "nbbo" },
{ channel: "equities" },
{ channel: "equities", ...equityScope },
{ channel: "flow", filters: flowFilters },
{ channel: "classifier-hits" }
]);
@ -2306,7 +2323,7 @@ export const getLiveManifest = (
return dedupeLiveSubscriptions([
...baselineSubs,
{ channel: "equities" },
{ channel: "equities", ...equityScope },
{ channel: "flow" },
{ channel: "alerts" },
{ channel: "classifier-hits" },
@ -2320,7 +2337,9 @@ const useLiveSession = (
pathname: string,
chartTicker: string,
chartIntervalMs: number,
flowFilters: OptionFlowFilters
flowFilters: OptionFlowFilters,
optionScope?: Pick<Extract<LiveSubscription, { channel: "options" }>, "underlying_ids" | "option_contract_id">,
equityScope?: Pick<Extract<LiveSubscription, { channel: "equities" }>, "underlying_ids">
): LiveSessionState => {
const [status, setStatus] = useState<WsStatus>(enabled ? "connecting" : "disconnected");
const [connectedAt, setConnectedAt] = useState<number | null>(null);
@ -2350,8 +2369,8 @@ const useLiveSession = (
const subscribedKeysRef = useRef<Set<string>>(new Set());
const subscribedMapRef = useRef<Map<string, LiveSubscription>>(new Map());
const manifest = useMemo(
() => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters),
[pathname, chartTicker, chartIntervalMs, flowFilters]
() => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope),
[pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope]
);
useEffect(() => {
@ -2616,6 +2635,42 @@ const useLiveSession = (
const currentKeys = subscribedKeysRef.current;
const toSubscribe = manifest.filter((sub) => !currentKeys.has(getLiveSubscriptionKey(sub)));
const removedKeys = Array.from(currentKeys).filter((key) => !nextKeys.has(key));
const resetScopedChannels = new Set(
[...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]
.map((key) => subscribedMapRef.current.get(key) ?? nextMap.get(key) ?? null)
.filter((sub): sub is LiveSubscription => sub !== null)
.map((sub) => sub.channel)
.filter((channel) => channel === "options" || channel === "equities")
);
if (resetScopedChannels.has("options")) {
setOptions([]);
}
if (resetScopedChannels.has("equities")) {
setEquities([]);
}
if (resetScopedChannels.size > 0) {
setHistoryCursors((current) => {
const next = { ...current };
for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) {
delete next[key];
}
return next;
});
setHistoryLoading((current) => {
const next = { ...current };
for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) {
delete next[key];
}
return next;
});
setHistoryErrors((current) => {
const next = { ...current };
for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) {
delete next[key];
}
return next;
});
}
if (removedKeys.length > 0) {
const removedSubs = removedKeys
@ -2660,6 +2715,7 @@ const useLiveSession = (
if (subscription.channel === "options" || subscription.channel === "flow") {
appendOptionFlowFilters(params, subscription.filters);
}
appendLiveScopeParams(params, subscription);
const response = await fetch(buildApiUrl(`${endpoint}?${params.toString()}`));
if (!response.ok) {
const detail = await readErrorDetail(response);
@ -3981,6 +4037,7 @@ const useTerminalState = () => {
const [selectedAlert, setSelectedAlert] = useState<AlertEvent | null>(null);
const [selectedDarkEvent, setSelectedDarkEvent] = useState<InferredDarkEvent | null>(null);
const [selectedClassifierHit, setSelectedClassifierHit] = useState<ClassifierHitEvent | null>(null);
const [selectedInstrument, setSelectedInstrument] = useState<SelectedInstrument>(null);
const [filterInput, setFilterInput] = useState<string>("");
const [flowFilters, setFlowFilters] = useState<OptionFlowFilters>(() => buildDefaultFlowFilters());
const [chartIntervalMs, setChartIntervalMs] = useState<number>(CANDLE_INTERVALS[0].ms);
@ -3992,20 +4049,52 @@ const useTerminalState = () => {
return Array.from(new Set(parts));
}, [filterInput]);
const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]);
const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]);
const instrumentUnderlying = selectedInstrument?.underlyingId.toUpperCase() ?? null;
const optionScope = useMemo(
() => ({
underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined,
option_contract_id:
selectedInstrument?.kind === "option-contract" ? selectedInstrument.contractId : undefined
}),
[activeTickers, instrumentUnderlying, selectedInstrument]
);
const equityScope = useMemo(
() => ({
underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined
}),
[activeTickers, instrumentUnderlying]
);
const chartTicker = useMemo(
() => instrumentUnderlying ?? activeTickers[0] ?? "SPY",
[activeTickers, instrumentUnderlying]
);
const selectedInstrumentLabel = useMemo(() => {
if (!selectedInstrument) {
return null;
}
if (selectedInstrument.kind === "equity") {
return `Equity: ${selectedInstrument.underlyingId}`;
}
const display = formatOptionContractLabel(selectedInstrument.contractId);
return display
? `Contract: ${display.ticker} ${display.expiration} ${display.strike}`
: `Contract: ${selectedInstrument.contractId}`;
}, [selectedInstrument]);
const liveSession = useLiveSession(
mode === "live",
pathname,
chartTicker,
chartIntervalMs,
flowFilters
flowFilters,
optionScope,
equityScope
);
const equitiesLiveSubscriptionActive = useMemo(
() =>
getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters).some(
getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope).some(
(sub) => sub.channel === "equities"
),
[pathname, chartTicker, chartIntervalMs, flowFilters]
[pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope]
);
const handleReplaySource = useCallback((value: string | null) => {
@ -4665,19 +4754,31 @@ const useTerminalState = () => {
if (!matchesOptionPrintFilters(print, flowFilters)) {
return false;
}
if (
selectedInstrument?.kind === "option-contract" &&
normalizeContractId(print.option_contract_id) !== selectedInstrument.contractId
) {
return false;
}
if (tickerSet.size === 0) {
return true;
return (
!instrumentUnderlying ||
extractUnderlying(normalizeContractId(print.option_contract_id)) === instrumentUnderlying
);
}
return matchesTicker(extractUnderlying(normalizeContractId(print.option_contract_id)));
});
}, [flowFilters, optionsFeed.items, matchesTicker, tickerSet]);
}, [flowFilters, optionsFeed.items, matchesTicker, tickerSet, selectedInstrument, instrumentUnderlying]);
const filteredEquities = useMemo(() => {
if (tickerSet.size === 0) {
if (instrumentUnderlying) {
return equitiesFeed.items.filter((print) => print.underlying_id.toUpperCase() === instrumentUnderlying);
}
return equitiesFeed.items;
}
return equitiesFeed.items.filter((print) => matchesTicker(print.underlying_id));
}, [equitiesFeed.items, matchesTicker, tickerSet]);
}, [equitiesFeed.items, matchesTicker, tickerSet, instrumentUnderlying]);
const equitiesSilentWarning = shouldShowEquitiesSilentFeedWarning({
wsStatus: liveSession.status,
@ -5000,6 +5101,9 @@ const useTerminalState = () => {
setSelectedDarkEvent,
selectedClassifierHit,
setSelectedClassifierHit,
selectedInstrument,
setSelectedInstrument,
selectedInstrumentLabel,
filterInput,
setFilterInput,
flowFilters,
@ -5473,6 +5577,15 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
const spot = print.execution_underlying_spot;
const iv = print.execution_iv;
const decor = state.classifierDecorByOptionTraceId.get(print.trace_id);
const underlyingId = (print.underlying_id ?? parsed?.root ?? extractUnderlying(contractId)).toUpperCase();
const focusContract = (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
state.setSelectedInstrument({
kind: "option-contract",
contractId,
underlyingId
});
};
const commonProps = {
className: `data-table-row data-table-row-button data-table-row-classified data-table-row-options${decor ? ` is-classified classifier-${decor.tone}` : ""}`,
style: decor ? ({ "--classifier-intensity": decor.intensity } as CSSProperties) : undefined
@ -5480,10 +5593,26 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
const cells = (
<>
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
<span className="data-table-cell">{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}</span>
<span className="data-table-cell">{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}</span>
<span className="data-table-cell data-table-cell-number">{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}</span>
<span className="data-table-cell">{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}</span>
<span className="data-table-cell">
<button className="instrument-cell-button" type="button" onClick={focusContract}>
{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}
</button>
</span>
<span className="data-table-cell">
<button className="instrument-cell-button" type="button" onClick={focusContract}>
{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}
</button>
</span>
<span className="data-table-cell data-table-cell-number">
<button className="instrument-cell-button" type="button" onClick={focusContract}>
{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}
</button>
</span>
<span className="data-table-cell">
<button className="instrument-cell-button" type="button" onClick={focusContract}>
{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}
</button>
</span>
<span className="data-table-cell data-table-cell-number">{typeof spot === "number" ? formatPrice(spot) : "--"}</span>
<span className="data-table-cell data-table-cell-number">
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
@ -5598,7 +5727,20 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => {
{virtual.visibleItems.map((print) => (
<div className="data-table-row data-table-row-equities" key={`${print.trace_id}-${print.seq}`}>
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
<span className="data-table-cell">{print.underlying_id}</span>
<span className="data-table-cell">
<button
className="instrument-cell-button"
type="button"
onClick={() =>
state.setSelectedInstrument({
kind: "equity",
underlyingId: print.underlying_id.toUpperCase()
})
}
>
{print.underlying_id}
</button>
</span>
<span className="data-table-cell data-table-cell-number">${formatPrice(print.price)}</span>
<span className="data-table-cell data-table-cell-number">{formatSize(print.size)}x</span>
<span className="data-table-cell">{print.exchange}</span>
@ -6237,6 +6379,14 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
>
Clear
</button>
{state.selectedInstrumentLabel ? (
<span className="instrument-focus-chip">
<span>{state.selectedInstrumentLabel}</span>
<button type="button" onClick={() => state.setSelectedInstrument(null)}>
Clear
</button>
</span>
) : null}
</div>
<div className="terminal-topbar-mode">
<button

View file

@ -552,6 +552,14 @@ export type OptionPrintQueryFilters = {
security?: "stock" | "etf" | "all";
optionTypes?: string[];
nbboSides?: string[];
underlyingIds?: string[];
optionContractId?: string;
sinceTs?: number;
};
export type EquityPrintQueryFilters = {
underlyingIds?: string[];
sinceTs?: number;
};
const buildOptionPrintFilterConditions = (
@ -590,6 +598,32 @@ const buildOptionPrintFilterConditions = (
conditions.push(`nbbo_side IN (${buildStringList(filters.nbboSides)})`);
}
if (filters.underlyingIds && filters.underlyingIds.length > 0) {
conditions.push(`underlying_id IN (${buildStringList(filters.underlyingIds)})`);
}
if (filters.optionContractId) {
conditions.push(`option_contract_id = ${quoteString(filters.optionContractId)}`);
}
if (typeof filters.sinceTs === "number" && Number.isFinite(filters.sinceTs)) {
conditions.push(`ts >= ${clampCursor(filters.sinceTs)}`);
}
return conditions;
};
const buildEquityPrintFilterConditions = (filters?: EquityPrintQueryFilters): string[] => {
const conditions: string[] = [];
if (!filters) {
return conditions;
}
if (filters.underlyingIds && filters.underlyingIds.length > 0) {
conditions.push(`underlying_id IN (${buildStringList(filters.underlyingIds)})`);
}
if (typeof filters.sinceTs === "number" && Number.isFinite(filters.sinceTs)) {
conditions.push(`ts >= ${clampCursor(filters.sinceTs)}`);
}
return conditions;
};
@ -798,11 +832,14 @@ export const fetchRecentOptionNBBO = async (
export const fetchRecentEquityPrints = async (
client: ClickHouseClient,
limit: number
limit: number,
filters?: EquityPrintQueryFilters
): Promise<EquityPrint[]> => {
const safeLimit = clampLimit(limit);
const conditions = buildEquityPrintFilterConditions(filters);
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
const result = await client.query({
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE}${whereClause} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
format: "JSONEachRow"
});
@ -983,14 +1020,20 @@ export const fetchEquityPrintsAfter = async (
client: ClickHouseClient,
afterTs: number,
afterSeq: number,
limit: number
limit: number,
filters?: EquityPrintQueryFilters
): Promise<EquityPrint[]> => {
const safeLimit = clampLimit(limit);
const safeAfterTs = clampCursor(afterTs);
const safeAfterSeq = clampCursor(afterSeq);
const conditions = [
`((ts, seq) > (${safeAfterTs}, ${safeAfterSeq}))`,
...buildEquityPrintFilterConditions(filters)
];
const result = await client.query({
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE (ts, seq) > (${safeAfterTs}, ${safeAfterSeq}) ORDER BY ts ASC, seq ASC LIMIT ${safeLimit}`,
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ts ASC, seq ASC LIMIT ${safeLimit}`,
format: "JSONEachRow"
});
@ -1252,11 +1295,16 @@ export const fetchEquityPrintsBefore = async (
client: ClickHouseClient,
beforeTs: number,
beforeSeq: number,
limit: number
limit: number,
filters?: EquityPrintQueryFilters
): Promise<EquityPrint[]> => {
const safeLimit = clampLimit(limit);
const conditions = [
buildBeforeTupleCondition("ts", "seq", beforeTs, beforeSeq),
...buildEquityPrintFilterConditions(filters)
];
const result = await client.query({
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE ${buildBeforeTupleCondition("ts", "seq", beforeTs, beforeSeq)} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
format: "JSONEachRow"
});

View file

@ -1,4 +1,10 @@
import { describe, expect, it } from "bun:test";
import {
createClickHouseClient,
fetchEquityPrintsAfter,
fetchEquityPrintsBefore,
fetchRecentEquityPrints
} from "../src/clickhouse";
import { equityPrintsTableDDL, EQUITY_PRINTS_TABLE } from "../src/equity-prints";
const basePrint = {
@ -24,4 +30,39 @@ describe("equity-prints storage helpers", () => {
expect(ddl).toContain(EQUITY_PRINTS_TABLE);
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS");
});
it("builds scoped recent, before, and after queries", async () => {
const queries: string[] = [];
const client = createClickHouseClient({ url: "http://127.0.0.1:8123" });
client.query = async ({ query }) => {
queries.push(query);
return {
async json<T>() {
return [] as T;
}
};
};
await fetchRecentEquityPrints(client, 25, {
underlyingIds: ["AAPL", "NVDA"],
sinceTs: 123
});
await fetchEquityPrintsBefore(client, 100, 5, 20, {
underlyingIds: ["AAPL"],
sinceTs: 50
});
await fetchEquityPrintsAfter(client, 100, 5, 20, {
underlyingIds: ["NVDA"],
sinceTs: 50
});
expect(queries[0]).toContain("underlying_id IN ('AAPL', 'NVDA')");
expect(queries[0]).toContain("ts >= 123");
expect(queries[1]).toContain("(ts, seq) < (100, 5)");
expect(queries[1]).toContain("underlying_id IN ('AAPL')");
expect(queries[1]).toContain("ts >= 50");
expect(queries[2]).toContain("((ts, seq) > (100, 5))");
expect(queries[2]).toContain("underlying_id IN ('NVDA')");
expect(queries[2]).toContain("ts >= 50");
});
});

View file

@ -58,7 +58,10 @@ describe("option-prints storage helpers", () => {
security: "stock",
nbboSides: ["AA", "A"],
optionTypes: ["call"],
minNotional: 25_000
minNotional: 25_000,
underlyingIds: ["AAPL", "NVDA"],
optionContractId: "AAPL-2025-01-17-200-C",
sinceTs: 123
});
await fetchOptionPrintsBefore(client, 100, 5, 20, "alpaca");
await fetchOptionPrintsByTraceIds(client, ["trace-1", "trace-2"]);
@ -68,6 +71,9 @@ describe("option-prints storage helpers", () => {
expect(queries[0]).toContain("nbbo_side IN ('AA', 'A')");
expect(queries[0]).toContain("option_type IN ('call')");
expect(queries[0]).toContain("notional >= 25000");
expect(queries[0]).toContain("underlying_id IN ('AAPL', 'NVDA')");
expect(queries[0]).toContain("option_contract_id = 'AAPL-2025-01-17-200-C'");
expect(queries[0]).toContain("ts >= 123");
expect(queries[1]).toContain("(ts, seq) < (100, 5)");
expect(queries[1]).toContain("startsWith(trace_id, 'alpaca')");
expect(queries[1]).toContain("ORDER BY ts DESC, seq DESC LIMIT 20");

View file

@ -55,14 +55,20 @@ export type LiveGenericChannel = z.infer<typeof LiveGenericChannelSchema>;
export const LiveSubscriptionSchema = z.discriminatedUnion("channel", [
z.object({
channel: z.literal("options"),
filters: OptionFlowFiltersSchema.optional()
filters: OptionFlowFiltersSchema.optional(),
underlying_ids: z.array(z.string().min(1)).optional(),
option_contract_id: z.string().min(1).optional()
}),
z.object({
channel: z.literal("flow"),
filters: OptionFlowFiltersSchema.optional()
}),
z.object({
channel: z.enum(["nbbo", "equities", "equity-quotes", "equity-joins", "classifier-hits", "alerts", "inferred-dark"])
channel: z.enum(["nbbo", "equity-quotes", "equity-joins", "classifier-hits", "alerts", "inferred-dark"])
}),
z.object({
channel: z.literal("equities"),
underlying_ids: z.array(z.string().min(1)).optional()
}),
z.object({
channel: z.literal("equity-candles"),
@ -181,9 +187,23 @@ export type LiveServerMessage = z.infer<typeof LiveServerMessageSchema>;
export const getSubscriptionKey = (subscription: LiveSubscription): string => {
switch (subscription.channel) {
case "options":
case "options": {
const underlyings = subscription.underlying_ids?.length
? `|underlyings:${[...subscription.underlying_ids].sort().join(",")}`
: "";
const contract = subscription.option_contract_id
? `|contract:${subscription.option_contract_id}`
: "";
return `${subscription.channel}|${optionFlowFilterKey(subscription.filters)}${underlyings}${contract}`;
}
case "flow":
return `${subscription.channel}|${optionFlowFilterKey(subscription.filters)}`;
case "equities": {
const underlyings = subscription.underlying_ids?.length
? `|underlyings:${[...subscription.underlying_ids].sort().join(",")}`
: "";
return `${subscription.channel}${underlyings}`;
}
case "equity-candles":
return `${subscription.channel}|${subscription.underlying_id}|${subscription.interval_ms}`;
case "equity-overlay":

View file

@ -23,6 +23,19 @@ describe("live protocol types", () => {
).toBe(
'options|{"view":"signal","securityTypes":["stock"],"nbboSides":["A","AA"],"optionTypes":["call","put"],"minNotional":25000}'
);
expect(
getSubscriptionKey({
channel: "options",
filters: { view: "signal" },
underlying_ids: ["NVDA", "AAPL"],
option_contract_id: "AAPL-2025-01-17-200-C"
})
).toBe(
'options|{"view":"signal"}|underlyings:AAPL,NVDA|contract:AAPL-2025-01-17-200-C'
);
expect(getSubscriptionKey({ channel: "equities", underlying_ids: ["NVDA", "AAPL"] })).toBe(
"equities|underlyings:AAPL,NVDA"
);
expect(
getSubscriptionKey({
channel: "equity-candles",

View file

@ -72,6 +72,7 @@ import {
fetchOptionPrintsByTraceIds,
fetchRecentOptionPrints
} from "@islandflow/storage";
import type { EquityPrintQueryFilters, OptionPrintQueryFilters } from "@islandflow/storage";
import {
AlertEventSchema,
ClassifierHitEventSchema,
@ -100,7 +101,7 @@ import {
} from "@islandflow/types";
import { createClient } from "redis";
import { z } from "zod";
import { LiveStateManager, shouldFanoutLiveEvent } from "./live";
import { LIVE_FEED_LOOKBACK_MS, LiveStateManager, shouldFanoutLiveEvent } from "./live";
const service = "api";
const logger = createLogger({ service });
@ -558,6 +559,62 @@ const buildHistoryResponse = <T extends { seq: number }>(
};
};
const parseScopeList = (url: URL, ...keys: string[]): string[] | undefined => {
const values = keys
.flatMap((key) => url.searchParams.getAll(key))
.flatMap((value) => value.split(","))
.map((value) => value.trim().toUpperCase())
.filter(Boolean);
const unique = Array.from(new Set(values));
return unique.length > 0 ? unique : undefined;
};
const parseLiveOptionPrintFilters = (url: URL): OptionPrintQueryFilters => {
const { storageFilters } = parseOptionPrintFilters(url);
return {
...storageFilters,
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
optionContractId: url.searchParams.get("option_contract_id") ?? undefined,
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
};
};
const parseLiveEquityPrintFilters = (url: URL): EquityPrintQueryFilters => ({
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
});
const matchesScopedOptionSubscription = (
print: { underlying_id?: string; option_contract_id: string },
subscription: LiveSubscription
): boolean => {
if (subscription.channel !== "options") {
return false;
}
if (subscription.option_contract_id && subscription.option_contract_id !== print.option_contract_id) {
return false;
}
if (subscription.underlying_ids?.length) {
const underlying = (print.underlying_id ?? "").toUpperCase();
return subscription.underlying_ids.map((value) => value.toUpperCase()).includes(underlying);
}
return true;
};
const matchesScopedEquitySubscription = (
print: { underlying_id: string },
subscription: LiveSubscription
): boolean => {
if (subscription.channel !== "equities") {
return false;
}
if (!subscription.underlying_ids?.length) {
return true;
}
const underlying = print.underlying_id.toUpperCase();
return subscription.underlying_ids.map((value) => value.toUpperCase()).includes(underlying);
};
const buildCandleCacheKey = (underlyingId: string, intervalMs: number): string => {
return `candles:equity:${intervalMs}:${underlyingId}`;
};
@ -987,7 +1044,7 @@ const run = async () => {
}
const matchingSubscriptions =
subscription.channel === "options" || subscription.channel === "flow"
subscription.channel === "options" || subscription.channel === "flow" || subscription.channel === "equities"
? [...subscriptionDefinitions.entries()].filter(([, candidate]) => candidate.channel === subscription.channel)
: [[getSubscriptionKey(subscription), subscription] as const];
@ -1003,7 +1060,15 @@ const run = async () => {
if (
candidate.channel === "options" &&
!matchesOptionPrintFilters(OptionPrintSchema.parse(item), candidate.filters)
(!matchesOptionPrintFilters(OptionPrintSchema.parse(item), candidate.filters) ||
!matchesScopedOptionSubscription(OptionPrintSchema.parse(item), candidate))
) {
continue;
}
if (
candidate.channel === "equities" &&
!matchesScopedEquitySubscription(EquityPrintSchema.parse(item), candidate)
) {
continue;
}
@ -1343,7 +1408,7 @@ const run = async () => {
try {
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
const source = parseReplaySource(url) ?? undefined;
const { storageFilters } = parseOptionPrintFilters(url);
const storageFilters = parseLiveOptionPrintFilters(url);
const data = await fetchOptionPrintsBefore(
clickhouse,
beforeTs,
@ -1373,7 +1438,13 @@ const run = async () => {
if (req.method === "GET" && url.pathname === "/history/equities") {
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
const data = await fetchEquityPrintsBefore(clickhouse, beforeTs, beforeSeq, limit);
const data = await fetchEquityPrintsBefore(
clickhouse,
beforeTs,
beforeSeq,
limit,
parseLiveEquityPrintFilters(url)
);
return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })));
}

View file

@ -12,6 +12,7 @@ import {
type ClickHouseClient
} from "@islandflow/storage";
import type { OptionPrintQueryFilters } from "@islandflow/storage";
import type { EquityPrintQueryFilters } from "@islandflow/storage";
import {
AlertEventSchema,
ClassifierHitEventSchema,
@ -38,6 +39,7 @@ import {
import type { RedisClientType } from "redis";
const CURSOR_HASH_KEY = "live:cursors";
export const LIVE_FEED_LOOKBACK_MS = 24 * 60 * 60 * 1000;
const DEFAULT_GENERIC_LIMIT = 10000;
const MAX_GENERIC_LIMIT = 100000;
@ -267,12 +269,24 @@ const extractFreshnessTs = (channel: LiveGenericChannel, item: any): number | nu
case "equity-quotes":
return typeof item.ts === "number" ? item.ts : null;
case "flow":
case "classifier-hits":
case "alerts":
case "inferred-dark":
return typeof item.source_ts === "number" ? item.source_ts : null;
default:
return null;
}
};
const isWithinLiveFeedLookback = (
channel: LiveGenericChannel,
item: unknown,
now = Date.now()
): boolean => {
const ts = extractFreshnessTs(channel, item);
return ts !== null && now - ts <= LIVE_FEED_LOOKBACK_MS;
};
export const isLiveItemFresh = (
channel: LiveGenericChannel,
item: unknown,
@ -289,7 +303,12 @@ export const isLiveItemFresh = (
return now - ts <= thresholdMs;
};
export const shouldFanoutLiveEvent = (_channel: LiveChannel, _item: unknown): boolean => true;
export const shouldFanoutLiveEvent = (channel: LiveChannel, item: unknown): boolean => {
if (channel === "equity-candles" || channel === "equity-overlay") {
return true;
}
return isWithinLiveFeedLookback(channel, item);
};
const nextBeforeForItems = <T>(items: T[], cursorOf: (item: T) => Cursor): Cursor | null => {
const last = items.at(-1);
@ -353,7 +372,13 @@ export class LiveStateManager {
const config = this.generic[channel];
if (this.redis?.isOpen) {
const payloads = await this.redis.lRange(config.redisKey, 0, config.limit - 1);
const cached = normalizeGenericItems(channel, parseJsonList(payloads, config.parse), config);
const cached = normalizeGenericItems(
channel,
parseJsonList(payloads, config.parse).filter((item) =>
isWithinLiveFeedLookback(channel, item)
),
config
);
if (cached.length > 0) {
this.genericItems.set(channel, cached);
this.stats.genericHydrateFromRedis += 1;
@ -370,7 +395,13 @@ export class LiveStateManager {
}
}
const fresh = normalizeGenericItems(channel, await config.fetchRecent(this.clickhouse, config.limit), config);
const fresh = normalizeGenericItems(
channel,
(await config.fetchRecent(this.clickhouse, config.limit)).filter((item) =>
isWithinLiveFeedLookback(channel, item)
),
config
);
this.stats.genericHydrateFromClickHouse += 1;
this.stats.cacheDepthByKey.set(config.redisKey, fresh.length);
this.genericItems.set(channel, fresh);
@ -382,16 +413,21 @@ export class LiveStateManager {
async getSnapshot(subscription: LiveSubscription): Promise<FeedSnapshot<unknown>> {
switch (subscription.channel) {
case "options": {
if (subscription.filters?.view === "raw") {
const scoped =
Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
if (subscription.filters?.view === "raw" || scoped) {
const storageFilters: OptionPrintQueryFilters = {
view: "raw",
view: subscription.filters?.view ?? "signal",
security:
subscription.filters.securityTypes?.length === 1
subscription.filters?.securityTypes?.length === 1
? subscription.filters.securityTypes[0]
: "all",
nbboSides: subscription.filters.nbboSides,
optionTypes: subscription.filters.optionTypes,
minNotional: subscription.filters.minNotional
nbboSides: subscription.filters?.nbboSides,
optionTypes: subscription.filters?.optionTypes,
minNotional: subscription.filters?.minNotional,
underlyingIds: subscription.underlying_ids,
optionContractId: subscription.option_contract_id,
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
};
const items = await fetchRecentOptionPrints(
this.clickhouse,
@ -409,6 +445,7 @@ export class LiveStateManager {
const config = this.generic.options;
const items = (this.genericItems.get("options") ?? []).filter((item) =>
isWithinLiveFeedLookback("options", item) &&
matchesOptionPrintFilters(item, subscription.filters)
);
return {
@ -421,6 +458,7 @@ export class LiveStateManager {
case "flow": {
const config = this.generic.flow;
const items = (this.genericItems.get("flow") ?? []).filter((item) =>
isWithinLiveFeedLookback("flow", item) &&
matchesFlowPacketFilters(item, subscription.filters)
);
return {
@ -430,6 +468,31 @@ export class LiveStateManager {
next_before: nextBeforeForItems(items, config.cursor)
};
}
case "equities": {
const config = this.generic.equities;
if (subscription.underlying_ids?.length) {
const filters: EquityPrintQueryFilters = {
underlyingIds: subscription.underlying_ids,
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
};
const items = await fetchRecentEquityPrints(this.clickhouse, config.limit, filters);
return {
subscription,
items,
watermark: items[0] ? { ts: items[0].ts, seq: items[0].seq } : null,
next_before: nextBeforeForItems(items, config.cursor)
};
}
const items = (this.genericItems.get("equities") ?? []).filter((item) =>
isWithinLiveFeedLookback("equities", item)
);
return {
subscription,
items,
watermark: this.genericCursors.get(config.cursorField) ?? null,
next_before: nextBeforeForItems(items, config.cursor)
};
}
case "equity-candles": {
const key = candleRedisKey(subscription.underlying_id, subscription.interval_ms);
const cursorField = candleCursorField(subscription.underlying_id, subscription.interval_ms);
@ -460,7 +523,9 @@ export class LiveStateManager {
}
default: {
const config = this.generic[subscription.channel];
const items = this.genericItems.get(subscription.channel) ?? [];
const items = (this.genericItems.get(subscription.channel) ?? []).filter((item) =>
isWithinLiveFeedLookback(subscription.channel, item)
);
return {
subscription,
items,
@ -506,6 +571,9 @@ export class LiveStateManager {
default: {
const config = this.generic[channel];
const parsed = config.parse(item);
if (!isWithinLiveFeedLookback(channel, parsed)) {
return null;
}
const items = this.genericItems.get(channel) ?? [];
const next = normalizeGenericItems(channel, [parsed, ...items], config);
this.genericItems.set(channel, next);

View file

@ -578,9 +578,11 @@ describe("LiveStateManager", () => {
expect(isLiveItemFresh("equity-joins", { source_ts: 1 }, 1_000_000)).toBe(true);
});
it("fans out stale live events so delayed data remains visible without refresh", () => {
expect(shouldFanoutLiveEvent("options", { ts: 1000 })).toBe(true);
expect(shouldFanoutLiveEvent("equities", { ts: 1000 })).toBe(true);
expect(shouldFanoutLiveEvent("flow", { source_ts: 1000 })).toBe(true);
it("gates live feed fanout to the rolling visibility window", () => {
const now = Date.now();
expect(shouldFanoutLiveEvent("options", { ts: now })).toBe(true);
expect(shouldFanoutLiveEvent("equities", { ts: now - 25 * 60 * 60 * 1000 })).toBe(false);
expect(shouldFanoutLiveEvent("flow", { source_ts: now - 25 * 60 * 60 * 1000 })).toBe(false);
expect(shouldFanoutLiveEvent("equity-candles", { ts: 1000 })).toBe(true);
});
});