import { useMemo, useRef, useState } from "react";
import { Box } from "../../../ui";
import { usePaneSettingValue, useShortcut } from "../../../public/react";
import { DataTableStackView, KeyValueRow, PaneStatusBody, StatGrid, Tabs, usePaneFooter, usePaneHeaderTabs, usePaneNoticeFooter, usePaneStatusFooter, usePaneTicker, type DataTableColumn } from "../../../components";
import { useThemeColors } from "../../../theme/theme-context";
import type { PaneProps } from "../../../types/plugin";
import type { TapeQuote, TapeSnapshot, TapeTrade } from "../../../api-client/tape";
import { canonicalExchange } from "../../../utils/exchanges";
import { listingIdentity } from "../shared/ticker-request";
import { SignInWall } from "../cloud/auth-actions";
import { isCloudSessionRequired, useResearchCloudSession } from "../shared/research-cloud-session";
import { newestFirst, quoteKey, quoteSpread, stickyTapePriceDigits, tapeClockMs, tapePrice, tapeQuantity, tapeStatistics, tapeTime, tapeTimeSeconds, tradeKey, type TapeDigits } from "./model";
import { useTape } from "./use-tape";
const TABS = [{ value: "trades", label: "Trades" }, { value: "quotes", label: "NBBO" }];
const TRADES: DataTableColumn[] = [
{ id: "time", label: "TIME UTC", width: 13, align: "left" },
{ id: "price", label: "PRICE", width: 12, align: "right" },
{ id: "size", label: "SHARES", width: 12, align: "right" },
{ id: "venue", label: "VENUE", width: 6, align: "left" },
{ id: "conditions", label: "CONDITIONS", width: 12, align: "left", flexGrow: 1 },
{ id: "tape", label: "TAPE", width: 4, align: "left" },
];
const QUOTES: DataTableColumn[] = [
{ id: "time", label: "TIME UTC", width: 13, align: "left" },
{ id: "bid", label: "BID", width: 11, align: "right" },
{ id: "bidSize", label: "LOTS", width: 8, align: "right" },
{ id: "bidExchange", label: "VENUE", width: 6, align: "left" },
{ id: "ask", label: "ASK", width: 11, align: "right" },
{ id: "askSize", label: "LOTS", width: 8, align: "right" },
{ id: "askExchange", label: "VENUE", width: 6, align: "left" },
{ id: "spread", label: "SPREAD BP", width: 10, align: "right" },
];
const rank = (value: number | null) => value == null ? "pctl --" : `${value.toFixed(0)} pctl`;
type TapeRow = { id: string; trade?: TapeTrade; quote?: TapeQuote };
export function TimeSalesPane(props: PaneProps) {
const { symbol, ticker } = usePaneTicker();
const session = useResearchCloudSession();
const identity = listingIdentity(symbol, ticker?.metadata.exchange ?? "");
const exchange = canonicalExchange(identity?.exchange ?? "");
if (!identity) return ;
return ;
}
function TimeSalesView({ width, height, focused, symbol, exchange }: PaneProps & { symbol: string; exchange: string }) {
const colors = useThemeColors();
const session = useResearchCloudSession();
const [refresh, setRefresh] = useState(0);
const resource = useTape(symbol, exchange, session.requestKey, refresh);
const [tab, setTab] = usePaneSettingValue("tab", "trades");
const [paused, setPaused] = useState<{ data: TapeSnapshot; epoch: number } | null>(null);
const [selected, setSelected] = useState(null);
const [detailState, setDetail] = useState<{ row: TapeRow; epoch: number } | null>(null);
const detail = detailState?.epoch === resource.epoch ? detailState.row : null;
const frozen = paused?.epoch === resource.epoch ? paused.data : null;
// A session change clears live and frozen copies before a different tier arrives.
const data = resource.data ? frozen?.access === resource.data.access ? frozen : resource.data : null;
const stats = useMemo(() => data ? tapeStatistics(data) : null, [data]);
const rows: TapeRow[] = useMemo(() => !data ? [] : tab === "quotes"
? newestFirst(data.quotes).map((quote) => ({ id: quoteKey(quote), quote }))
: newestFirst(data.trades).map((trade) => ({ id: tradeKey(trade), trade })), [data, tab]);
const spreads = useMemo(() => {
const quotes = data?.quotes ?? [];
const bps = quotes.map((quote) => quoteSpread(quote)).filter((spread) => spread.state === "normal" && spread.bps != null).map((spread) => spread.bps!).sort((a, b) => a - b);
return {
median: bps.length ? bps[Math.floor((bps.length - 1) / 2)]! : null,
widest: bps.length ? bps[bps.length - 1]! : null,
abnormal: quotes.filter((quote) => { const state = quoteSpread(quote).state; return state === "locked" || state === "crossed"; }).length,
};
}, [data]);
const tradeDecision = useRef(null);
const quoteDecision = useRef(null);
const tradeDigits = useMemo(() => (tradeDecision.current = stickyTapePriceDigits(tradeDecision.current, symbol,
[...(data?.trades.map((trade) => trade.price) ?? []), data?.session.low ?? null, data?.session.high ?? null]))?.digits ?? 2, [data, symbol]);
const quoteDigits = useMemo(() => (quoteDecision.current = stickyTapePriceDigits(quoteDecision.current, symbol,
data?.quotes.flatMap((quote) => [quote.bid, quote.ask]) ?? []))?.digits ?? 2, [data, symbol]);
const freeze = () => { setPaused(frozen || !resource.data ? null : { data: resource.data, epoch: resource.epoch }); setDetail(null); };
const reload = () => { setPaused(null); setDetail(null); setRefresh((value) => value + 1); };
useShortcut((event) => {
if (!focused || event.targetEditable || event.ctrl || event.meta) return;
if (event.name === "space") { event.preventDefault(); freeze(); }
if (event.name === "r") { event.preventDefault(); reload(); }
});
usePaneFooter("time-sales:actions", () => ({ hints: [{ id: "pause", key: "space", label: frozen ? "resume" : "pause", onPress: freeze }] }), [frozen, resource.data, resource.epoch]);
usePaneNoticeFooter({ registrationId: "time-sales:notices", focused, notices: [...(data?.gaps ?? []), ...(resource.transport ? [resource.transport] : [])] });
usePaneStatusFooter({ registrationId: "time-sales:status", loading: resource.loading, error: resource.error,
info: data ? [{ id: "feed", parts: [{ text: `${data.feed === "sip" ? "real-time" : "15m delayed"} · ${tapeTimeSeconds(data.asOf)} UTC`, tone: "muted" }] },
...(frozen ? [{ id: "paused", parts: [{ text: "paused", tone: "warning" as const }] }] : []),
...(!data.connected || resource.snapshotOnly ? [{ id: "snapshot", parts: [{ text: "snapshot", tone: "warning" as const }] }] : [])] : [] });
const selectTab = (value: string) => { setTab(value); setDetail(null); setSelected(null); };
const tabsInHeader = usePaneHeaderTabs({ tabs: TABS, activeValue: tab, onSelect: selectTab, focused: focused && !detail });
const tabRows = tabsInHeader ? 0 : 1;
if (!data && isCloudSessionRequired(resource.error)) return ;
return
{!tabsInHeader && }
{data && stats ? columns={tab === "quotes" ? QUOTES : TRADES} items={rows}
focused={focused} rootWidth={width} rootHeight={Math.max(3, height - tabRows)}
selection={{ kind: "id", selectedId: selected, getId: (row) => row.id, onChange: setSelected }}
onActivate={(row) => { setPaused({ data, epoch: resource.epoch }); setDetail({ row, epoch: resource.epoch }); }} getItemKey={(row) => row.id}
sortColumnId={null} sortDirection="desc" freezeFirstColumn
detailOpen={!!detail && !!resource.data} onBack={() => setDetail(null)} detailTitle={detail?.trade ? `Trade ${detail.trade.id}` : "Quote"}
detailContent={detail && resource.data ? : null}
rootBefore={}
renderCell={(row, column) => {
if (row.trade) {
const trade = row.trade;
return { text: column.id === "time" ? tapeClockMs(trade.timestamp) : column.id === "price" ? tapePrice(trade.price, tradeDigits)
: column.id === "size" ? tapeQuantity(trade.size) : column.id === "venue" ? trade.exchange
: column.id === "conditions" ? trade.conditions.join(" ") : trade.tape,
color: trade.size >= 10_000 ? colors.warning : column.id === "price" ? colors.text : colors.textMuted };
}
const quote = row.quote!, spread = quoteSpread(quote);
return { text: column.id === "time" ? tapeClockMs(quote.timestamp) : column.id === "bid" ? tapePrice(quote.bid, quoteDigits) : column.id === "ask" ? tapePrice(quote.ask, quoteDigits)
: column.id === "bidSize" ? tapeQuantity(quote.bidSize) : column.id === "askSize" ? tapeQuantity(quote.askSize)
: column.id === "bidExchange" ? quote.bidExchange : column.id === "askExchange" ? quote.askExchange
: spread.bps == null ? "--" : spread.bps.toFixed(2), color: spread.state === "normal" ? colors.text : colors.warning };
}} emptyStateTitle="No observations in this window." /> : null}
;
}
function TapeDetail({ row, data, width }: { row: TapeRow; data: TapeSnapshot; width: number }) {
const trade = row.trade, quote = row.quote;
return
{trade ? <>
= 10_000 ? "large print" : undefined} />
> : quote ? <>
> : null}
;
}