Implement options snapshot tape table
This commit is contained in:
parent
6abfff30d3
commit
e78387130a
15 changed files with 904 additions and 128 deletions
|
|
@ -1,3 +1,4 @@
|
|||
{"_type":"issue","id":"islandflow-b3o","title":"Implement options tape table with execution spot","description":"Redesign OptionsPane into a dense classifier-colored table and preserve execution-time underlying spot on option prints from equity quote mid.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-04T04:41:59Z","created_by":"dirtydishes","updated_at":"2026-05-04T05:14:26Z","started_at":"2026-05-04T04:42:08Z","closed_at":"2026-05-04T05:14:26Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-ug1","title":"Fix false NBBO-missing badges in live Options tape","description":"Investigate and fix client-side cases where Options rows show NBBO missing/stale even when a fresh NBBO quote exists in the live nbbo map. Update rendering logic to prefer fresh quote-derived status and add regression tests.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-29T15:58:31Z","created_by":"dirtydishes","updated_at":"2026-04-29T16:01:28Z","started_at":"2026-04-29T15:58:35Z","closed_at":"2026-04-29T16:01:28Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"islandflow-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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 parsed = parseOptionContractId(contractId);
|
||||
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 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;
|
||||
|
||||
return (
|
||||
<div className="row" key={`${print.trace_id}-${print.seq}`}>
|
||||
<div>
|
||||
<div className={`contract${contractDisplay ? " option-contract" : ""}`}>
|
||||
{contractDisplay ? (
|
||||
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>{contractDisplay.ticker}</span>
|
||||
<span>{contractDisplay.strike}</span>
|
||||
<span>{contractDisplay.expiration}</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 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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
<span>{print.option_type ?? "--"}</span>
|
||||
<span className="notional-emphasis">${formatCompactUsd(notional)}</span>
|
||||
<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>
|
||||
<span className={`nbbo-tag nbbo-tag-${nbboSide.toLowerCase()}`}>{nbboSide}</span>
|
||||
) : (
|
||||
<div className="meta nbbo-meta">
|
||||
<span className="pill nbbo-missing">
|
||||
{print.nbbo_side === "STALE" ? "NBBO stale" : "NBBO missing"}
|
||||
</span>
|
||||
</div>
|
||||
"--"
|
||||
)}
|
||||
</div>
|
||||
<div className="time">{formatTime(print.ts)}</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>
|
||||
);
|
||||
})}
|
||||
{virtual.bottomSpacerHeight > 0 ? (
|
||||
<div style={{ height: `${virtual.bottomSpacerHeight}px` }} aria-hidden />
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Pane>
|
||||
|
|
|
|||
20
options-overhaul-phase1.md
Normal file
20
options-overhaul-phase1.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Options Overhaul Phase 1: Snapshot Tape Table
|
||||
|
||||
Implemented Phase 1 snapshot semantics for the Options tape.
|
||||
|
||||
## Completed
|
||||
|
||||
- Added flat execution snapshot fields to `OptionPrintSchema` / `OptionPrint`.
|
||||
- Added ClickHouse columns and migrations for execution NBBO, underlying spot, and IV context.
|
||||
- Added ingest enrichment that selects option NBBO and equity quote context at or before the option print timestamp.
|
||||
- New enriched prints mirror `nbbo_side` from `execution_nbbo_side`.
|
||||
- Added synthetic per-contract IV state with pressure, decay, and clamps.
|
||||
- Redesigned the Options pane as a dense table using preserved spot/IV/NBBO side first.
|
||||
- Added classifier-hit row color mapping and click/keyboard drawer interaction for classified rows.
|
||||
- Updated `/tape` live subscriptions to include `classifier-hits`.
|
||||
- Added focused tests for schema, storage, enrichment, synthetic IV, and frontend table/classifier helpers.
|
||||
|
||||
## Verification
|
||||
|
||||
- `bun test packages/types/tests/events.test.ts packages/storage/tests/option-prints.test.ts services/ingest-options/tests/enrichment.test.ts services/ingest-options/tests/synthetic.test.ts apps/web/app/terminal.test.ts`
|
||||
- `bun run build` from `apps/web`
|
||||
|
|
@ -512,7 +512,23 @@ const normalizeOptionRow = (row: unknown): unknown => {
|
|||
"ts",
|
||||
"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) {
|
||||
|
|
|
|||
|
|
@ -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 []`,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
41
packages/types/tests/events.test.ts
Normal file
41
packages/types/tests/events.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { OptionPrintSchema } from "../src/events";
|
||||
|
||||
describe("event schemas", () => {
|
||||
it("accepts option print execution context fields", () => {
|
||||
const parsed = OptionPrintSchema.parse({
|
||||
source_ts: 100,
|
||||
ingest_ts: 101,
|
||||
seq: 1,
|
||||
trace_id: "trace-1",
|
||||
ts: 100,
|
||||
option_contract_id: "SPY-2025-01-17-450-C",
|
||||
price: 1.25,
|
||||
size: 10,
|
||||
exchange: "TEST",
|
||||
execution_nbbo_bid: 1.2,
|
||||
execution_nbbo_ask: 1.3,
|
||||
execution_nbbo_mid: 1.25,
|
||||
execution_nbbo_spread: 0.1,
|
||||
execution_nbbo_bid_size: 20,
|
||||
execution_nbbo_ask_size: 30,
|
||||
execution_nbbo_ts: 99,
|
||||
execution_nbbo_age_ms: 1,
|
||||
execution_nbbo_side: "MID",
|
||||
execution_underlying_spot: 450.05,
|
||||
execution_underlying_bid: 450,
|
||||
execution_underlying_ask: 450.1,
|
||||
execution_underlying_mid: 450.05,
|
||||
execution_underlying_spread: 0.1,
|
||||
execution_underlying_ts: 98,
|
||||
execution_underlying_age_ms: 2,
|
||||
execution_underlying_source: "equity_quote_mid",
|
||||
execution_iv: 0.42,
|
||||
execution_iv_source: "synthetic_pressure_model"
|
||||
});
|
||||
|
||||
expect(parsed.execution_nbbo_side).toBe("MID");
|
||||
expect(parsed.execution_underlying_spot).toBe(450.05);
|
||||
expect(parsed.execution_iv_source).toBe("synthetic_pressure_model");
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,9 @@ type SyntheticOptionsAdapterConfig = {
|
|||
|
||||
type Burst = {
|
||||
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) {
|
||||
|
|
|
|||
125
services/ingest-options/src/enrichment.ts
Normal file
125
services/ingest-options/src/enrichment.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
OptionPrintSchema,
|
||||
classifyOptionNbboSide,
|
||||
deriveOptionPrintMetadata,
|
||||
evaluateOptionSignal,
|
||||
type EquityQuote,
|
||||
type OptionNBBO,
|
||||
type OptionPrint,
|
||||
type OptionsSignalConfig
|
||||
} from "@islandflow/types";
|
||||
|
||||
export const MAX_CONTEXT_HISTORY = 64;
|
||||
|
||||
export type ContextHistory<T extends { ts: number; seq: number }> = Map<string, T[]>;
|
||||
|
||||
export const rememberContext = <T extends { ts: number; seq: number }>(
|
||||
history: ContextHistory<T>,
|
||||
key: string,
|
||||
value: T
|
||||
): void => {
|
||||
const bucket = history.get(key) ?? [];
|
||||
const existingIndex = bucket.findIndex((item) => item.ts === value.ts && item.seq === value.seq);
|
||||
if (existingIndex >= 0) {
|
||||
bucket[existingIndex] = value;
|
||||
} else {
|
||||
bucket.push(value);
|
||||
}
|
||||
bucket.sort((a, b) => {
|
||||
const delta = a.ts - b.ts;
|
||||
return delta !== 0 ? delta : a.seq - b.seq;
|
||||
});
|
||||
if (bucket.length > MAX_CONTEXT_HISTORY) {
|
||||
bucket.splice(0, bucket.length - MAX_CONTEXT_HISTORY);
|
||||
}
|
||||
history.set(key, bucket);
|
||||
};
|
||||
|
||||
export const selectAtOrBefore = <T extends { ts: number; seq: number }>(
|
||||
items: readonly T[] | undefined,
|
||||
ts: number
|
||||
): T | null => {
|
||||
if (!items?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let selected: T | null = null;
|
||||
for (const item of items) {
|
||||
if (item.ts > ts) {
|
||||
continue;
|
||||
}
|
||||
if (!selected || item.ts > selected.ts || (item.ts === selected.ts && item.seq >= selected.seq)) {
|
||||
selected = item;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
export const enrichOptionPrint = (
|
||||
rawPrint: OptionPrint,
|
||||
optionQuote: OptionNBBO | null | undefined,
|
||||
equityQuote: EquityQuote | null | undefined,
|
||||
config: OptionsSignalConfig
|
||||
): OptionPrint => {
|
||||
const derived = deriveOptionPrintMetadata(rawPrint, optionQuote, config);
|
||||
const executionNbboSide = optionQuote
|
||||
? classifyOptionNbboSide(rawPrint.price, optionQuote, rawPrint.ts, config.nbboMaxAgeMs)
|
||||
: undefined;
|
||||
const nbboMid =
|
||||
optionQuote && Number.isFinite(optionQuote.bid) && Number.isFinite(optionQuote.ask)
|
||||
? Number(((optionQuote.bid + optionQuote.ask) / 2).toFixed(4))
|
||||
: undefined;
|
||||
const nbboSpread =
|
||||
optionQuote && Number.isFinite(optionQuote.bid) && Number.isFinite(optionQuote.ask)
|
||||
? Number(Math.max(0, optionQuote.ask - optionQuote.bid).toFixed(4))
|
||||
: undefined;
|
||||
const underlyingMid =
|
||||
equityQuote && Number.isFinite(equityQuote.bid) && Number.isFinite(equityQuote.ask)
|
||||
? Number(((equityQuote.bid + equityQuote.ask) / 2).toFixed(4))
|
||||
: undefined;
|
||||
const underlyingSpread =
|
||||
equityQuote && Number.isFinite(equityQuote.bid) && Number.isFinite(equityQuote.ask)
|
||||
? Number(Math.max(0, equityQuote.ask - equityQuote.bid).toFixed(4))
|
||||
: undefined;
|
||||
|
||||
const enrichedForSignal: OptionPrint = {
|
||||
...rawPrint,
|
||||
...derived,
|
||||
nbbo_side: executionNbboSide ?? derived.nbbo_side,
|
||||
...(optionQuote
|
||||
? {
|
||||
execution_nbbo_bid: optionQuote.bid,
|
||||
execution_nbbo_ask: optionQuote.ask,
|
||||
execution_nbbo_mid: nbboMid,
|
||||
execution_nbbo_spread: nbboSpread,
|
||||
execution_nbbo_bid_size: optionQuote.bidSize,
|
||||
execution_nbbo_ask_size: optionQuote.askSize,
|
||||
execution_nbbo_ts: optionQuote.ts,
|
||||
execution_nbbo_age_ms: rawPrint.ts - optionQuote.ts,
|
||||
execution_nbbo_side: executionNbboSide,
|
||||
nbbo_side: executionNbboSide
|
||||
}
|
||||
: {}),
|
||||
...(equityQuote && underlyingMid !== undefined
|
||||
? {
|
||||
execution_underlying_spot: underlyingMid,
|
||||
execution_underlying_bid: equityQuote.bid,
|
||||
execution_underlying_ask: equityQuote.ask,
|
||||
execution_underlying_mid: underlyingMid,
|
||||
execution_underlying_spread: underlyingSpread,
|
||||
execution_underlying_ts: equityQuote.ts,
|
||||
execution_underlying_age_ms: rawPrint.ts - equityQuote.ts,
|
||||
execution_underlying_source: "equity_quote_mid" as const
|
||||
}
|
||||
: {}),
|
||||
signal_profile: config.mode
|
||||
};
|
||||
|
||||
const signalDecision = evaluateOptionSignal(enrichedForSignal, config);
|
||||
return OptionPrintSchema.parse({
|
||||
...enrichedForSignal,
|
||||
signal_pass: signalDecision.signalPass,
|
||||
signal_reasons: signalDecision.signalReasons,
|
||||
signal_profile: signalDecision.signalProfile
|
||||
});
|
||||
};
|
||||
|
|
@ -4,12 +4,16 @@ import {
|
|||
SUBJECT_OPTION_NBBO,
|
||||
SUBJECT_OPTION_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;
|
||||
|
|
|
|||
88
services/ingest-options/tests/enrichment.test.ts
Normal file
88
services/ingest-options/tests/enrichment.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import type { EquityQuote, OptionNBBO, OptionPrint, OptionsSignalConfig } from "@islandflow/types";
|
||||
import { enrichOptionPrint, selectAtOrBefore } from "../src/enrichment";
|
||||
|
||||
const config: OptionsSignalConfig = {
|
||||
mode: "all",
|
||||
minNotional: 0,
|
||||
etfMinNotional: 0,
|
||||
bidSideMinNotional: 0,
|
||||
midMinNotional: 0,
|
||||
missingNbboMinNotional: 0,
|
||||
largePrintMinSize: 1,
|
||||
largePrintMinNotional: 0,
|
||||
sweepMinNotional: 0,
|
||||
autoKeepMinNotional: 100_000,
|
||||
nbboMaxAgeMs: 1_500,
|
||||
etfUnderlyings: new Set(["SPY"])
|
||||
};
|
||||
|
||||
const print: OptionPrint = {
|
||||
source_ts: 1_000,
|
||||
ingest_ts: 1_000,
|
||||
seq: 1,
|
||||
trace_id: "print-1",
|
||||
ts: 1_000,
|
||||
option_contract_id: "SPY-2025-01-17-450-C",
|
||||
price: 1.3,
|
||||
size: 10,
|
||||
exchange: "TEST"
|
||||
};
|
||||
|
||||
const nbbo = (overrides: Partial<OptionNBBO> = {}): OptionNBBO => ({
|
||||
source_ts: 990,
|
||||
ingest_ts: 990,
|
||||
seq: 1,
|
||||
trace_id: "nbbo-1",
|
||||
ts: 990,
|
||||
option_contract_id: "SPY-2025-01-17-450-C",
|
||||
bid: 1.2,
|
||||
ask: 1.3,
|
||||
bidSize: 20,
|
||||
askSize: 30,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const equityQuote = (overrides: Partial<EquityQuote> = {}): EquityQuote => ({
|
||||
source_ts: 980,
|
||||
ingest_ts: 980,
|
||||
seq: 1,
|
||||
trace_id: "eq-1",
|
||||
ts: 980,
|
||||
underlying_id: "SPY",
|
||||
bid: 450,
|
||||
ask: 450.1,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe("option print enrichment", () => {
|
||||
it("attaches preserved NBBO context and mirrors nbbo_side", () => {
|
||||
const enriched = enrichOptionPrint(print, nbbo(), null, config);
|
||||
|
||||
expect(enriched.execution_nbbo_bid).toBe(1.2);
|
||||
expect(enriched.execution_nbbo_ask).toBe(1.3);
|
||||
expect(enriched.execution_nbbo_mid).toBe(1.25);
|
||||
expect(enriched.execution_nbbo_age_ms).toBe(10);
|
||||
expect(enriched.execution_nbbo_side).toBe("A");
|
||||
expect(enriched.nbbo_side).toBe(enriched.execution_nbbo_side);
|
||||
});
|
||||
|
||||
it("attaches preserved underlying quote mid as spot", () => {
|
||||
const enriched = enrichOptionPrint(print, null, equityQuote(), config);
|
||||
|
||||
expect(enriched.execution_underlying_spot).toBe(450.05);
|
||||
expect(enriched.execution_underlying_mid).toBe(450.05);
|
||||
expect(enriched.execution_underlying_source).toBe("equity_quote_mid");
|
||||
expect(enriched.execution_underlying_age_ms).toBe(20);
|
||||
});
|
||||
|
||||
it("selects context at or before the print timestamp only", () => {
|
||||
const selected = selectAtOrBefore(
|
||||
[nbbo({ ts: 900, seq: 1, bid: 1 }), nbbo({ ts: 1_001, seq: 2, bid: 2 })],
|
||||
print.ts
|
||||
);
|
||||
|
||||
expect(selected?.ts).toBe(900);
|
||||
expect(selected?.bid).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue