Implement options snapshot tape table
This commit is contained in:
parent
6abfff30d3
commit
e78387130a
15 changed files with 904 additions and 128 deletions
|
|
@ -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 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue