diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 9ea6697..d8a7377 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -384,6 +384,59 @@ input { color: #ffd89a; } +.instrument-focus-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + max-width: min(360px, 32vw); + padding: 6px 8px 6px 10px; + border: 1px solid rgba(255, 216, 154, 0.34); + border-radius: 8px; + background: rgba(245, 166, 35, 0.08); + color: #ffe2aa; + font-family: var(--font-mono), monospace; + font-size: 0.72rem; + font-weight: 700; +} + +.instrument-focus-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.instrument-focus-chip button, +.instrument-cell-button { + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.instrument-focus-chip button { + padding: 4px 6px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.62rem; +} + +.instrument-cell-button { + padding: 0; + text-align: inherit; + text-decoration: underline; + text-decoration-color: rgba(255, 216, 154, 0.36); + text-underline-offset: 3px; +} + +.instrument-cell-button:hover, +.instrument-cell-button:focus-visible { + color: #ffd89a; + outline: none; +} + .pause-button { padding: 7px 10px; font-size: 0.66rem; diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index 9eb51d0..36a231e 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -73,6 +73,32 @@ describe("live manifest", () => { expect(optionsSubscription?.filters).toBe(filters); }); + + it("includes scoped option and equity subscriptions", () => { + const manifest = getLiveManifest( + "/tape", + "AAPL", + 60000, + buildDefaultFlowFilters(), + { + underlying_ids: ["AAPL"], + option_contract_id: "AAPL-2025-01-17-200-C" + }, + { underlying_ids: ["AAPL"] } + ); + const optionsSubscription = manifest.find( + (subscription): subscription is Extract<(typeof manifest)[number], { channel: "options" }> => + subscription.channel === "options" + ); + const equitiesSubscription = manifest.find( + (subscription): subscription is Extract<(typeof manifest)[number], { channel: "equities" }> => + subscription.channel === "equities" + ); + + expect(optionsSubscription?.underlying_ids).toEqual(["AAPL"]); + expect(optionsSubscription?.option_contract_id).toBe("AAPL-2025-01-17-200-C"); + expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]); + }); }); describe("live tape pausable helpers", () => { diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index bf87281..d3fa9c8 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -13,6 +13,7 @@ import { useState, type CSSProperties, type Dispatch, + type MouseEvent as ReactMouseEvent, type ReactNode, type SetStateAction } from "react"; @@ -124,6 +125,11 @@ type ChartCandle = { close: number; }; +type SelectedInstrument = + | null + | { kind: "equity"; underlyingId: string } + | { kind: "option-contract"; contractId: string; underlyingId: string }; + const formatIntervalLabel = (intervalMs: number): string => { const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs); if (match) { @@ -2247,6 +2253,15 @@ const appendOptionFlowFilters = (params: URLSearchParams, filters: OptionFlowFil } }; +const appendLiveScopeParams = (params: URLSearchParams, subscription: LiveSubscription): void => { + if ((subscription.channel === "options" || subscription.channel === "equities") && subscription.underlying_ids?.length) { + params.set("underlying_ids", subscription.underlying_ids.join(",")); + } + if (subscription.channel === "options" && subscription.option_contract_id) { + params.set("option_contract_id", subscription.option_contract_id); + } +}; + const dedupeLiveSubscriptions = (subscriptions: LiveSubscription[]): LiveSubscription[] => { const seen = new Set(); return subscriptions.filter((subscription) => { @@ -2263,9 +2278,11 @@ export const getLiveManifest = ( pathname: string, chartTicker: string, chartIntervalMs: number, - flowFilters: OptionFlowFilters + flowFilters: OptionFlowFilters, + optionScope?: Pick, "underlying_ids" | "option_contract_id">, + equityScope?: Pick, "underlying_ids"> ): LiveSubscription[] => { - const baselineSubs: LiveSubscription[] = [{ channel: "options", filters: flowFilters }]; + const baselineSubs: LiveSubscription[] = [{ channel: "options", filters: flowFilters, ...optionScope }]; const chartSubs: LiveSubscription[] = [ { channel: "equity-candles", underlying_id: chartTicker, interval_ms: chartIntervalMs }, { channel: "equity-overlay", underlying_id: chartTicker } @@ -2274,9 +2291,9 @@ export const getLiveManifest = ( if (pathname === "/tape") { return dedupeLiveSubscriptions([ ...baselineSubs, - { channel: "options", filters: flowFilters }, + { channel: "options", filters: flowFilters, ...optionScope }, { channel: "nbbo" }, - { channel: "equities" }, + { channel: "equities", ...equityScope }, { channel: "flow", filters: flowFilters }, { channel: "classifier-hits" } ]); @@ -2306,7 +2323,7 @@ export const getLiveManifest = ( return dedupeLiveSubscriptions([ ...baselineSubs, - { channel: "equities" }, + { channel: "equities", ...equityScope }, { channel: "flow" }, { channel: "alerts" }, { channel: "classifier-hits" }, @@ -2320,7 +2337,9 @@ const useLiveSession = ( pathname: string, chartTicker: string, chartIntervalMs: number, - flowFilters: OptionFlowFilters + flowFilters: OptionFlowFilters, + optionScope?: Pick, "underlying_ids" | "option_contract_id">, + equityScope?: Pick, "underlying_ids"> ): LiveSessionState => { const [status, setStatus] = useState(enabled ? "connecting" : "disconnected"); const [connectedAt, setConnectedAt] = useState(null); @@ -2350,8 +2369,8 @@ const useLiveSession = ( const subscribedKeysRef = useRef>(new Set()); const subscribedMapRef = useRef>(new Map()); const manifest = useMemo( - () => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters), - [pathname, chartTicker, chartIntervalMs, flowFilters] + () => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope), + [pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope] ); useEffect(() => { @@ -2616,6 +2635,42 @@ const useLiveSession = ( const currentKeys = subscribedKeysRef.current; const toSubscribe = manifest.filter((sub) => !currentKeys.has(getLiveSubscriptionKey(sub))); const removedKeys = Array.from(currentKeys).filter((key) => !nextKeys.has(key)); + const resetScopedChannels = new Set( + [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)] + .map((key) => subscribedMapRef.current.get(key) ?? nextMap.get(key) ?? null) + .filter((sub): sub is LiveSubscription => sub !== null) + .map((sub) => sub.channel) + .filter((channel) => channel === "options" || channel === "equities") + ); + if (resetScopedChannels.has("options")) { + setOptions([]); + } + if (resetScopedChannels.has("equities")) { + setEquities([]); + } + if (resetScopedChannels.size > 0) { + setHistoryCursors((current) => { + const next = { ...current }; + for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) { + delete next[key]; + } + return next; + }); + setHistoryLoading((current) => { + const next = { ...current }; + for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) { + delete next[key]; + } + return next; + }); + setHistoryErrors((current) => { + const next = { ...current }; + for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) { + delete next[key]; + } + return next; + }); + } if (removedKeys.length > 0) { const removedSubs = removedKeys @@ -2660,6 +2715,7 @@ const useLiveSession = ( if (subscription.channel === "options" || subscription.channel === "flow") { appendOptionFlowFilters(params, subscription.filters); } + appendLiveScopeParams(params, subscription); const response = await fetch(buildApiUrl(`${endpoint}?${params.toString()}`)); if (!response.ok) { const detail = await readErrorDetail(response); @@ -3981,6 +4037,7 @@ const useTerminalState = () => { const [selectedAlert, setSelectedAlert] = useState(null); const [selectedDarkEvent, setSelectedDarkEvent] = useState(null); const [selectedClassifierHit, setSelectedClassifierHit] = useState(null); + const [selectedInstrument, setSelectedInstrument] = useState(null); const [filterInput, setFilterInput] = useState(""); const [flowFilters, setFlowFilters] = useState(() => buildDefaultFlowFilters()); const [chartIntervalMs, setChartIntervalMs] = useState(CANDLE_INTERVALS[0].ms); @@ -3992,20 +4049,52 @@ const useTerminalState = () => { return Array.from(new Set(parts)); }, [filterInput]); const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]); - const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]); + const instrumentUnderlying = selectedInstrument?.underlyingId.toUpperCase() ?? null; + const optionScope = useMemo( + () => ({ + underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined, + option_contract_id: + selectedInstrument?.kind === "option-contract" ? selectedInstrument.contractId : undefined + }), + [activeTickers, instrumentUnderlying, selectedInstrument] + ); + const equityScope = useMemo( + () => ({ + underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined + }), + [activeTickers, instrumentUnderlying] + ); + const chartTicker = useMemo( + () => instrumentUnderlying ?? activeTickers[0] ?? "SPY", + [activeTickers, instrumentUnderlying] + ); + const selectedInstrumentLabel = useMemo(() => { + if (!selectedInstrument) { + return null; + } + if (selectedInstrument.kind === "equity") { + return `Equity: ${selectedInstrument.underlyingId}`; + } + const display = formatOptionContractLabel(selectedInstrument.contractId); + return display + ? `Contract: ${display.ticker} ${display.expiration} ${display.strike}` + : `Contract: ${selectedInstrument.contractId}`; + }, [selectedInstrument]); const liveSession = useLiveSession( mode === "live", pathname, chartTicker, chartIntervalMs, - flowFilters + flowFilters, + optionScope, + equityScope ); const equitiesLiveSubscriptionActive = useMemo( () => - getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters).some( + getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope).some( (sub) => sub.channel === "equities" ), - [pathname, chartTicker, chartIntervalMs, flowFilters] + [pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope] ); const handleReplaySource = useCallback((value: string | null) => { @@ -4665,19 +4754,31 @@ const useTerminalState = () => { if (!matchesOptionPrintFilters(print, flowFilters)) { return false; } + if ( + selectedInstrument?.kind === "option-contract" && + normalizeContractId(print.option_contract_id) !== selectedInstrument.contractId + ) { + return false; + } if (tickerSet.size === 0) { - return true; + return ( + !instrumentUnderlying || + extractUnderlying(normalizeContractId(print.option_contract_id)) === instrumentUnderlying + ); } return matchesTicker(extractUnderlying(normalizeContractId(print.option_contract_id))); }); - }, [flowFilters, optionsFeed.items, matchesTicker, tickerSet]); + }, [flowFilters, optionsFeed.items, matchesTicker, tickerSet, selectedInstrument, instrumentUnderlying]); const filteredEquities = useMemo(() => { if (tickerSet.size === 0) { + if (instrumentUnderlying) { + return equitiesFeed.items.filter((print) => print.underlying_id.toUpperCase() === instrumentUnderlying); + } return equitiesFeed.items; } return equitiesFeed.items.filter((print) => matchesTicker(print.underlying_id)); - }, [equitiesFeed.items, matchesTicker, tickerSet]); + }, [equitiesFeed.items, matchesTicker, tickerSet, instrumentUnderlying]); const equitiesSilentWarning = shouldShowEquitiesSilentFeedWarning({ wsStatus: liveSession.status, @@ -5000,6 +5101,9 @@ const useTerminalState = () => { setSelectedDarkEvent, selectedClassifierHit, setSelectedClassifierHit, + selectedInstrument, + setSelectedInstrument, + selectedInstrumentLabel, filterInput, setFilterInput, flowFilters, @@ -5473,6 +5577,15 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => { const spot = print.execution_underlying_spot; const iv = print.execution_iv; const decor = state.classifierDecorByOptionTraceId.get(print.trace_id); + const underlyingId = (print.underlying_id ?? parsed?.root ?? extractUnderlying(contractId)).toUpperCase(); + const focusContract = (event: ReactMouseEvent) => { + event.stopPropagation(); + state.setSelectedInstrument({ + kind: "option-contract", + contractId, + underlyingId + }); + }; const commonProps = { className: `data-table-row data-table-row-button data-table-row-classified data-table-row-options${decor ? ` is-classified classifier-${decor.tone}` : ""}`, style: decor ? ({ "--classifier-intensity": decor.intensity } as CSSProperties) : undefined @@ -5480,10 +5593,26 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => { const cells = ( <> {formatTime(print.ts)} - {contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)} - {contractDisplay?.expiration ?? parsed?.expiry ?? "--"} - {contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"} - {parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"} + + + + + + + + + + + + {typeof spot === "number" ? formatPrice(spot) : "--"} {formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"} @@ -5598,7 +5727,20 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => { {virtual.visibleItems.map((print) => (
{formatTime(print.ts)} - {print.underlying_id} + + + ${formatPrice(print.price)} {formatSize(print.size)}x {print.exchange} @@ -6237,6 +6379,14 @@ export function TerminalAppShell({ children }: { children: ReactNode }) { > Clear + {state.selectedInstrumentLabel ? ( + + {state.selectedInstrumentLabel} + + + ) : null}