diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8d689c2..287cf8e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -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-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} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 3399a97..8e2cfca 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -873,6 +873,89 @@ h3 { 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, .drawer-row-title { margin-bottom: 6px; diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index f3f10be..883b9cd 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "bun:test"; import { buildDefaultFlowFilters, + classifierToneForFamily, deriveAlertDirection, countActiveFlowFilterGroups, formatCompactUsd, formatOptionContractLabel, flushPausableTapeData, getAlertWindowAnchorTs, + getOptionTableSnapshot, getLiveFeedStatus, normalizeAlertSeverity, nextFlowFilterPopoverState, @@ -14,6 +16,7 @@ import { reducePausableTapeData, shouldRetainLiveSnapshotHistory, shouldShowEquitiesSilentFeedWarning, + selectPrimaryClassifierHit, statusLabel, toggleFilterValue } from "./terminal"; @@ -171,6 +174,54 @@ describe("options display formatters", () => { expect(formatCompactUsd(1_250_000)).toBe("1.3M"); 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", () => { diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index e2a0d9a..4a29481 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -11,6 +11,7 @@ import { useMemo, useRef, useState, + type CSSProperties, type Dispatch, type ReactNode, type SetStateAction @@ -982,7 +983,8 @@ const LIVE_SNAPSHOT_HISTORY_CHANNELS = new Set([ "options", "nbbo", "equities", - "flow" + "flow", + "classifier-hits" ]); export const shouldRetainLiveSnapshotHistory = ( @@ -1027,6 +1029,80 @@ const classifyNbboSide = (price: number, quote: OptionNBBO | null | undefined): return price >= mid ? "A" : "B"; }; +type ClassifierDecor = { + hit: ClassifierHitEvent; + family: string; + tone: string; + intensity: number; +}; + +const CLASSIFIER_FAMILY_TONES: Record = { + 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 = { listRef: React.RefObject; isAtTop: boolean; @@ -2125,7 +2201,8 @@ const getLiveManifest = ( { channel: "options", filters: flowFilters }, { channel: "nbbo" }, { channel: "equities" }, - { channel: "flow", filters: flowFilters } + { channel: "flow", filters: flowFilters }, + { channel: "classifier-hits" } ]; } @@ -4157,6 +4234,39 @@ const useTerminalState = () => { return traceId.slice(idx); }, []); + const classifierHitsByPacketId = useMemo(() => { + const map = new Map(); + 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(); + 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(); + 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(() => { if (!selectedClassifierHit) { return null; @@ -4632,6 +4742,9 @@ const useTerminalState = () => { equityPrintMap, equityJoinMap: resolvedEquityJoinMap, flowPacketMap: resolvedFlowPacketMap, + classifierHitsByPacketId, + packetIdByOptionTraceId, + classifierDecorByOptionTraceId, selectedEvidence, selectedFlowPacket, selectedDarkEvidence, @@ -5002,7 +5115,7 @@ type OptionsPaneProps = { const OptionsPane = ({ limit }: OptionsPaneProps) => { const state = useTerminal(); 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 ( { /> } > -
+
{items.length === 0 ? (
{state.tickerSet.size > 0 @@ -5040,103 +5153,92 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => { : "Replay queue empty. Ensure ClickHouse has data."}
) : ( - <> +
+
+ TIME + SYM + EXP + STRIKE + C/P + SPOT + DETAILS + TYPE + VALUE + SIDE + IV + CLASSIFIER +
{virtual.topSpacerHeight > 0 ? (
) : null} {virtual.visibleItems.map((print) => { - const contractId = normalizeContractId(print.option_contract_id); - const contractDisplay = formatOptionContractLabel(contractId); - const quote = state.nbboMap.get(contractId); - const nbboAge = quote ? Math.abs(print.ts - quote.ts) : null; - const nbboStale = nbboAge !== null && nbboAge > NBBO_MAX_AGE_MS_SAFE; - const nbboMid = quote ? (quote.bid + quote.ask) / 2 : null; - const nbboSide = print.nbbo_side ?? classifyNbboSide(print.price, quote); - const notional = print.notional ?? print.price * print.size * 100; - - return ( -
-
-
- {contractDisplay ? ( - <> - {contractDisplay.ticker} - {contractDisplay.strike} - {contractDisplay.expiration} - + const contractId = normalizeContractId(print.option_contract_id); + const parsed = parseOptionContractId(contractId); + const contractDisplay = formatOptionContractLabel(contractId); + const quote = state.nbboMap.get(contractId); + const hasPreservedNbbo = typeof print.execution_nbbo_side === "string"; + const nbboSide = + print.execution_nbbo_side ?? + print.nbbo_side ?? + (!hasPreservedNbbo ? classifyNbboSide(print.price, quote) : null); + const notional = print.notional ?? print.price * print.size * 100; + const spot = print.execution_underlying_spot; + const iv = print.execution_iv; + const decor = state.classifierDecorByOptionTraceId.get(print.trace_id); + const commonProps = { + className: `options-table-row${decor ? ` is-classified classifier-${decor.tone}` : ""}`, + style: decor ? ({ "--classifier-intensity": decor.intensity } as CSSProperties) : undefined + }; + const cells = ( + <> + {formatTime(print.ts)} + {contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)} + {contractDisplay?.expiration ?? parsed?.expiry ?? "--"} + {contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"} + {parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"} + {typeof spot === "number" ? formatPrice(spot) : "--"} + + {formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"} + + {print.option_type ?? "--"} + ${formatCompactUsd(notional)} + + {nbboSide ? ( + {nbboSide} ) : ( - formatContractLabel(contractId) + "--" )} -
-
- ${formatPrice(print.price)} - {formatSize(print.size)}x - {print.exchange} - Notional ${formatCompactUsd(notional)} - {print.conditions?.map((condition) => { - const normalized = condition.toUpperCase(); - const tone = - normalized === "SWEEP" - ? "condition-sweep" - : normalized === "ISO" - ? "condition-iso" - : "condition-neutral"; - return ( - - {normalized} - - ); - })} -
- {quote ? ( -
- Bid ${formatPrice(quote.bid)} - Ask ${formatPrice(quote.ask)} - Mid ${formatPrice(nbboMid ?? 0)} - {Math.round(nbboAge ?? 0)}ms - {nbboSide ? ( - - - {nbboSide} - - - - A - Ask - - - AA - Above Ask - - - B - Bid - - - BB - Below Bid - - - - ) : null} - {print.nbbo_side === "STALE" || nbboStale ? Stale : null} -
- ) : ( -
- - {print.nbbo_side === "STALE" ? "NBBO stale" : "NBBO missing"} - -
- )} + + {typeof iv === "number" ? formatPct(iv) : "--"} + {decor ? humanizeClassifierId(decor.family) : "--"} + + ); + + return decor ? ( + + ) : ( +
+ {cells}
-
{formatTime(print.ts)}
-
- ); + ); })} {virtual.bottomSpacerHeight > 0 ? (
) : null} - +
)}
diff --git a/options-overhaul-phase1.md b/options-overhaul-phase1.md new file mode 100644 index 0000000..e8dbdaa --- /dev/null +++ b/options-overhaul-phase1.md @@ -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` diff --git a/packages/storage/src/clickhouse.ts b/packages/storage/src/clickhouse.ts index 5656214..c53caa4 100644 --- a/packages/storage/src/clickhouse.ts +++ b/packages/storage/src/clickhouse.ts @@ -512,7 +512,23 @@ const normalizeOptionRow = (row: unknown): unknown => { "ts", "price", "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) { diff --git a/packages/storage/src/option-prints.ts b/packages/storage/src/option-prints.ts index 7d9c983..8d28472 100644 --- a/packages/storage/src/option-prints.ts +++ b/packages/storage/src/option-prints.ts @@ -19,6 +19,25 @@ CREATE TABLE IF NOT EXISTS ${OPTION_PRINTS_TABLE} ( option_type Nullable(String), notional Nullable(Float64), 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), signal_pass Nullable(Bool), 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 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 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 signal_pass Nullable(Bool)`, `ALTER TABLE ${OPTION_PRINTS_TABLE} ADD COLUMN IF NOT EXISTS signal_reasons Array(String) DEFAULT []`, diff --git a/packages/storage/tests/option-prints.test.ts b/packages/storage/tests/option-prints.test.ts index 7643eeb..203ca9f 100644 --- a/packages/storage/tests/option-prints.test.ts +++ b/packages/storage/tests/option-prints.test.ts @@ -25,10 +25,20 @@ describe("option-prints storage helpers", () => { 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", () => { const ddl = optionPrintsTableDDL(); expect(ddl).toContain(OPTION_PRINTS_TABLE); 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 () => { diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 072e427..0ba5e57 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -22,6 +22,31 @@ export const OptionPrintSchema = EventMetaSchema.merge( option_type: z.preprocess((value) => (value === null ? undefined : value), OptionTypeSchema.optional()), notional: z.preprocess((value) => (value === null ? undefined : value), z.number().nonnegative().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()), signal_pass: z.preprocess((value) => (value === null ? undefined : value), z.boolean().optional()), signal_reasons: z.array(z.string().min(1)).optional(), diff --git a/packages/types/tests/events.test.ts b/packages/types/tests/events.test.ts new file mode 100644 index 0000000..c4b6b7e --- /dev/null +++ b/packages/types/tests/events.test.ts @@ -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"); + }); +}); diff --git a/services/ingest-options/src/adapters/synthetic.ts b/services/ingest-options/src/adapters/synthetic.ts index 7875f4f..a1d50e1 100644 --- a/services/ingest-options/src/adapters/synthetic.ts +++ b/services/ingest-options/src/adapters/synthetic.ts @@ -13,6 +13,9 @@ type SyntheticOptionsAdapterConfig = { type Burst = { contractId: string; + underlying: number; + expiryOffsetDays: number; + strike: number; basePrice: number; baseSize: number; exchange: string; @@ -23,7 +26,16 @@ type Burst = { seed: number; }; +export type SyntheticContractIvState = { + iv: number; + pressure: number; + lastTs: number; +}; + 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 MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -36,7 +48,7 @@ type SyntheticOptionsProfile = { pricePlacements: Record[]>; }; -type PricePlacement = "AA" | "A" | "MID" | "B" | "BB"; +export type PricePlacement = "AA" | "A" | "MID" | "B" | "BB"; type WeightedValue = { value: T; @@ -347,6 +359,55 @@ const formatExpiry = (now: number, offsetDays: number): string => { 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 symbol = SYNTHETIC_SYMBOLS[burstIndex % SYNTHETIC_SYMBOLS.length]; const symbolHash = hashSymbol(symbol); @@ -392,6 +453,9 @@ const buildBurst = (burstIndex: number, now: number, profile: SyntheticOptionsPr return { contractId, + underlying: baseUnderlying, + expiryOffsetDays: expiryOffset, + strike, basePrice: basePricePer, baseSize, exchange, @@ -420,6 +484,7 @@ export const createSyntheticOptionsAdapter = ( let nbboSeq = 0; let burstIndex = 0; let currentBurst: Burst | null = null; + const ivByContract = new Map(); let remainingRuns = 0; let timer: ReturnType | null = null; let stopped = false; @@ -448,12 +513,28 @@ export const createSyntheticOptionsAdapter = ( const priceJitter = ((i % 3) - 1) * 0.004; const sizeJitter = ((i % 3) - 1) * 0.08; const priceMultiplier = 1 + burst.priceStep * i + priceJitter; - const mid = Math.max(0.05, Number((burst.basePrice * priceMultiplier).toFixed(2))); - const spread = Math.max(0.02, Number((mid * 0.02).toFixed(2))); + const placement = pickPlacement(burst, i, profile); + 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 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 placement = pickPlacement(burst, i, profile); let tradePrice = mid; if (placement === "AA") { @@ -476,9 +557,11 @@ export const createSyntheticOptionsAdapter = ( ts: now + i * 5, option_contract_id: burst.contractId, price: tradePrice, - size: Math.max(1, Math.round(burst.baseSize * (1 + sizeJitter))), + size, exchange: burst.exchange, - conditions: burst.conditions + conditions: burst.conditions, + execution_iv: ivState.iv, + execution_iv_source: "synthetic_pressure_model" }; if (handlers.onNBBO) { diff --git a/services/ingest-options/src/enrichment.ts b/services/ingest-options/src/enrichment.ts new file mode 100644 index 0000000..2104990 --- /dev/null +++ b/services/ingest-options/src/enrichment.ts @@ -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 = Map; + +export const rememberContext = ( + history: ContextHistory, + 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 = ( + 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 + }); +}; diff --git a/services/ingest-options/src/index.ts b/services/ingest-options/src/index.ts index 4c8010c..a5fe14c 100644 --- a/services/ingest-options/src/index.ts +++ b/services/ingest-options/src/index.ts @@ -4,12 +4,16 @@ import { SUBJECT_OPTION_NBBO, SUBJECT_OPTION_PRINTS, SUBJECT_OPTION_SIGNAL_PRINTS, + SUBJECT_EQUITY_QUOTES, + STREAM_EQUITY_QUOTES, STREAM_OPTION_NBBO, STREAM_OPTION_PRINTS, STREAM_OPTION_SIGNAL_PRINTS, + buildDurableConsumer, connectJetStreamWithRetry, ensureStream, - publishJson + publishJson, + subscribeJson } from "@islandflow/bus"; import { createClickHouseClient, @@ -21,9 +25,10 @@ import { import { OptionNBBOSchema, OptionPrintSchema, - evaluateOptionSignal, + EquityQuoteSchema, deriveOptionPrintMetadata, resolveSyntheticMarketModes, + type EquityQuote, type OptionNBBO, type OptionPrint, type OptionsSignalConfig @@ -33,6 +38,7 @@ import { createDatabentoOptionsAdapter } from "./adapters/databento"; import { createIbkrOptionsAdapter } from "./adapters/ibkr"; import { createSyntheticOptionsAdapter } from "./adapters/synthetic"; import type { OptionIngestAdapter, StopHandler } from "./adapters/types"; +import { enrichOptionPrint, rememberContext, selectAtOrBefore, type ContextHistory } from "./enrichment"; import { z } from "zod"; const service = "ingest-options"; @@ -135,7 +141,9 @@ const state = { shuttingDown: false, shutdownPromise: null as Promise | null }; -const latestNbboByContract = new Map(); + +const nbboHistoryByContract: ContextHistory = new Map(); +const equityQuoteHistoryByUnderlying: ContextHistory = new Map(); const getErrorMessage = (error: unknown): string => { return error instanceof Error ? error.message : String(error); @@ -338,6 +346,19 @@ const run = async () => { 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({ url: env.CLICKHOUSE_URL, database: env.CLICKHOUSE_DATABASE @@ -365,26 +386,15 @@ const run = async () => { } const rawPrint = OptionPrintSchema.parse(candidate); - const derived = deriveOptionPrintMetadata( - rawPrint, - latestNbboByContract.get(rawPrint.option_contract_id), - optionsSignalConfig + const parsedMetadata = deriveOptionPrintMetadata(rawPrint, null, optionsSignalConfig); + const optionQuote = selectAtOrBefore( + nbboHistoryByContract.get(rawPrint.option_contract_id), + rawPrint.ts ); - const signalDecision = evaluateOptionSignal( - { - ...rawPrint, - ...derived, - signal_profile: optionsSignalConfig.mode - }, - optionsSignalConfig - ); - const print = OptionPrintSchema.parse({ - ...rawPrint, - ...derived, - signal_pass: signalDecision.signalPass, - signal_reasons: signalDecision.signalReasons, - signal_profile: signalDecision.signalProfile - }); + const equityQuote = parsedMetadata.underlying_id + ? selectAtOrBefore(equityQuoteHistoryByUnderlying.get(parsedMetadata.underlying_id), rawPrint.ts) + : null; + const print = enrichOptionPrint(rawPrint, optionQuote, equityQuote, optionsSignalConfig); try { await insertOptionPrint(clickhouse, print); @@ -422,14 +432,7 @@ const run = async () => { } const nbbo = OptionNBBOSchema.parse(candidate); - const existing = latestNbboByContract.get(nbbo.option_contract_id); - if ( - !existing || - nbbo.ts > existing.ts || - (nbbo.ts === existing.ts && nbbo.seq >= existing.seq) - ) { - latestNbboByContract.set(nbbo.option_contract_id, nbbo); - } + rememberContext(nbboHistoryByContract, nbbo.option_contract_id, nbbo); try { await insertOptionNBBO(clickhouse, nbbo); @@ -447,6 +450,33 @@ const run = async () => { } }); + const equityQuoteConsumer = buildDurableConsumer("ingest-options-equity-quotes"); + equityQuoteConsumer.deliverAll(); + const equityQuoteSubscription = await subscribeJson( + 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) => { if (state.shutdownPromise) { return state.shutdownPromise; diff --git a/services/ingest-options/tests/enrichment.test.ts b/services/ingest-options/tests/enrichment.test.ts new file mode 100644 index 0000000..d5d505a --- /dev/null +++ b/services/ingest-options/tests/enrichment.test.ts @@ -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 => ({ + 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 => ({ + 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); + }); +}); diff --git a/services/ingest-options/tests/synthetic.test.ts b/services/ingest-options/tests/synthetic.test.ts index 95f11e3..e0c8407 100644 --- a/services/ingest-options/tests/synthetic.test.ts +++ b/services/ingest-options/tests/synthetic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { buildSyntheticBurstForTest } from "../src/adapters/synthetic"; +import { buildSyntheticBurstForTest, updateSyntheticIvForTest } from "../src/adapters/synthetic"; const totalBurstNotional = (burst: { basePrice: number; @@ -24,3 +24,66 @@ describe("synthetic options burst sizing", () => { 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); + }); +});