import { useCallback, useMemo, useState } from "react"; import { CurveSurface, curveGhostColors, DataTableView, PaneStatusBody, StatGrid, statGridRows, Tabs, usePaneHeaderTabs, usePaneNoticeFooter, usePaneStatusFooter, type DataTableColumn, type StatItem } from "../../../components"; import { ApiRequestError } from "../../../api-client/errors"; import type { FuturesContract } from "../../../api-client/futures-curve"; import { useAsyncResource, usePaneSettingValue, usePluginPaneState, useShortcut } from "../../../public/react"; import { usePaneInstance, usePaneTitle } from "../../../state/app/context"; import { useThemeColors } from "../../../theme/theme-context"; import type { PaneProps } from "../../../types/plugin"; import { Box } from "../../../ui"; import { formatPercentRaw } from "../../../utils/format"; import { useAutoRefresh } from "../shared/auto-refresh"; import { futuresSessionRefreshInterval } from "../shared/futures-session"; import { useResearchCloudSession } from "../shared/research-cloud-session"; import { getCachedFuturesCurve, loadFuturesCurve } from "./client"; import { curveAxisPrice, curvePrice, curveRank, curveTimestamp, DEFAULT_CURVE_HORIZON, futuresCurveSeries, newestQuote, normalizeCurveRoot, sortCurveContracts } from "./model"; const TABS = [{ value: "curve", label: "Curve" }, { value: "contracts", label: "Contracts" }]; const COLUMNS: DataTableColumn[] = [ { id: "symbol", label: "CONTRACT", width: 15, align: "left" }, { id: "expiry", label: "EXPIRY", width: 10, align: "left" }, { id: "price", label: "PRICE", width: 12, align: "right" }, { id: "percentile", label: "PCTL", width: 5, align: "right" }, { id: "oi", label: "OPEN INT", width: 10, align: "right" }, { id: "volume", label: "VOLUME", width: 10, align: "right" }, { id: "asOf", label: "AS OF UTC", width: 16, align: "left" }, ]; const clearDenied = (error: unknown) => error instanceof ApiRequestError && [401, 403].includes(error.status ?? 0); const signedPercent = (value: number | null) => value == null ? "--" : formatPercentRaw(value); const integer = (value: number | null) => value == null ? "--" : value.toLocaleString("en-US"); export function FuturesCurvePane(props: PaneProps) { const pane = usePaneInstance(); const requested = pane?.settings?.root ?? pane?.params?.root ?? "ES"; const root = normalizeCurveRoot(requested); return root ? : ; } function FuturesCurveView({ width, height, focused, root }: PaneProps & { root: string }) { const colors = useThemeColors(); const session = useResearchCloudSession(); const loader = useCallback((force: boolean) => loadFuturesCurve(root, force), [root, session.requestKey]); const resource = useAsyncResource(loader, { initialData: () => getCachedFuturesCurve(root), clearOnError: clearDenied }); const [tab, setTab] = usePluginPaneState("tab", "curve"); const [selected, setSelected] = usePluginPaneState("contract", null); const [sort, setSort] = useState({ id: "expiry", direction: "asc" as "asc" | "desc" }); const [horizon] = usePaneSettingValue("horizon", DEFAULT_CURVE_HORIZON); const data = resource.data; usePaneTitle(`CTM ${root}`); const staleCount = data?.contracts.filter((row) => row.stale).length ?? 0; const newest = data ? newestQuote(data.contracts) : null; // The footer carries the newest quote time, so the legend does not repeat it. const curves = useMemo(() => data ? futuresCurveSeries(data, { current: colors.positive, ghosts: curveGhostColors(colors) }, horizon) .map((series) => series.id === "current" && curveTimestamp(series.asOf ?? null) === curveTimestamp(newest) ? { ...series, asOf: undefined } : series) : [], [data, colors, horizon, newest]); const rows = useMemo(() => sortCurveContracts(data?.contracts ?? [], sort.id, sort.direction), [data, sort]); const selectedRow = data?.contracts.find((row) => row.symbol === selected) ?? data?.contracts[0]; // The highlighted row carries the selected contract's price and rank. const slopeDate = data?.slope.asOf && curveTimestamp(data.slope.asOf) !== curveTimestamp(newest) ? curveTimestamp(data.slope.asOf) : null; const statItems: StatItem[] = data ? [ { id: "roll", label: "Ann. roll yield", value: signedPercent(data.slope.annualizedRollYield), detail: curveRank(data.slope.rollPercentile, data.slope.samples) }, { id: "spread", label: "M2-M1", value: data.slope.value == null ? "--" : curvePrice(data.slope.value, root), detail: [data.slope.state, curveRank(data.slope.percentile, data.slope.samples), slopeDate].filter(Boolean).join(" · ") }, ] : []; const tabsInHeader = usePaneHeaderTabs({ tabs: TABS, activeValue: tab, onSelect: setTab, focused }); const tabRows = tabsInHeader ? 0 : 1; const bodyHeight = Math.max(9, height - tabRows - statGridRows(statItems, width)); const tableHeight = tab === "curve" ? Math.max(3, Math.min(rows.length + 2, Math.floor(bodyHeight * 0.4))) : bodyHeight; const curveHeight = tab === "curve" ? Math.max(6, bodyHeight - tableHeight) : 0; // Delayed contract quotes move all session; the curve follows them once a // minute while Globex trades and on the research cadence otherwise. A // settlement curve changes once a day. useAutoRefresh(resource.updatedAt, resource.load, { intervalMs: data?.source === "cboe" ? null : futuresSessionRefreshInterval(), }); useShortcut((event) => { if (focused && !event.targetEditable && !event.ctrl && !event.meta && event.name === "r") { event.preventDefault(); void resource.reload(); } }); usePaneNoticeFooter({ registrationId: "futures-curve:notices", focused, notices: data?.gaps ?? [] }); const delay = Math.max(0, ...(data?.contracts.map((row) => row.delayMinutes ?? 0) ?? [])); usePaneStatusFooter({ registrationId: "futures-curve", loading: resource.loading, error: resource.error, info: data ? [ { id: "source", parts: [{ text: `${data.source === "cboe" ? "settlement" : delay > 0 ? `${delay}m delayed` : "dated quotes"} · ${data.quoteUnit ?? data.currency ?? "units unavailable"} · ${curveTimestamp(newest)}${newest?.includes("T") ? " UTC" : ""}`, tone: "muted" }] }, ...(staleCount ? [{ id: "stale", parts: [{ text: `${staleCount} of ${data.contracts.length} stale`, tone: "warning" as const }] }] : []), ] : [], }); const renderCell = useCallback((row: FuturesContract, column: DataTableColumn) => { if (column.id === "symbol") return { text: row.symbol }; if (column.id === "expiry") return { text: row.expiration, color: colors.textMuted }; if (column.id === "price") return { text: curvePrice(row.price, root) }; if (column.id === "percentile") return { text: row.samples < 2 || row.percentile == null ? "--" : row.percentile.toFixed(0) }; if (column.id === "oi") return { text: integer(row.openInterest) }; if (column.id === "volume") return { text: integer(row.volume) }; return { text: curveTimestamp(row.asOf), color: row.stale ? colors.warning : colors.textMuted }; }, [colors, root]); return {!tabsInHeader && } {data ? <> {tab === "curve" ? curvePrice(value, root)} formatAxisValue={(value, domain) => curveAxisPrice(value, domain, root)} formatX={(value) => new Date(Math.round(value / 86_400_000) * 86_400_000).toISOString().slice(0, 10)} selectedPointId={selectedRow?.symbol ?? null} onSelectedPointChange={setSelected} /> : null} row.symbol, onChange: setSelected }} onActivate={(row) => setSelected(row.symbol)} getItemKey={(row) => row.symbol} renderCell={renderCell} sortColumnId={sort.id} sortDirection={sort.direction} onHeaderClick={(id) => setSort((current) => ({ id, direction: current.id === id && current.direction === "asc" ? "desc" : "asc" }))} emptyStateTitle="No listed contracts available." /> : null} ; }