Implement options snapshot tape table
This commit is contained in:
parent
6abfff30d3
commit
e78387130a
15 changed files with 904 additions and 128 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
{"_type":"issue","id":"islandflow-b3o","title":"Implement options tape table with execution spot","description":"Redesign OptionsPane into a dense classifier-colored table and preserve execution-time underlying spot on option prints from equity quote mid.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:41:59Z","created_by":"dirtydishes","updated_at":"2026-05-04T05:14:26Z","started_at":"2026-05-04T04:42:08Z","closed_at":"2026-05-04T05:14:26Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-ug1","title":"Fix false NBBO-missing badges in live Options tape","description":"Investigate and fix client-side cases where Options rows show NBBO missing/stale even when a fresh NBBO quote exists in the live nbbo map. Update rendering logic to prefer fresh quote-derived status and add regression tests.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-29T15:58:31Z","created_by":"dirtydishes","updated_at":"2026-04-29T16:01:28Z","started_at":"2026-04-29T15:58:35Z","closed_at":"2026-04-29T16:01:28Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-ug1","title":"Fix false NBBO-missing badges in live Options tape","description":"Investigate and fix client-side cases where Options rows show NBBO missing/stale even when a fresh NBBO quote exists in the live nbbo map. Update rendering logic to prefer fresh quote-derived status and add regression tests.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-29T15:58:31Z","created_by":"dirtydishes","updated_at":"2026-04-29T16:01:28Z","started_at":"2026-04-29T15:58:35Z","closed_at":"2026-04-29T16:01:28Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -873,6 +873,89 @@ h3 {
|
||||||
background: linear-gradient(180deg, rgba(245, 166, 35, 0.07), rgba(255, 255, 255, 0.018));
|
background: linear-gradient(180deg, rgba(245, 166, 35, 0.07), rgba(255, 255, 255, 0.018));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.options-table-wrap {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-table {
|
||||||
|
min-width: 1040px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-table-head,
|
||||||
|
.options-table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px 72px 76px 72px 44px 76px 130px 70px 82px 64px 56px minmax(150px, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-table-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(8, 11, 16, 0.98);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-table-row {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.02 + var(--classifier-intensity, 0) * 0.12)), transparent 62%),
|
||||||
|
rgba(255, 255, 255, 0.012);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-table-row:hover,
|
||||||
|
.options-table-row:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(var(--classifier-rgb, 192, 200, 210), calc(0.04 + var(--classifier-intensity, 0) * 0.18)), transparent 68%),
|
||||||
|
rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-table-row.is-classified {
|
||||||
|
cursor: pointer;
|
||||||
|
border-left: 3px solid rgba(var(--classifier-rgb), calc(0.35 + var(--classifier-intensity) * 0.45));
|
||||||
|
padding-left: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-table-row > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classifier-green { --classifier-rgb: 37, 193, 122; }
|
||||||
|
.classifier-red { --classifier-rgb: 255, 107, 95; }
|
||||||
|
.classifier-amber { --classifier-rgb: 245, 166, 35; }
|
||||||
|
.classifier-copper { --classifier-rgb: 198, 122, 75; }
|
||||||
|
.classifier-blue { --classifier-rgb: 77, 163, 255; }
|
||||||
|
.classifier-teal { --classifier-rgb: 64, 210, 190; }
|
||||||
|
.classifier-yellowgreen { --classifier-rgb: 174, 210, 78; }
|
||||||
|
.classifier-violet { --classifier-rgb: 170, 130, 255; }
|
||||||
|
.classifier-cyan { --classifier-rgb: 94, 214, 255; }
|
||||||
|
.classifier-magenta { --classifier-rgb: 255, 92, 205; }
|
||||||
|
.classifier-neutral { --classifier-rgb: 192, 200, 210; }
|
||||||
|
|
||||||
.contract,
|
.contract,
|
||||||
.drawer-row-title {
|
.drawer-row-title {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import {
|
import {
|
||||||
buildDefaultFlowFilters,
|
buildDefaultFlowFilters,
|
||||||
|
classifierToneForFamily,
|
||||||
deriveAlertDirection,
|
deriveAlertDirection,
|
||||||
countActiveFlowFilterGroups,
|
countActiveFlowFilterGroups,
|
||||||
formatCompactUsd,
|
formatCompactUsd,
|
||||||
formatOptionContractLabel,
|
formatOptionContractLabel,
|
||||||
flushPausableTapeData,
|
flushPausableTapeData,
|
||||||
getAlertWindowAnchorTs,
|
getAlertWindowAnchorTs,
|
||||||
|
getOptionTableSnapshot,
|
||||||
getLiveFeedStatus,
|
getLiveFeedStatus,
|
||||||
normalizeAlertSeverity,
|
normalizeAlertSeverity,
|
||||||
nextFlowFilterPopoverState,
|
nextFlowFilterPopoverState,
|
||||||
|
|
@ -14,6 +16,7 @@ import {
|
||||||
reducePausableTapeData,
|
reducePausableTapeData,
|
||||||
shouldRetainLiveSnapshotHistory,
|
shouldRetainLiveSnapshotHistory,
|
||||||
shouldShowEquitiesSilentFeedWarning,
|
shouldShowEquitiesSilentFeedWarning,
|
||||||
|
selectPrimaryClassifierHit,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
toggleFilterValue
|
toggleFilterValue
|
||||||
} from "./terminal";
|
} from "./terminal";
|
||||||
|
|
@ -171,6 +174,54 @@ describe("options display formatters", () => {
|
||||||
expect(formatCompactUsd(1_250_000)).toBe("1.3M");
|
expect(formatCompactUsd(1_250_000)).toBe("1.3M");
|
||||||
expect(formatCompactUsd(Number.NaN)).toBe("0.00");
|
expect(formatCompactUsd(Number.NaN)).toBe("0.00");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders options table snapshot values from preserved spot and IV", () => {
|
||||||
|
expect(
|
||||||
|
getOptionTableSnapshot({
|
||||||
|
price: 1.25,
|
||||||
|
size: 10,
|
||||||
|
notional: 12_500,
|
||||||
|
execution_nbbo_side: "A",
|
||||||
|
execution_underlying_spot: 450.05,
|
||||||
|
execution_iv: 0.42
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
spot: "450.05",
|
||||||
|
iv: "42%",
|
||||||
|
side: "A",
|
||||||
|
details: "10@1.25_A",
|
||||||
|
value: "12.5K"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders legacy options table snapshot spot and IV as dashes", () => {
|
||||||
|
const snapshot = getOptionTableSnapshot({
|
||||||
|
price: 1,
|
||||||
|
size: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(snapshot.spot).toBe("--");
|
||||||
|
expect(snapshot.iv).toBe("--");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifier row decoration helpers", () => {
|
||||||
|
it("maps classifier families to row tones", () => {
|
||||||
|
expect(classifierToneForFamily("large_bullish_call_sweep")).toBe("green");
|
||||||
|
expect(classifierToneForFamily("large_bearish_put_sweep")).toBe("red");
|
||||||
|
expect(classifierToneForFamily("straddle")).toBe("blue");
|
||||||
|
expect(classifierToneForFamily("unknown_family")).toBe("neutral");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects primary hits by confidence, source timestamp, then seq", () => {
|
||||||
|
const hit = selectPrimaryClassifierHit([
|
||||||
|
{ ...makeAlert({ classifier_id: "old", confidence: 0.9, source_ts: 1_000, seq: 1 }), direction: "bullish", explanations: [] },
|
||||||
|
{ ...makeAlert({ classifier_id: "new", confidence: 0.9, source_ts: 2_000, seq: 1 }), direction: "bullish", explanations: [] },
|
||||||
|
{ ...makeAlert({ classifier_id: "low", confidence: 0.5, source_ts: 3_000, seq: 9 }), direction: "bullish", explanations: [] }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(hit?.classifier_id).toBe("new");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("flow filter popup helpers", () => {
|
describe("flow filter popup helpers", () => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SetStateAction
|
type SetStateAction
|
||||||
|
|
@ -982,7 +983,8 @@ const LIVE_SNAPSHOT_HISTORY_CHANNELS = new Set<LiveSubscription["channel"]>([
|
||||||
"options",
|
"options",
|
||||||
"nbbo",
|
"nbbo",
|
||||||
"equities",
|
"equities",
|
||||||
"flow"
|
"flow",
|
||||||
|
"classifier-hits"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const shouldRetainLiveSnapshotHistory = (
|
export const shouldRetainLiveSnapshotHistory = (
|
||||||
|
|
@ -1027,6 +1029,80 @@ const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined):
|
||||||
return price >= mid ? "A" : "B";
|
return price >= mid ? "A" : "B";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ClassifierDecor = {
|
||||||
|
hit: ClassifierHitEvent;
|
||||||
|
family: string;
|
||||||
|
tone: string;
|
||||||
|
intensity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLASSIFIER_FAMILY_TONES: Record<string, string> = {
|
||||||
|
large_bullish_call_sweep: "green",
|
||||||
|
large_bearish_put_sweep: "red",
|
||||||
|
unusual_contract_spike: "amber",
|
||||||
|
large_call_sell_overwrite: "copper",
|
||||||
|
large_put_sell_write: "copper",
|
||||||
|
straddle: "blue",
|
||||||
|
strangle: "blue",
|
||||||
|
vertical_spread: "teal",
|
||||||
|
ladder_accumulation: "yellowgreen",
|
||||||
|
roll_up_down_out: "violet",
|
||||||
|
far_dated_conviction: "cyan",
|
||||||
|
zero_dte_gamma_punch: "magenta"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectPrimaryClassifierHit = (
|
||||||
|
hits: readonly ClassifierHitEvent[]
|
||||||
|
): ClassifierHitEvent | null => {
|
||||||
|
if (hits.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [...hits].sort((a, b) => {
|
||||||
|
const confidenceDelta = b.confidence - a.confidence;
|
||||||
|
if (confidenceDelta !== 0) {
|
||||||
|
return confidenceDelta;
|
||||||
|
}
|
||||||
|
const tsDelta = b.source_ts - a.source_ts;
|
||||||
|
if (tsDelta !== 0) {
|
||||||
|
return tsDelta;
|
||||||
|
}
|
||||||
|
return b.seq - a.seq;
|
||||||
|
})[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const classifierToneForFamily = (classifierId: string): string =>
|
||||||
|
CLASSIFIER_FAMILY_TONES[classifierId] ?? "neutral";
|
||||||
|
|
||||||
|
const buildClassifierDecor = (hit: ClassifierHitEvent): ClassifierDecor => ({
|
||||||
|
hit,
|
||||||
|
family: hit.classifier_id,
|
||||||
|
tone: classifierToneForFamily(hit.classifier_id),
|
||||||
|
intensity: clamp(hit.confidence, 0.25, 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getOptionTableSnapshot = (
|
||||||
|
print: Pick<
|
||||||
|
OptionPrint,
|
||||||
|
| "price"
|
||||||
|
| "size"
|
||||||
|
| "notional"
|
||||||
|
| "nbbo_side"
|
||||||
|
| "execution_nbbo_side"
|
||||||
|
| "execution_underlying_spot"
|
||||||
|
| "execution_iv"
|
||||||
|
>,
|
||||||
|
fallbackSide: OptionNbboSide | null = null
|
||||||
|
): { spot: string; iv: string; side: string; details: string; value: string } => {
|
||||||
|
const side = print.execution_nbbo_side ?? print.nbbo_side ?? fallbackSide ?? "--";
|
||||||
|
return {
|
||||||
|
spot: typeof print.execution_underlying_spot === "number" ? formatPrice(print.execution_underlying_spot) : "--",
|
||||||
|
iv: typeof print.execution_iv === "number" ? formatPct(print.execution_iv) : "--",
|
||||||
|
side,
|
||||||
|
details: `${formatSize(print.size)}@${formatPrice(print.price)}_${side}`,
|
||||||
|
value: formatCompactUsd(print.notional ?? print.price * print.size * 100)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type ListScrollState = {
|
type ListScrollState = {
|
||||||
listRef: React.RefObject<HTMLDivElement>;
|
listRef: React.RefObject<HTMLDivElement>;
|
||||||
isAtTop: boolean;
|
isAtTop: boolean;
|
||||||
|
|
@ -2125,7 +2201,8 @@ const getLiveManifest = (
|
||||||
{ channel: "options", filters: flowFilters },
|
{ channel: "options", filters: flowFilters },
|
||||||
{ channel: "nbbo" },
|
{ channel: "nbbo" },
|
||||||
{ channel: "equities" },
|
{ channel: "equities" },
|
||||||
{ channel: "flow", filters: flowFilters }
|
{ channel: "flow", filters: flowFilters },
|
||||||
|
{ channel: "classifier-hits" }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4157,6 +4234,39 @@ const useTerminalState = () => {
|
||||||
return traceId.slice(idx);
|
return traceId.slice(idx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const classifierHitsByPacketId = useMemo(() => {
|
||||||
|
const map = new Map<string, ClassifierHitEvent[]>();
|
||||||
|
for (const hit of classifierHitsFeed.items) {
|
||||||
|
const packetId = extractPacketIdFromClassifierHitTrace(hit.trace_id);
|
||||||
|
if (!packetId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(packetId, [...(map.get(packetId) ?? []), hit]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [classifierHitsFeed.items, extractPacketIdFromClassifierHitTrace]);
|
||||||
|
|
||||||
|
const packetIdByOptionTraceId = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const packet of flowFeed.items) {
|
||||||
|
for (const member of packet.members) {
|
||||||
|
map.set(member, packet.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [flowFeed.items]);
|
||||||
|
|
||||||
|
const classifierDecorByOptionTraceId = useMemo(() => {
|
||||||
|
const map = new Map<string, ClassifierDecor>();
|
||||||
|
for (const [traceId, packetId] of packetIdByOptionTraceId) {
|
||||||
|
const primary = selectPrimaryClassifierHit(classifierHitsByPacketId.get(packetId) ?? []);
|
||||||
|
if (primary) {
|
||||||
|
map.set(traceId, buildClassifierDecor(primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [classifierHitsByPacketId, packetIdByOptionTraceId]);
|
||||||
|
|
||||||
const selectedClassifierPacketId = useMemo(() => {
|
const selectedClassifierPacketId = useMemo(() => {
|
||||||
if (!selectedClassifierHit) {
|
if (!selectedClassifierHit) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -4632,6 +4742,9 @@ const useTerminalState = () => {
|
||||||
equityPrintMap,
|
equityPrintMap,
|
||||||
equityJoinMap: resolvedEquityJoinMap,
|
equityJoinMap: resolvedEquityJoinMap,
|
||||||
flowPacketMap: resolvedFlowPacketMap,
|
flowPacketMap: resolvedFlowPacketMap,
|
||||||
|
classifierHitsByPacketId,
|
||||||
|
packetIdByOptionTraceId,
|
||||||
|
classifierDecorByOptionTraceId,
|
||||||
selectedEvidence,
|
selectedEvidence,
|
||||||
selectedFlowPacket,
|
selectedFlowPacket,
|
||||||
selectedDarkEvidence,
|
selectedDarkEvidence,
|
||||||
|
|
@ -5002,7 +5115,7 @@ type OptionsPaneProps = {
|
||||||
const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
||||||
const state = useTerminal();
|
const state = useTerminal();
|
||||||
const items = limit ? state.filteredOptions.slice(0, limit) : state.filteredOptions;
|
const items = limit ? state.filteredOptions.slice(0, limit) : state.filteredOptions;
|
||||||
const virtual = useVirtualList(items, state.optionsScroll.listRef, !limit, 96);
|
const virtual = useVirtualList(items, state.optionsScroll.listRef, !limit, 34);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pane
|
<Pane
|
||||||
|
|
@ -5028,7 +5141,7 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="list terminal-list" ref={state.optionsScroll.listRef}>
|
<div className="options-table-wrap" ref={state.optionsScroll.listRef}>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
{state.tickerSet.size > 0
|
{state.tickerSet.size > 0
|
||||||
|
|
@ -5040,103 +5153,92 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
||||||
: "Replay queue empty. Ensure ClickHouse has data."}
|
: "Replay queue empty. Ensure ClickHouse has data."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="options-table" role="table" aria-label="Options tape">
|
||||||
|
<div className="options-table-head" role="row">
|
||||||
|
<span>TIME</span>
|
||||||
|
<span>SYM</span>
|
||||||
|
<span>EXP</span>
|
||||||
|
<span>STRIKE</span>
|
||||||
|
<span>C/P</span>
|
||||||
|
<span>SPOT</span>
|
||||||
|
<span>DETAILS</span>
|
||||||
|
<span>TYPE</span>
|
||||||
|
<span>VALUE</span>
|
||||||
|
<span>SIDE</span>
|
||||||
|
<span>IV</span>
|
||||||
|
<span>CLASSIFIER</span>
|
||||||
|
</div>
|
||||||
{virtual.topSpacerHeight > 0 ? (
|
{virtual.topSpacerHeight > 0 ? (
|
||||||
<div style={{ height: `${virtual.topSpacerHeight}px` }} aria-hidden />
|
<div style={{ height: `${virtual.topSpacerHeight}px` }} aria-hidden />
|
||||||
) : null}
|
) : null}
|
||||||
{virtual.visibleItems.map((print) => {
|
{virtual.visibleItems.map((print) => {
|
||||||
const contractId = normalizeContractId(print.option_contract_id);
|
const contractId = normalizeContractId(print.option_contract_id);
|
||||||
|
const parsed = parseOptionContractId(contractId);
|
||||||
const contractDisplay = formatOptionContractLabel(contractId);
|
const contractDisplay = formatOptionContractLabel(contractId);
|
||||||
const quote = state.nbboMap.get(contractId);
|
const quote = state.nbboMap.get(contractId);
|
||||||
const nbboAge = quote ? Math.abs(print.ts - quote.ts) : null;
|
const hasPreservedNbbo = typeof print.execution_nbbo_side === "string";
|
||||||
const nbboStale = nbboAge !== null && nbboAge > NBBO_MAX_AGE_MS_SAFE;
|
const nbboSide =
|
||||||
const nbboMid = quote ? (quote.bid + quote.ask) / 2 : null;
|
print.execution_nbbo_side ??
|
||||||
const nbboSide = print.nbbo_side ?? classifyNbboSide(print.price, quote);
|
print.nbbo_side ??
|
||||||
|
(!hasPreservedNbbo ? classifyNbboSide(print.price, quote) : null);
|
||||||
const notional = print.notional ?? print.price * print.size * 100;
|
const notional = print.notional ?? print.price * print.size * 100;
|
||||||
|
const spot = print.execution_underlying_spot;
|
||||||
return (
|
const iv = print.execution_iv;
|
||||||
<div className="row" key={`${print.trace_id}-${print.seq}`}>
|
const decor = state.classifierDecorByOptionTraceId.get(print.trace_id);
|
||||||
<div>
|
const commonProps = {
|
||||||
<div className={`contract${contractDisplay ? " option-contract" : ""}`}>
|
className: `options-table-row${decor ? ` is-classified classifier-${decor.tone}` : ""}`,
|
||||||
{contractDisplay ? (
|
style: decor ? ({ "--classifier-intensity": decor.intensity } as CSSProperties) : undefined
|
||||||
|
};
|
||||||
|
const cells = (
|
||||||
<>
|
<>
|
||||||
<span>{contractDisplay.ticker}</span>
|
<span className="mono">{formatTime(print.ts)}</span>
|
||||||
<span>{contractDisplay.strike}</span>
|
<span>{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}</span>
|
||||||
<span>{contractDisplay.expiration}</span>
|
<span>{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}</span>
|
||||||
</>
|
<span>{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}</span>
|
||||||
) : (
|
<span>{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}</span>
|
||||||
formatContractLabel(contractId)
|
<span>{typeof spot === "number" ? formatPrice(spot) : "--"}</span>
|
||||||
)}
|
<span className="mono">
|
||||||
</div>
|
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
|
||||||
<div className="meta">
|
|
||||||
<span>${formatPrice(print.price)}</span>
|
|
||||||
<span>{formatSize(print.size)}x</span>
|
|
||||||
<span>{print.exchange}</span>
|
|
||||||
<span className="notional-emphasis">Notional ${formatCompactUsd(notional)}</span>
|
|
||||||
{print.conditions?.map((condition) => {
|
|
||||||
const normalized = condition.toUpperCase();
|
|
||||||
const tone =
|
|
||||||
normalized === "SWEEP"
|
|
||||||
? "condition-sweep"
|
|
||||||
: normalized === "ISO"
|
|
||||||
? "condition-iso"
|
|
||||||
: "condition-neutral";
|
|
||||||
return (
|
|
||||||
<span className={`condition-chip ${tone}`} key={`${print.trace_id}-${condition}`}>
|
|
||||||
{normalized}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
<span>{print.option_type ?? "--"}</span>
|
||||||
})}
|
<span className="notional-emphasis">${formatCompactUsd(notional)}</span>
|
||||||
</div>
|
<span>
|
||||||
{quote ? (
|
|
||||||
<div className="meta nbbo-meta">
|
|
||||||
<span>Bid ${formatPrice(quote.bid)}</span>
|
|
||||||
<span>Ask ${formatPrice(quote.ask)}</span>
|
|
||||||
<span>Mid ${formatPrice(nbboMid ?? 0)}</span>
|
|
||||||
<span>{Math.round(nbboAge ?? 0)}ms</span>
|
|
||||||
{nbboSide ? (
|
{nbboSide ? (
|
||||||
<span className="nbbo-side" tabIndex={0} aria-label="NBBO side legend">
|
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
|
||||||
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>
|
|
||||||
{nbboSide}
|
|
||||||
</span>
|
|
||||||
<span className="nbbo-tooltip" role="tooltip">
|
|
||||||
<span className="nbbo-tooltip-row">
|
|
||||||
<span className="nbbo-tag nbbo-tag-a">A</span>
|
|
||||||
<span>Ask</span>
|
|
||||||
</span>
|
|
||||||
<span className="nbbo-tooltip-row">
|
|
||||||
<span className="nbbo-tag nbbo-tag-aa">AA</span>
|
|
||||||
<span>Above Ask</span>
|
|
||||||
</span>
|
|
||||||
<span className="nbbo-tooltip-row">
|
|
||||||
<span className="nbbo-tag nbbo-tag-b">B</span>
|
|
||||||
<span>Bid</span>
|
|
||||||
</span>
|
|
||||||
<span className="nbbo-tooltip-row">
|
|
||||||
<span className="nbbo-tag nbbo-tag-bb">BB</span>
|
|
||||||
<span>Below Bid</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{print.nbbo_side === "STALE" || nbboStale ? <span className="pill nbbo-stale">Stale</span> : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="meta nbbo-meta">
|
"--"
|
||||||
<span className="pill nbbo-missing">
|
|
||||||
{print.nbbo_side === "STALE" ? "NBBO stale" : "NBBO missing"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
<div className="time">{formatTime(print.ts)}</div>
|
<span>{typeof iv === "number" ? formatPct(iv) : "--"}</span>
|
||||||
|
<span>{decor ? humanizeClassifierId(decor.family) : "--"}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return decor ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...commonProps}
|
||||||
|
key={`${print.trace_id}-${print.seq}`}
|
||||||
|
onClick={() => state.openFromClassifierHit(decor.hit)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
state.openFromClassifierHit(decor.hit);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div {...commonProps} key={`${print.trace_id}-${print.seq}`}>
|
||||||
|
{cells}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{virtual.bottomSpacerHeight > 0 ? (
|
{virtual.bottomSpacerHeight > 0 ? (
|
||||||
<div style={{ height: `${virtual.bottomSpacerHeight}px` }} aria-hidden />
|
<div style={{ height: `${virtual.bottomSpacerHeight}px` }} aria-hidden />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Pane>
|
</Pane>
|
||||||
|
|
|
||||||
20
options-overhaul-phase1.md
Normal file
20
options-overhaul-phase1.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Options Overhaul Phase 1: Snapshot Tape Table
|
||||||
|
|
||||||
|
Implemented Phase 1 snapshot semantics for the Options tape.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- Added flat execution snapshot fields to `OptionPrintSchema` / `OptionPrint`.
|
||||||
|
- Added ClickHouse columns and migrations for execution NBBO, underlying spot, and IV context.
|
||||||
|
- Added ingest enrichment that selects option NBBO and equity quote context at or before the option print timestamp.
|
||||||
|
- New enriched prints mirror `nbbo_side` from `execution_nbbo_side`.
|
||||||
|
- Added synthetic per-contract IV state with pressure, decay, and clamps.
|
||||||
|
- Redesigned the Options pane as a dense table using preserved spot/IV/NBBO side first.
|
||||||
|
- Added classifier-hit row color mapping and click/keyboard drawer interaction for classified rows.
|
||||||
|
- Updated `/tape` live subscriptions to include `classifier-hits`.
|
||||||
|
- Added focused tests for schema, storage, enrichment, synthetic IV, and frontend table/classifier helpers.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bun test packages/types/tests/events.test.ts packages/storage/tests/option-prints.test.ts services/ingest-options/tests/enrichment.test.ts services/ingest-options/tests/synthetic.test.ts apps/web/app/terminal.test.ts`
|
||||||
|
- `bun run build` from `apps/web`
|
||||||
|
|
@ -512,7 +512,23 @@ const normalizeOptionRow = (row: unknown): unknown => {
|
||||||
"ts",
|
"ts",
|
||||||
"price",
|
"price",
|
||||||
"size",
|
"size",
|
||||||
"notional"
|
"notional",
|
||||||
|
"execution_nbbo_bid",
|
||||||
|
"execution_nbbo_ask",
|
||||||
|
"execution_nbbo_mid",
|
||||||
|
"execution_nbbo_spread",
|
||||||
|
"execution_nbbo_bid_size",
|
||||||
|
"execution_nbbo_ask_size",
|
||||||
|
"execution_nbbo_ts",
|
||||||
|
"execution_nbbo_age_ms",
|
||||||
|
"execution_underlying_spot",
|
||||||
|
"execution_underlying_bid",
|
||||||
|
"execution_underlying_ask",
|
||||||
|
"execution_underlying_mid",
|
||||||
|
"execution_underlying_spread",
|
||||||
|
"execution_underlying_ts",
|
||||||
|
"execution_underlying_age_ms",
|
||||||
|
"execution_iv"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ("is_etf" in record) {
|
if ("is_etf" in record) {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,25 @@ CREATE TABLE IF NOT EXISTS ${OPTION_PRINTS_TABLE} (
|
||||||
option_type Nullable(String),
|
option_type Nullable(String),
|
||||||
notional Nullable(Float64),
|
notional Nullable(Float64),
|
||||||
nbbo_side Nullable(String),
|
nbbo_side Nullable(String),
|
||||||
|
execution_nbbo_bid Nullable(Float64),
|
||||||
|
execution_nbbo_ask Nullable(Float64),
|
||||||
|
execution_nbbo_mid Nullable(Float64),
|
||||||
|
execution_nbbo_spread Nullable(Float64),
|
||||||
|
execution_nbbo_bid_size Nullable(UInt32),
|
||||||
|
execution_nbbo_ask_size Nullable(UInt32),
|
||||||
|
execution_nbbo_ts Nullable(UInt64),
|
||||||
|
execution_nbbo_age_ms Nullable(Float64),
|
||||||
|
execution_nbbo_side Nullable(String),
|
||||||
|
execution_underlying_spot Nullable(Float64),
|
||||||
|
execution_underlying_bid Nullable(Float64),
|
||||||
|
execution_underlying_ask Nullable(Float64),
|
||||||
|
execution_underlying_mid Nullable(Float64),
|
||||||
|
execution_underlying_spread Nullable(Float64),
|
||||||
|
execution_underlying_ts Nullable(UInt64),
|
||||||
|
execution_underlying_age_ms Nullable(Float64),
|
||||||
|
execution_underlying_source Nullable(String),
|
||||||
|
execution_iv Nullable(Float64),
|
||||||
|
execution_iv_source Nullable(String),
|
||||||
is_etf Nullable(Bool),
|
is_etf Nullable(Bool),
|
||||||
signal_pass Nullable(Bool),
|
signal_pass Nullable(Bool),
|
||||||
signal_reasons Array(String) DEFAULT [],
|
signal_reasons Array(String) DEFAULT [],
|
||||||
|
|
@ -35,6 +54,25 @@ export const optionPrintsTableMigrations = (): string[] => {
|
||||||
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS option_type Nullable(String)`,
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS option_type Nullable(String)`,
|
||||||
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS notional Nullable(Float64)`,
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS notional Nullable(Float64)`,
|
||||||
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS nbbo_side Nullable(String)`,
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS nbbo_side Nullable(String)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_bid Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_ask Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_mid Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_spread Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_bid_size Nullable(UInt32)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_ask_size Nullable(UInt32)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_ts Nullable(UInt64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_age_ms Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_nbbo_side Nullable(String)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_spot Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_bid Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_ask Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_mid Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_spread Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_ts Nullable(UInt64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_age_ms Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_underlying_source Nullable(String)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_iv Nullable(Float64)`,
|
||||||
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS execution_iv_source Nullable(String)`,
|
||||||
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS is_etf Nullable(Bool)`,
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS is_etf Nullable(Bool)`,
|
||||||
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_pass Nullable(Bool)`,
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_pass Nullable(Bool)`,
|
||||||
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_reasons Array(String) DEFAULT []`,
|
`ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_reasons Array(String) DEFAULT []`,
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,20 @@ describe("option-prints storage helpers", () => {
|
||||||
expect(normalized.conditions).toEqual([]);
|
expect(normalized.conditions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy rows with missing execution context", () => {
|
||||||
|
const normalized = normalizeOptionPrint(basePrint);
|
||||||
|
expect(normalized.execution_nbbo_bid).toBeUndefined();
|
||||||
|
expect(normalized.execution_underlying_spot).toBeUndefined();
|
||||||
|
expect(normalized.execution_iv).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("includes the correct table name in the DDL", () => {
|
it("includes the correct table name in the DDL", () => {
|
||||||
const ddl = optionPrintsTableDDL();
|
const ddl = optionPrintsTableDDL();
|
||||||
expect(ddl).toContain(OPTION_PRINTS_TABLE);
|
expect(ddl).toContain(OPTION_PRINTS_TABLE);
|
||||||
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS");
|
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS");
|
||||||
|
expect(ddl).toContain("execution_nbbo_bid Nullable(Float64)");
|
||||||
|
expect(ddl).toContain("execution_underlying_spot Nullable(Float64)");
|
||||||
|
expect(ddl).toContain("execution_iv Nullable(Float64)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds before/history and trace lookup queries", async () => {
|
it("builds before/history and trace lookup queries", async () => {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,31 @@ export const OptionPrintSchema = EventMetaSchema.merge(
|
||||||
option_type: z.preprocess((value) => (value === null ? undefined : value), OptionTypeSchema.optional()),
|
option_type: z.preprocess((value) => (value === null ? undefined : value), OptionTypeSchema.optional()),
|
||||||
notional: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()),
|
notional: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()),
|
||||||
nbbo_side: z.preprocess((value) => (value === null ? undefined : value), OptionNbboSideSchema.optional()),
|
nbbo_side: z.preprocess((value) => (value === null ? undefined : value), OptionNbboSideSchema.optional()),
|
||||||
|
execution_nbbo_bid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_nbbo_ask: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_nbbo_mid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_nbbo_spread: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_nbbo_bid_size: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()),
|
||||||
|
execution_nbbo_ask_size: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()),
|
||||||
|
execution_nbbo_ts: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()),
|
||||||
|
execution_nbbo_age_ms: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()),
|
||||||
|
execution_nbbo_side: z.preprocess((value) => (value === null ? undefined : value), OptionNbboSideSchema.optional()),
|
||||||
|
execution_underlying_spot: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_underlying_bid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_underlying_ask: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_underlying_mid: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_underlying_spread: z.preprocess((value) => (value === null ? undefined : value), z.number().optional()),
|
||||||
|
execution_underlying_ts: z.preprocess((value) => (value === null ? undefined : value), z.number().int().nonnegative().optional()),
|
||||||
|
execution_underlying_age_ms: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()),
|
||||||
|
execution_underlying_source: z.preprocess(
|
||||||
|
(value) => (value === null ? undefined : value),
|
||||||
|
z.literal("equity_quote_mid").optional()
|
||||||
|
),
|
||||||
|
execution_iv: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().optional()),
|
||||||
|
execution_iv_source: z.preprocess(
|
||||||
|
(value) => (value === null ? undefined : value),
|
||||||
|
z.enum(["provider", "synthetic_pressure_model"]).optional()
|
||||||
|
),
|
||||||
is_etf: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()),
|
is_etf: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()),
|
||||||
signal_pass: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()),
|
signal_pass: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()),
|
||||||
signal_reasons: z.array(z.string().min(1)).optional(),
|
signal_reasons: z.array(z.string().min(1)).optional(),
|
||||||
|
|
|
||||||
41
packages/types/tests/events.test.ts
Normal file
41
packages/types/tests/events.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { OptionPrintSchema } from "../src/events";
|
||||||
|
|
||||||
|
describe("event schemas", () => {
|
||||||
|
it("accepts option print execution context fields", () => {
|
||||||
|
const parsed = OptionPrintSchema.parse({
|
||||||
|
source_ts: 100,
|
||||||
|
ingest_ts: 101,
|
||||||
|
seq: 1,
|
||||||
|
trace_id: "trace-1",
|
||||||
|
ts: 100,
|
||||||
|
option_contract_id: "SPY-2025-01-17-450-C",
|
||||||
|
price: 1.25,
|
||||||
|
size: 10,
|
||||||
|
exchange: "TEST",
|
||||||
|
execution_nbbo_bid: 1.2,
|
||||||
|
execution_nbbo_ask: 1.3,
|
||||||
|
execution_nbbo_mid: 1.25,
|
||||||
|
execution_nbbo_spread: 0.1,
|
||||||
|
execution_nbbo_bid_size: 20,
|
||||||
|
execution_nbbo_ask_size: 30,
|
||||||
|
execution_nbbo_ts: 99,
|
||||||
|
execution_nbbo_age_ms: 1,
|
||||||
|
execution_nbbo_side: "MID",
|
||||||
|
execution_underlying_spot: 450.05,
|
||||||
|
execution_underlying_bid: 450,
|
||||||
|
execution_underlying_ask: 450.1,
|
||||||
|
execution_underlying_mid: 450.05,
|
||||||
|
execution_underlying_spread: 0.1,
|
||||||
|
execution_underlying_ts: 98,
|
||||||
|
execution_underlying_age_ms: 2,
|
||||||
|
execution_underlying_source: "equity_quote_mid",
|
||||||
|
execution_iv: 0.42,
|
||||||
|
execution_iv_source: "synthetic_pressure_model"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.execution_nbbo_side).toBe("MID");
|
||||||
|
expect(parsed.execution_underlying_spot).toBe(450.05);
|
||||||
|
expect(parsed.execution_iv_source).toBe("synthetic_pressure_model");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,9 @@ type SyntheticOptionsAdapterConfig = {
|
||||||
|
|
||||||
type Burst = {
|
type Burst = {
|
||||||
contractId: string;
|
contractId: string;
|
||||||
|
underlying: number;
|
||||||
|
expiryOffsetDays: number;
|
||||||
|
strike: number;
|
||||||
basePrice: number;
|
basePrice: number;
|
||||||
baseSize: number;
|
baseSize: number;
|
||||||
exchange: string;
|
exchange: string;
|
||||||
|
|
@ -23,7 +26,16 @@ type Burst = {
|
||||||
seed: number;
|
seed: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SyntheticContractIvState = {
|
||||||
|
iv: number;
|
||||||
|
pressure: number;
|
||||||
|
lastTs: number;
|
||||||
|
};
|
||||||
|
|
||||||
const OPTION_CONTRACT_MULTIPLIER = 100;
|
const OPTION_CONTRACT_MULTIPLIER = 100;
|
||||||
|
const IV_MIN = 0.05;
|
||||||
|
const IV_MAX = 2.5;
|
||||||
|
const IV_DECAY_HALF_LIFE_MS = 60_000;
|
||||||
|
|
||||||
const SYNTHETIC_SYMBOLS = ["SPY", ...(SP500_SYMBOLS as readonly string[])];
|
const SYNTHETIC_SYMBOLS = ["SPY", ...(SP500_SYMBOLS as readonly string[])];
|
||||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
@ -36,7 +48,7 @@ type SyntheticOptionsProfile = {
|
||||||
pricePlacements: Record<string, WeightedValue<PricePlacement>[]>;
|
pricePlacements: Record<string, WeightedValue<PricePlacement>[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PricePlacement = "AA" | "A" | "MID" | "B" | "BB";
|
export type PricePlacement = "AA" | "A" | "MID" | "B" | "BB";
|
||||||
|
|
||||||
type WeightedValue<T> = {
|
type WeightedValue<T> = {
|
||||||
value: T;
|
value: T;
|
||||||
|
|
@ -347,6 +359,55 @@ const formatExpiry = (now: number, offsetDays: number): string => {
|
||||||
return expiryDate.toISOString().slice(0, 10);
|
return expiryDate.toISOString().slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clampValue = (value: number, min: number, max: number): number => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeSyntheticIv = (dteDays: number, moneyness: number): number => {
|
||||||
|
const dteBoost = dteDays <= 0 ? 0.22 : dteDays <= 7 ? 0.14 : dteDays <= 30 ? 0.06 : 0;
|
||||||
|
const moneynessBoost = clampValue(Math.abs(moneyness - 1) * 0.8, 0, 0.2);
|
||||||
|
return clampValue(0.24 + dteBoost + moneynessBoost, 0.18, 0.65);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSyntheticIvForTest = (
|
||||||
|
state: SyntheticContractIvState | undefined,
|
||||||
|
input: {
|
||||||
|
ts: number;
|
||||||
|
placement: PricePlacement;
|
||||||
|
size: number;
|
||||||
|
notional: number;
|
||||||
|
dteDays: number;
|
||||||
|
moneyness: number;
|
||||||
|
}
|
||||||
|
): SyntheticContractIvState => {
|
||||||
|
const previous = state ?? {
|
||||||
|
iv: initializeSyntheticIv(input.dteDays, input.moneyness),
|
||||||
|
pressure: 0,
|
||||||
|
lastTs: input.ts
|
||||||
|
};
|
||||||
|
const elapsed = Math.max(0, input.ts - previous.lastTs);
|
||||||
|
const decay = Math.pow(0.5, elapsed / IV_DECAY_HALF_LIFE_MS);
|
||||||
|
let pressure = previous.pressure * decay;
|
||||||
|
|
||||||
|
if (input.placement === "AA" || input.placement === "A") {
|
||||||
|
const sizeImpact = Math.log10(Math.max(10, input.size)) * 0.012;
|
||||||
|
const notionalImpact = Math.log10(Math.max(1_000, input.notional)) * 0.01;
|
||||||
|
pressure += input.placement === "AA" ? sizeImpact + notionalImpact : (sizeImpact + notionalImpact) * 0.65;
|
||||||
|
} else if (input.placement === "MID") {
|
||||||
|
pressure += 0.001;
|
||||||
|
} else {
|
||||||
|
pressure -= input.placement === "BB" ? 0.018 : 0.01;
|
||||||
|
}
|
||||||
|
|
||||||
|
pressure = clampValue(pressure, -0.25, 1.85);
|
||||||
|
const baseline = initializeSyntheticIv(input.dteDays, input.moneyness);
|
||||||
|
const iv = clampValue(baseline + pressure * 0.42, IV_MIN, IV_MAX);
|
||||||
|
return { iv: Number(iv.toFixed(4)), pressure, lastTs: input.ts };
|
||||||
|
};
|
||||||
|
|
||||||
const buildBurst = (burstIndex: number, now: number, profile: SyntheticOptionsProfile): Burst => {
|
const buildBurst = (burstIndex: number, now: number, profile: SyntheticOptionsProfile): Burst => {
|
||||||
const symbol = SYNTHETIC_SYMBOLS[burstIndex % SYNTHETIC_SYMBOLS.length];
|
const symbol = SYNTHETIC_SYMBOLS[burstIndex % SYNTHETIC_SYMBOLS.length];
|
||||||
const symbolHash = hashSymbol(symbol);
|
const symbolHash = hashSymbol(symbol);
|
||||||
|
|
@ -392,6 +453,9 @@ const buildBurst = (burstIndex: number, now: number, profile: SyntheticOptionsPr
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contractId,
|
contractId,
|
||||||
|
underlying: baseUnderlying,
|
||||||
|
expiryOffsetDays: expiryOffset,
|
||||||
|
strike,
|
||||||
basePrice: basePricePer,
|
basePrice: basePricePer,
|
||||||
baseSize,
|
baseSize,
|
||||||
exchange,
|
exchange,
|
||||||
|
|
@ -420,6 +484,7 @@ export const createSyntheticOptionsAdapter = (
|
||||||
let nbboSeq = 0;
|
let nbboSeq = 0;
|
||||||
let burstIndex = 0;
|
let burstIndex = 0;
|
||||||
let currentBurst: Burst | null = null;
|
let currentBurst: Burst | null = null;
|
||||||
|
const ivByContract = new Map<string, SyntheticContractIvState>();
|
||||||
let remainingRuns = 0;
|
let remainingRuns = 0;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
@ -448,12 +513,28 @@ export const createSyntheticOptionsAdapter = (
|
||||||
const priceJitter = ((i % 3) - 1) * 0.004;
|
const priceJitter = ((i % 3) - 1) * 0.004;
|
||||||
const sizeJitter = ((i % 3) - 1) * 0.08;
|
const sizeJitter = ((i % 3) - 1) * 0.08;
|
||||||
const priceMultiplier = 1 + burst.priceStep * i + priceJitter;
|
const priceMultiplier = 1 + burst.priceStep * i + priceJitter;
|
||||||
const mid = Math.max(0.05, Number((burst.basePrice * priceMultiplier).toFixed(2)));
|
const placement = pickPlacement(burst, i, profile);
|
||||||
const spread = Math.max(0.02, Number((mid * 0.02).toFixed(2)));
|
const size = Math.max(1, Math.round(burst.baseSize * (1 + sizeJitter)));
|
||||||
|
const previousIv = ivByContract.get(burst.contractId);
|
||||||
|
const provisionalNotional = burst.basePrice * size * OPTION_CONTRACT_MULTIPLIER;
|
||||||
|
const ivState = updateSyntheticIvForTest(previousIv, {
|
||||||
|
ts: now + i * 5,
|
||||||
|
placement,
|
||||||
|
size,
|
||||||
|
notional: provisionalNotional,
|
||||||
|
dteDays: burst.expiryOffsetDays,
|
||||||
|
moneyness: burst.strike / burst.underlying
|
||||||
|
});
|
||||||
|
ivByContract.set(burst.contractId, ivState);
|
||||||
|
const ivDrift = Math.max(0, ivState.iv - initializeSyntheticIv(burst.expiryOffsetDays, burst.strike / burst.underlying));
|
||||||
|
const mid = Math.max(
|
||||||
|
0.05,
|
||||||
|
Number((burst.basePrice * priceMultiplier * (1 + ivDrift * 1.15)).toFixed(2))
|
||||||
|
);
|
||||||
|
const spread = Math.max(0.02, Number((mid * (0.02 + Math.min(0.035, ivState.iv * 0.01))).toFixed(2)));
|
||||||
const bid = Math.max(0.01, Number((mid - spread / 2).toFixed(2)));
|
const bid = Math.max(0.01, Number((mid - spread / 2).toFixed(2)));
|
||||||
const ask = Math.max(bid + 0.01, Number((mid + spread / 2).toFixed(2)));
|
const ask = Math.max(bid + 0.01, Number((mid + spread / 2).toFixed(2)));
|
||||||
const tick = Math.max(0.01, Number((spread * 0.25).toFixed(2)));
|
const tick = Math.max(0.01, Number((spread * 0.25).toFixed(2)));
|
||||||
const placement = pickPlacement(burst, i, profile);
|
|
||||||
let tradePrice = mid;
|
let tradePrice = mid;
|
||||||
|
|
||||||
if (placement === "AA") {
|
if (placement === "AA") {
|
||||||
|
|
@ -476,9 +557,11 @@ export const createSyntheticOptionsAdapter = (
|
||||||
ts: now + i * 5,
|
ts: now + i * 5,
|
||||||
option_contract_id: burst.contractId,
|
option_contract_id: burst.contractId,
|
||||||
price: tradePrice,
|
price: tradePrice,
|
||||||
size: Math.max(1, Math.round(burst.baseSize * (1 + sizeJitter))),
|
size,
|
||||||
exchange: burst.exchange,
|
exchange: burst.exchange,
|
||||||
conditions: burst.conditions
|
conditions: burst.conditions,
|
||||||
|
execution_iv: ivState.iv,
|
||||||
|
execution_iv_source: "synthetic_pressure_model"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (handlers.onNBBO) {
|
if (handlers.onNBBO) {
|
||||||
|
|
|
||||||
125
services/ingest-options/src/enrichment.ts
Normal file
125
services/ingest-options/src/enrichment.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import {
|
||||||
|
OptionPrintSchema,
|
||||||
|
classifyOptionNbboSide,
|
||||||
|
deriveOptionPrintMetadata,
|
||||||
|
evaluateOptionSignal,
|
||||||
|
type EquityQuote,
|
||||||
|
type OptionNBBO,
|
||||||
|
type OptionPrint,
|
||||||
|
type OptionsSignalConfig
|
||||||
|
} from "@islandflow/types";
|
||||||
|
|
||||||
|
export const MAX_CONTEXT_HISTORY = 64;
|
||||||
|
|
||||||
|
export type ContextHistory<T extends { ts: number; seq: number }> = Map<string, T[]>;
|
||||||
|
|
||||||
|
export const rememberContext = <T extends { ts: number; seq: number }>(
|
||||||
|
history: ContextHistory<T>,
|
||||||
|
key: string,
|
||||||
|
value: T
|
||||||
|
): void => {
|
||||||
|
const bucket = history.get(key) ?? [];
|
||||||
|
const existingIndex = bucket.findIndex((item) => item.ts === value.ts && item.seq === value.seq);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
bucket[existingIndex] = value;
|
||||||
|
} else {
|
||||||
|
bucket.push(value);
|
||||||
|
}
|
||||||
|
bucket.sort((a, b) => {
|
||||||
|
const delta = a.ts - b.ts;
|
||||||
|
return delta !== 0 ? delta : a.seq - b.seq;
|
||||||
|
});
|
||||||
|
if (bucket.length > MAX_CONTEXT_HISTORY) {
|
||||||
|
bucket.splice(0, bucket.length - MAX_CONTEXT_HISTORY);
|
||||||
|
}
|
||||||
|
history.set(key, bucket);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectAtOrBefore = <T extends { ts: number; seq: number }>(
|
||||||
|
items: readonly T[] | undefined,
|
||||||
|
ts: number
|
||||||
|
): T | null => {
|
||||||
|
if (!items?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected: T | null = null;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.ts > ts) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!selected || item.ts > selected.ts || (item.ts === selected.ts && item.seq >= selected.seq)) {
|
||||||
|
selected = item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enrichOptionPrint = (
|
||||||
|
rawPrint: OptionPrint,
|
||||||
|
optionQuote: OptionNBBO | null | undefined,
|
||||||
|
equityQuote: EquityQuote | null | undefined,
|
||||||
|
config: OptionsSignalConfig
|
||||||
|
): OptionPrint => {
|
||||||
|
const derived = deriveOptionPrintMetadata(rawPrint, optionQuote, config);
|
||||||
|
const executionNbboSide = optionQuote
|
||||||
|
? classifyOptionNbboSide(rawPrint.price, optionQuote, rawPrint.ts, config.nbboMaxAgeMs)
|
||||||
|
: undefined;
|
||||||
|
const nbboMid =
|
||||||
|
optionQuote && Number.isFinite(optionQuote.bid) && Number.isFinite(optionQuote.ask)
|
||||||
|
? Number(((optionQuote.bid + optionQuote.ask) / 2).toFixed(4))
|
||||||
|
: undefined;
|
||||||
|
const nbboSpread =
|
||||||
|
optionQuote && Number.isFinite(optionQuote.bid) && Number.isFinite(optionQuote.ask)
|
||||||
|
? Number(Math.max(0, optionQuote.ask - optionQuote.bid).toFixed(4))
|
||||||
|
: undefined;
|
||||||
|
const underlyingMid =
|
||||||
|
equityQuote && Number.isFinite(equityQuote.bid) && Number.isFinite(equityQuote.ask)
|
||||||
|
? Number(((equityQuote.bid + equityQuote.ask) / 2).toFixed(4))
|
||||||
|
: undefined;
|
||||||
|
const underlyingSpread =
|
||||||
|
equityQuote && Number.isFinite(equityQuote.bid) && Number.isFinite(equityQuote.ask)
|
||||||
|
? Number(Math.max(0, equityQuote.ask - equityQuote.bid).toFixed(4))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const enrichedForSignal: OptionPrint = {
|
||||||
|
...rawPrint,
|
||||||
|
...derived,
|
||||||
|
nbbo_side: executionNbboSide ?? derived.nbbo_side,
|
||||||
|
...(optionQuote
|
||||||
|
? {
|
||||||
|
execution_nbbo_bid: optionQuote.bid,
|
||||||
|
execution_nbbo_ask: optionQuote.ask,
|
||||||
|
execution_nbbo_mid: nbboMid,
|
||||||
|
execution_nbbo_spread: nbboSpread,
|
||||||
|
execution_nbbo_bid_size: optionQuote.bidSize,
|
||||||
|
execution_nbbo_ask_size: optionQuote.askSize,
|
||||||
|
execution_nbbo_ts: optionQuote.ts,
|
||||||
|
execution_nbbo_age_ms: rawPrint.ts - optionQuote.ts,
|
||||||
|
execution_nbbo_side: executionNbboSide,
|
||||||
|
nbbo_side: executionNbboSide
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(equityQuote && underlyingMid !== undefined
|
||||||
|
? {
|
||||||
|
execution_underlying_spot: underlyingMid,
|
||||||
|
execution_underlying_bid: equityQuote.bid,
|
||||||
|
execution_underlying_ask: equityQuote.ask,
|
||||||
|
execution_underlying_mid: underlyingMid,
|
||||||
|
execution_underlying_spread: underlyingSpread,
|
||||||
|
execution_underlying_ts: equityQuote.ts,
|
||||||
|
execution_underlying_age_ms: rawPrint.ts - equityQuote.ts,
|
||||||
|
execution_underlying_source: "equity_quote_mid" as const
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
signal_profile: config.mode
|
||||||
|
};
|
||||||
|
|
||||||
|
const signalDecision = evaluateOptionSignal(enrichedForSignal, config);
|
||||||
|
return OptionPrintSchema.parse({
|
||||||
|
...enrichedForSignal,
|
||||||
|
signal_pass: signalDecision.signalPass,
|
||||||
|
signal_reasons: signalDecision.signalReasons,
|
||||||
|
signal_profile: signalDecision.signalProfile
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -4,12 +4,16 @@ import {
|
||||||
SUBJECT_OPTION_NBBO,
|
SUBJECT_OPTION_NBBO,
|
||||||
SUBJECT_OPTION_PRINTS,
|
SUBJECT_OPTION_PRINTS,
|
||||||
SUBJECT_OPTION_SIGNAL_PRINTS,
|
SUBJECT_OPTION_SIGNAL_PRINTS,
|
||||||
|
SUBJECT_EQUITY_QUOTES,
|
||||||
|
STREAM_EQUITY_QUOTES,
|
||||||
STREAM_OPTION_NBBO,
|
STREAM_OPTION_NBBO,
|
||||||
STREAM_OPTION_PRINTS,
|
STREAM_OPTION_PRINTS,
|
||||||
STREAM_OPTION_SIGNAL_PRINTS,
|
STREAM_OPTION_SIGNAL_PRINTS,
|
||||||
|
buildDurableConsumer,
|
||||||
connectJetStreamWithRetry,
|
connectJetStreamWithRetry,
|
||||||
ensureStream,
|
ensureStream,
|
||||||
publishJson
|
publishJson,
|
||||||
|
subscribeJson
|
||||||
} from "@islandflow/bus";
|
} from "@islandflow/bus";
|
||||||
import {
|
import {
|
||||||
createClickHouseClient,
|
createClickHouseClient,
|
||||||
|
|
@ -21,9 +25,10 @@ import {
|
||||||
import {
|
import {
|
||||||
OptionNBBOSchema,
|
OptionNBBOSchema,
|
||||||
OptionPrintSchema,
|
OptionPrintSchema,
|
||||||
evaluateOptionSignal,
|
EquityQuoteSchema,
|
||||||
deriveOptionPrintMetadata,
|
deriveOptionPrintMetadata,
|
||||||
resolveSyntheticMarketModes,
|
resolveSyntheticMarketModes,
|
||||||
|
type EquityQuote,
|
||||||
type OptionNBBO,
|
type OptionNBBO,
|
||||||
type OptionPrint,
|
type OptionPrint,
|
||||||
type OptionsSignalConfig
|
type OptionsSignalConfig
|
||||||
|
|
@ -33,6 +38,7 @@ import { createDatabentoOptionsAdapter } from "./adapters/databento";
|
||||||
import { createIbkrOptionsAdapter } from "./adapters/ibkr";
|
import { createIbkrOptionsAdapter } from "./adapters/ibkr";
|
||||||
import { createSyntheticOptionsAdapter } from "./adapters/synthetic";
|
import { createSyntheticOptionsAdapter } from "./adapters/synthetic";
|
||||||
import type { OptionIngestAdapter, StopHandler } from "./adapters/types";
|
import type { OptionIngestAdapter, StopHandler } from "./adapters/types";
|
||||||
|
import { enrichOptionPrint, rememberContext, selectAtOrBefore, type ContextHistory } from "./enrichment";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const service = "ingest-options";
|
const service = "ingest-options";
|
||||||
|
|
@ -135,7 +141,9 @@ const state = {
|
||||||
shuttingDown: false,
|
shuttingDown: false,
|
||||||
shutdownPromise: null as Promise<void> | null
|
shutdownPromise: null as Promise<void> | null
|
||||||
};
|
};
|
||||||
const latestNbboByContract = new Map<string, OptionNBBO>();
|
|
||||||
|
const nbboHistoryByContract: ContextHistory<OptionNBBO> = new Map();
|
||||||
|
const equityQuoteHistoryByUnderlying: ContextHistory<EquityQuote> = new Map();
|
||||||
|
|
||||||
const getErrorMessage = (error: unknown): string => {
|
const getErrorMessage = (error: unknown): string => {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
|
@ -338,6 +346,19 @@ const run = async () => {
|
||||||
num_replicas: 1
|
num_replicas: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ensureStream(jsm, {
|
||||||
|
name: STREAM_EQUITY_QUOTES,
|
||||||
|
subjects: [SUBJECT_EQUITY_QUOTES],
|
||||||
|
retention: "limits",
|
||||||
|
storage: "file",
|
||||||
|
discard: "old",
|
||||||
|
max_msgs_per_subject: -1,
|
||||||
|
max_msgs: -1,
|
||||||
|
max_bytes: -1,
|
||||||
|
max_age: 0,
|
||||||
|
num_replicas: 1
|
||||||
|
});
|
||||||
|
|
||||||
const clickhouse = createClickHouseClient({
|
const clickhouse = createClickHouseClient({
|
||||||
url: env.CLICKHOUSE_URL,
|
url: env.CLICKHOUSE_URL,
|
||||||
database: env.CLICKHOUSE_DATABASE
|
database: env.CLICKHOUSE_DATABASE
|
||||||
|
|
@ -365,26 +386,15 @@ const run = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPrint = OptionPrintSchema.parse(candidate);
|
const rawPrint = OptionPrintSchema.parse(candidate);
|
||||||
const derived = deriveOptionPrintMetadata(
|
const parsedMetadata = deriveOptionPrintMetadata(rawPrint, null, optionsSignalConfig);
|
||||||
rawPrint,
|
const optionQuote = selectAtOrBefore(
|
||||||
latestNbboByContract.get(rawPrint.option_contract_id),
|
nbboHistoryByContract.get(rawPrint.option_contract_id),
|
||||||
optionsSignalConfig
|
rawPrint.ts
|
||||||
);
|
);
|
||||||
const signalDecision = evaluateOptionSignal(
|
const equityQuote = parsedMetadata.underlying_id
|
||||||
{
|
? selectAtOrBefore(equityQuoteHistoryByUnderlying.get(parsedMetadata.underlying_id), rawPrint.ts)
|
||||||
...rawPrint,
|
: null;
|
||||||
...derived,
|
const print = enrichOptionPrint(rawPrint, optionQuote, equityQuote, optionsSignalConfig);
|
||||||
signal_profile: optionsSignalConfig.mode
|
|
||||||
},
|
|
||||||
optionsSignalConfig
|
|
||||||
);
|
|
||||||
const print = OptionPrintSchema.parse({
|
|
||||||
...rawPrint,
|
|
||||||
...derived,
|
|
||||||
signal_pass: signalDecision.signalPass,
|
|
||||||
signal_reasons: signalDecision.signalReasons,
|
|
||||||
signal_profile: signalDecision.signalProfile
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await insertOptionPrint(clickhouse, print);
|
await insertOptionPrint(clickhouse, print);
|
||||||
|
|
@ -422,14 +432,7 @@ const run = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const nbbo = OptionNBBOSchema.parse(candidate);
|
const nbbo = OptionNBBOSchema.parse(candidate);
|
||||||
const existing = latestNbboByContract.get(nbbo.option_contract_id);
|
rememberContext(nbboHistoryByContract, nbbo.option_contract_id, nbbo);
|
||||||
if (
|
|
||||||
!existing ||
|
|
||||||
nbbo.ts > existing.ts ||
|
|
||||||
(nbbo.ts === existing.ts && nbbo.seq >= existing.seq)
|
|
||||||
) {
|
|
||||||
latestNbboByContract.set(nbbo.option_contract_id, nbbo);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await insertOptionNBBO(clickhouse, nbbo);
|
await insertOptionNBBO(clickhouse, nbbo);
|
||||||
|
|
@ -447,6 +450,33 @@ const run = async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const equityQuoteConsumer = buildDurableConsumer("ingest-options-equity-quotes");
|
||||||
|
equityQuoteConsumer.deliverAll();
|
||||||
|
const equityQuoteSubscription = await subscribeJson<EquityQuote>(
|
||||||
|
js,
|
||||||
|
SUBJECT_EQUITY_QUOTES,
|
||||||
|
equityQuoteConsumer
|
||||||
|
);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
for await (const msg of equityQuoteSubscription.messages) {
|
||||||
|
if (state.shuttingDown) {
|
||||||
|
msg.ack();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const quote = EquityQuoteSchema.parse(equityQuoteSubscription.decode(msg));
|
||||||
|
rememberContext(equityQuoteHistoryByUnderlying, quote.underlying_id.toUpperCase(), quote);
|
||||||
|
msg.ack();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("failed to process equity quote context", {
|
||||||
|
error: getErrorMessage(error)
|
||||||
|
});
|
||||||
|
msg.ack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
if (state.shutdownPromise) {
|
if (state.shutdownPromise) {
|
||||||
return state.shutdownPromise;
|
return state.shutdownPromise;
|
||||||
|
|
|
||||||
88
services/ingest-options/tests/enrichment.test.ts
Normal file
88
services/ingest-options/tests/enrichment.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import type { EquityQuote, OptionNBBO, OptionPrint, OptionsSignalConfig } from "@islandflow/types";
|
||||||
|
import { enrichOptionPrint, selectAtOrBefore } from "../src/enrichment";
|
||||||
|
|
||||||
|
const config: OptionsSignalConfig = {
|
||||||
|
mode: "all",
|
||||||
|
minNotional: 0,
|
||||||
|
etfMinNotional: 0,
|
||||||
|
bidSideMinNotional: 0,
|
||||||
|
midMinNotional: 0,
|
||||||
|
missingNbboMinNotional: 0,
|
||||||
|
largePrintMinSize: 1,
|
||||||
|
largePrintMinNotional: 0,
|
||||||
|
sweepMinNotional: 0,
|
||||||
|
autoKeepMinNotional: 100_000,
|
||||||
|
nbboMaxAgeMs: 1_500,
|
||||||
|
etfUnderlyings: new Set(["SPY"])
|
||||||
|
};
|
||||||
|
|
||||||
|
const print: OptionPrint = {
|
||||||
|
source_ts: 1_000,
|
||||||
|
ingest_ts: 1_000,
|
||||||
|
seq: 1,
|
||||||
|
trace_id: "print-1",
|
||||||
|
ts: 1_000,
|
||||||
|
option_contract_id: "SPY-2025-01-17-450-C",
|
||||||
|
price: 1.3,
|
||||||
|
size: 10,
|
||||||
|
exchange: "TEST"
|
||||||
|
};
|
||||||
|
|
||||||
|
const nbbo = (overrides: Partial<OptionNBBO> = {}): OptionNBBO => ({
|
||||||
|
source_ts: 990,
|
||||||
|
ingest_ts: 990,
|
||||||
|
seq: 1,
|
||||||
|
trace_id: "nbbo-1",
|
||||||
|
ts: 990,
|
||||||
|
option_contract_id: "SPY-2025-01-17-450-C",
|
||||||
|
bid: 1.2,
|
||||||
|
ask: 1.3,
|
||||||
|
bidSize: 20,
|
||||||
|
askSize: 30,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const equityQuote = (overrides: Partial<EquityQuote> = {}): EquityQuote => ({
|
||||||
|
source_ts: 980,
|
||||||
|
ingest_ts: 980,
|
||||||
|
seq: 1,
|
||||||
|
trace_id: "eq-1",
|
||||||
|
ts: 980,
|
||||||
|
underlying_id: "SPY",
|
||||||
|
bid: 450,
|
||||||
|
ask: 450.1,
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("option print enrichment", () => {
|
||||||
|
it("attaches preserved NBBO context and mirrors nbbo_side", () => {
|
||||||
|
const enriched = enrichOptionPrint(print, nbbo(), null, config);
|
||||||
|
|
||||||
|
expect(enriched.execution_nbbo_bid).toBe(1.2);
|
||||||
|
expect(enriched.execution_nbbo_ask).toBe(1.3);
|
||||||
|
expect(enriched.execution_nbbo_mid).toBe(1.25);
|
||||||
|
expect(enriched.execution_nbbo_age_ms).toBe(10);
|
||||||
|
expect(enriched.execution_nbbo_side).toBe("A");
|
||||||
|
expect(enriched.nbbo_side).toBe(enriched.execution_nbbo_side);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches preserved underlying quote mid as spot", () => {
|
||||||
|
const enriched = enrichOptionPrint(print, null, equityQuote(), config);
|
||||||
|
|
||||||
|
expect(enriched.execution_underlying_spot).toBe(450.05);
|
||||||
|
expect(enriched.execution_underlying_mid).toBe(450.05);
|
||||||
|
expect(enriched.execution_underlying_source).toBe("equity_quote_mid");
|
||||||
|
expect(enriched.execution_underlying_age_ms).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects context at or before the print timestamp only", () => {
|
||||||
|
const selected = selectAtOrBefore(
|
||||||
|
[nbbo({ ts: 900, seq: 1, bid: 1 }), nbbo({ ts: 1_001, seq: 2, bid: 2 })],
|
||||||
|
print.ts
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected?.ts).toBe(900);
|
||||||
|
expect(selected?.bid).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { buildSyntheticBurstForTest } from "../src/adapters/synthetic";
|
import { buildSyntheticBurstForTest, updateSyntheticIvForTest } from "../src/adapters/synthetic";
|
||||||
|
|
||||||
const totalBurstNotional = (burst: {
|
const totalBurstNotional = (burst: {
|
||||||
basePrice: number;
|
basePrice: number;
|
||||||
|
|
@ -24,3 +24,66 @@ describe("synthetic options burst sizing", () => {
|
||||||
expect(totalBurstNotional(burst)).toBeLessThanOrEqual(240_000);
|
expect(totalBurstNotional(burst)).toBeLessThanOrEqual(240_000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("synthetic options IV model", () => {
|
||||||
|
it("increases under repeated same-contract ask buying", () => {
|
||||||
|
let state = updateSyntheticIvForTest(undefined, {
|
||||||
|
ts: 1_000,
|
||||||
|
placement: "A",
|
||||||
|
size: 100,
|
||||||
|
notional: 20_000,
|
||||||
|
dteDays: 1,
|
||||||
|
moneyness: 1.02
|
||||||
|
});
|
||||||
|
const firstIv = state.iv;
|
||||||
|
|
||||||
|
state = updateSyntheticIvForTest(state, {
|
||||||
|
ts: 1_100,
|
||||||
|
placement: "AA",
|
||||||
|
size: 300,
|
||||||
|
notional: 80_000,
|
||||||
|
dteDays: 1,
|
||||||
|
moneyness: 1.02
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.iv).toBeGreaterThan(firstIv);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decays after inactivity", () => {
|
||||||
|
const active = updateSyntheticIvForTest(undefined, {
|
||||||
|
ts: 1_000,
|
||||||
|
placement: "AA",
|
||||||
|
size: 500,
|
||||||
|
notional: 120_000,
|
||||||
|
dteDays: 7,
|
||||||
|
moneyness: 1.1
|
||||||
|
});
|
||||||
|
const decayed = updateSyntheticIvForTest(active, {
|
||||||
|
ts: 181_000,
|
||||||
|
placement: "MID",
|
||||||
|
size: 10,
|
||||||
|
notional: 1_000,
|
||||||
|
dteDays: 7,
|
||||||
|
moneyness: 1.1
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decayed.iv).toBeLessThan(active.iv);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps IV within clamps", () => {
|
||||||
|
let state = undefined;
|
||||||
|
for (let i = 0; i < 80; i += 1) {
|
||||||
|
state = updateSyntheticIvForTest(state, {
|
||||||
|
ts: 1_000 + i * 10,
|
||||||
|
placement: "AA",
|
||||||
|
size: 10_000,
|
||||||
|
notional: 5_000_000,
|
||||||
|
dteDays: 0,
|
||||||
|
moneyness: 1.8
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.iv).toBeGreaterThanOrEqual(0.05);
|
||||||
|
expect(state.iv).toBeLessThanOrEqual(2.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue