Fix contract-focused options tape hydration
This commit is contained in:
parent
73e25ddf70
commit
b73e62bdba
8 changed files with 657 additions and 171 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
{"_type":"issue","id":"islandflow-pre","title":"Fix contract-focused options tape hydration","description":"Implement contract-focused options tape hydration so focused contract views preserve the clicked seed row, stop reapplying broad flow filters in the Options pane, and use raw contract-scoped ClickHouse queries consistently across live snapshots, history, and replay. Includes frontend replay source-grouping changes and regression tests for focus seed durability, focused filtering, and contract-scoped API behavior.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-08T03:27:31Z","created_by":"dirtydishes","updated_at":"2026-05-08T03:37:18Z","started_at":"2026-05-08T03:27:35Z","closed_at":"2026-05-08T03:37:18Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-9xs","title":"Fix terminal hydration and virtual row measurement crash","description":"Fix client crash caused by options-support hydration on non-JSON/404 responses and satisfy tanstack virtual measured-row data-index requirement across virtualized tables.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T06:14:33Z","created_by":"dirtydishes","updated_at":"2026-05-07T06:17:09Z","started_at":"2026-05-07T06:14:43Z","closed_at":"2026-05-07T06:17:09Z","close_reason":"Completed: added data-index attributes on measured virtual rows, hardened options-support hydration error handling/content-type validation, and guarded trace-id hydration loops against malformed payload entries.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-9xs","title":"Fix terminal hydration and virtual row measurement crash","description":"Fix client crash caused by options-support hydration on non-JSON/404 responses and satisfy tanstack virtual measured-row data-index requirement across virtualized tables.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T06:14:33Z","created_by":"dirtydishes","updated_at":"2026-05-07T06:17:09Z","started_at":"2026-05-07T06:14:43Z","closed_at":"2026-05-07T06:17:09Z","close_reason":"Completed: added data-index attributes on measured virtual rows, hardened options-support hydration error handling/content-type validation, and guarded trace-id hydration loops against malformed payload entries.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-35g","title":"Fix Docker deployment workspace lockfile drift","description":"Refresh deployment/docker workspace lockfile for Docker builds, add a drift guard for Docker-built workspaces, and document the separate deployment snapshot so frozen Bun installs cannot fail when repo dependencies change.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T06:02:06Z","created_by":"dirtydishes","updated_at":"2026-05-07T06:07:50Z","started_at":"2026-05-07T06:02:15Z","closed_at":"2026-05-07T06:07:50Z","close_reason":"Completed: synced deployment Docker workspace snapshot from repo root, refreshed deployment bun.lock, added sync/check scripts, and documented maintenance workflow. Local docker compose build validation is blocked here because Docker daemon is unavailable.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-35g","title":"Fix Docker deployment workspace lockfile drift","description":"Refresh deployment/docker workspace lockfile for Docker builds, add a drift guard for Docker-built workspaces, and document the separate deployment snapshot so frozen Bun installs cannot fail when repo dependencies change.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T06:02:06Z","created_by":"dirtydishes","updated_at":"2026-05-07T06:07:50Z","started_at":"2026-05-07T06:02:15Z","closed_at":"2026-05-07T06:07:50Z","close_reason":"Completed: synced deployment Docker workspace snapshot from repo root, refreshed deployment bun.lock, added sync/check scripts, and documented maintenance workflow. Local docker compose build validation is blocked here because Docker daemon is unavailable.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-2ij","title":"Harden tape virtualization, scoped focus, and live feed health","description":"Implement the coordinated tape stability plan across web and API.\n\nScope:\n- replace fixed-height tape virtualization with measured virtualization and virtual-end history loading\n- replace scrollHeight anchoring with key-based anchor restore\n- compose canonical tape lists across seed/live/history sources\n- preserve clicked contract/ticker context during scoped focus transitions\n- separate backend hot-channel health from scoped quiet empty states\n- shrink browser hot windows and modestly reduce server cache limits\n- add regression tests and development instrumentation\n\nAcceptance:\n- no giant blank spacer gaps during tape scrolling\n- scroll remains stable while live data and history mutate the list\n- clicked deep-history option/equity rows remain visible immediately after focus\n- narrow scopes do not surface Feed behind unless backend channel health is stale\n","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T05:35:18Z","created_by":"dirtydishes","updated_at":"2026-05-07T05:52:14Z","started_at":"2026-05-07T05:35:21Z","closed_at":"2026-05-07T05:52:14Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-2ij","title":"Harden tape virtualization, scoped focus, and live feed health","description":"Implement the coordinated tape stability plan across web and API.\n\nScope:\n- replace fixed-height tape virtualization with measured virtualization and virtual-end history loading\n- replace scrollHeight anchoring with key-based anchor restore\n- compose canonical tape lists across seed/live/history sources\n- preserve clicked contract/ticker context during scoped focus transitions\n- separate backend hot-channel health from scoped quiet empty states\n- shrink browser hot windows and modestly reduce server cache limits\n- add regression tests and development instrumentation\n\nAcceptance:\n- no giant blank spacer gaps during tape scrolling\n- scroll remains stable while live data and history mutate the list\n- clicked deep-history option/equity rows remain visible immediately after focus\n- narrow scopes do not surface Feed behind unless backend channel health is stale\n","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-07T05:35:18Z","created_by":"dirtydishes","updated_at":"2026-05-07T05:52:14Z","started_at":"2026-05-07T05:35:21Z","closed_at":"2026-05-07T05:52:14Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,23 @@ import {
|
||||||
NAV_ITEMS,
|
NAV_ITEMS,
|
||||||
appendHistoryTail,
|
appendHistoryTail,
|
||||||
buildDefaultFlowFilters,
|
buildDefaultFlowFilters,
|
||||||
|
buildOptionTapeQueryParams,
|
||||||
classifierToneForFamily,
|
classifierToneForFamily,
|
||||||
composeTapeItems,
|
composeTapeItems,
|
||||||
deriveAlertDirection,
|
deriveAlertDirection,
|
||||||
countActiveFlowFilterGroups,
|
countActiveFlowFilterGroups,
|
||||||
|
filterOptionTapeItems,
|
||||||
findAnchorRestoreIndex,
|
findAnchorRestoreIndex,
|
||||||
formatCompactUsd,
|
formatCompactUsd,
|
||||||
formatOptionContractLabel,
|
formatOptionContractLabel,
|
||||||
flushPausableTapeData,
|
flushPausableTapeData,
|
||||||
|
getEffectiveOptionPrintFilters,
|
||||||
getAlertWindowAnchorTs,
|
getAlertWindowAnchorTs,
|
||||||
getHotChannelFeedStatus,
|
getHotChannelFeedStatus,
|
||||||
getScopedLiveAutoHydrationChannels,
|
getScopedLiveAutoHydrationChannels,
|
||||||
getLiveHistoryRetentionCap,
|
getLiveHistoryRetentionCap,
|
||||||
getOptionTableSnapshot,
|
getOptionTableSnapshot,
|
||||||
|
getOptionScope,
|
||||||
getLiveFeedStatus,
|
getLiveFeedStatus,
|
||||||
getLiveManifest,
|
getLiveManifest,
|
||||||
getRouteFeatures,
|
getRouteFeatures,
|
||||||
|
|
@ -30,6 +34,7 @@ import {
|
||||||
shouldIncludeEquitiesForDarkUnderlyingFallback,
|
shouldIncludeEquitiesForDarkUnderlyingFallback,
|
||||||
shouldShowEquitiesSilentFeedWarning,
|
shouldShowEquitiesSilentFeedWarning,
|
||||||
selectPrimaryClassifierHit,
|
selectPrimaryClassifierHit,
|
||||||
|
shouldClearOptionFocusSeed,
|
||||||
smartMoneyProfileLabel,
|
smartMoneyProfileLabel,
|
||||||
smartMoneyToneForProfile,
|
smartMoneyToneForProfile,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
|
@ -42,6 +47,25 @@ const makeItem = (traceId: string, seq: number, ts: number) => ({
|
||||||
ts
|
ts
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeOptionPrint = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
({
|
||||||
|
trace_id: "opt-1",
|
||||||
|
seq: 1,
|
||||||
|
ts: 1_000,
|
||||||
|
source_ts: 1_000,
|
||||||
|
ingest_ts: 1_001,
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||||
|
underlying_id: "AAPL",
|
||||||
|
option_type: "call",
|
||||||
|
nbbo_side: "A",
|
||||||
|
notional: 250_000,
|
||||||
|
signal_pass: true,
|
||||||
|
price: 1,
|
||||||
|
size: 10,
|
||||||
|
exchange: "X",
|
||||||
|
...overrides
|
||||||
|
}) as any;
|
||||||
|
|
||||||
const makeAlert = (overrides: Record<string, unknown> = {}) =>
|
const makeAlert = (overrides: Record<string, unknown> = {}) =>
|
||||||
({
|
({
|
||||||
trace_id: "alert-1",
|
trace_id: "alert-1",
|
||||||
|
|
@ -125,6 +149,31 @@ describe("live manifest", () => {
|
||||||
expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]);
|
expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops option-print filters for contract-focused options subscriptions but keeps flow filters", () => {
|
||||||
|
const filters = {
|
||||||
|
...buildDefaultFlowFilters(),
|
||||||
|
minNotional: 500_000,
|
||||||
|
optionTypes: ["put"] as const
|
||||||
|
};
|
||||||
|
const manifest = getLiveManifest(
|
||||||
|
"/tape",
|
||||||
|
"AAPL",
|
||||||
|
60000,
|
||||||
|
filters,
|
||||||
|
{
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
},
|
||||||
|
{ underlying_ids: ["AAPL"] },
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const optionsSubscription = manifest.find((subscription) => subscription.channel === "options");
|
||||||
|
const flowSubscription = manifest.find((subscription) => subscription.channel === "flow");
|
||||||
|
|
||||||
|
expect(optionsSubscription?.filters).toBeUndefined();
|
||||||
|
expect(flowSubscription?.filters).toBe(filters);
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes /signals subscriptions to signals channels only", () => {
|
it("scopes /signals subscriptions to signals channels only", () => {
|
||||||
const channels = getLiveManifest("/signals", "SPY", 60000, buildDefaultFlowFilters()).map(
|
const channels = getLiveManifest("/signals", "SPY", 60000, buildDefaultFlowFilters()).map(
|
||||||
(subscription) => subscription.channel
|
(subscription) => subscription.channel
|
||||||
|
|
@ -154,6 +203,130 @@ describe("live manifest", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("contract-focused option helpers", () => {
|
||||||
|
it("uses the focused contract underlying for option scope even when ticker input differs", () => {
|
||||||
|
expect(
|
||||||
|
getOptionScope(["MSFT"], "AAPL", {
|
||||||
|
kind: "option-contract",
|
||||||
|
contractId: "AAPL-2025-01-17-200-C",
|
||||||
|
underlyingId: "AAPL"
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores broad flow filters for focused contract options", () => {
|
||||||
|
const filters = {
|
||||||
|
...buildDefaultFlowFilters(),
|
||||||
|
minNotional: 500_000
|
||||||
|
};
|
||||||
|
const items = [
|
||||||
|
makeOptionPrint({
|
||||||
|
trace_id: "focused-low",
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||||
|
notional: 100_000,
|
||||||
|
signal_pass: false
|
||||||
|
}),
|
||||||
|
makeOptionPrint({
|
||||||
|
trace_id: "focused-high",
|
||||||
|
seq: 2,
|
||||||
|
ts: 2_000,
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||||
|
notional: 750_000
|
||||||
|
}),
|
||||||
|
makeOptionPrint({
|
||||||
|
trace_id: "other-contract",
|
||||||
|
seq: 3,
|
||||||
|
ts: 3_000,
|
||||||
|
option_contract_id: "MSFT-2025-01-17-300-C",
|
||||||
|
underlying_id: "MSFT",
|
||||||
|
notional: 900_000
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterOptionTapeItems(
|
||||||
|
items,
|
||||||
|
getEffectiveOptionPrintFilters(filters, true),
|
||||||
|
{
|
||||||
|
kind: "option-contract",
|
||||||
|
contractId: "AAPL-2025-01-17-200-C",
|
||||||
|
underlyingId: "AAPL"
|
||||||
|
},
|
||||||
|
new Set(["MSFT"]),
|
||||||
|
"AAPL"
|
||||||
|
).map((item) => item.trace_id)
|
||||||
|
).toEqual(["focused-low", "focused-high"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes option_contract_id and drops broad filters in focused replay query params", () => {
|
||||||
|
const filters = {
|
||||||
|
...buildDefaultFlowFilters(),
|
||||||
|
minNotional: 500_000,
|
||||||
|
optionTypes: ["put"] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildOptionTapeQueryParams(getEffectiveOptionPrintFilters(filters, true), {
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
underlying_ids: "AAPL",
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the focus seed until the matching scoped subscription has loaded it", () => {
|
||||||
|
const seedItem = makeOptionPrint({
|
||||||
|
trace_id: "focused-seed",
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
});
|
||||||
|
const seed = {
|
||||||
|
scopeKey: "option-contract:AAPL-2025-01-17-200-C",
|
||||||
|
subscriptionKey: getLiveSubscriptionKey({
|
||||||
|
channel: "options",
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
}),
|
||||||
|
items: [seedItem]
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldClearOptionFocusSeed(
|
||||||
|
seed,
|
||||||
|
"option-contract:AAPL-2025-01-17-200-C",
|
||||||
|
getLiveSubscriptionKey({
|
||||||
|
channel: "options",
|
||||||
|
filters: {
|
||||||
|
...buildDefaultFlowFilters(),
|
||||||
|
minNotional: 500_000
|
||||||
|
},
|
||||||
|
underlying_ids: ["AAPL"]
|
||||||
|
}),
|
||||||
|
[makeOptionPrint({ trace_id: "broad-old" })],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldClearOptionFocusSeed(
|
||||||
|
seed,
|
||||||
|
"option-contract:AAPL-2025-01-17-200-C",
|
||||||
|
getLiveSubscriptionKey({
|
||||||
|
channel: "options",
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
}),
|
||||||
|
[seedItem],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("route feature map", () => {
|
describe("route feature map", () => {
|
||||||
it("maps /tape to tape panes and dependencies", () => {
|
it("maps /tape to tape panes and dependencies", () => {
|
||||||
const features = getRouteFeatures("/tape");
|
const features = getRouteFeatures("/tape");
|
||||||
|
|
|
||||||
|
|
@ -350,9 +350,17 @@ type SelectedInstrument =
|
||||||
|
|
||||||
type TapeFocusSeed<T> = {
|
type TapeFocusSeed<T> = {
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
|
subscriptionKey?: string;
|
||||||
items: T[];
|
items: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OptionScope = Pick<
|
||||||
|
Extract<LiveSubscription, { channel: "options" }>,
|
||||||
|
"underlying_ids" | "option_contract_id"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type EquityScope = Pick<Extract<LiveSubscription, { channel: "equities" }>, "underlying_ids">;
|
||||||
|
|
||||||
const formatIntervalLabel = (intervalMs: number): string => {
|
const formatIntervalLabel = (intervalMs: number): string => {
|
||||||
const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs);
|
const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|
@ -1956,6 +1964,13 @@ const useTape = <T extends SortableItem & { seq: number }>(
|
||||||
const replaySourceKey = config.replaySourceKey ?? null;
|
const replaySourceKey = config.replaySourceKey ?? null;
|
||||||
const onReplaySourceKey = config.onReplaySourceKey;
|
const onReplaySourceKey = config.onReplaySourceKey;
|
||||||
const queryParams = config.queryParams;
|
const queryParams = config.queryParams;
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(
|
||||||
|
Object.entries(queryParams ?? {}).sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
),
|
||||||
|
[queryParams]
|
||||||
|
);
|
||||||
const hotWindowLimit = config.hotWindowLimit ?? LIVE_HOT_WINDOW;
|
const hotWindowLimit = config.hotWindowLimit ?? LIVE_HOT_WINDOW;
|
||||||
const [status, setStatus] = useState<WsStatus>("connecting");
|
const [status, setStatus] = useState<WsStatus>("connecting");
|
||||||
const [items, setItems] = useState<T[]>([]);
|
const [items, setItems] = useState<T[]>([]);
|
||||||
|
|
@ -2046,7 +2061,7 @@ const useTape = <T extends SortableItem & { seq: number }>(
|
||||||
pendingRef.current = [];
|
pendingRef.current = [];
|
||||||
pendingCountRef.current = 0;
|
pendingCountRef.current = 0;
|
||||||
cancelFlush();
|
cancelFlush();
|
||||||
}, [mode, replaySourceKey, cancelFlush]);
|
}, [mode, replaySourceKey, queryKey, cancelFlush]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "replay" || !latestPath) {
|
if (mode !== "replay" || !latestPath) {
|
||||||
|
|
@ -2091,7 +2106,7 @@ const useTape = <T extends SortableItem & { seq: number }>(
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [mode, latestPath, getItemTs, replaySourceKey, queryParams]);
|
}, [mode, latestPath, getItemTs, replaySourceKey, queryKey, queryParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "live" || config.liveEnabled === false) {
|
if (mode !== "live" || config.liveEnabled === false) {
|
||||||
|
|
@ -2242,9 +2257,14 @@ const useTape = <T extends SortableItem & { seq: number }>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onReplaySourceKey && sourcePrefix && replaySourceNotifiedRef.current !== sourcePrefix) {
|
if (onReplaySourceKey) {
|
||||||
|
if (sourcePrefix && replaySourceNotifiedRef.current !== sourcePrefix) {
|
||||||
replaySourceNotifiedRef.current = sourcePrefix;
|
replaySourceNotifiedRef.current = sourcePrefix;
|
||||||
onReplaySourceKey(sourcePrefix);
|
onReplaySourceKey(sourcePrefix);
|
||||||
|
} else if (!sourcePrefix && replaySourceNotifiedRef.current !== null) {
|
||||||
|
replaySourceNotifiedRef.current = null;
|
||||||
|
onReplaySourceKey(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = sourcePrefix
|
const filtered = sourcePrefix
|
||||||
|
|
@ -2330,6 +2350,7 @@ const useTape = <T extends SortableItem & { seq: number }>(
|
||||||
getReplayKey,
|
getReplayKey,
|
||||||
replaySourceKey,
|
replaySourceKey,
|
||||||
onReplaySourceKey,
|
onReplaySourceKey,
|
||||||
|
queryKey,
|
||||||
queryParams
|
queryParams
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -2784,6 +2805,99 @@ const appendOptionFlowFilters = (params: URLSearchParams, filters: OptionFlowFil
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appendOptionScopeParams = (
|
||||||
|
params: URLSearchParams,
|
||||||
|
optionScope: OptionScope | undefined
|
||||||
|
): void => {
|
||||||
|
if (optionScope?.underlying_ids?.length) {
|
||||||
|
params.set("underlying_ids", optionScope.underlying_ids.join(","));
|
||||||
|
}
|
||||||
|
if (optionScope?.option_contract_id) {
|
||||||
|
params.set("option_contract_id", optionScope.option_contract_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEffectiveOptionPrintFilters = (
|
||||||
|
flowFilters: OptionFlowFilters,
|
||||||
|
isOptionContractFocused: boolean
|
||||||
|
): OptionFlowFilters | undefined => {
|
||||||
|
return isOptionContractFocused ? undefined : flowFilters;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOptionScope = (
|
||||||
|
activeTickers: string[],
|
||||||
|
instrumentUnderlying: string | null,
|
||||||
|
selectedInstrument: SelectedInstrument
|
||||||
|
): OptionScope => ({
|
||||||
|
underlying_ids:
|
||||||
|
selectedInstrument?.kind === "option-contract"
|
||||||
|
? instrumentUnderlying
|
||||||
|
? [instrumentUnderlying]
|
||||||
|
: undefined
|
||||||
|
: activeTickers.length > 0
|
||||||
|
? activeTickers
|
||||||
|
: instrumentUnderlying
|
||||||
|
? [instrumentUnderlying]
|
||||||
|
: undefined,
|
||||||
|
option_contract_id:
|
||||||
|
selectedInstrument?.kind === "option-contract" ? selectedInstrument.contractId : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildOptionTapeQueryParams = (
|
||||||
|
filters: OptionFlowFilters | undefined,
|
||||||
|
optionScope: OptionScope | undefined
|
||||||
|
): Record<string, string | undefined> => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
appendOptionFlowFilters(params, filters);
|
||||||
|
appendOptionScopeParams(params, optionScope);
|
||||||
|
return Object.fromEntries(params.entries());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterOptionTapeItems = (
|
||||||
|
items: OptionPrint[],
|
||||||
|
filters: OptionFlowFilters | undefined,
|
||||||
|
selectedInstrument: SelectedInstrument,
|
||||||
|
tickerSet: Set<string>,
|
||||||
|
instrumentUnderlying: string | null
|
||||||
|
): OptionPrint[] => {
|
||||||
|
return items.filter((print) => {
|
||||||
|
const contractId = normalizeContractId(print.option_contract_id);
|
||||||
|
if (selectedInstrument?.kind === "option-contract") {
|
||||||
|
return contractId === selectedInstrument.contractId;
|
||||||
|
}
|
||||||
|
if (!matchesOptionPrintFilters(print, filters)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const underlying = extractUnderlying(contractId);
|
||||||
|
if (tickerSet.size === 0) {
|
||||||
|
return !instrumentUnderlying || underlying === instrumentUnderlying;
|
||||||
|
}
|
||||||
|
return Boolean(underlying) && tickerSet.has(underlying.toUpperCase());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldClearOptionFocusSeed = (
|
||||||
|
seed: TapeFocusSeed<OptionPrint> | null,
|
||||||
|
optionFocusScopeKey: string | null,
|
||||||
|
currentOptionSubscriptionKey: string | null,
|
||||||
|
liveItems: OptionPrint[],
|
||||||
|
historyItems: OptionPrint[]
|
||||||
|
): boolean => {
|
||||||
|
if (!seed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (seed.scopeKey !== optionFocusScopeKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (seed.subscriptionKey && seed.subscriptionKey !== currentOptionSubscriptionKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const liveKeys = new Set(
|
||||||
|
composeTapeItems([], liveItems, historyItems).map((item) => getTapeItemKey(item))
|
||||||
|
);
|
||||||
|
return seed.items.every((item) => liveKeys.has(getTapeItemKey(item)));
|
||||||
|
};
|
||||||
|
|
||||||
const appendLiveScopeParams = (params: URLSearchParams, subscription: LiveSubscription): void => {
|
const appendLiveScopeParams = (params: URLSearchParams, subscription: LiveSubscription): void => {
|
||||||
if ((subscription.channel === "options" || subscription.channel === "equities") && subscription.underlying_ids?.length) {
|
if ((subscription.channel === "options" || subscription.channel === "equities") && subscription.underlying_ids?.length) {
|
||||||
params.set("underlying_ids", subscription.underlying_ids.join(","));
|
params.set("underlying_ids", subscription.underlying_ids.join(","));
|
||||||
|
|
@ -2810,8 +2924,9 @@ export const getLiveManifest = (
|
||||||
chartTicker: string,
|
chartTicker: string,
|
||||||
chartIntervalMs: number,
|
chartIntervalMs: number,
|
||||||
flowFilters: OptionFlowFilters,
|
flowFilters: OptionFlowFilters,
|
||||||
optionScope?: Pick<Extract<LiveSubscription, { channel: "options" }>, "underlying_ids" | "option_contract_id">,
|
optionScope?: OptionScope,
|
||||||
equityScope?: Pick<Extract<LiveSubscription, { channel: "equities" }>, "underlying_ids">
|
equityScope?: EquityScope,
|
||||||
|
optionPrintFilters?: OptionFlowFilters
|
||||||
): LiveSubscription[] => {
|
): LiveSubscription[] => {
|
||||||
const features = getRouteFeatures(pathname);
|
const features = getRouteFeatures(pathname);
|
||||||
const subscriptions: LiveSubscription[] = [];
|
const subscriptions: LiveSubscription[] = [];
|
||||||
|
|
@ -2819,7 +2934,10 @@ export const getLiveManifest = (
|
||||||
if (features.options) {
|
if (features.options) {
|
||||||
subscriptions.push({
|
subscriptions.push({
|
||||||
channel: "options",
|
channel: "options",
|
||||||
filters: flowFilters,
|
filters:
|
||||||
|
optionScope?.option_contract_id && optionPrintFilters === undefined
|
||||||
|
? undefined
|
||||||
|
: optionPrintFilters ?? flowFilters,
|
||||||
...optionScope,
|
...optionScope,
|
||||||
snapshot_limit: LIVE_HOT_WINDOW_OPTIONS
|
snapshot_limit: LIVE_HOT_WINDOW_OPTIONS
|
||||||
});
|
});
|
||||||
|
|
@ -2868,11 +2986,7 @@ export const getLiveManifest = (
|
||||||
const useLiveSession = (
|
const useLiveSession = (
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
pathname: string,
|
pathname: string,
|
||||||
chartTicker: string,
|
manifest: LiveSubscription[]
|
||||||
chartIntervalMs: number,
|
|
||||||
flowFilters: OptionFlowFilters,
|
|
||||||
optionScope?: Pick<Extract<LiveSubscription, { channel: "options" }>, "underlying_ids" | "option_contract_id">,
|
|
||||||
equityScope?: Pick<Extract<LiveSubscription, { channel: "equities" }>, "underlying_ids">
|
|
||||||
): 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 [connectedAt, setConnectedAt] = useState<number | null>(null);
|
||||||
|
|
@ -2938,11 +3052,6 @@ const useLiveSession = (
|
||||||
const lastEventAtRef = useRef<number | null>(null);
|
const lastEventAtRef = useRef<number | null>(null);
|
||||||
const subscribedKeysRef = useRef<Set<string>>(new Set());
|
const subscribedKeysRef = useRef<Set<string>>(new Set());
|
||||||
const subscribedMapRef = useRef<Map<string, LiveSubscription>>(new Map());
|
const subscribedMapRef = useRef<Map<string, LiveSubscription>>(new Map());
|
||||||
const manifest = useMemo(
|
|
||||||
() => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope),
|
|
||||||
[pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope]
|
|
||||||
);
|
|
||||||
|
|
||||||
const replaceArrayState = <T,>(
|
const replaceArrayState = <T,>(
|
||||||
setter: Dispatch<SetStateAction<T[]>>,
|
setter: Dispatch<SetStateAction<T[]>>,
|
||||||
ref: { current: T[] },
|
ref: { current: T[] },
|
||||||
|
|
@ -4857,20 +4966,21 @@ const useTerminalState = () => {
|
||||||
}, [filterInput]);
|
}, [filterInput]);
|
||||||
const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]);
|
const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]);
|
||||||
const instrumentUnderlying = selectedInstrument?.underlyingId.toUpperCase() ?? null;
|
const instrumentUnderlying = selectedInstrument?.underlyingId.toUpperCase() ?? null;
|
||||||
|
const isOptionContractFocused = selectedInstrument?.kind === "option-contract";
|
||||||
|
const focusedOptionContractId =
|
||||||
|
selectedInstrument?.kind === "option-contract" ? selectedInstrument.contractId : null;
|
||||||
const optionFocusScopeKey =
|
const optionFocusScopeKey =
|
||||||
selectedInstrument?.kind === "option-contract"
|
focusedOptionContractId ? `option-contract:${focusedOptionContractId}` : null;
|
||||||
? `option-contract:${selectedInstrument.contractId}`
|
|
||||||
: null;
|
|
||||||
const equityFocusScopeKey =
|
const equityFocusScopeKey =
|
||||||
selectedInstrument?.kind === "equity"
|
selectedInstrument?.kind === "equity"
|
||||||
? `equity:${selectedInstrument.underlyingId.toUpperCase()}`
|
? `equity:${selectedInstrument.underlyingId.toUpperCase()}`
|
||||||
: null;
|
: null;
|
||||||
|
const effectiveOptionPrintFilters = useMemo(
|
||||||
|
() => getEffectiveOptionPrintFilters(flowFilters, isOptionContractFocused),
|
||||||
|
[flowFilters, isOptionContractFocused]
|
||||||
|
);
|
||||||
const optionScope = useMemo(
|
const optionScope = useMemo(
|
||||||
() => ({
|
() => getOptionScope(activeTickers, instrumentUnderlying, selectedInstrument),
|
||||||
underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined,
|
|
||||||
option_contract_id:
|
|
||||||
selectedInstrument?.kind === "option-contract" ? selectedInstrument.contractId : undefined
|
|
||||||
}),
|
|
||||||
[activeTickers, instrumentUnderlying, selectedInstrument]
|
[activeTickers, instrumentUnderlying, selectedInstrument]
|
||||||
);
|
);
|
||||||
const equityScope = useMemo(
|
const equityScope = useMemo(
|
||||||
|
|
@ -4895,14 +5005,39 @@ const useTerminalState = () => {
|
||||||
? `Contract: ${display.ticker} ${display.expiration} ${display.strike}`
|
? `Contract: ${display.ticker} ${display.expiration} ${display.strike}`
|
||||||
: `Contract: ${selectedInstrument.contractId}`;
|
: `Contract: ${selectedInstrument.contractId}`;
|
||||||
}, [selectedInstrument]);
|
}, [selectedInstrument]);
|
||||||
const liveSession = useLiveSession(
|
const liveManifest = useMemo(
|
||||||
mode === "live",
|
() =>
|
||||||
|
getLiveManifest(
|
||||||
|
pathname,
|
||||||
|
chartTicker.toUpperCase(),
|
||||||
|
chartIntervalMs,
|
||||||
|
flowFilters,
|
||||||
|
optionScope,
|
||||||
|
equityScope,
|
||||||
|
effectiveOptionPrintFilters
|
||||||
|
),
|
||||||
|
[
|
||||||
pathname,
|
pathname,
|
||||||
chartTicker,
|
chartTicker,
|
||||||
chartIntervalMs,
|
chartIntervalMs,
|
||||||
flowFilters,
|
flowFilters,
|
||||||
optionScope,
|
optionScope,
|
||||||
equityScope
|
equityScope,
|
||||||
|
effectiveOptionPrintFilters
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const liveSession = useLiveSession(mode === "live", pathname, liveManifest);
|
||||||
|
const currentOptionSubscription = useMemo(
|
||||||
|
() =>
|
||||||
|
liveManifest.find(
|
||||||
|
(subscription): subscription is Extract<LiveSubscription, { channel: "options" }> =>
|
||||||
|
subscription.channel === "options"
|
||||||
|
) ?? null,
|
||||||
|
[liveManifest]
|
||||||
|
);
|
||||||
|
const currentOptionSubscriptionKey = useMemo(
|
||||||
|
() => (currentOptionSubscription ? getLiveSubscriptionKey(currentOptionSubscription) : null),
|
||||||
|
[currentOptionSubscription]
|
||||||
);
|
);
|
||||||
const equitiesLiveSubscriptionActive = routeFeatures.equities;
|
const equitiesLiveSubscriptionActive = routeFeatures.equities;
|
||||||
|
|
||||||
|
|
@ -4966,18 +5101,8 @@ const useTerminalState = () => {
|
||||||
);
|
);
|
||||||
const disableReplayGrouping = useCallback(() => null, []);
|
const disableReplayGrouping = useCallback(() => null, []);
|
||||||
const optionQueryParams = useMemo<Record<string, string | undefined>>(
|
const optionQueryParams = useMemo<Record<string, string | undefined>>(
|
||||||
() => ({
|
() => buildOptionTapeQueryParams(effectiveOptionPrintFilters, optionScope),
|
||||||
view: flowFilters.view ?? "signal",
|
[effectiveOptionPrintFilters, optionScope]
|
||||||
security:
|
|
||||||
flowFilters.securityTypes?.length === 1 ? flowFilters.securityTypes[0] : undefined,
|
|
||||||
side: flowFilters.nbboSides?.length ? flowFilters.nbboSides.join(",") : undefined,
|
|
||||||
type: flowFilters.optionTypes?.length ? flowFilters.optionTypes.join(",") : undefined,
|
|
||||||
min_notional:
|
|
||||||
typeof flowFilters.minNotional === "number"
|
|
||||||
? String(flowFilters.minNotional)
|
|
||||||
: undefined
|
|
||||||
}),
|
|
||||||
[flowFilters]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const options = useTape<OptionPrint>({
|
const options = useTape<OptionPrint>({
|
||||||
|
|
@ -4992,9 +5117,10 @@ const useTerminalState = () => {
|
||||||
pollMs: mode === "replay" ? 200 : undefined,
|
pollMs: mode === "replay" ? 200 : undefined,
|
||||||
captureScroll: optionsAnchor.capture,
|
captureScroll: optionsAnchor.capture,
|
||||||
onNewItems: optionsScroll.onNewItems,
|
onNewItems: optionsScroll.onNewItems,
|
||||||
getReplayKey: extractReplaySource,
|
getReplayKey: isOptionContractFocused ? disableReplayGrouping : extractReplaySource,
|
||||||
onReplaySourceKey: handleReplaySource,
|
onReplaySourceKey: isOptionContractFocused ? undefined : handleReplaySource,
|
||||||
queryParams: optionQueryParams
|
queryParams: optionQueryParams,
|
||||||
|
replaySourceKey: isOptionContractFocused ? null : replaySource
|
||||||
});
|
});
|
||||||
|
|
||||||
const equities = useTape<EquityPrint>({
|
const equities = useTape<EquityPrint>({
|
||||||
|
|
@ -5010,6 +5136,12 @@ const useTerminalState = () => {
|
||||||
onNewItems: equitiesScroll.onNewItems
|
onNewItems: equitiesScroll.onNewItems
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOptionContractFocused && replaySource !== null) {
|
||||||
|
setReplaySource(null);
|
||||||
|
}
|
||||||
|
}, [isOptionContractFocused, replaySource]);
|
||||||
|
|
||||||
const equityJoins = useTape<EquityPrintJoin>({
|
const equityJoins = useTape<EquityPrintJoin>({
|
||||||
mode,
|
mode,
|
||||||
liveEnabled: false,
|
liveEnabled: false,
|
||||||
|
|
@ -5922,25 +6054,20 @@ const useTerminalState = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredOptions = useMemo(() => {
|
const filteredOptions = useMemo(() => {
|
||||||
return optionsFeed.items.filter((print) => {
|
return filterOptionTapeItems(
|
||||||
if (!matchesOptionPrintFilters(print, flowFilters)) {
|
optionsFeed.items,
|
||||||
return false;
|
effectiveOptionPrintFilters,
|
||||||
}
|
selectedInstrument,
|
||||||
if (
|
tickerSet,
|
||||||
selectedInstrument?.kind === "option-contract" &&
|
instrumentUnderlying
|
||||||
normalizeContractId(print.option_contract_id) !== selectedInstrument.contractId
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (tickerSet.size === 0) {
|
|
||||||
return (
|
|
||||||
!instrumentUnderlying ||
|
|
||||||
extractUnderlying(normalizeContractId(print.option_contract_id)) === instrumentUnderlying
|
|
||||||
);
|
);
|
||||||
}
|
}, [
|
||||||
return matchesTicker(extractUnderlying(normalizeContractId(print.option_contract_id)));
|
effectiveOptionPrintFilters,
|
||||||
});
|
instrumentUnderlying,
|
||||||
}, [flowFilters, optionsFeed.items, matchesTicker, tickerSet, selectedInstrument, instrumentUnderlying]);
|
optionsFeed.items,
|
||||||
|
selectedInstrument,
|
||||||
|
tickerSet
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredEquities = useMemo(() => {
|
const filteredEquities = useMemo(() => {
|
||||||
if (tickerSet.size === 0) {
|
if (tickerSet.size === 0) {
|
||||||
|
|
@ -5956,16 +6083,24 @@ const useTerminalState = () => {
|
||||||
if (!optionFocusSeed) {
|
if (!optionFocusSeed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (optionFocusSeed.scopeKey !== optionFocusScopeKey) {
|
if (
|
||||||
setOptionFocusSeed(null);
|
shouldClearOptionFocusSeed(
|
||||||
return;
|
optionFocusSeed,
|
||||||
}
|
optionFocusScopeKey,
|
||||||
const composedBaseItems = composeTapeItems([], liveOptions.liveItems ?? [], liveOptions.historyItems ?? []);
|
currentOptionSubscriptionKey,
|
||||||
const liveKeys = new Set(composedBaseItems.map((item) => getTapeItemKey(item)));
|
liveOptions.liveItems ?? [],
|
||||||
if (optionFocusSeed.items.every((item) => liveKeys.has(getTapeItemKey(item)))) {
|
liveOptions.historyItems ?? []
|
||||||
|
)
|
||||||
|
) {
|
||||||
setOptionFocusSeed(null);
|
setOptionFocusSeed(null);
|
||||||
}
|
}
|
||||||
}, [liveOptions.historyItems, liveOptions.liveItems, optionFocusScopeKey, optionFocusSeed]);
|
}, [
|
||||||
|
currentOptionSubscriptionKey,
|
||||||
|
liveOptions.historyItems,
|
||||||
|
liveOptions.liveItems,
|
||||||
|
optionFocusScopeKey,
|
||||||
|
optionFocusSeed
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!equityFocusSeed) {
|
if (!equityFocusSeed) {
|
||||||
|
|
@ -5988,15 +6123,21 @@ const useTerminalState = () => {
|
||||||
const parsed = parseOptionContractId(contractId);
|
const parsed = parseOptionContractId(contractId);
|
||||||
const underlyingId = (print.underlying_id ?? parsed?.root ?? extractUnderlying(contractId)).toUpperCase();
|
const underlyingId = (print.underlying_id ?? parsed?.root ?? extractUnderlying(contractId)).toUpperCase();
|
||||||
const scopeKey = `option-contract:${contractId}`;
|
const scopeKey = `option-contract:${contractId}`;
|
||||||
|
const subscriptionKey = getLiveSubscriptionKey({
|
||||||
|
channel: "options",
|
||||||
|
underlying_ids: [underlyingId],
|
||||||
|
option_contract_id: contractId
|
||||||
|
});
|
||||||
const seedItems = composeTapeItems(
|
const seedItems = composeTapeItems(
|
||||||
[print],
|
[print],
|
||||||
filteredOptions.filter((candidate) => normalizeContractId(candidate.option_contract_id) === contractId),
|
filteredOptions.filter((candidate) => normalizeContractId(candidate.option_contract_id) === contractId),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
setOptionFocusSeed({ scopeKey, items: seedItems });
|
setOptionFocusSeed({ scopeKey, subscriptionKey, items: seedItems });
|
||||||
bumpTapeDebugMetric("focusSeedRowCount", seedItems.length);
|
bumpTapeDebugMetric("focusSeedRowCount", seedItems.length);
|
||||||
logTapeDebug("option focus seed captured", {
|
logTapeDebug("option focus seed captured", {
|
||||||
contract_id: contractId,
|
contract_id: contractId,
|
||||||
|
subscription_key: subscriptionKey,
|
||||||
row_count: seedItems.length
|
row_count: seedItems.length
|
||||||
});
|
});
|
||||||
setSelectedInstrument({
|
setSelectedInstrument({
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ import {
|
||||||
fetchClassifierHitsByPacketIds,
|
fetchClassifierHitsByPacketIds,
|
||||||
fetchRecentOptionPrints
|
fetchRecentOptionPrints
|
||||||
} from "@islandflow/storage";
|
} from "@islandflow/storage";
|
||||||
import type { EquityPrintQueryFilters, OptionPrintQueryFilters } from "@islandflow/storage";
|
import type { EquityPrintQueryFilters } from "@islandflow/storage";
|
||||||
import {
|
import {
|
||||||
AlertEventSchema,
|
AlertEventSchema,
|
||||||
ClassifierHitEventSchema,
|
ClassifierHitEventSchema,
|
||||||
|
|
@ -99,11 +99,6 @@ import {
|
||||||
LiveSubscriptionSchema,
|
LiveSubscriptionSchema,
|
||||||
matchesFlowPacketFilters,
|
matchesFlowPacketFilters,
|
||||||
matchesOptionPrintFilters,
|
matchesOptionPrintFilters,
|
||||||
OptionFlowFilters,
|
|
||||||
OptionFlowViewSchema,
|
|
||||||
OptionNbboSideSchema,
|
|
||||||
OptionSecurityTypeSchema,
|
|
||||||
OptionTypeSchema,
|
|
||||||
FlowPacketSchema,
|
FlowPacketSchema,
|
||||||
SmartMoneyEventSchema,
|
SmartMoneyEventSchema,
|
||||||
OptionNBBOSchema,
|
OptionNBBOSchema,
|
||||||
|
|
@ -113,6 +108,7 @@ import {
|
||||||
import { createClient } from "redis";
|
import { createClient } from "redis";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { HOT_LIVE_REDIS_KEYS, LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
import { HOT_LIVE_REDIS_KEYS, LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
||||||
|
import { parseOptionPrintQuery } from "./option-queries";
|
||||||
|
|
||||||
const service = "api";
|
const service = "api";
|
||||||
const logger = createLogger({ service });
|
const logger = createLogger({ service });
|
||||||
|
|
@ -224,33 +220,6 @@ const equityPrintRangeSchema = z.object({
|
||||||
end_ts: z.coerce.number().int().nonnegative(),
|
end_ts: z.coerce.number().int().nonnegative(),
|
||||||
limit: limitSchema.optional()
|
limit: limitSchema.optional()
|
||||||
});
|
});
|
||||||
const optionSideListSchema = z
|
|
||||||
.string()
|
|
||||||
.transform((value) =>
|
|
||||||
value
|
|
||||||
.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
.pipe(z.array(OptionNbboSideSchema));
|
|
||||||
const optionTypeListSchema = z
|
|
||||||
.string()
|
|
||||||
.transform((value) =>
|
|
||||||
value
|
|
||||||
.split(",")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
)
|
|
||||||
.pipe(z.array(OptionTypeSchema));
|
|
||||||
const optionSecuritySchema = z.enum(["stock", "etf", "all"]);
|
|
||||||
const optionFilterQuerySchema = z.object({
|
|
||||||
view: OptionFlowViewSchema.optional(),
|
|
||||||
security: optionSecuritySchema.optional(),
|
|
||||||
side: optionSideListSchema.optional(),
|
|
||||||
type: optionTypeListSchema.optional(),
|
|
||||||
min_notional: z.coerce.number().nonnegative().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
type Channel =
|
type Channel =
|
||||||
| "options"
|
| "options"
|
||||||
| "options-nbbo"
|
| "options-nbbo"
|
||||||
|
|
@ -351,43 +320,6 @@ const applyDeliverPolicy = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseOptionPrintFilters = (
|
|
||||||
url: URL
|
|
||||||
): {
|
|
||||||
view: z.infer<typeof OptionFlowViewSchema>;
|
|
||||||
storageFilters: Parameters<typeof fetchRecentOptionPrints>[3];
|
|
||||||
liveFilters: OptionFlowFilters;
|
|
||||||
} => {
|
|
||||||
const parsed = optionFilterQuerySchema.parse({
|
|
||||||
view: url.searchParams.get("view") ?? undefined,
|
|
||||||
security: url.searchParams.get("security") ?? undefined,
|
|
||||||
side: url.searchParams.get("side") ?? undefined,
|
|
||||||
type: url.searchParams.get("type") ?? undefined,
|
|
||||||
min_notional: url.searchParams.get("min_notional") ?? undefined
|
|
||||||
});
|
|
||||||
const view = parsed.view ?? "signal";
|
|
||||||
const security = parsed.security ?? (view === "raw" ? "all" : "stock");
|
|
||||||
const storageFilters = {
|
|
||||||
view,
|
|
||||||
security,
|
|
||||||
minNotional: parsed.min_notional,
|
|
||||||
nbboSides: parsed.side,
|
|
||||||
optionTypes: parsed.type
|
|
||||||
} as const;
|
|
||||||
const liveFilters: OptionFlowFilters = {
|
|
||||||
view,
|
|
||||||
securityTypes:
|
|
||||||
security === "all"
|
|
||||||
? undefined
|
|
||||||
: ([security] as Array<z.infer<typeof OptionSecurityTypeSchema>>),
|
|
||||||
nbboSides: parsed.side,
|
|
||||||
optionTypes: parsed.type,
|
|
||||||
minNotional: parsed.min_notional
|
|
||||||
};
|
|
||||||
|
|
||||||
return { view, storageFilters, liveFilters };
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseReplayParams = (url: URL): { afterTs: number; afterSeq: number; limit: number } => {
|
const parseReplayParams = (url: URL): { afterTs: number; afterSeq: number; limit: number } => {
|
||||||
const params = replayParamsSchema.parse({
|
const params = replayParamsSchema.parse({
|
||||||
after_ts: url.searchParams.get("after_ts") ?? undefined,
|
after_ts: url.searchParams.get("after_ts") ?? undefined,
|
||||||
|
|
@ -605,15 +537,6 @@ const parseScopeList = (url: URL, ...keys: string[]): string[] | undefined => {
|
||||||
return unique.length > 0 ? unique : undefined;
|
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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseLiveEquityPrintFilters = (url: URL): EquityPrintQueryFilters => ({
|
const parseLiveEquityPrintFilters = (url: URL): EquityPrintQueryFilters => ({
|
||||||
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids")
|
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids")
|
||||||
});
|
});
|
||||||
|
|
@ -1399,7 +1322,7 @@ const run = async () => {
|
||||||
try {
|
try {
|
||||||
const limit = parseLimit(url.searchParams.get("limit"));
|
const limit = parseLimit(url.searchParams.get("limit"));
|
||||||
const source = parseReplaySource(url) ?? undefined;
|
const source = parseReplaySource(url) ?? undefined;
|
||||||
const { storageFilters } = parseOptionPrintFilters(url);
|
const { storageFilters } = parseOptionPrintQuery(url);
|
||||||
const data = await fetchRecentOptionPrints(clickhouse, limit, source, storageFilters);
|
const data = await fetchRecentOptionPrints(clickhouse, limit, source, storageFilters);
|
||||||
return jsonResponse({ data });
|
return jsonResponse({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1525,7 +1448,7 @@ const run = async () => {
|
||||||
try {
|
try {
|
||||||
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
||||||
const source = parseReplaySource(url) ?? undefined;
|
const source = parseReplaySource(url) ?? undefined;
|
||||||
const storageFilters = parseLiveOptionPrintFilters(url);
|
const { storageFilters } = parseOptionPrintQuery(url);
|
||||||
const data = await fetchOptionPrintsBefore(
|
const data = await fetchOptionPrintsBefore(
|
||||||
clickhouse,
|
clickhouse,
|
||||||
beforeTs,
|
beforeTs,
|
||||||
|
|
@ -1668,7 +1591,7 @@ const run = async () => {
|
||||||
try {
|
try {
|
||||||
const { afterTs, afterSeq, limit } = parseReplayParams(url);
|
const { afterTs, afterSeq, limit } = parseReplayParams(url);
|
||||||
const source = parseReplaySource(url) ?? undefined;
|
const source = parseReplaySource(url) ?? undefined;
|
||||||
const { storageFilters } = parseOptionPrintFilters(url);
|
const { storageFilters } = parseOptionPrintQuery(url);
|
||||||
const data = await fetchOptionPrintsAfter(
|
const data = await fetchOptionPrintsAfter(
|
||||||
clickhouse,
|
clickhouse,
|
||||||
afterTs,
|
afterTs,
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,30 @@ const snapshotLimitFor = (subscription: LiveSubscription, configuredLimit: numbe
|
||||||
return Math.max(1, Math.min(configuredLimit, Math.floor(requested)));
|
return Math.max(1, Math.min(configuredLimit, Math.floor(requested)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildOptionSnapshotFilters = (
|
||||||
|
subscription: Extract<LiveSubscription, { channel: "options" }>
|
||||||
|
): OptionPrintQueryFilters => {
|
||||||
|
if (subscription.option_contract_id) {
|
||||||
|
return {
|
||||||
|
view: "raw",
|
||||||
|
optionContractId: subscription.option_contract_id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: subscription.filters?.view ?? "signal",
|
||||||
|
security:
|
||||||
|
subscription.filters?.securityTypes?.length === 1
|
||||||
|
? subscription.filters.securityTypes[0]
|
||||||
|
: "all",
|
||||||
|
nbboSides: subscription.filters?.nbboSides,
|
||||||
|
optionTypes: subscription.filters?.optionTypes,
|
||||||
|
minNotional: subscription.filters?.minNotional,
|
||||||
|
underlyingIds: subscription.underlying_ids,
|
||||||
|
optionContractId: subscription.option_contract_id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const candleRedisKey = (underlyingId: string, intervalMs: number): string =>
|
const candleRedisKey = (underlyingId: string, intervalMs: number): string =>
|
||||||
`live:equity-candles:${underlyingId}:${intervalMs}`;
|
`live:equity-candles:${underlyingId}:${intervalMs}`;
|
||||||
|
|
||||||
|
|
@ -489,18 +513,7 @@ export class LiveStateManager {
|
||||||
if (subscription.filters?.view === "raw" || scoped) {
|
if (subscription.filters?.view === "raw" || scoped) {
|
||||||
this.stats.scopedClickHouseSnapshots += 1;
|
this.stats.scopedClickHouseSnapshots += 1;
|
||||||
const limit = snapshotLimitFor(subscription, this.generic.options.limit);
|
const limit = snapshotLimitFor(subscription, this.generic.options.limit);
|
||||||
const storageFilters: OptionPrintQueryFilters = {
|
const storageFilters = buildOptionSnapshotFilters(subscription);
|
||||||
view: subscription.filters?.view ?? "signal",
|
|
||||||
security:
|
|
||||||
subscription.filters?.securityTypes?.length === 1
|
|
||||||
? subscription.filters.securityTypes[0]
|
|
||||||
: "all",
|
|
||||||
nbboSides: subscription.filters?.nbboSides,
|
|
||||||
optionTypes: subscription.filters?.optionTypes,
|
|
||||||
minNotional: subscription.filters?.minNotional,
|
|
||||||
underlyingIds: subscription.underlying_ids,
|
|
||||||
optionContractId: subscription.option_contract_id
|
|
||||||
};
|
|
||||||
const items = await fetchRecentOptionPrints(
|
const items = await fetchRecentOptionPrints(
|
||||||
this.clickhouse,
|
this.clickhouse,
|
||||||
limit,
|
limit,
|
||||||
|
|
|
||||||
107
services/api/src/option-queries.ts
Normal file
107
services/api/src/option-queries.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import type { OptionPrintQueryFilters } from "@islandflow/storage";
|
||||||
|
import {
|
||||||
|
OptionFlowViewSchema,
|
||||||
|
OptionNbboSideSchema,
|
||||||
|
OptionSecurityTypeSchema,
|
||||||
|
OptionTypeSchema,
|
||||||
|
type OptionFlowFilters
|
||||||
|
} from "@islandflow/types";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const optionSideListSchema = z
|
||||||
|
.string()
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
.pipe(z.array(OptionNbboSideSchema));
|
||||||
|
|
||||||
|
const optionTypeListSchema = z
|
||||||
|
.string()
|
||||||
|
.transform((value) =>
|
||||||
|
value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
.pipe(z.array(OptionTypeSchema));
|
||||||
|
|
||||||
|
const optionSecuritySchema = z.enum(["stock", "etf", "all"]);
|
||||||
|
|
||||||
|
const optionFilterQuerySchema = z.object({
|
||||||
|
view: OptionFlowViewSchema.optional(),
|
||||||
|
security: optionSecuritySchema.optional(),
|
||||||
|
side: optionSideListSchema.optional(),
|
||||||
|
type: optionTypeListSchema.optional(),
|
||||||
|
min_notional: z.coerce.number().nonnegative().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ParsedOptionPrintQuery = {
|
||||||
|
scope: {
|
||||||
|
underlyingIds?: string[];
|
||||||
|
optionContractId?: string;
|
||||||
|
};
|
||||||
|
flowFilters: OptionFlowFilters;
|
||||||
|
storageFilters: OptionPrintQueryFilters;
|
||||||
|
isContractDrilldown: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseOptionPrintQuery = (url: URL): ParsedOptionPrintQuery => {
|
||||||
|
const parsed = optionFilterQuerySchema.parse({
|
||||||
|
view: url.searchParams.get("view") ?? undefined,
|
||||||
|
security: url.searchParams.get("security") ?? undefined,
|
||||||
|
side: url.searchParams.get("side") ?? undefined,
|
||||||
|
type: url.searchParams.get("type") ?? undefined,
|
||||||
|
min_notional: url.searchParams.get("min_notional") ?? undefined
|
||||||
|
});
|
||||||
|
const scope = {
|
||||||
|
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
|
||||||
|
optionContractId: url.searchParams.get("option_contract_id") ?? undefined
|
||||||
|
};
|
||||||
|
const view = parsed.view ?? "signal";
|
||||||
|
const security = parsed.security ?? (view === "raw" ? "all" : "stock");
|
||||||
|
const flowFilters: OptionFlowFilters = {
|
||||||
|
view,
|
||||||
|
securityTypes:
|
||||||
|
security === "all"
|
||||||
|
? undefined
|
||||||
|
: ([security] as Array<z.infer<typeof OptionSecurityTypeSchema>>),
|
||||||
|
nbboSides: parsed.side,
|
||||||
|
optionTypes: parsed.type,
|
||||||
|
minNotional: parsed.min_notional
|
||||||
|
};
|
||||||
|
const isContractDrilldown = Boolean(scope.optionContractId);
|
||||||
|
const storageFilters: OptionPrintQueryFilters = isContractDrilldown
|
||||||
|
? {
|
||||||
|
view: "raw",
|
||||||
|
optionContractId: scope.optionContractId
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
view,
|
||||||
|
security,
|
||||||
|
minNotional: parsed.min_notional,
|
||||||
|
nbboSides: parsed.side,
|
||||||
|
optionTypes: parsed.type,
|
||||||
|
underlyingIds: scope.underlyingIds,
|
||||||
|
optionContractId: scope.optionContractId
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
flowFilters,
|
||||||
|
storageFilters,
|
||||||
|
isContractDrilldown
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import type { ClickHouseClient } from "@islandflow/storage";
|
import type { ClickHouseClient } from "@islandflow/storage";
|
||||||
import {
|
import {
|
||||||
|
buildOptionSnapshotFilters,
|
||||||
HOT_LIVE_REDIS_KEYS,
|
HOT_LIVE_REDIS_KEYS,
|
||||||
LiveStateManager,
|
LiveStateManager,
|
||||||
isLiveItemFresh,
|
isLiveItemFresh,
|
||||||
|
|
@ -450,6 +451,74 @@ describe("LiveStateManager", () => {
|
||||||
expect(isLiveItemFresh("options", snapshot.items[0], now)).toBe(false);
|
expect(isLiveItemFresh("options", snapshot.items[0], now)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds raw contract-only snapshot filters for focused option subscriptions", () => {
|
||||||
|
expect(
|
||||||
|
buildOptionSnapshotFilters({
|
||||||
|
channel: "options",
|
||||||
|
filters: {
|
||||||
|
view: "signal",
|
||||||
|
minNotional: 500_000,
|
||||||
|
nbboSides: ["A"],
|
||||||
|
optionTypes: ["call"],
|
||||||
|
securityTypes: ["stock"]
|
||||||
|
},
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
view: "raw",
|
||||||
|
optionContractId: "AAPL-2025-01-17-200-C"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns raw contract rows for focused option snapshots even when broad filters would reject them", async () => {
|
||||||
|
const manager = new LiveStateManager(
|
||||||
|
makeClickHouse((query) => {
|
||||||
|
expect(query).toContain("option_contract_id = 'AAPL-2025-01-17-200-C'");
|
||||||
|
expect(query).not.toContain("signal_pass = 1");
|
||||||
|
expect(query).not.toContain("notional >=");
|
||||||
|
expect(query).not.toContain("nbbo_side IN");
|
||||||
|
expect(query).not.toContain("option_type IN");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source_ts: 1_000,
|
||||||
|
ingest_ts: 1_001,
|
||||||
|
seq: 1,
|
||||||
|
trace_id: "opt-raw",
|
||||||
|
ts: 1_000,
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||||
|
underlying_id: "AAPL",
|
||||||
|
option_type: "put",
|
||||||
|
nbbo_side: "B",
|
||||||
|
notional: 50_000,
|
||||||
|
signal_pass: false,
|
||||||
|
price: 1,
|
||||||
|
size: 5,
|
||||||
|
exchange: "X"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = await manager.getSnapshot({
|
||||||
|
channel: "options",
|
||||||
|
filters: {
|
||||||
|
view: "signal",
|
||||||
|
minNotional: 500_000,
|
||||||
|
nbboSides: ["A"],
|
||||||
|
optionTypes: ["call"],
|
||||||
|
securityTypes: ["stock"]
|
||||||
|
},
|
||||||
|
underlying_ids: ["AAPL"],
|
||||||
|
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((snapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
|
||||||
|
"opt-raw"
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds scoped equity snapshots from clickhouse rows older than 24h", async () => {
|
it("seeds scoped equity snapshots from clickhouse rows older than 24h", async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const staleTs = now - 25 * 60 * 60 * 1000;
|
const staleTs = now - 25 * 60 * 60 * 1000;
|
||||||
|
|
|
||||||
59
services/api/tests/option-queries.test.ts
Normal file
59
services/api/tests/option-queries.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { parseOptionPrintQuery } from "../src/option-queries";
|
||||||
|
|
||||||
|
describe("parseOptionPrintQuery", () => {
|
||||||
|
it("keeps broad option flow filters for non-contract requests", () => {
|
||||||
|
const url = new URL(
|
||||||
|
"http://localhost/prints/options?view=signal&security=stock&side=A&type=call&min_notional=500000&underlying_ids=AAPL,MSFT"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parseOptionPrintQuery(url)).toEqual({
|
||||||
|
scope: {
|
||||||
|
underlyingIds: ["AAPL", "MSFT"],
|
||||||
|
optionContractId: undefined
|
||||||
|
},
|
||||||
|
flowFilters: {
|
||||||
|
view: "signal",
|
||||||
|
securityTypes: ["stock"],
|
||||||
|
nbboSides: ["A"],
|
||||||
|
optionTypes: ["call"],
|
||||||
|
minNotional: 500000
|
||||||
|
},
|
||||||
|
storageFilters: {
|
||||||
|
view: "signal",
|
||||||
|
security: "stock",
|
||||||
|
nbboSides: ["A"],
|
||||||
|
optionTypes: ["call"],
|
||||||
|
minNotional: 500000,
|
||||||
|
underlyingIds: ["AAPL", "MSFT"],
|
||||||
|
optionContractId: undefined
|
||||||
|
},
|
||||||
|
isContractDrilldown: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches contract requests to raw contract-only storage filters", () => {
|
||||||
|
const url = new URL(
|
||||||
|
"http://localhost/replay/options?view=signal&security=stock&side=A&type=call&min_notional=500000&underlying_id=AAPL&option_contract_id=AAPL-2025-01-17-200-C"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parseOptionPrintQuery(url)).toEqual({
|
||||||
|
scope: {
|
||||||
|
underlyingIds: ["AAPL"],
|
||||||
|
optionContractId: "AAPL-2025-01-17-200-C"
|
||||||
|
},
|
||||||
|
flowFilters: {
|
||||||
|
view: "signal",
|
||||||
|
securityTypes: ["stock"],
|
||||||
|
nbboSides: ["A"],
|
||||||
|
optionTypes: ["call"],
|
||||||
|
minNotional: 500000
|
||||||
|
},
|
||||||
|
storageFilters: {
|
||||||
|
view: "raw",
|
||||||
|
optionContractId: "AAPL-2025-01-17-200-C"
|
||||||
|
},
|
||||||
|
isContractDrilldown: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue