Implement options snapshot tape table

This commit is contained in:
dirtydishes 2026-05-04 01:14:52 -04:00
parent 6abfff30d3
commit e78387130a
15 changed files with 904 additions and 128 deletions

View file

@ -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}

View file

@ -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;

View file

@ -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", () => {

View file

@ -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<LiveSubscription["channel"]>([
"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<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 = {
listRef: React.RefObject<HTMLDivElement>;
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<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(() => {
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 (
<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 ? (
<div className="empty">
{state.tickerSet.size > 0
@ -5040,103 +5153,92 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
: "Replay queue empty. Ensure ClickHouse has data."}
</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 ? (
<div style={{ height: `${virtual.topSpacerHeight}px` }} aria-hidden />
) : 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 (
<div className="row" key={`${print.trace_id}-${print.seq}`}>
<div>
<div className={`contract${contractDisplay ? " option-contract" : ""}`}>
{contractDisplay ? (
<>
<span>{contractDisplay.ticker}</span>
<span>{contractDisplay.strike}</span>
<span>{contractDisplay.expiration}</span>
</>
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 = (
<>
<span className="mono">{formatTime(print.ts)}</span>
<span>{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}</span>
<span>{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}</span>
<span>{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}</span>
<span>{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}</span>
<span>{typeof spot === "number" ? formatPrice(spot) : "--"}</span>
<span className="mono">
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
</span>
<span>{print.option_type ?? "--"}</span>
<span className="notional-emphasis">${formatCompactUsd(notional)}</span>
<span>
{nbboSide ? (
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
) : (
formatContractLabel(contractId)
"--"
)}
</div>
<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>
);
})}
</div>
{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 ? (
<span className="nbbo-side" tabIndex={0} aria-label="NBBO side legend">
<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>
)}
</span>
<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 className="time">{formatTime(print.ts)}</div>
</div>
);
);
})}
{virtual.bottomSpacerHeight > 0 ? (
<div style={{ height: `${virtual.bottomSpacerHeight}px` }} aria-hidden />
) : null}
</>
</div>
)}
</div>
</Pane>

View 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`

View file

@ -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) {

View file

@ -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 []`,

View file

@ -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 () => {

View file

@ -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(),

View 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");
});
});

View file

@ -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<string, WeightedValue<PricePlacement>[]>;
};
type PricePlacement = "AA" | "A" | "MID" | "B" | "BB";
export type PricePlacement = "AA" | "A" | "MID" | "B" | "BB";
type WeightedValue<T> = {
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<string, SyntheticContractIvState>();
let remainingRuns = 0;
let timer: ReturnType<typeof setInterval> | 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) {

View 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
});
};

View file

@ -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<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 => {
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<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) => {
if (state.shutdownPromise) {
return state.shutdownPromise;

View 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);
});
});

View file

@ -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);
});
});