diff --git a/.changelog/unreleased/818-ob-049-source-freshness.md b/.changelog/unreleased/818-ob-049-source-freshness.md new file mode 100644 index 00000000..95c92d0e --- /dev/null +++ b/.changelog/unreleased/818-ob-049-source-freshness.md @@ -0,0 +1,7 @@ +--- +type: added +area: trade +pr: 818 +breaking: false +--- +The order book now shows whether its market data is live, stale or reconnecting, and no longer treats a quiet market as a dead connection. diff --git a/.changelog/unreleased/819-ob-052-053-depth-controls.md b/.changelog/unreleased/819-ob-052-053-depth-controls.md new file mode 100644 index 00000000..ccee4deb --- /dev/null +++ b/.changelog/unreleased/819-ob-052-053-depth-controls.md @@ -0,0 +1,7 @@ +--- +type: added +area: trade +pr: 819 +breaking: false +--- +Order book depth now supports price grouping, base or quote size units and bids-only or asks-only layouts, remembered separately for each market. diff --git a/apps/web/src/features/trade/components/orderbook/DepthControls.tsx b/apps/web/src/features/trade/components/orderbook/DepthControls.tsx new file mode 100644 index 00000000..4cd7dc3c --- /dev/null +++ b/apps/web/src/features/trade/components/orderbook/DepthControls.tsx @@ -0,0 +1,118 @@ +import { RadioGroup, RadioGroupItem } from "@workspace/ui/components/radio-group" +import { VisuallyHidden } from "@workspace/ui/components/visually-hidden" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/select" +import { + + + groupingOptions +} from "../../lib/orderbook/display-prefs" +import { isBookLayout } from "../../lib/orderbook/layout" +import type {MarketDisplayMetadata, SizeUnit} from "../../lib/orderbook/display-prefs"; +import type {BookLayout} from "../../lib/orderbook/layout"; + +type Props = { + /** Null or unusable metadata disables the controls that depend on it. */ + metadata: MarketDisplayMetadata | null + groupingMultiplier: number + unit: SizeUnit + layout: BookLayout + onGroupingChange: (multiplier: number) => void + onUnitChange: (unit: SizeUnit) => void + onLayoutChange: (layout: BookLayout) => void +} + +const LAYOUT_LABELS: ReadonlyArray<{ value: BookLayout; text: string; name: string }> = [ + { value: "both", text: "Both", name: "Show bids and asks" }, + { value: "bids", text: "Bids", name: "Show bids only" }, + { value: "asks", text: "Asks", name: "Show asks only" }, +] + +/** + * OB-052 / OB-053: grouping, size unit and layout controls for the depth view. + * + * All three are real form controls with explicit accessible names, so they work + * with a keyboard (arrow keys move within a radio group; the select opens with + * Enter/Space/arrow keys) and announce the unit rather than just a number. + */ +export function DepthControls({ + metadata, + groupingMultiplier, + unit, + layout, + onGroupingChange, + onUnitChange, + onLayoutChange, +}: Props) { + const options = groupingOptions(metadata) + const canGroup = options.length > 0 && metadata !== null + + return ( +
+ + + { + if (value === "base" || value === "quote") onUnitChange(value) + }} + > + + + + + { + if (isBookLayout(value)) onLayoutChange(value) + }} + > + {LAYOUT_LABELS.map(({ value, text, name }) => ( + + ))} + +
+ ) +} diff --git a/apps/web/src/features/trade/components/orderbook/DepthPanel.test.tsx b/apps/web/src/features/trade/components/orderbook/DepthPanel.test.tsx new file mode 100644 index 00000000..6798364a --- /dev/null +++ b/apps/web/src/features/trade/components/orderbook/DepthPanel.test.tsx @@ -0,0 +1,311 @@ +import { useEffect } from "react" +import { cleanup, render, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { useOrderBookDisplayStore } from "../../store/orderbook-display-store" +import { DepthPanel } from "./DepthPanel" +import type { SourceHealth } from "../../hooks/useSourceHealth" +import type { BookLevel } from "../../lib/orderbook/book-reducer" +import type { MarketDisplayMetadata } from "../../lib/orderbook/display-prefs" + +const BTC: MarketDisplayMetadata = { + marketId: "BTC-USD", + baseSymbol: "BTC", + quoteSymbol: "USD", + priceScale: 2, + sizeScale: 4, + tickSize: 1n, +} +const XLM: MarketDisplayMetadata = { + marketId: "XLM-USD", + baseSymbol: "XLM", + quoteSymbol: "USD", + priceScale: 5, + sizeScale: 1, + tickSize: 10n, +} + +const L = (price: bigint, size: bigint): BookLevel => ({ price, size }) + +// 0.01 price atoms, 0.0001 size atoms. +const BIDS = [L(10_007n, 10_000n), L(10_005n, 20_000n), L(10_004n, 5_000n), L(9_996n, 30_000n)] +const ASKS = [L(10_008n, 10_000n), L(10_010n, 20_000n), L(10_013n, 5_000n), L(10_020n, 30_000n)] + +const LIVE: SourceHealth = { + status: "connected", + lastUpdateTime: 1, + staleDuration: null, + reconnectAttempt: 0, + isExecutable: true, + message: "Live market data", +} + +function priceCells(): Array { + return screen + .getAllByRole("row") + .filter((row) => within(row).queryAllByRole("cell").length > 0) + .map((row) => within(row).getAllByRole("cell")[0].textContent) +} + +beforeEach(() => { + localStorage.clear() + useOrderBookDisplayStore.setState({ layout: "both", byMarket: {} }) +}) + +afterEach(cleanup) + +describe("layouts (OB-053)", () => { + it("both: asks above the spread worst-first, bids below best-first", () => { + render() + expect(priceCells()).toEqual([ + "100.20", "100.13", "100.10", "100.08", // asks, best ask next to the spread + "100.07", "100.05", "100.04", "99.96", // bids, best bid first + ]) + }) + + it("bids only: only bids, best-first", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name: "Show bids only" })) + expect(priceCells()).toEqual(["100.07", "100.05", "100.04"]) + }) + + it("asks only: only asks, still worst-first toward the spread", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name: "Show asks only" })) + expect(priceCells()).toEqual(["100.13", "100.10", "100.08"]) + }) + + it.each(["Show bids and asks", "Show bids only", "Show asks only"])( + "%s keeps the market identity, the spread and the freshness badge visible", + async (name) => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name })) + + expect(screen.getByRole("heading", { name: "BTC/USD" })).toBeInTheDocument() + expect(screen.getByRole("separator", { name: "Spread" })).toHaveTextContent("0.01") + expect(screen.getByText("Live")).toBeInTheDocument() + }, + ) + + it("exposes the layout controls with explicit accessible names", () => { + render() + const group = screen.getByRole("radiogroup", { name: "Order book layout" }) + expect(within(group).getAllByRole("radio")).toHaveLength(3) + // Each option is announced by its full action, not the short visible text. + for (const name of ["Show bids and asks", "Show bids only", "Show asks only"]) { + expect(within(group).getByRole("radio", { name })).toBeInTheDocument() + } + }) + + it("the layout is reachable and changeable by keyboard alone", async () => { + const user = userEvent.setup({ delay: null }) + render() + screen.getByRole("radio", { name: "Show bids and asks" }).focus() + await user.keyboard("{ArrowRight}") + expect(screen.getByRole("radio", { name: "Show bids only" })).toBeChecked() + expect(useOrderBookDisplayStore.getState().layout).toBe("bids") + }) + + it("preserves the selected grouping and unit across layouts", async () => { + const user = userEvent.setup({ delay: null }) + useOrderBookDisplayStore.getState().setGrouping(BTC, 5) + useOrderBookDisplayStore.getState().setUnit("BTC-USD", "quote") + render() + + await user.click(screen.getByRole("radio", { name: "Show bids only" })) + await user.click(screen.getByRole("radio", { name: "Show bids and asks" })) + + // 0.05 grouping: asks round up (100.08 -> 100.10), bids round down (100.07 -> 100.05). + expect(priceCells()).toContain("100.10") + expect(priceCells()).toContain("100.05") + expect(screen.getByRole("radio", { name: "Size in USD" })).toBeChecked() + expect(screen.getByRole("table", { name: /size in USD/ })).toBeInTheDocument() + }) +}) + +describe("grouping (OB-052)", () => { + it("aggregates rows to the chosen grouping using the exact fixtures", async () => { + const user = userEvent.setup({ delay: null }) + render() + + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "0.05" })) + + await waitFor(() => + expect(priceCells()).toEqual([ + "100.20", "100.15", "100.10", // asks: 100.08+100.10 -> 100.10, 100.13 -> 100.15 + "100.05", "100.00", "99.95", // bids: 100.07+100.05 -> 100.05, 100.04 -> 100.00, 99.96 -> 99.95 + ]), + ) + }) + + it("does not change the spread, which comes from the canonical book", async () => { + const user = userEvent.setup({ delay: null }) + render() + const before = screen.getByRole("separator", { name: "Spread" }).textContent + + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "10" })) + + await waitFor(() => expect(useOrderBookDisplayStore.getState().byMarket["BTC-USD"]?.groupingMultiplier).toBe(1000)) + expect(screen.getByRole("separator", { name: "Spread" }).textContent).toBe(before) + }) + + it("does not modify the levels it was given", async () => { + const bids = Object.freeze(BIDS.map((l) => Object.freeze({ ...l }))) + const asks = Object.freeze(ASKS.map((l) => Object.freeze({ ...l }))) + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "0.5" })) + expect(bids).toEqual(BIDS) + expect(asks).toEqual(ASKS) + }) + + it("offers groupings from the market's own tick size", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + const options = await screen.findAllByRole("option") + expect(options.map((o) => o.textContent)).toEqual(["0.0001", "0.0002", "0.0005", "0.001", "0.005", "0.01", "0.05", "0.1"]) + }) + + it("keeps each market's grouping when switching between markets", async () => { + const user = userEvent.setup({ delay: null }) + const { rerender } = render() + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "0.5" })) + await waitFor(() => expect(useOrderBookDisplayStore.getState().byMarket["BTC-USD"]?.groupingMultiplier).toBe(50)) + + rerender() + expect(screen.getByRole("heading", { name: "XLM/USD" })).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toHaveTextContent("0.0001") // XLM untouched: native + + rerender() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toHaveTextContent("0.5") + }) + + it("resets a stored grouping the market stops offering", async () => { + useOrderBookDisplayStore.getState().setGrouping(BTC, 100) + const { rerender } = render() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toHaveTextContent("1") + + // Precision metadata becomes unusable (capabilities change). + rerender() + await waitFor(() => + expect(useOrderBookDisplayStore.getState().byMarket["BTC-USD"]?.groupingMultiplier).toBe(1), + ) + }) +}) + +describe("units (OB-052)", () => { + it("shows base sizes with the base symbol by default", () => { + render() + expect(screen.getByRole("columnheader", { name: "Size (BTC)" })).toBeInTheDocument() + expect(screen.getByRole("radio", { name: "Size in BTC" })).toBeChecked() + }) + + it("switches to exact quote values and labels them in the quote symbol", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name: "Size in USD" })) + + expect(screen.getByRole("columnheader", { name: "Size (USD)" })).toBeInTheDocument() + // Best bid: 100.07 * 1.0000 BTC = 100.07 USD. + const bidRow = screen.getAllByRole("row").at(-1)! + expect(within(bidRow).getAllByRole("cell")[1]).toHaveTextContent("100.07") + }) + + it("the unit is remembered per market", async () => { + const user = userEvent.setup({ delay: null }) + const { rerender } = render() + await user.click(screen.getByRole("radio", { name: "Size in USD" })) + + rerender() + expect(screen.getByRole("radio", { name: "Size in XLM" })).toBeChecked() + rerender() + expect(screen.getByRole("radio", { name: "Size in USD" })).toBeChecked() + }) +}) + +describe("stability while the feed is live (OB-053)", () => { + it("rapid layout, grouping and unit switches never remount the feed that owns the levels", async () => { + let mounts = 0 + let unmounts = 0 + function Feed() { + // Stands in for the component that holds the live subscription. + useEffect(() => { + mounts++ + return () => { + unmounts++ + } + }, []) + return + } + const user = userEvent.setup({ delay: null }) + render() + + for (let i = 0; i < 4; i++) { + await user.click(screen.getByRole("radio", { name: "Show bids only" })) + await user.click(screen.getByRole("radio", { name: "Show asks only" })) + await user.click(screen.getByRole("radio", { name: "Show bids and asks" })) + await user.click(screen.getByRole("radio", { name: "Size in USD" })) + await user.click(screen.getByRole("radio", { name: "Size in BTC" })) + } + + expect(mounts).toBe(1) + expect(unmounts).toBe(0) + expect(screen.getByRole("heading", { name: "BTC/USD" })).toBeInTheDocument() + }, 20_000) + + it("keeps the same DOM nodes for rows that persist across a book update", () => { + const { rerender } = render() + const rows = () => screen.getAllByRole("row") + const beforeRow = rows().find((r) => r.textContent.startsWith("100.07"))! + + // A new update: same levels plus one more bid deeper in the book. + rerender() + + const afterRow = rows().find((r) => r.textContent.startsWith("100.07"))! + expect(afterRow).toBe(beforeRow) + }) + + it("does not animate row movement or fills (no transitions on the ladder)", () => { + render() + // The ladder itself; the shared select and radio controls carry their own focus styling. + const ladder = screen.getByRole("table") + for (const el of [ladder, ...Array.from(ladder.querySelectorAll("*"))]) { + expect(el.className.toString()).not.toMatch(/transition|animate|duration/) + } + }) +}) + +describe("unusable market metadata", () => { + it("explains why depth is unavailable and disables the precision controls", () => { + render() + expect(screen.getByText(/precision is unavailable/i)).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toBeDisabled() + expect(screen.queryAllByRole("row")).toHaveLength(0) + }) + + it("handles no metadata at all", () => { + render() + expect(screen.getByRole("heading", { name: "Market unavailable" })).toBeInTheDocument() + }) +}) + +describe("empty and crossed books", () => { + it("says there is no depth to show", () => { + render() + expect(screen.getByText("No depth to show.")).toBeInTheDocument() + expect(screen.getByRole("separator", { name: "Spread" })).toHaveTextContent("—") + }) + + it("marks a crossed book instead of showing a negative spread", () => { + render() + expect(screen.getByRole("separator", { name: "Spread" })).toHaveTextContent("crossed") + }) +}) diff --git a/apps/web/src/features/trade/components/orderbook/DepthPanel.tsx b/apps/web/src/features/trade/components/orderbook/DepthPanel.tsx new file mode 100644 index 00000000..ffb4205b --- /dev/null +++ b/apps/web/src/features/trade/components/orderbook/DepthPanel.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo } from "react" +import { cn } from "@workspace/ui/lib/utils" +import { buildDepth } from "../../lib/orderbook/depth" +import { + + formatBucketPrice, + formatScaled, + isValidMetadata, + rowSizeDisplay, + tickFor +} from "../../lib/orderbook/display-prefs" +import { depthFraction, selectVisibleDepth } from "../../lib/orderbook/layout" +import { selectDisplayPrefs, useOrderBookDisplayStore } from "../../store/orderbook-display-store" +import { DepthControls } from "./DepthControls" +import { SourceHealthBadge } from "./SourceHealthBadge" +import type {VisibleDepth} from "../../lib/orderbook/layout"; +import type {MarketDisplayMetadata} from "../../lib/orderbook/display-prefs"; +import type {DepthRow} from "../../lib/orderbook/depth"; +import type { BookLevel } from "../../lib/orderbook/book-reducer" +import type { SourceHealth } from "../../hooks/useSourceHealth" + +type Props = { + metadata: MarketDisplayMetadata | null + /** Canonical levels, in any order. Never modified here. */ + bids: ReadonlyArray + asks: ReadonlyArray + /** Rows available for depth, excluding the spread row. */ + rows: number + /** Freshness of the feed the levels came from; shown in every layout. */ + health?: SourceHealth | null + className?: string +} + +function Row({ + row, + side, + visible, + metadata, + unit, +}: { + row: DepthRow + side: "bid" | "ask" + visible: VisibleDepth + metadata: MarketDisplayMetadata + unit: "base" | "quote" +}) { + const display = rowSizeDisplay(row, unit, metadata) + const fill = `${(depthFraction(row, visible) * 100).toFixed(1)}%` + return ( +
+ {/* Depth bar. Deliberately not animated: rows re-sort as the book moves and a + transition here would make the ladder shimmer rather than clarify anything. */} + + ) +} + +/** + * OB-052 / OB-053: the depth ladder with grouping, unit and layout controls. + * + * The live feed is NOT owned here: levels arrive as props from a parent that + * holds the subscription, so switching layout, grouping or unit only recomputes + * a display view and can never remount or restart the feed. + * + * Display only. Bucket prices are labels, not executable prices, and nothing + * here feeds a price into an order ticket. + */ +export function DepthPanel({ metadata, bids, asks, rows, health = null, className }: Props) { + const layout = useOrderBookDisplayStore((s) => s.layout) + const setLayout = useOrderBookDisplayStore((s) => s.setLayout) + const setGrouping = useOrderBookDisplayStore((s) => s.setGrouping) + const setUnit = useOrderBookDisplayStore((s) => s.setUnit) + const reconcileMarket = useOrderBookDisplayStore((s) => s.reconcileMarket) + const byMarket = useOrderBookDisplayStore((s) => s.byMarket) + + const usable = isValidMetadata(metadata) ? metadata : null + + // Replace stored choices the market no longer supports (precision changed, etc.). + // Runs for unusable metadata too: a market that lost its precision can no longer + // honour a stored grouping, and that has to be reset rather than kept. + useEffect(() => { + if (metadata?.marketId) reconcileMarket(metadata) + }, [metadata, reconcileMarket]) + + const prefs = usable ? selectDisplayPrefs({ byMarket }, usable) : { groupingMultiplier: 1, unit: "base" as const } + const { groupingMultiplier } = prefs + + // Depends on the grouping only: the unit changes how a row is printed, not what it contains. + const depth = useMemo( + () => (usable ? buildDepth(bids, asks, { tick: tickFor({ groupingMultiplier, unit: "base" }, usable) }) : null), + [usable, bids, asks, groupingMultiplier], + ) + const visible = useMemo(() => (depth ? selectVisibleDepth(depth, layout, rows) : null), [depth, layout, rows]) + + return ( +
+
+ {/* Market identity is part of every layout. */} +

+ {usable ? `${usable.baseSymbol}/${usable.quoteSymbol}` : "Market unavailable"} +

+ {health ? : null} +
+ +
+ usable && setGrouping(usable, multiplier)} + onUnitChange={(unit) => usable && setUnit(usable.marketId, unit)} + onLayoutChange={setLayout} + /> +
+ + {usable && depth && visible ? ( +
+
+ Price + Size ({prefs.unit === "base" ? usable.baseSymbol : usable.quoteSymbol}) + Total +
+ + {visible.asks.map((row) => ( + + ))} + + {/* The spread comes from the canonical book, so grouping cannot move it. */} +
+ + Spread{" "} + + {depth.crossed ? "crossed" : depth.spread !== null ? formatScaled(depth.spread, usable.priceScale, 2) : "—"} + + +
+ + {visible.bids.map((row) => ( + + ))} + + {visible.asks.length === 0 && visible.bids.length === 0 ? ( +

No depth to show.

+ ) : null} +
+ ) : ( +

+ Market precision is unavailable, so depth cannot be grouped or sized. +

+ )} +
+ ) +} diff --git a/apps/web/src/features/trade/components/orderbook/OrderBookPanel.test.tsx b/apps/web/src/features/trade/components/orderbook/OrderBookPanel.test.tsx index 91f0bd2a..b395cb19 100644 --- a/apps/web/src/features/trade/components/orderbook/OrderBookPanel.test.tsx +++ b/apps/web/src/features/trade/components/orderbook/OrderBookPanel.test.tsx @@ -22,6 +22,12 @@ vi.mock("../../hooks/useOrderBook", () => ({ }, })) +let mockSource: unknown = null + +vi.mock("../../hooks/useSourceFreshness", () => ({ + useSourceFreshness: () => mockSource, +})) + vi.mock("../../../settings/store/preferences-store", () => ({ usePreferencesStore: () => ({ slippageTolerance: 0.5 }), })) @@ -209,4 +215,54 @@ describe("OrderBookPanel", () => { expect(container).toBeInTheDocument() expect(screen.getByText("Reference Data")).toBeInTheDocument() }) + + describe("source health (OB-049)", () => { + beforeEach(() => { + mockSource = null + }) + + afterEach(() => { + mockSource = null + }) + + it("shows no health badge until a state is available", () => { + render() + expect(screen.getByText("Reference Data")).toBeInTheDocument() + expect(screen.queryByText("Live")).not.toBeInTheDocument() + expect(screen.queryByText("Stale")).not.toBeInTheDocument() + }) + + it("shows Live for a fresh feed", () => { + mockSource = { + health: { + status: "connected", + lastUpdateTime: 1, + staleDuration: null, + reconnectAttempt: 0, + isExecutable: true, + message: "Live market data", + }, + } + render() + expect(screen.getByText("Live")).toBeInTheDocument() + }) + + it("labels a silent feed Stale and explains why", () => { + mockSource = { + health: { + status: "stale", + lastUpdateTime: 1, + staleDuration: 30_000, + reconnectAttempt: 0, + isExecutable: false, + message: "No data received — connection may be dead", + }, + } + render() + const badge = screen.getByText("Stale") + expect(badge).toBeInTheDocument() + expect(badge.closest("[title]")?.getAttribute("title")).toMatch(/connection may be dead/) + expect(screen.getByText("(30s ago)")).toBeInTheDocument() + }) + }) }) diff --git a/apps/web/src/features/trade/components/orderbook/OrderBookPanel.tsx b/apps/web/src/features/trade/components/orderbook/OrderBookPanel.tsx index 0f2494bb..05f22737 100644 --- a/apps/web/src/features/trade/components/orderbook/OrderBookPanel.tsx +++ b/apps/web/src/features/trade/components/orderbook/OrderBookPanel.tsx @@ -1,5 +1,7 @@ import { useOrderBook } from "../../hooks/useOrderBook" +import { useSourceFreshness } from "../../hooks/useSourceFreshness" import { useChartPreferencesStore } from "../../store/chart-preferences-store" +import { SourceHealthBadge } from "./SourceHealthBadge" import { RecentTradesTape } from "./RecentTradesTape" import { DepthChart } from "./DepthChart" @@ -11,6 +13,7 @@ export function OrderBookPanel({ symbol }: Props) { const orderbookView = useChartPreferencesStore((s) => s.orderbookView) const setOrderbookView = useChartPreferencesStore((s) => s.setOrderbookView) const { bids, asks, status, isLoading } = useOrderBook(symbol) + const source = useSourceFreshness(symbol) return (
@@ -57,7 +60,10 @@ export function OrderBookPanel({ symbol }: Props) { Depth Chart
- Reference Data + + Reference Data + {source ? : null} +
{/* ── Tab Content ──────────────────────────────────────────────────── */} diff --git a/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts b/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts new file mode 100644 index 00000000..1cf4675d --- /dev/null +++ b/apps/web/src/features/trade/hooks/useSourceFreshness.test.ts @@ -0,0 +1,156 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { FRESHNESS_TICK_MS, useSourceFreshness } from "./useSourceFreshness" +import type { FreshnessResult } from "../lib/source-freshness" +import type { SourceHealth } from "./useSourceHealth" + +type View = { health: SourceHealth; freshness: FreshnessResult } + +const freshness = (over: Partial = {}): FreshnessResult => ({ + state: "fresh", + reason: "live", + canSeedQuote: true, + lastMessageAgeMs: 0, + lastValidUpdateAgeMs: 0, + providerLagMs: 0, + clockOffsetMs: 40, + ...over, +}) + +const health = (over: Partial = {}): SourceHealth => ({ + status: "connected", + lastUpdateTime: 1, + staleDuration: null, + reconnectAttempt: 0, + isExecutable: true, + message: "Live market data", + ...over, +}) + +let current: View +const unsubscribe = vi.fn() +let bookListener: (() => void) | null = null +const getOrCreate = vi.fn() + +vi.mock("../lib/market-data-stream", () => ({ + marketSubscriptionManager: { getOrCreate: (...args: Array) => getOrCreate(...args) }, +})) + +beforeEach(() => { + vi.useFakeTimers() + current = { health: health(), freshness: freshness() } + bookListener = null + unsubscribe.mockReset() + getOrCreate.mockReset() + getOrCreate.mockReturnValue({ + getSourceHealth: () => current, + subscribeBook: (cb: () => void) => { + bookListener = cb + return unsubscribe + }, + }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe("useSourceFreshness", () => { + it("returns null without a symbol and does not subscribe", () => { + const { result } = renderHook(() => useSourceFreshness(undefined)) + expect(result.current).toBeNull() + expect(getOrCreate).not.toHaveBeenCalled() + }) + + it("reports the current freshness on mount", () => { + const { result } = renderHook(() => useSourceFreshness("BTC")) + expect(result.current?.freshness.state).toBe("fresh") + expect(result.current?.health.status).toBe("connected") + }) + + it("notices silence, which raises no event, on its timer", () => { + const { result } = renderHook(() => useSourceFreshness("BTC")) + expect(result.current?.freshness.state).toBe("fresh") + + current = { + health: health({ status: "stale", staleDuration: 21_000, isExecutable: false, message: "No data received — connection may be dead" }), + freshness: freshness({ state: "stale", reason: "transport-silent", canSeedQuote: false }), + } + act(() => { + vi.advanceTimersByTime(FRESHNESS_TICK_MS) + }) + + expect(result.current?.freshness).toMatchObject({ state: "stale", reason: "transport-silent" }) + }) + + it("re-evaluates immediately when the book changes, not only on the timer", () => { + const { result } = renderHook(() => useSourceFreshness("BTC")) + current = { + health: health({ status: "reconnecting", isExecutable: false, message: "Reconnecting to market data…" }), + freshness: freshness({ state: "reconnecting", reason: "transport-closed", canSeedQuote: false }), + } + act(() => bookListener?.()) + expect(result.current?.freshness.state).toBe("reconnecting") + }) + + it("does not re-render while nothing visible changed", () => { + let renders = 0 + renderHook(() => { + renders++ + return useSourceFreshness("BTC") + }) + const afterMount = renders + + // The ages move every tick, but the state, reason and message are the same. + for (let i = 1; i <= 10; i++) { + current = { + health: health(), + freshness: freshness({ lastMessageAgeMs: i * 1_000, lastValidUpdateAgeMs: i * 1_000 }), + } + act(() => { + vi.advanceTimersByTime(FRESHNESS_TICK_MS) + }) + } + expect(renders).toBe(afterMount) + }) + + it("re-renders each whole second once the feed is stale, so the shown age stays right", () => { + current = { + health: health({ status: "stale", staleDuration: 21_000, isExecutable: false, message: "stale" }), + freshness: freshness({ state: "stale", reason: "transport-silent", canSeedQuote: false }), + } + const { result } = renderHook(() => useSourceFreshness("BTC")) + expect(result.current?.health.staleDuration).toBe(21_000) + + current = { ...current, health: { ...current.health, staleDuration: 22_000 } } + act(() => { + vi.advanceTimersByTime(FRESHNESS_TICK_MS) + }) + expect(result.current?.health.staleDuration).toBe(22_000) + }) + + it("stops its timer and unsubscribes on unmount", () => { + const { unmount } = renderHook(() => useSourceFreshness("BTC")) + unmount() + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it("switches subscription when the symbol changes", () => { + const { rerender } = renderHook(({ symbol }) => useSourceFreshness(symbol), { + initialProps: { symbol: "BTC" }, + }) + rerender({ symbol: "ETH" }) + expect(unsubscribe).toHaveBeenCalledTimes(1) + expect(getOrCreate).toHaveBeenLastCalledWith("ETH") + }) + + it("clears the view when the symbol goes away", () => { + const { result, rerender } = renderHook(({ symbol }) => useSourceFreshness(symbol), { + initialProps: { symbol: "BTC" }, + }) + expect(result.current).not.toBeNull() + rerender({ symbol: undefined }) + expect(result.current).toBeNull() + }) +}) diff --git a/apps/web/src/features/trade/hooks/useSourceFreshness.ts b/apps/web/src/features/trade/hooks/useSourceFreshness.ts new file mode 100644 index 00000000..d444ebef --- /dev/null +++ b/apps/web/src/features/trade/hooks/useSourceFreshness.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react" +import { marketSubscriptionManager } from "../lib/market-data-stream" +import type { FreshnessResult } from "../lib/source-freshness" +import type { SourceHealth } from "./useSourceHealth" + +export type SourceFreshnessView = { + health: SourceHealth + freshness: FreshnessResult +} + +/** How often freshness is re-evaluated. Silence has no event, so it has to be polled. */ +export const FRESHNESS_TICK_MS = 1_000 + +/** + * OB-049: Live source health and freshness for a symbol's order book feed. + * + * Staleness is the ABSENCE of messages, which raises no event, so the state is + * re-evaluated on a timer as well as whenever the book changes. Re-renders are + * limited to visible changes: a new state, reason or message, or a change in + * the whole seconds shown once the feed is not fresh. + */ +export function useSourceFreshness(symbol: string | undefined): SourceFreshnessView | null { + const [view, setView] = useState(null) + + useEffect(() => { + if (!symbol) { + setView(null) + return + } + + const shared = marketSubscriptionManager.getOrCreate(symbol) + let lastKey = "" + + const evaluate = () => { + const next = shared.getSourceHealth(Date.now()) + const { freshness, health } = next + const shownSeconds = + freshness.state === "fresh" || health.staleDuration === null + ? "" + : Math.floor(health.staleDuration / 1000) + const key = `${freshness.state}|${freshness.reason}|${freshness.canSeedQuote}|${health.message}|${shownSeconds}` + if (key === lastKey) return + lastKey = key + setView(next) + } + + // Subscribing keeps the shared stream alive and re-evaluates on every book change. + const unsubscribe = shared.subscribeBook(evaluate) + const timer = setInterval(evaluate, FRESHNESS_TICK_MS) + evaluate() + + return () => { + clearInterval(timer) + unsubscribe() + } + }, [symbol]) + + return view +} diff --git a/apps/web/src/features/trade/lib/market-data-stream.freshness.test.ts b/apps/web/src/features/trade/lib/market-data-stream.freshness.test.ts new file mode 100644 index 00000000..fcccb43a --- /dev/null +++ b/apps/web/src/features/trade/lib/market-data-stream.freshness.test.ts @@ -0,0 +1,240 @@ +// OB-049: the shared market subscription must report health from what it +// actually receives. These tests drive the real subscription through a fake +// WebSocket, with the REST depth snapshot served by an explicit MSW handler, +// and control the clock by faking `Date` only (timers stay real, so MSW and +// the stream's own watchdogs are undisturbed). +// +// Mocked-transport tests: nothing here talks to Binance. They pin how the +// stream classifies quiet markets, dead transports, malformed frames and a +// lagging provider; they do not verify the live venue. + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" +import { HttpResponse, http } from "msw" +import { setupServer } from "msw/node" +import { marketSubscriptionManager } from "./market-data-stream" +import { quoteFromDepth } from "./source-freshness" + +const server = setupServer() +beforeAll(() => server.listen({ onUnhandledRequest: "error" })) +afterAll(() => server.close()) + +const T0 = new Date("2026-01-01T00:00:00.000Z").getTime() +const SYMBOL = "FRESHTEST" + +class FakeWebSocket { + static OPEN = 1 + static instances: Array = [] + readyState = 1 + onopen: (() => void) | null = null + onmessage: ((evt: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: (() => void) | null = null + sent: Array = [] + + constructor(public url: string) { + FakeWebSocket.instances.push(this) + } + send(data: string) { + this.sent.push(data) + } + close() { + this.readyState = 3 + } + // Test helpers + open() { + this.onopen?.() + } + frame(payload: unknown) { + this.onmessage?.({ data: typeof payload === "string" ? payload : JSON.stringify(payload) }) + } + drop() { + this.onclose?.() + } +} + +function serveSnapshot(lastUpdateId = 100) { + server.use( + http.get("https://api.binance.com/api/v3/depth", () => + HttpResponse.json({ + lastUpdateId, + bids: [["99.00", "1"], ["98.00", "2"]], + asks: [["101.00", "1"], ["102.00", "2"]], + }), + ), + ) +} + +const ack = (id = 1) => ({ result: null, id }) +const depth = (u: number, providerTime?: number) => ({ + e: "depthUpdate", + ...(providerTime === undefined ? {} : { E: providerTime }), + u, + b: [["99.00", "1.5"]], + a: [], +}) + +let unsubscribe: (() => void) | null = null + +/** Subscribe to the book, connect the fake socket and wait for the snapshot to apply. */ +async function connectedFeed() { + serveSnapshot() + const shared = marketSubscriptionManager.getOrCreate(SYMBOL) + unsubscribe = shared.subscribeBook(() => {}) + const ws = FakeWebSocket.instances.at(-1)! + ws.open() + await vi.waitFor(() => expect(shared.getBookState().bids.length).toBeGreaterThan(0)) + return { shared, ws } +} + +beforeEach(() => { + FakeWebSocket.instances = [] + vi.stubGlobal("WebSocket", FakeWebSocket) + vi.useFakeTimers({ toFake: ["Date"], now: T0 }) +}) + +afterEach(() => { + unsubscribe?.() + unsubscribe = null + marketSubscriptionManager.removeInstance(SYMBOL) + server.resetHandlers() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +const advance = (ms: number) => vi.setSystemTime(Date.now() + ms) + +describe("source freshness from the live stream", () => { + it("is unavailable until the snapshot has been applied", async () => { + serveSnapshot() + const shared = marketSubscriptionManager.getOrCreate(SYMBOL) + unsubscribe = shared.subscribeBook(() => {}) + expect(shared.getFreshness()).toMatchObject({ state: "unavailable", reason: "connecting" }) + expect(shared.getFreshness().canSeedQuote).toBe(false) + + // Let the in-flight snapshot request settle so it cannot outlive this test. + await vi.waitFor(() => expect(shared.getBookState().bids.length).toBeGreaterThan(0)) + }) + + it("is fresh once connected with a snapshot, and can seed a quote from the book", async () => { + const { shared } = await connectedFeed() + const freshness = shared.getFreshness() + expect(freshness).toMatchObject({ state: "fresh", canSeedQuote: true }) + expect(quoteFromDepth(shared.getBookState(), freshness)).toEqual({ bid: 99, ask: 101, mid: 100 }) + }) + + it("stays fresh through minutes with no book changes while acks keep arriving (no trades)", async () => { + const { shared, ws } = await connectedFeed() + for (let i = 0; i < 24; i++) { + advance(10_000) + ws.frame(ack(i + 2)) + } + const freshness = shared.getFreshness() + expect(freshness).toMatchObject({ state: "fresh", reason: "quiet-market", canSeedQuote: true }) + expect(freshness.lastValidUpdateAgeMs).toBeGreaterThanOrEqual(240_000) + }) + + it("goes stale when the transport says open but goes silent", async () => { + const { shared } = await connectedFeed() + advance(21_000) + const freshness = shared.getFreshness() + expect(freshness).toMatchObject({ state: "stale", reason: "transport-silent", canSeedQuote: false }) + expect(quoteFromDepth(shared.getBookState(), freshness)).toBeNull() + }) + + it("is reconnecting after the socket drops, and its stale depth cannot seed a quote", async () => { + const { shared, ws } = await connectedFeed() + ws.drop() + advance(500) + const freshness = shared.getFreshness() + expect(freshness).toMatchObject({ state: "reconnecting", canSeedQuote: false }) + expect(shared.getSourceHealth().health.status).toBe("reconnecting") + expect(quoteFromDepth(shared.getBookState(), freshness)).toBeNull() + }) + + it("counts malformed frames, keeps the last valid book, and reports degraded", async () => { + const { shared, ws } = await connectedFeed() + const before = shared.getBookState().bids.map((l) => l.price) + + ws.frame("not json at all") + ws.frame("null") + ws.frame({ e: "depthUpdate", u: 101, b: "oops", a: [] }) // wrong shape + advance(1_000) + + expect(shared.getFreshness()).toMatchObject({ + state: "stale", + reason: "malformed-updates", + canSeedQuote: false, + }) + // The bad frames never touched the book. + expect(shared.getBookState().bids.map((l) => l.price)).toEqual(before) + // And the transport is clearly alive: this is not the dead-transport case. + expect(shared.getFreshness().lastMessageAgeMs).toBeLessThan(2_000) + }) + + it("recovers from a malformed streak with the next valid update", async () => { + const { shared, ws } = await connectedFeed() + ws.frame("garbage") + ws.frame("garbage") + ws.frame("garbage") + expect(shared.getFreshness().reason).toBe("malformed-updates") + + ws.frame(depth(101, Date.now() - 30)) + expect(shared.getFreshness()).toMatchObject({ state: "fresh", canSeedQuote: true }) + }) + + it("flags a provider that delivers updates far later than their timestamps", async () => { + const { shared, ws } = await connectedFeed() + for (let i = 0; i < 4; i++) { + advance(100) + ws.frame(depth(101 + i, Date.now() - 30)) // normal ~30ms delivery + } + expect(shared.getFreshness().state).toBe("fresh") + + advance(100) + ws.frame(depth(200, Date.now() - 9_000)) // 9s old on arrival + expect(shared.getFreshness()).toMatchObject({ + state: "stale", + reason: "provider-lagging", + canSeedQuote: false, + }) + }) + + it("does not read a constant clock offset as lag", async () => { + const { shared, ws } = await connectedFeed() + const offset = 15 * 60_000 // provider clock 15 minutes behind ours + for (let i = 0; i < 6; i++) { + advance(100) + ws.frame(depth(101 + i, Date.now() - offset - 30)) + } + expect(shared.getFreshness()).toMatchObject({ state: "fresh", providerLagMs: 0 }) + }) + + it("ignores a replayed old revision for freshness", async () => { + const { shared, ws } = await connectedFeed() + ws.frame(depth(150, Date.now() - 30)) + advance(15_000) + ws.frame(depth(120, Date.now() - 30)) // older revision replayed + const freshness = shared.getFreshness() + // The replay proves the transport is alive but does not make the book newer. + expect(freshness.lastMessageAgeMs).toBe(0) + expect(freshness.lastValidUpdateAgeMs).toBe(15_000) + }) + + it("requires a fresh snapshot after a reconnect before depth may seed a quote again", async () => { + const { shared, ws } = await connectedFeed() + ws.drop() + // Reconnect: a new socket opens and the depth snapshot is refetched. + serveSnapshot(500) + vi.useRealTimers() + await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBeGreaterThan(1), { timeout: 3_000 }) + vi.useFakeTimers({ toFake: ["Date"], now: T0 + 1_000 }) + + const next = FakeWebSocket.instances.at(-1)! + next.open() + // Between the socket opening and the snapshot landing, depth is unusable. + expect(shared.getFreshness()).toMatchObject({ state: "unavailable", reason: "awaiting-snapshot" }) + + await vi.waitFor(() => expect(shared.getFreshness().canSeedQuote).toBe(true)) + expect(shared.getFreshness().state).toBe("fresh") + }) +}) diff --git a/apps/web/src/features/trade/lib/market-data-stream.ts b/apps/web/src/features/trade/lib/market-data-stream.ts index a29ab9dd..6a0fdae9 100644 --- a/apps/web/src/features/trade/lib/market-data-stream.ts +++ b/apps/web/src/features/trade/lib/market-data-stream.ts @@ -1,7 +1,20 @@ import { BINANCE_PERIOD, BINANCE_SYMBOL, fetchOracleCandles, type OhlcBar } from "./oracle" +import { + DEFAULT_FRESHNESS_POLICY, + createTelemetry, + deriveFreshness, + recordMalformed, + recordMessage, + recordSnapshot, + recordValidUpdate, + resetSnapshot, + toSourceHealth, +} from "./source-freshness" +import type { FeedTelemetry, FeedTransport, FreshnessResult } from "./source-freshness" import type { OrderBookLevel, OrderBookState } from "../hooks/useOrderBook" import type { TradeItem, UseRecentTradesResult } from "../hooks/useRecentTrades" import { deduplicateAndSortTrades } from "../hooks/useRecentTrades" +import type { SourceHealth } from "../hooks/useSourceHealth" export type StreamStatus = "connecting" | "connected" | "polling" | "disconnected" | "error" @@ -27,6 +40,13 @@ type RawBook = { lastUpdateId: number } +/** A depth frame must carry both sides as arrays and a numeric revision to be applied. */ +export function isValidDepthMsg(data: unknown): boolean { + if (typeof data !== "object" || data === null) return false + const m = data as { b?: unknown; a?: unknown; u?: unknown } + return Array.isArray(m.b) && Array.isArray(m.a) && typeof m.u === "number" && Number.isFinite(m.u) +} + export function applyDelta(map: Map, entries: Array<[string, string]>) { for (const [price, size] of entries) { if (parseFloat(size) === 0) { @@ -57,6 +77,8 @@ export function buildLevels(map: Map, ascending: boolean): Array type BinanceDiffMsg = { e?: string + /** Provider event time (ms since epoch), when present. */ + E?: number u: number b: Array<[string, string]> a: Array<[string, string]> @@ -134,6 +156,10 @@ class SharedMarketSubscription { isLoading: true, } + // OB-049: connection health, last message, last valid revision and provider + // timestamps, tracked separately so a quiet market is never read as a dead feed. + private telemetry: FeedTelemetry = createTelemetry() + // Trades state private currentTradesResult: UseRecentTradesResult = { trades: [], @@ -171,6 +197,34 @@ class SharedMarketSubscription { return this.liveBars.get(period) ?? null } + /** OB-049: Transport state, derived from the stream status so there is one source of truth. */ + private feedTransport(): FeedTransport { + switch (this.status) { + case "connected": + case "polling": // polling refreshes `lastMessageAt` with every successful fetch + return "open" + case "disconnected": + return "closed" + case "error": + return "error" + case "connecting": + return "connecting" + } + } + + /** OB-049: Fresh / stale / reconnecting / unavailable for the book, with the reason. */ + public getFreshness(now: number = Date.now()): FreshnessResult { + this.telemetry.transport = this.feedTransport() + this.telemetry.reconnectAttempt = this.reconnectAttempt + return deriveFreshness(this.telemetry, now, DEFAULT_FRESHNESS_POLICY) + } + + /** OB-049: The freshness result in the shape the source health badge renders. */ + public getSourceHealth(now: number = Date.now()): { health: SourceHealth; freshness: FreshnessResult } { + const freshness = this.getFreshness(now) + return { health: toSourceHealth(freshness, this.telemetry, now), freshness } + } + /** OB-113: Exposed for testing — number of WS reconnect attempts made. */ public getReconnectAttempt(): number { return this.reconnectAttempt @@ -377,6 +431,7 @@ class SharedMarketSubscription { // snapshot's lastUpdateId is known. if (this.bookSubscribers.size > 0) { this.snapshotLoaded = false + resetSnapshot(this.telemetry) this.bookBuffer = [] void this.fetchDepthSnapshot() } @@ -386,6 +441,7 @@ class SharedMarketSubscription { if (this.isDestroyed) return // OB-113: Any arriving message proves the connection is alive. this.resetHeartbeat() + recordMessage(this.telemetry, Date.now()) this.handleWsMessage(evt.data) } @@ -440,10 +496,17 @@ class SharedMarketSubscription { private handleWsMessage(raw: unknown) { try { const data = typeof raw === "string" ? JSON.parse(raw) : raw - if (!data) return + if (!data || typeof data !== "object") { + recordMalformed(this.telemetry, Date.now()) + return + } // Depth event if (data.e === "depthUpdate" || (data.b && data.a && data.u)) { + if (!isValidDepthMsg(data)) { + recordMalformed(this.telemetry, Date.now()) + return + } this.handleDepthUpdate(data as BinanceDiffMsg) } // Trade event @@ -455,11 +518,18 @@ class SharedMarketSubscription { this.handleKlineUpdate(data as BinanceKlineMsg) } } catch { - // Ignore malformed payloads + // Malformed payloads never reach the book; they are counted so a feed + // that only sends garbage is reported as degraded. + recordMalformed(this.telemetry, Date.now()) } } private handleDepthUpdate(msg: BinanceDiffMsg) { + recordValidUpdate(this.telemetry, DEFAULT_FRESHNESS_POLICY, { + receivedAt: Date.now(), + revision: msg.u, + providerTimestamp: typeof msg.E === "number" ? msg.E : null, + }) if (this.snapshotLoaded) { applyDelta(this.book.bids, msg.b) applyDelta(this.book.asks, msg.a) @@ -585,8 +655,14 @@ class SharedMarketSubscription { applyDelta(this.book.bids, msg.b) applyDelta(this.book.asks, msg.a) } + // The book is at the newest revision seen, including buffered updates. + const newestRevision = this.bookBuffer.reduce( + (max, msg) => Math.max(max, msg.u), + this.book.lastUpdateId, + ) this.bookBuffer = [] this.snapshotLoaded = true + recordSnapshot(this.telemetry, Date.now(), newestRevision) this.publishBook() } catch { if (this.isDestroyed) return diff --git a/apps/web/src/features/trade/lib/orderbook/conformance/README.md b/apps/web/src/features/trade/lib/orderbook/conformance/README.md new file mode 100644 index 00000000..435adbb5 --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/conformance/README.md @@ -0,0 +1,78 @@ +# Order book feed replay and execution conformance (OB-050) + +A deterministic suite that replays feed and order event streams through the +production code and checks that every route ends at the authoritative final +state. Run it with: + +```bash +bun run --cwd apps/web test -- src/features/trade/lib/orderbook/conformance +``` + +## What is tested, and against which versions + +| Subject | Module | Last changed at | +|---|---|---| +| Book reconciliation (snapshot, delta, gap, reset) | `lib/orderbook/book-reducer.ts` | `c1c9e6c` | +| Display depth (used for the spread check) | `lib/orderbook/depth.ts` | `a4c0386` | +| Freshness and quote gating | `lib/source-freshness.ts` | this PR | +| Order lifecycle stage derivation | `lib/order-lifecycle.ts` | `2ce96b9` | +| Indexer `Order` entity the lifecycle statuses come from | `apps/s03-indexer/schema.graphql` | `652d4e6` | + +Suite written against `upstream/main` at `72fcb98` (2026-09-26). The reducer +models revisions as one strictly increasing integer where a delta is valid only +directly after its predecessor; that is the schema this suite covers. There is +no deployment identifier because nothing here connects to a deployment. + +## What the scenarios cover + +- **Duplicates:** deltas delivered two or three times, duplicated snapshots, and + duplicates buffered before a snapshot. +- **Disconnect and reconnect:** the book is discarded and not actionable until a + fresh snapshot arrives; buffered deltas the snapshot already covers are dropped + and the rest replayed in revision order; repeated disconnects start clean. +- **Missing revisions:** a hole makes the book non-actionable and reports the + expected and received revisions; nothing past the hole is applied; a late + arrival of the missing revision does not repair it; only a snapshot does. +- **Partial fills:** a partial fill is never presented as filled; fills are + counted once by event id; over-fills are capped. +- **Cancellation races:** cancel-versus-fill outcomes are decided by the + authoritative sequence, not by arrival order. Every arrival permutation of + each race is checked, and all give the same final stage. +- **Randomized convergence:** seeded, so identical on every machine. Any + duplicated, reordered delivery that ends in a covering snapshot converges; an + in-order stream with replays converges without one; an unsorted burst + buffered before the snapshot replays in revision order. + +The expected book comes from an independent oracle (`feed-replay.ts`), not from +the reducer under test. Mutation checks were run against the suite: breaking the +gap check, the duplicate check, or the reset in the reducer, or making the stage +derivation call a partial fill filled, each makes it fail. + +## What is mocked, and what is not verified + +**Everything here is mocked.** Fixtures are hand-built in +`conformance.test.ts`. They are modelled on how a level-2 book stream behaves and +are not captured from any venue. No network is used. + +**Not verified against a live upstream** (nothing in this PR claims otherwise): + +- that any real venue's messages map onto the reducer's revision model; +- the real ordering guarantees of the indexer's order and fill events; +- real reconnect timing, rate limiting or snapshot latency. + +**Order and fill events use a reference fold** (`order-fold.ts`, test support, +not production code) because the app does not yet consume the indexer's +order/fill events as a stream. The fold states the rules the account UI relies +on; the production part under test is `deriveOrderLifecycleStage`. When the +indexer's order/fill data is consumed directly (OB-047), the fold should be +replaced by the production reducer and these scenarios kept as its tests. + +**Book revisions are not linked to orders or fills.** The issue asks for that +"where the source exposes that relationship". The indexer's `Order` entity +carries ledger numbers and timestamps, not book revisions, so there is no +relationship to test. Ordering between orders and fills is by ledger sequence. + +## Extending it + +Add a scenario as steps in `conformance.test.ts` and, for books, compare with +`oracleBook`. Keep randomness seeded via `mulberry32` so failures reproduce. diff --git a/apps/web/src/features/trade/lib/orderbook/conformance/conformance.test.ts b/apps/web/src/features/trade/lib/orderbook/conformance/conformance.test.ts new file mode 100644 index 00000000..6cb997c0 --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/conformance/conformance.test.ts @@ -0,0 +1,354 @@ +// OB-050: feed replay and execution conformance. +// +// Every test here is a MOCKED, deterministic test over fixtures built in this +// file. Nothing talks to a venue or an indexer. See README.md in this directory +// for the versions covered and for what has NOT been verified against a live +// upstream. + +import { describe, expect, it } from "vitest" +import { buildDepth } from "../depth" +import { + DEFAULT_FRESHNESS_POLICY as POLICY, + createTelemetry, + deriveFreshness, + recordMessage, + recordSnapshot, + recordTransport, + recordValidUpdate, + resetSnapshot, +} from "../../source-freshness" +import { foldOrderEvents } from "./order-fold" +import { mulberry32, oracleBook, replayFeed, shuffled, viewOf } from "./feed-replay" +import type {OrderEvent} from "./order-fold"; +import type {FeedStep} from "./feed-replay"; +import type { BookDelta, BookLevel, BookSnapshot } from "../book-reducer" + +const L = (price: bigint, size: bigint): BookLevel => ({ price, size }) + +const snap = (revision: bigint, bids: Array, asks: Array): BookSnapshot => ({ revision, bids, asks }) +const delta = (revision: bigint, bids: Array = [], asks: Array = []): BookDelta => ({ revision, bids, asks }) + +// A short, hand-checkable book history. Prices are integer atoms. +const S100 = snap(100n, [L(100n, 5n), L(99n, 4n)], [L(101n, 5n), L(102n, 4n)]) +const D101 = delta(101n, [L(100n, 7n)], []) +const D102 = delta(102n, [], [L(101n, 0n), L(103n, 9n)]) // removes 101, adds 103 +const D103 = delta(103n, [L(98n, 3n), L(99n, 0n)], []) +const D104 = delta(104n, [L(100n, 0n)], [L(102n, 6n)]) +const D105 = delta(105n, [L(100n, 2n)], []) +const ALL = [D101, D102, D103, D104, D105] + +const step = (d: BookDelta): FeedStep => ({ kind: "delta", delta: d }) +const snapStep = (s: BookSnapshot): FeedStep => ({ kind: "snapshot", snapshot: s }) + +describe("baseline: the oracle agrees with the reducer on a clean stream", () => { + it("converges to the oracle book, live and actionable", () => { + const { state } = replayFeed([snapStep(S100), ...ALL.map(step)]) + expect(viewOf(state)).toEqual(oracleBook([S100], ALL)) + expect(state).toMatchObject({ status: "live", actionable: true, revision: 105n, ignored: 0 }) + }) + + it("hand-checked final book", () => { + const { state } = replayFeed([snapStep(S100), ...ALL.map(step)]) + // 100: 5 -> 7 -> 0 -> 2 ; 99 removed at 103 ; 98 added at 103 + expect(viewOf(state).bids).toEqual([L(100n, 2n), L(98n, 3n)]) + // 101 removed at 102 ; 103 added ; 102: 4 -> 6 + expect(viewOf(state).asks).toEqual([L(102n, 6n), L(103n, 9n)]) + }) +}) + +describe("duplicates", () => { + it("every delta delivered two or three times converges and stays actionable", () => { + const steps: Array = [snapStep(S100)] + ALL.forEach((d, i) => { + steps.push(step(d), step(d)) + if (i % 2 === 0) steps.push(step(d)) + }) + const { state } = replayFeed(steps) + expect(viewOf(state)).toEqual(oracleBook([S100], ALL)) + expect(state.actionable).toBe(true) + // 5 deltas: 2 extra copies each, +1 for the three that were sent three times. + expect(state.ignored).toBe(5 + 3) + }) + + it("a duplicated snapshot changes nothing", () => { + const { state } = replayFeed([snapStep(S100), snapStep(S100), ...ALL.map(step), snapStep(S100)]) + // The last (older) snapshot is ignored once the book is live and ahead of it. + expect(viewOf(state)).toEqual(oracleBook([S100], ALL)) + }) + + it("duplicates buffered before the snapshot are collapsed", () => { + const { state } = replayFeed([step(D101), step(D101), step(D102), snapStep(S100)]) + expect(viewOf(state)).toEqual(oracleBook([S100], [D101, D102])) + expect(state.status).toBe("live") + }) +}) + +describe("disconnect and reconnect", () => { + it("is not actionable from the disconnect until a fresh snapshot is applied", () => { + const trace = replayFeed([ + snapStep(S100), + step(D101), + step(D102), + { kind: "disconnect" }, + step(D103), // arrives on the new connection before its snapshot + step(D104), + ]) + expect(trace.states[2].actionable).toBe(true) + for (const s of trace.states.slice(3)) expect(s.actionable).toBe(false) + expect(trace.state.status).toBe("awaiting_snapshot") + expect(trace.state.bids.size + trace.state.asks.size).toBe(0) // no stale book is kept + }) + + it("converges to the authoritative book once the reconnect snapshot arrives", () => { + const reconnectSnapshot = snap(103n, [L(100n, 7n), L(98n, 3n)], [L(102n, 4n), L(103n, 9n)]) + const { state } = replayFeed([ + snapStep(S100), + step(D101), + { kind: "disconnect" }, + step(D103), + step(D104), + step(D105), + snapStep(reconnectSnapshot), + ]) + expect(state).toMatchObject({ status: "live", actionable: true, revision: 105n }) + expect(viewOf(state)).toEqual(oracleBook([reconnectSnapshot], [D104, D105])) + }) + + it("drops buffered deltas the reconnect snapshot already includes", () => { + const covering = snap(104n, [L(98n, 3n)], [L(102n, 6n), L(103n, 9n)]) + const { state } = replayFeed([{ kind: "disconnect" }, step(D101), step(D103), step(D104), step(D105), snapStep(covering)]) + expect(state.revision).toBe(105n) + expect(state.ignored).toBeGreaterThanOrEqual(3) + expect(viewOf(state)).toEqual(oracleBook([covering], [D105])) + }) + + it("repeated disconnects each start clean", () => { + const { state } = replayFeed([ + snapStep(S100), + { kind: "disconnect" }, + { kind: "disconnect" }, + snapStep(S100), + ...ALL.map(step), + ]) + expect(viewOf(state)).toEqual(oracleBook([S100], ALL)) + }) + + it("freshness agrees: no quote-able depth while disconnected, again after the snapshot", () => { + const t = createTelemetry() + recordTransport(t, "open") + recordSnapshot(t, 1_000, 100) + recordValidUpdate(t, POLICY, { receivedAt: 1_100, revision: 101, providerTimestamp: 1_060 }) + expect(deriveFreshness(t, 1_200).canSeedQuote).toBe(true) + + recordTransport(t, "closed", 1) + resetSnapshot(t) + expect(deriveFreshness(t, 2_000).canSeedQuote).toBe(false) + + recordTransport(t, "open", 0) + recordMessage(t, 3_000) + expect(deriveFreshness(t, 3_001)).toMatchObject({ state: "unavailable", reason: "awaiting-snapshot", canSeedQuote: false }) + + recordSnapshot(t, 3_100, 103) + expect(deriveFreshness(t, 3_200).canSeedQuote).toBe(true) + }) +}) + +describe("missing revisions", () => { + it("a hole makes the book non-actionable and reports exactly what was expected", () => { + const { state } = replayFeed([snapStep(S100), step(D101), step(D103)]) // 102 never arrives + expect(state).toMatchObject({ status: "gap", actionable: false }) + expect(state.gap).toEqual({ expected: 102n, received: 103n }) + }) + + it("does not apply anything past the hole, so the last good book is not corrupted", () => { + const good = replayFeed([snapStep(S100), step(D101)]).state + const { state } = replayFeed([snapStep(S100), step(D101), step(D103), step(D104)]) + expect(viewOf(state).bids).toEqual(viewOf(good).bids) + expect(viewOf(state).asks).toEqual(viewOf(good).asks) + expect(state.revision).toBe(101n) + }) + + it("a late arrival of the missing revision does not repair a gap on its own", () => { + const { state } = replayFeed([snapStep(S100), step(D101), step(D103), step(D102)]) + expect(state.status).toBe("gap") + expect(state.actionable).toBe(false) + }) + + it("recovers only through a snapshot, and converges to the authoritative book", () => { + const recovery = snap(104n, [L(98n, 3n)], [L(102n, 6n), L(103n, 9n)]) + const { state } = replayFeed([snapStep(S100), step(D101), step(D103), step(D105), snapStep(recovery)]) + expect(state).toMatchObject({ status: "live", actionable: true, revision: 105n }) + expect(viewOf(state)).toEqual(oracleBook([recovery], [D105])) + }) + + it("a recovery snapshot that still leaves a hole stays non-actionable", () => { + const short = snap(101n, [L(100n, 7n), L(99n, 4n)], [L(101n, 5n), L(102n, 4n)]) + const { state } = replayFeed([snapStep(S100), step(D101), step(D103), step(D105), snapStep(short)]) + // 102 and 104 are still missing relative to what was buffered. + expect(state.actionable).toBe(false) + }) + + it("a gap never yields a quote-able book by any later delta", () => { + const { states } = replayFeed([snapStep(S100), step(D101), step(D103), step(D104), step(D105)]) + for (const s of states.slice(2)) expect(s.actionable).toBe(false) + }) +}) + +describe("partial fills are not fully filled", () => { + const created = (size = 100): OrderEvent => ({ eventId: "e1", sequence: 1, type: "created", sizeUsd: size }) + const fill = (id: string, seq: number, size: number): OrderEvent => ({ eventId: id, sequence: seq, type: "fill", sizeUsd: size }) + + it("a partial fill is presented as partially filled, never filled", () => { + const v = foldOrderEvents([created(), fill("f1", 2, 40)]) + expect(v).toMatchObject({ stage: "partially-filled", filledSizeUsd: 40, originalSizeUsd: 100 }) + }) + + it("fills that sum to the order size complete it", () => { + const v = foldOrderEvents([created(), fill("f1", 2, 40), fill("f2", 3, 60)]) + expect(v).toMatchObject({ stage: "filled", filledSizeUsd: 100 }) + }) + + it("a duplicated fill event is counted once, so it cannot fake completion", () => { + const v = foldOrderEvents([created(), fill("f1", 2, 60), fill("f1", 2, 60)]) + expect(v).toMatchObject({ stage: "partially-filled", filledSizeUsd: 60, duplicates: 1 }) + }) + + it("an order the indexer has not reported is pending, not accepted or filled", () => { + expect(foldOrderEvents([fill("f1", 2, 10)]).stage).toBe("pending") + }) + + it("an over-fill is capped at the order size", () => { + const v = foldOrderEvents([created(), fill("f1", 2, 70), fill("f2", 3, 70)]) + expect(v).toMatchObject({ stage: "filled", filledSizeUsd: 100 }) + }) +}) + +describe("cancellation races converge to the authoritative outcome", () => { + const created: OrderEvent = { eventId: "e1", sequence: 1, type: "created", sizeUsd: 100 } + const fill = (id: string, seq: number, size: number): OrderEvent => ({ eventId: id, sequence: seq, type: "fill", sizeUsd: size }) + const cancelReq: OrderEvent = { eventId: "c1", sequence: 3, type: "cancel-requested" } + + it("cancel requested while resting is shown as pending cancellation, never as cancelled", () => { + expect(foldOrderEvents([created, cancelReq]).stage).toBe("pending-cancellation") + }) + + it("cancel confirmed before the rest fills: cancelled, and the partial fill is kept", () => { + const v = foldOrderEvents([created, fill("f1", 2, 30), cancelReq, { eventId: "c2", sequence: 4, type: "cancel-confirmed" }]) + expect(v).toMatchObject({ stage: "cancelled", filledSizeUsd: 30 }) + }) + + it("the rest fills before the cancel is sequenced: filled, and the cancel is a no-op", () => { + const v = foldOrderEvents([ + created, + fill("f1", 2, 30), + fill("f2", 3, 70), // sequenced before the cancel request below + { eventId: "c1", sequence: 4, type: "cancel-requested" }, + { eventId: "c2", sequence: 5, type: "cancel-confirmed" }, + ]) + expect(v).toMatchObject({ stage: "filled", filledSizeUsd: 100 }) + }) + + it("a cancel rejected by the venue returns the order to its resting stage", () => { + const v = foldOrderEvents([created, fill("f1", 2, 30), cancelReq, { eventId: "c2", sequence: 4, type: "cancel-rejected" }]) + expect(v.stage).toBe("partially-filled") + }) + + it("a fill sequenced after the order was cancelled is not counted", () => { + const v = foldOrderEvents([ + created, + { eventId: "c1", sequence: 2, type: "cancel-requested" }, + { eventId: "c2", sequence: 3, type: "cancel-confirmed" }, + fill("late", 4, 50), + ]) + expect(v).toMatchObject({ stage: "cancelled", filledSizeUsd: 0 }) + }) + + it("never claims filled for an order whose remainder was cancelled", () => { + const v = foldOrderEvents([created, fill("f1", 2, 99), cancelReq, { eventId: "c2", sequence: 4, type: "cancel-confirmed" }]) + expect(v.stage).toBe("cancelled") + expect(v.filledSizeUsd).toBeLessThan(v.originalSizeUsd) + }) + + // The strongest check: however the events reach us, the outcome is the same. + function permutations(items: ReadonlyArray): Array> { + if (items.length <= 1) return [[...items]] + return items.flatMap((item, i) => permutations([...items.slice(0, i), ...items.slice(i + 1)]).map((rest) => [item, ...rest])) + } + + it.each([ + ["fill wins the race", [created, fill("f1", 2, 40), fill("f2", 3, 60), { eventId: "c1", sequence: 4, type: "cancel-requested" }, { eventId: "c2", sequence: 5, type: "cancel-confirmed" }], "filled"], + ["cancel wins the race", [created, fill("f1", 2, 40), { eventId: "c1", sequence: 3, type: "cancel-requested" }, { eventId: "c2", sequence: 4, type: "cancel-confirmed" }, fill("late", 5, 60)], "cancelled"], + ["cancel rejected", [created, fill("f1", 2, 40), { eventId: "c1", sequence: 3, type: "cancel-requested" }, { eventId: "c2", sequence: 4, type: "cancel-rejected" }], "partially-filled"], + ] as Array<[string, Array, string]>)("%s: every one of the arrival orders gives the same final state", (_label, events, expected) => { + const outcomes = new Set() + for (const arrival of permutations(events)) { + const v = foldOrderEvents(arrival) + outcomes.add(`${v.stage}|${v.filledSizeUsd}`) + } + expect(outcomes.size).toBe(1) + expect([...outcomes][0].split("|")[0]).toBe(expected) + }) + + it("duplicating every event in any order still converges", () => { + const events: Array = [created, fill("f1", 2, 40), fill("f2", 3, 60)] + const doubled = [...events, ...events] + for (const arrival of permutations(doubled).slice(0, 200)) { + expect(foldOrderEvents(arrival)).toMatchObject({ stage: "filled", filledSizeUsd: 100 }) + } + }) +}) + +describe("randomized convergence (seeded, deterministic)", () => { + function randomHistory(seed: number, count: number) { + const random = mulberry32(seed) + const level = () => L(90n + BigInt(Math.floor(random() * 20)), BigInt(Math.floor(random() * 6))) // size 0 removes + const deltas: Array = [] + for (let i = 1; i <= count; i++) { + deltas.push(delta(100n + BigInt(i), [level(), level()], [level(), level()].map((l) => L(l.price + 15n, l.size)))) + } + return { random, deltas } + } + + it.each([1, 2, 3, 7, 42, 99, 1234, 20260926])("seed %s: any duplicated, reordered delivery ending in a covering snapshot converges", (seed) => { + const { random, deltas } = randomHistory(seed, 25) + const authoritative = oracleBook([S100], deltas) + const finalSnapshot = snap( + authoritative.revision, + authoritative.bids, + authoritative.asks + ) + + const noisy = shuffled([...deltas, ...deltas.filter(() => random() < 0.5)], random) + const { state } = replayFeed([snapStep(S100), ...noisy.map(step), snapStep(finalSnapshot)]) + + expect(state).toMatchObject({ status: "live", actionable: true, revision: authoritative.revision }) + expect(viewOf(state)).toEqual(authoritative) + }) + + it.each([5, 6, 8, 13])("seed %s: an in-order stream with duplicates interleaved converges without a snapshot", (seed) => { + const { random, deltas } = randomHistory(seed, 30) + const steps: Array = [snapStep(S100)] + for (const d of deltas) { + steps.push(step(d)) + while (random() < 0.4) steps.push(step(deltas[Math.floor(random() * deltas.indexOf(d) + 1) - 1] ?? d)) // replay something already seen + } + const { state } = replayFeed(steps) + expect(viewOf(state)).toEqual(oracleBook([S100], deltas)) + expect(state.actionable).toBe(true) + }) + + it.each([3, 11, 21])("seed %s: an unsorted burst buffered before the snapshot replays in revision order", (seed) => { + const { random, deltas } = randomHistory(seed, 20) + const { state } = replayFeed([...shuffled(deltas, random).map(step), snapStep(S100)]) + expect(viewOf(state)).toEqual(oracleBook([S100], deltas)) + expect(state.status).toBe("live") + }) + + it("the book never crosses in the fixtures, and the depth spread matches the canonical best prices", () => { + const { state } = replayFeed([snapStep(S100), ...ALL.map(step)]) + const view = viewOf(state) + const depth = buildDepth(view.bids, view.asks, { tick: 1n }) + expect(depth.crossed).toBe(false) + expect(depth.spread).toBe(view.asks[0].price - view.bids[0].price) + }) +}) diff --git a/apps/web/src/features/trade/lib/orderbook/conformance/feed-replay.ts b/apps/web/src/features/trade/lib/orderbook/conformance/feed-replay.ts new file mode 100644 index 00000000..f410897b --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/conformance/feed-replay.ts @@ -0,0 +1,126 @@ +// OB-050: deterministic feed replay for the conformance suite (test support). +// +// A scenario is a list of steps a feed can produce. `replayFeed` folds them +// through the production `bookReducer`; `oracleBook` computes the expected +// final book by a different route (plain maps, revisions sorted and de-duplicated +// up front), so a bug in the reducer cannot make the expectation wrong the same way. + +import { + + + + + + askLevels, + bidLevels, + bookReducer, + createBookState +} from "../book-reducer" +import type {BookAction, BookDelta, BookLevel, BookSnapshot, BookState} from "../book-reducer"; + +export type FeedStep = + | { kind: "snapshot"; snapshot: BookSnapshot } + | { kind: "delta"; delta: BookDelta } + /** The transport dropped: whatever the client held is no longer trustworthy. */ + | { kind: "disconnect" } + +export type ReplayTrace = { + state: BookState + /** The book state after every step, for asserting what was visible along the way. */ + states: Array +} + +function toAction(step: FeedStep): BookAction { + switch (step.kind) { + case "snapshot": + return { type: "snapshot", snapshot: step.snapshot } + case "delta": + return { type: "delta", delta: step.delta } + case "disconnect": + // A reconnecting client discards its book and waits for a fresh snapshot. + return { type: "reset" } + } +} + +export function replayFeed(steps: ReadonlyArray): ReplayTrace { + let state = createBookState() + const states: Array = [] + for (const step of steps) { + state = bookReducer(state, toAction(step)) + states.push(state) + } + return { state, states } +} + +export type BookView = { + revision: bigint + bids: Array + asks: Array +} + +function apply(side: Map, levels: ReadonlyArray) { + for (const { price, size } of levels) { + if (size <= 0n) side.delete(price) + else side.set(price, size) + } +} + +const bestFirst = (side: Map, descending: boolean): Array => + [...side.entries()] + .map(([price, size]) => ({ price, size })) + .sort((a, b) => (a.price === b.price ? 0 : (a.price < b.price) === descending ? 1 : -1)) + +/** + * The book a correct client must end up with: start from the LATEST snapshot, + * then apply every distinct delta above its revision, in revision order. Assumes + * the deltas cover a contiguous range (the caller decides that; a scenario with a + * hole is expressed by ending on a snapshot instead). + */ +export function oracleBook( + snapshots: ReadonlyArray, + deltas: ReadonlyArray +): BookView { + const base = snapshots.reduce((best, s) => (s.revision > best.revision ? s : best)) + const bids = new Map() + const asks = new Map() + apply(bids, base.bids) + apply(asks, base.asks) + + const byRevision = new Map() + for (const delta of deltas) byRevision.set(delta.revision, delta) + const ordered = [...byRevision.values()].filter((d) => d.revision > base.revision).sort((a, b) => (a.revision < b.revision ? -1 : 1)) + + let revision = base.revision + for (const delta of ordered) { + apply(bids, delta.bids) + apply(asks, delta.asks) + revision = delta.revision + } + return { revision, bids: bestFirst(bids, true), asks: bestFirst(asks, false) } +} + +/** The reducer's book in the same shape as `oracleBook`, for direct comparison. */ +export function viewOf(state: BookState): BookView { + return { revision: state.revision ?? -1n, bids: bidLevels(state), asks: askLevels(state) } +} + +/** Small seeded PRNG so "random" scenarios are identical on every run and machine. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export function shuffled(items: ReadonlyArray, random: () => number): Array { + const out = [...items] + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)) + ;[out[i], out[j]] = [out[j], out[i]] + } + return out +} diff --git a/apps/web/src/features/trade/lib/orderbook/conformance/order-fold.ts b/apps/web/src/features/trade/lib/orderbook/conformance/order-fold.ts new file mode 100644 index 00000000..58eb5629 --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/conformance/order-fold.ts @@ -0,0 +1,91 @@ +// OB-050: reference fold of an order's event stream (test support). +// +// This is NOT production code. It states, in one place, the rules the account +// UI relies on so the conformance suite can check the production stage +// derivation (`deriveOrderLifecycleStage`) against every arrival order: +// +// - events are identified by `eventId`; a repeat is the same event, not a new one; +// - the authoritative order of events is `sequence` (ledger order), never arrival order; +// - fills add to the filled size once each; +// - a cancellation that is sequenced AFTER the order is fully filled has no effect, +// and a fill can never be sequenced after the order was cancelled; +// - a cancellation request is visible as "pending cancellation" until it is confirmed +// or rejected. +// +// When the indexer's order/fill schema is consumed directly (OB-047), this fold +// should be replaced by the production reducer and these scenarios kept as its tests. + +import { deriveOrderLifecycleStage } from "../../order-lifecycle" +import type {OrderLifecycleStage} from "../../order-lifecycle"; + +export type OrderEvent = + | { eventId: string; sequence: number; type: "created"; sizeUsd: number } + | { eventId: string; sequence: number; type: "fill"; sizeUsd: number } + | { eventId: string; sequence: number; type: "cancel-requested" } + | { eventId: string; sequence: number; type: "cancel-confirmed" } + | { eventId: string; sequence: number; type: "cancel-rejected" } + +export type OrderView = { + stage: OrderLifecycleStage + originalSizeUsd: number + filledSizeUsd: number + /** Events actually counted (after de-duplication). */ + applied: number + /** Events ignored as repeats. */ + duplicates: number +} + +export function foldOrderEvents(events: ReadonlyArray): OrderView { + const seen = new Set() + const unique: Array = [] + let duplicates = 0 + for (const event of events) { + if (seen.has(event.eventId)) { + duplicates++ + continue + } + seen.add(event.eventId) + unique.push(event) + } + unique.sort((a, b) => a.sequence - b.sequence) + + let original = 0 + let filled = 0 + let cancelRequested = false + let cancelled = false + let created = false + + for (const event of unique) { + switch (event.type) { + case "created": + created = true + original = event.sizeUsd + break + case "fill": + // Nothing can fill an order that is already cancelled or already complete. + if (!cancelled && filled < original) filled = Math.min(original, filled + event.sizeUsd) + break + case "cancel-requested": + if (!cancelled && filled < original) cancelRequested = true + break + case "cancel-confirmed": + if (cancelRequested && !cancelled && filled < original) { + cancelled = true + cancelRequested = false + } + break + case "cancel-rejected": + cancelRequested = false + break + } + } + + const stage = deriveOrderLifecycleStage({ + status: cancelled ? "CANCELLED" : filled >= original && original > 0 ? "EXECUTED" : "CREATED", + awaitingIndex: !created, + pendingCancellation: cancelRequested, + originalSizeUsd: original, + filledSizeUsd: filled, + }) + return { stage, originalSizeUsd: original, filledSizeUsd: filled, applied: unique.length, duplicates } +} diff --git a/apps/web/src/features/trade/lib/orderbook/display-prefs.test.ts b/apps/web/src/features/trade/lib/orderbook/display-prefs.test.ts new file mode 100644 index 00000000..209fb83d --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/display-prefs.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest" +import { buildDepth, groupLevels } from "./depth" +import { + DEFAULT_DISPLAY_PREFS, + GROUPING_MULTIPLIERS, + + formatBucketPrice, + formatScaled, + groupingOptions, + isValidMetadata, + rowSizeDisplay, + sanitizeDisplayPrefs, + tickFor +} from "./display-prefs" +import type {MarketDisplayMetadata} from "./display-prefs"; +import type {DepthRow} from "./depth"; +import type { BookLevel } from "./book-reducer" + +// BTC/USD-like: prices to 2 decimals, sizes to 4, native tick 0.01. +const BTC: MarketDisplayMetadata = { + marketId: "BTC-USD", + baseSymbol: "BTC", + quoteSymbol: "USD", + priceScale: 2, + sizeScale: 4, + tickSize: 1n, +} +// XLM/USD-like: prices to 5 decimals, sizes to 1, native tick 0.00010. +const XLM: MarketDisplayMetadata = { + marketId: "XLM-USD", + baseSymbol: "XLM", + quoteSymbol: "USD", + priceScale: 5, + sizeScale: 1, + tickSize: 10n, +} + +const L = (price: bigint, size: bigint): BookLevel => ({ price, size }) + +describe("formatScaled", () => { + it.each([ + [0n, 2, "0"], + [5n, 2, "0.05"], + [100n, 2, "1"], + [123456n, 2, "1,234.56"], + [1_000_000n, 0, "1,000,000"], + [1n, 5, "0.00001"], + [-1250n, 2, "-12.5"], + ])("formats %s at scale %s as %s", (value, scale, expected) => { + expect(formatScaled(value, scale)).toBe(expected) + }) + + it("keeps a minimum number of decimals", () => { + expect(formatScaled(1200n, 2, 2)).toBe("12.00") + expect(formatScaled(1250n, 2, 1)).toBe("12.5") + }) + + it("is exact beyond Number's safe integer range", () => { + expect(formatScaled(123456789012345678901234567890n, 4)).toBe("12,345,678,901,234,567,890,123,456.789") + }) +}) + +describe("isValidMetadata", () => { + it("accepts a well-formed market", () => { + expect(isValidMetadata(BTC)).toBe(true) + }) + + it.each([ + ["missing", undefined], + ["null", null], + ["zero tick", { ...BTC, tickSize: 0n }], + ["negative tick", { ...BTC, tickSize: -1n }], + ["fractional scale", { ...BTC, priceScale: 1.5 }], + ["negative scale", { ...BTC, sizeScale: -1 }], + ["oversized scale", { ...BTC, priceScale: 19 }], + ["empty symbol", { ...BTC, baseSymbol: "" }], + ["empty id", { ...BTC, marketId: "" }], + ])("rejects %s metadata", (_label, meta) => { + expect(isValidMetadata(meta)).toBe(false) + }) +}) + +describe("groupingOptions", () => { + it("offers multiples of the native tick with exact labels", () => { + expect(groupingOptions(BTC).map((o) => [o.multiplier, o.tick, o.label])).toEqual([ + [1, 1n, "0.01"], + [2, 2n, "0.02"], + [5, 5n, "0.05"], + [10, 10n, "0.1"], + [50, 50n, "0.5"], + [100, 100n, "1"], + [500, 500n, "5"], + [1000, 1000n, "10"], + ]) + }) + + it("scales with a different tick size", () => { + const options = groupingOptions(XLM) + expect(options[0]).toMatchObject({ multiplier: 1, tick: 10n, label: "0.0001" }) + expect(options.at(-1)).toMatchObject({ multiplier: 1000, tick: 10_000n, label: "0.1" }) + }) + + it("offers nothing for unusable metadata so the control can be disabled", () => { + expect(groupingOptions(null)).toEqual([]) + expect(groupingOptions({ ...BTC, tickSize: 0n })).toEqual([]) + }) + + it("every option is an exact multiple of the native tick", () => { + for (const meta of [BTC, XLM]) { + for (const option of groupingOptions(meta)) { + expect(option.tick % meta.tickSize).toBe(0n) + } + } + }) +}) + +describe("sanitizeDisplayPrefs", () => { + it("keeps valid stored preferences", () => { + expect(sanitizeDisplayPrefs({ groupingMultiplier: 10, unit: "quote" }, BTC)).toEqual({ + prefs: { groupingMultiplier: 10, unit: "quote" }, + reset: { grouping: false, unit: false }, + }) + }) + + it("uses defaults, without reporting a reset, when nothing is stored", () => { + for (const raw of [undefined, null]) { + expect(sanitizeDisplayPrefs(raw, BTC)).toEqual({ + prefs: DEFAULT_DISPLAY_PREFS, + reset: { grouping: false, unit: false }, + }) + } + }) + + it("resets a grouping the market no longer offers, and says so", () => { + const result = sanitizeDisplayPrefs({ groupingMultiplier: 7, unit: "quote" }, BTC) + expect(result.prefs).toEqual({ groupingMultiplier: 1, unit: "quote" }) + expect(result.reset).toEqual({ grouping: true, unit: false }) + }) + + it("resets an unknown unit", () => { + const result = sanitizeDisplayPrefs({ groupingMultiplier: 5, unit: "lots" }, BTC) + expect(result.prefs).toEqual({ groupingMultiplier: 5, unit: "base" }) + expect(result.reset).toEqual({ grouping: false, unit: true }) + }) + + it.each([ + ["a string", "garbage"], + ["a number", 42], + ["an array", [1, 2]], + ["wrong field types", { groupingMultiplier: "10", unit: 3 }], + ])("recovers from %s", (_label, raw) => { + const result = sanitizeDisplayPrefs(raw, BTC) + expect(result.prefs).toEqual(DEFAULT_DISPLAY_PREFS) + }) + + it("resets the grouping when the market's capabilities disappear", () => { + const result = sanitizeDisplayPrefs({ groupingMultiplier: 100, unit: "base" }, { ...BTC, tickSize: 0n }) + expect(result.prefs.groupingMultiplier).toBe(1) + expect(result.reset.grouping).toBe(true) + }) + + it("a multiplier survives a switch to a market with a different tick", () => { + const stored = { groupingMultiplier: 10, unit: "quote" } + expect(sanitizeDisplayPrefs(stored, BTC).prefs).toEqual(stored) + expect(sanitizeDisplayPrefs(stored, XLM).prefs).toEqual(stored) + expect(tickFor(stored as never, BTC)).toBe(10n) + expect(tickFor(stored as never, XLM)).toBe(100n) + }) +}) + +describe("grouping matches exact aggregation fixtures", () => { + // Prices in 0.01 atoms. Bids best-first: 100.07, 100.05, 100.04, 99.96 ... Asks: 100.08 ... + const bids = [L(10_007n, 10_000n), L(10_005n, 20_000n), L(10_004n, 5_000n), L(9_996n, 30_000n), L(9_990n, 1_000n)] + const asks = [L(10_008n, 10_000n), L(10_010n, 20_000n), L(10_013n, 5_000n), L(10_020n, 30_000n), L(10_031n, 1_000n)] + + it("groups bids down and asks up to 0.05 (5 ticks) with exact totals", () => { + const bidSide = groupLevels(bids, "bid", { tick: 5n }) + expect(bidSide.rows.map((r) => [r.price, r.size])).toEqual([ + [10_005n, 30_000n], // 100.07 + 100.05 -> 100.05 + [10_000n, 5_000n], // 100.04 -> 100.00 + [9_995n, 30_000n], // 99.96 -> 99.95 + [9_990n, 1_000n], + ]) + const askSide = groupLevels(asks, "ask", { tick: 5n }) + expect(askSide.rows.map((r) => [r.price, r.size])).toEqual([ + [10_010n, 30_000n], // 100.08 + 100.10 -> 100.10 (8 rounds up) + [10_015n, 5_000n], // 100.13 -> 100.15 + [10_020n, 30_000n], + [10_035n, 1_000n], // 100.31 -> 100.35 + ]) + }) + + it("never shows a price better than any level it contains", () => { + for (const option of groupingOptions(BTC)) { + for (const row of groupLevels(bids, "bid", { tick: option.tick }).rows) { + const contained = bids.filter((l) => l.price - (l.price % option.tick) === row.price) + for (const level of contained) expect(row.price).toBeLessThanOrEqual(level.price) + } + for (const row of groupLevels(asks, "ask", { tick: option.tick }).rows) { + const contained = asks.filter((l) => (l.price % option.tick === 0n ? l.price : l.price - (l.price % option.tick) + option.tick) === row.price) + for (const level of contained) expect(row.price).toBeGreaterThanOrEqual(level.price) + } + } + }) + + it("conserves total size and quote value at every grouping", () => { + const totalSize = bids.reduce((s, l) => s + l.size, 0n) + const totalQuote = bids.reduce((s, l) => s + l.price * l.size, 0n) + for (const option of groupingOptions(BTC)) { + const side = groupLevels(bids, "bid", { tick: option.tick }) + expect(side.totalSize).toBe(totalSize) + expect(side.totalQuote).toBe(totalQuote) + } + }) + + it("does not change the canonical book or the spread", () => { + const frozenBids = Object.freeze(bids.map((l) => Object.freeze({ ...l }))) + const frozenAsks = Object.freeze(asks.map((l) => Object.freeze({ ...l }))) + const before = JSON.stringify( + [frozenBids, frozenAsks], + (_k, v) => (typeof v === "bigint" ? v.toString() : v) + ) + const baseline = buildDepth(frozenBids, frozenAsks, { tick: 1n }) + + for (const option of groupingOptions(BTC)) { + const grouped = buildDepth(frozenBids, frozenAsks, { tick: option.tick }) + // Spread is computed from canonical levels, so no grouping can move it. + expect(grouped.spread).toBe(baseline.spread) + expect(grouped.bestBid).toBe(10_007n) + expect(grouped.bestAsk).toBe(10_008n) + } + expect( + JSON.stringify([frozenBids, frozenAsks], (_k, v) => (typeof v === "bigint" ? v.toString() : v)) + ).toBe(before) + }) +}) + +describe("rowSizeDisplay", () => { + const row: DepthRow = { + price: 10_005n, + size: 30_000n, // 3.0000 BTC + quote: 10_007n * 10_000n + 10_005n * 20_000n, // exact sum of price*size + cumulativeSize: 30_000n, + cumulativeQuote: 10_007n * 10_000n + 10_005n * 20_000n, + } + + it("shows base size at the market's size precision", () => { + expect(rowSizeDisplay(row, "base", BTC)).toEqual({ size: "3", cumulative: "3", symbol: "BTC" }) + }) + + it("shows quote value exactly, in the quote symbol", () => { + // 100.07*1 + 100.05*2 = 300.17 + expect(rowSizeDisplay(row, "quote", BTC)).toEqual({ size: "300.17", cumulative: "300.17", symbol: "USD" }) + }) + + it("labels the unit on both figures", () => { + expect(rowSizeDisplay(row, "base", XLM).symbol).toBe("XLM") + expect(rowSizeDisplay(row, "quote", XLM).symbol).toBe("USD") + }) +}) + +describe("formatBucketPrice", () => { + it("shows at least two decimals up to the market precision", () => { + expect(formatBucketPrice(10_000n, BTC)).toBe("100.00") + expect(formatBucketPrice(10_005n, BTC)).toBe("100.05") + expect(formatBucketPrice(12_345n, XLM)).toBe("0.12345") + expect(formatBucketPrice(12_300n, XLM)).toBe("0.123") + }) +}) + +it("the grouping multipliers are the documented set", () => { + expect(GROUPING_MULTIPLIERS).toEqual([1, 2, 5, 10, 50, 100, 500, 1000]) +}) diff --git a/apps/web/src/features/trade/lib/orderbook/display-prefs.ts b/apps/web/src/features/trade/lib/orderbook/display-prefs.ts new file mode 100644 index 00000000..b63c7ce2 --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/display-prefs.ts @@ -0,0 +1,177 @@ +// OB-052: display grouping and size-unit preferences for the order book. +// +// Everything here is display-only. Grouping never changes the canonical book +// (OB-045/OB-046 keep it in integer atoms) and never touches an order ticket +// price: a bucket price is a label, not an executable price. +// +// Preferences are stored as a grouping MULTIPLIER of the market's native tick +// (1x, 2x, 5x ...), not as an absolute price, so the same choice stays +// meaningful when markets with different tick sizes are switched between. A +// stored value is only ever used after `sanitizeDisplayPrefs` has checked it +// against the market's current metadata: if the tick, precision or capabilities +// change and the stored choice is no longer offered, it is reset, and the caller +// is told what was reset. + +import type { DepthRow } from "./depth" + +export type SizeUnit = "base" | "quote" + +export const SIZE_UNITS: ReadonlyArray = ["base", "quote"] + +/** Runtime check for a value that arrived from storage or an event handler. */ +export function isSizeUnit(value: unknown): value is SizeUnit { + return value === "base" || value === "quote" +} + +/** Grouping steps offered, as multiples of the market's native tick. */ +export const GROUPING_MULTIPLIERS: ReadonlyArray = [1, 2, 5, 10, 50, 100, 500, 1000] + +/** + * What the display needs to know about a market. Prices and sizes are integer + * atoms; `priceScale` and `sizeScale` are their decimal places. + */ +export type MarketDisplayMetadata = { + marketId: string + baseSymbol: string + quoteSymbol: string + /** Decimal places of a price atom. */ + priceScale: number + /** Decimal places of a size atom. */ + sizeScale: number + /** Native price increment in price atoms; must be positive. */ + tickSize: bigint +} + +export type DisplayPrefs = { + /** Grouping as a multiple of the native tick; always one of the offered multipliers. */ + groupingMultiplier: number + unit: SizeUnit +} + +export const DEFAULT_DISPLAY_PREFS: DisplayPrefs = { groupingMultiplier: 1, unit: "base" } + +const MAX_SCALE = 18 + +function isScale(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 0 && (value as number) <= MAX_SCALE +} + +/** Metadata is only usable if every field is present and sane. */ +export function isValidMetadata(meta: MarketDisplayMetadata | null | undefined): meta is MarketDisplayMetadata { + return ( + !!meta && + typeof meta.marketId === "string" && + meta.marketId.length > 0 && + typeof meta.baseSymbol === "string" && + meta.baseSymbol.length > 0 && + typeof meta.quoteSymbol === "string" && + meta.quoteSymbol.length > 0 && + isScale(meta.priceScale) && + isScale(meta.sizeScale) && + typeof meta.tickSize === "bigint" && + meta.tickSize > 0n + ) +} + +/** Exact decimal string for an atom value, with trailing zeros trimmed to `minDecimals`. */ +export function formatScaled(value: bigint, scale: number, minDecimals = 0): string { + const negative = value < 0n + const abs = negative ? -value : value + const digits = abs.toString().padStart(scale + 1, "0") + const whole = scale === 0 ? digits : digits.slice(0, digits.length - scale) + let fraction = scale === 0 ? "" : digits.slice(digits.length - scale) + while (fraction.length > minDecimals && fraction.endsWith("0")) fraction = fraction.slice(0, -1) + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + return `${negative ? "-" : ""}${grouped}${fraction ? `.${fraction}` : ""}` +} + +export type GroupingOption = { + multiplier: number + /** Bucket width in price atoms. */ + tick: bigint + /** Human label, e.g. "0.50". */ + label: string +} + +/** + * The groupings this market supports. Empty for unusable metadata, so a caller + * can disable the control rather than offer a grouping it cannot honour. + */ +export function groupingOptions(meta: MarketDisplayMetadata | null | undefined): Array { + if (!isValidMetadata(meta)) return [] + return GROUPING_MULTIPLIERS.map((multiplier) => { + const tick = meta.tickSize * BigInt(multiplier) + return { multiplier, tick, label: formatScaled(tick, meta.priceScale) } + }) +} + +/** Bucket width in price atoms for the given preferences. Falls back to the native tick. */ +export function tickFor(prefs: DisplayPrefs, meta: MarketDisplayMetadata): bigint { + return meta.tickSize * BigInt(prefs.groupingMultiplier) +} + +export type SanitizedPrefs = { + prefs: DisplayPrefs + /** Which stored values were invalid for this market and replaced by defaults. */ + reset: { grouping: boolean; unit: boolean } +} + +/** + * Turn whatever was stored (possibly corrupt, from another version, or from a + * market whose precision has since changed) into preferences that are valid + * for `meta`. Never throws. + */ +export function sanitizeDisplayPrefs(raw: unknown, meta: MarketDisplayMetadata | null | undefined): SanitizedPrefs { + const stored = typeof raw === "object" && raw !== null ? (raw as Record) : null + const offered = new Set(groupingOptions(meta).map((o) => o.multiplier)) + + const wantedGrouping = stored?.groupingMultiplier + const groupingValid = typeof wantedGrouping === "number" && offered.has(wantedGrouping) + const wantedUnit = stored?.unit + const unitValid = isSizeUnit(wantedUnit) + + return { + prefs: { + groupingMultiplier: groupingValid ? wantedGrouping : DEFAULT_DISPLAY_PREFS.groupingMultiplier, + unit: unitValid ? wantedUnit : DEFAULT_DISPLAY_PREFS.unit, + }, + // Nothing stored is not a reset: only a stored value that had to be replaced is. + reset: { + grouping: stored !== null && stored.groupingMultiplier !== undefined && !groupingValid, + unit: stored !== null && stored.unit !== undefined && !unitValid, + }, + } +} + +export type RowSizeDisplay = { + size: string + cumulative: string + /** Symbol the two figures are denominated in. */ + symbol: string +} + +/** + * A row's size and running total in the chosen unit. Base is the atom size at + * `sizeScale`; quote is the sum of `price * size`, which carries + * `priceScale + sizeScale` decimal places (see OB-046), so it is exact. + */ +export function rowSizeDisplay(row: DepthRow, unit: SizeUnit, meta: MarketDisplayMetadata): RowSizeDisplay { + if (unit === "quote") { + const scale = meta.priceScale + meta.sizeScale + return { + size: formatScaled(row.quote, scale, 2), + cumulative: formatScaled(row.cumulativeQuote, scale, 2), + symbol: meta.quoteSymbol, + } + } + return { + size: formatScaled(row.size, meta.sizeScale), + cumulative: formatScaled(row.cumulativeSize, meta.sizeScale), + symbol: meta.baseSymbol, + } +} + +/** A bucket price, shown at the market's price precision. */ +export function formatBucketPrice(price: bigint, meta: MarketDisplayMetadata): string { + return formatScaled(price, meta.priceScale, Math.min(meta.priceScale, 2)) +} diff --git a/apps/web/src/features/trade/lib/orderbook/layout.test.ts b/apps/web/src/features/trade/lib/orderbook/layout.test.ts new file mode 100644 index 00000000..c455df93 --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/layout.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest" +import { buildDepth } from "./depth" +import { + BOOK_LAYOUTS, + DEFAULT_BOOK_LAYOUT, + allocateRows, + depthFraction, + isBookLayout, + selectVisibleDepth, + toBookLayout, +} from "./layout" +import type { BookLevel } from "./book-reducer" + +const L = (price: bigint, size: bigint): BookLevel => ({ price, size }) + +const depth = buildDepth( + [L(100n, 4n), L(99n, 3n), L(98n, 2n), L(97n, 1n)], + [L(101n, 1n), L(102n, 2n), L(103n, 3n), L(104n, 4n)], + { tick: 1n } +) + +describe("layout guards", () => { + it("accepts only the three layouts", () => { + for (const layout of BOOK_LAYOUTS) expect(isBookLayout(layout)).toBe(true) + for (const bad of ["both ", "BOTH", "", null, undefined, 1, {}]) expect(isBookLayout(bad)).toBe(false) + }) + + it("falls back to the default for anything else", () => { + expect(toBookLayout("asks")).toBe("asks") + expect(toBookLayout("sideways")).toBe(DEFAULT_BOOK_LAYOUT) + expect(toBookLayout(undefined)).toBe(DEFAULT_BOOK_LAYOUT) + }) +}) + +describe("allocateRows", () => { + it("gives a single-side layout every row", () => { + expect(allocateRows("bids", 12)).toEqual({ bidRows: 12, askRows: 0 }) + expect(allocateRows("asks", 12)).toEqual({ bidRows: 0, askRows: 12 }) + }) + + it("splits both sides evenly, with an odd row going to asks", () => { + expect(allocateRows("both", 10)).toEqual({ bidRows: 5, askRows: 5 }) + expect(allocateRows("both", 11)).toEqual({ bidRows: 5, askRows: 6 }) + expect(allocateRows("both", 9)).toEqual({ bidRows: 4, askRows: 5 }) + }) + + it("never lets a side disappear in the both layout", () => { + expect(allocateRows("both", 0)).toEqual({ bidRows: 1, askRows: 1 }) + expect(allocateRows("both", 1)).toEqual({ bidRows: 1, askRows: 1 }) + expect(allocateRows("both", 2)).toEqual({ bidRows: 1, askRows: 1 }) + }) + + it("is deterministic and consumes exactly the rows available", () => { + for (let rows = 2; rows <= 40; rows++) { + const a = allocateRows("both", rows) + expect(allocateRows("both", rows)).toEqual(a) + expect(a.bidRows + a.askRows).toBe(rows) + expect(a.askRows - a.bidRows).toBeLessThanOrEqual(1) + } + }) + + it.each([Number.NaN, Number.POSITIVE_INFINITY, -5, 3.9])("sanitizes %s", (rows) => { + const a = allocateRows("bids", rows) + expect(Number.isInteger(a.bidRows)).toBe(true) + expect(a.bidRows).toBeGreaterThanOrEqual(0) + }) +}) + +describe("selectVisibleDepth", () => { + it("both: best bid first, asks worst-first so the best ask sits by the spread", () => { + const v = selectVisibleDepth(depth, "both", 6) + expect(v.asks.map((r) => r.price)).toEqual([103n, 102n, 101n]) + expect(v.bids.map((r) => r.price)).toEqual([100n, 99n, 98n]) + }) + + it("bids only: no ask rows, bids sorted best-first", () => { + const v = selectVisibleDepth(depth, "bids", 3) + expect(v.asks).toEqual([]) + expect(v.bids.map((r) => r.price)).toEqual([100n, 99n, 98n]) + }) + + it("asks only: no bid rows, still worst-first towards the spread", () => { + const v = selectVisibleDepth(depth, "asks", 3) + expect(v.bids).toEqual([]) + expect(v.asks.map((r) => r.price)).toEqual([103n, 102n, 101n]) + }) + + it("keeps the best level on each side when rows are trimmed", () => { + const v = selectVisibleDepth(depth, "both", 2) + expect(v.asks.map((r) => r.price)).toEqual([101n]) + expect(v.bids.map((r) => r.price)).toEqual([100n]) + }) + + it("never shows more rows than the book has", () => { + const v = selectVisibleDepth(depth, "bids", 100) + expect(v.bids).toHaveLength(4) + }) + + it("does not mutate the depth", () => { + const before = depth.asks.rows.map((r) => r.price) + selectVisibleDepth(depth, "both", 6) + expect(depth.asks.rows.map((r) => r.price)).toEqual(before) + }) + + it("preserves grouping: rows come from the grouped depth it is given", () => { + const grouped = buildDepth( + [L(100n, 4n), L(99n, 3n), L(98n, 2n), L(97n, 1n)], + [L(101n, 1n), L(102n, 2n), L(103n, 3n), L(104n, 4n)], + { tick: 2n } + ) + const v = selectVisibleDepth(grouped, "both", 4) + expect(v.bids.map((r) => r.price)).toEqual([100n, 98n]) + expect(v.asks.map((r) => r.price)).toEqual([104n, 102n]) + }) +}) + +describe("depthFraction", () => { + it("scales against the largest cumulative size actually shown", () => { + const both = selectVisibleDepth(depth, "both", 8) + const bids = selectVisibleDepth(depth, "bids", 4) + const deepestBid = bids.bids.at(-1)! + // Both sides have 10 cumulative here, so the deepest row fills the bar in either layout. + expect(depthFraction(deepestBid, both)).toBe(1) + expect(depthFraction(deepestBid, bids)).toBe(1) + }) + + it("a single-side layout is not dwarfed by the hidden side", () => { + const lopsided = buildDepth([L(100n, 1n)], [L(101n, 100n)], { tick: 1n }) + const bidsOnly = selectVisibleDepth(lopsided, "bids", 3) + const both = selectVisibleDepth(lopsided, "both", 4) + expect(depthFraction(bidsOnly.bids[0], bidsOnly)).toBe(1) + expect(depthFraction(both.bids[0], both)).toBe(0.01) + }) + + it("is 0 for an empty book and never above 1", () => { + const empty = selectVisibleDepth(buildDepth([], [], { tick: 1n }), "both", 4) + expect(empty.asks).toEqual([]) + const row = { price: 1n, size: 1n, quote: 1n, cumulativeSize: 5n, cumulativeQuote: 1n } + expect(depthFraction(row, empty)).toBe(0) + const one = selectVisibleDepth(depth, "bids", 1) + expect(depthFraction({ ...row, cumulativeSize: 999n }, one)).toBe(1) + }) +}) diff --git a/apps/web/src/features/trade/lib/orderbook/layout.ts b/apps/web/src/features/trade/lib/orderbook/layout.ts new file mode 100644 index 00000000..9b3b2682 --- /dev/null +++ b/apps/web/src/features/trade/lib/orderbook/layout.ts @@ -0,0 +1,85 @@ +// OB-053: depth layouts (both sides, bids only, asks only). +// +// A layout decides which side's rows are shown and how many; it never changes +// the data. The spread and the market identity stay visible in every layout +// (see the component), and rows are always sorted best-first from the spread. + +import type { BookDepth, DepthRow } from "./depth" + +export type BookLayout = "both" | "bids" | "asks" + +export const BOOK_LAYOUTS: ReadonlyArray = ["both", "bids", "asks"] + +export const DEFAULT_BOOK_LAYOUT: BookLayout = "both" + +export function isBookLayout(value: unknown): value is BookLayout { + return value === "both" || value === "bids" || value === "asks" +} + +/** Falls back to the default for anything that is not a known layout. */ +export function toBookLayout(value: unknown): BookLayout { + return isBookLayout(value) ? value : DEFAULT_BOOK_LAYOUT +} + +export type RowAllocation = { + bidRows: number + askRows: number +} + +/** + * Split `availableRows` (rows for depth, not counting the spread row) between + * the sides. Deterministic: a single-side layout gets every row; both sides + * split evenly and an odd row goes to the ask side, so resizing one row at a + * time alternates predictably instead of jumping. `both` never shows fewer + * than one row per side, since a side that vanishes would hide the very + * context that layout exists to show. + */ +export function allocateRows(layout: BookLayout, availableRows: number): RowAllocation { + const rows = Number.isFinite(availableRows) ? Math.max(0, Math.floor(availableRows)) : 0 + switch (layout) { + case "bids": + return { bidRows: rows, askRows: 0 } + case "asks": + return { bidRows: 0, askRows: rows } + case "both": { + const total = Math.max(rows, 2) + const askRows = Math.ceil(total / 2) + return { bidRows: total - askRows, askRows } + } + } +} + +export type VisibleDepth = { + layout: BookLayout + /** Ask rows in display order: worst price first, best ask last, next to the spread. */ + asks: Array + /** Bid rows in display order: best bid first, next to the spread. */ + bids: Array + allocation: RowAllocation +} + +/** The rows to draw for a layout, sorted and trimmed. Input rows are best-first. */ +export function selectVisibleDepth(depth: BookDepth, layout: BookLayout, availableRows: number): VisibleDepth { + const allocation = allocateRows(layout, availableRows) + return { + layout, + asks: depth.asks.rows.slice(0, allocation.askRows).reverse(), + bids: depth.bids.rows.slice(0, allocation.bidRows), + allocation, + } +} + +/** + * Width of a row's depth bar as a fraction in [0, 1]. Scaled against the + * largest cumulative size that is actually visible in this layout, so a + * single-side layout uses its full width rather than being dwarfed by a side + * that is not shown. + */ +export function depthFraction(row: DepthRow, visible: VisibleDepth): number { + const last = (rows: Array) => rows.reduce((max, r) => (r.cumulativeSize > max ? r.cumulativeSize : max), 0n) + const max = last(visible.asks) > last(visible.bids) ? last(visible.asks) : last(visible.bids) + if (max <= 0n) return 0 + // Basis-point precision in exact integer arithmetic, then to a fraction. + const bps = (row.cumulativeSize * 10_000n) / max + return Number(bps > 10_000n ? 10_000n : bps) / 10_000 +} diff --git a/apps/web/src/features/trade/lib/source-freshness.test.ts b/apps/web/src/features/trade/lib/source-freshness.test.ts new file mode 100644 index 00000000..4af2f323 --- /dev/null +++ b/apps/web/src/features/trade/lib/source-freshness.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from "vitest" +import { + + DEFAULT_FRESHNESS_POLICY as POLICY, + createTelemetry, + deriveFreshness, + quoteFromDepth, + recordMalformed, + recordMessage, + recordSnapshot, + recordTransport, + recordValidUpdate, + resetSnapshot, + toSourceHealth +} from "./source-freshness" +import type {FeedTelemetry} from "./source-freshness"; + +const T0 = 1_000_000 + +/** A healthy open feed: snapshot applied at T0, then live updates. */ +function liveFeed(opts: { providerOffset?: number } = {}): FeedTelemetry { + const t = createTelemetry() + recordTransport(t, "open") + recordSnapshot(t, T0, 100) + recordValidUpdate(t, POLICY, { + receivedAt: T0 + 100, + revision: 101, + providerTimestamp: T0 + 100 - 40 - (opts.providerOffset ?? 0), + }) + return t +} + +const book = { + bids: [{ price: 99, size: 1 }], + asks: [{ price: 101, size: 1 }], +} + +describe("initial and unavailable states", () => { + it("is unavailable, not stale, before the first connection completes", () => { + const t = createTelemetry() + const f = deriveFreshness(t, T0) + expect(f).toMatchObject({ state: "unavailable", reason: "connecting", canSeedQuote: false }) + expect(f.lastMessageAgeMs).toBeNull() + expect(f.lastValidUpdateAgeMs).toBeNull() + }) + + it("is reconnecting when a first-ever connection is being retried", () => { + const t = createTelemetry() + recordTransport(t, "connecting", 2) + expect(deriveFreshness(t, T0)).toMatchObject({ state: "reconnecting", reason: "connecting" }) + }) + + it("is unavailable while an open transport has not delivered a snapshot", () => { + const t = createTelemetry() + recordTransport(t, "open") + recordMessage(t, T0) + expect(deriveFreshness(t, T0 + 1)).toMatchObject({ + state: "unavailable", + reason: "awaiting-snapshot", + canSeedQuote: false, + }) + }) + + it("delta updates before any snapshot do not create a usable book", () => { + const t = createTelemetry() + recordTransport(t, "open") + recordValidUpdate(t, POLICY, { receivedAt: T0, revision: 5, providerTimestamp: T0 - 10 }) + expect(deriveFreshness(t, T0 + 1).canSeedQuote).toBe(false) + }) +}) + +describe("a healthy feed", () => { + it("is fresh and may seed a quote", () => { + const f = deriveFreshness(liveFeed(), T0 + 500) + expect(f).toMatchObject({ state: "fresh", reason: "live", canSeedQuote: true }) + }) + + it("reports ages from local receipt times", () => { + const f = deriveFreshness(liveFeed(), T0 + 1_100) + expect(f.lastMessageAgeMs).toBe(1_000) + expect(f.lastValidUpdateAgeMs).toBe(1_000) + }) +}) + +describe("a market with no trades is not a dead connection", () => { + it("stays fresh when the book is unchanged for minutes but heartbeats keep arriving", () => { + const t = liveFeed() + const start = T0 + 100 + // Heartbeat/ack frames every 10s for 4 minutes, and not one book change. + for (let s = 10_000; s <= 240_000; s += 10_000) recordMessage(t, start + s) + + const f = deriveFreshness(t, start + 240_000) + expect(f).toMatchObject({ state: "fresh", reason: "quiet-market", canSeedQuote: true }) + expect(f.lastValidUpdateAgeMs).toBe(240_000) + }) + + it("still reports quiet-market without heartbeat frames as long as some frame is recent", () => { + const t = liveFeed() + recordMessage(t, T0 + 60_000) + expect(deriveFreshness(t, T0 + 65_000)).toMatchObject({ + state: "fresh", + reason: "quiet-market", + }) + }) +}) + +describe("dead transport", () => { + it("goes stale when an open transport goes silent past the heartbeat timeout", () => { + const t = liveFeed() + const f = deriveFreshness(t, T0 + 100 + POLICY.heartbeatTimeoutMs + 1) + expect(f).toMatchObject({ state: "stale", reason: "transport-silent", canSeedQuote: false }) + }) + + it("is not stale exactly at the timeout boundary", () => { + const t = liveFeed() + expect(deriveFreshness(t, T0 + 100 + POLICY.heartbeatTimeoutMs).state).toBe("fresh") + }) + + it("is reconnecting once the transport reports closed, keeping the last book visible", () => { + const t = liveFeed() + recordTransport(t, "closed", 1) + expect(deriveFreshness(t, T0 + 2_000)).toMatchObject({ + state: "reconnecting", + reason: "transport-closed", + canSeedQuote: false, + }) + }) + + it("is reconnecting after a transport error when a book exists, unavailable when none does", () => { + const withBook = liveFeed() + recordTransport(withBook, "error", 1) + expect(deriveFreshness(withBook, T0 + 1_000)).toMatchObject({ + state: "reconnecting", + reason: "transport-error", + }) + + const noBook = createTelemetry() + recordTransport(noBook, "error", 1) + expect(deriveFreshness(noBook, T0)).toMatchObject({ + state: "unavailable", + reason: "transport-error", + }) + }) + + it("expires the last book after a long outage", () => { + const t = liveFeed() + recordTransport(t, "closed", 6) + const f = deriveFreshness(t, T0 + 100 + POLICY.expireAfterMs + 1) + expect(f).toMatchObject({ state: "unavailable", reason: "data-expired", canSeedQuote: false }) + }) + + it("expires an open but silent-for-ever feed too", () => { + const t = liveFeed() + expect(deriveFreshness(t, T0 + 100 + POLICY.expireAfterMs + 1)).toMatchObject({ + state: "unavailable", + reason: "data-expired", + }) + }) + + it("recovers to fresh once a reconnect delivers a new snapshot", () => { + const t = liveFeed() + recordTransport(t, "closed", 1) + resetSnapshot(t) + recordTransport(t, "open", 0) + recordMessage(t, T0 + 5_000) + expect(deriveFreshness(t, T0 + 5_001).reason).toBe("awaiting-snapshot") + + recordSnapshot(t, T0 + 5_200, 500) + expect(deriveFreshness(t, T0 + 5_300)).toMatchObject({ state: "fresh", canSeedQuote: true }) + }) +}) + +describe("malformed updates", () => { + it("counts as stale after the threshold of consecutive bad frames", () => { + const t = liveFeed() + for (let i = 1; i < POLICY.malformedThreshold; i++) recordMalformed(t, T0 + 200 + i) + expect(deriveFreshness(t, T0 + 300).state).toBe("fresh") + + recordMalformed(t, T0 + 400) + expect(deriveFreshness(t, T0 + 500)).toMatchObject({ + state: "stale", + reason: "malformed-updates", + canSeedQuote: false, + }) + }) + + it("is not mistaken for a dead transport: frames are arriving", () => { + const t = liveFeed() + for (let i = 0; i < 5; i++) recordMalformed(t, T0 + 1_000 * (i + 1)) + const f = deriveFreshness(t, T0 + 5_500) + expect(f.reason).toBe("malformed-updates") + expect(f.lastMessageAgeMs).toBe(500) + }) + + it("clears as soon as a valid update arrives", () => { + const t = liveFeed() + for (let i = 0; i < 5; i++) recordMalformed(t, T0 + 200 + i) + recordValidUpdate(t, POLICY, { receivedAt: T0 + 300, revision: 102, providerTimestamp: T0 + 260 }) + expect(deriveFreshness(t, T0 + 301)).toMatchObject({ state: "fresh", canSeedQuote: true }) + }) + + it("keeps a running total for diagnostics", () => { + const t = liveFeed() + recordMalformed(t, T0 + 200) + recordValidUpdate(t, POLICY, { receivedAt: T0 + 300, revision: 102 }) + recordMalformed(t, T0 + 400) + expect(t.malformedTotal).toBe(2) + expect(t.consecutiveMalformed).toBe(1) + }) +}) + +describe("lagging provider", () => { + /** Establish a normal ~40ms delivery baseline, then deliver one update very late. */ + function feedThenLate(extraDelay: number): FeedTelemetry { + const t = liveFeed() + for (let i = 0; i < 5; i++) { + const received = T0 + 200 + i * 100 + recordValidUpdate(t, POLICY, { receivedAt: received, revision: 102 + i, providerTimestamp: received - 40 }) + } + const received = T0 + 1_000 + recordValidUpdate(t, POLICY, { + receivedAt: received, + revision: 200, + providerTimestamp: received - 40 - extraDelay, + }) + return t + } + + it("goes stale when the provider delivers updates far later than its own timestamps", () => { + const f = deriveFreshness(feedThenLate(POLICY.maxProviderLagMs + 1_000), T0 + 1_001) + expect(f).toMatchObject({ state: "stale", reason: "provider-lagging", canSeedQuote: false }) + expect(f.providerLagMs).toBe(POLICY.maxProviderLagMs + 1_000) + }) + + it("tolerates lag within the policy", () => { + const f = deriveFreshness(feedThenLate(1_000), T0 + 1_001) + expect(f.state).toBe("fresh") + expect(f.providerLagMs).toBe(1_000) + }) + + it("is not the same as a quiet market: lag needs delivered-late updates, not a long gap", () => { + const t = liveFeed() + recordMessage(t, T0 + 30_000) + const f = deriveFreshness(t, T0 + 31_000) + expect(f.reason).toBe("quiet-market") + expect(f.providerLagMs).toBe(0) + }) + + it("recovers when the provider catches up", () => { + const t = feedThenLate(POLICY.maxProviderLagMs + 1_000) + const received = T0 + 2_000 + recordValidUpdate(t, POLICY, { receivedAt: received, revision: 201, providerTimestamp: received - 40 }) + expect(deriveFreshness(t, received + 1).state).toBe("fresh") + }) + + it("has no lag figure when the provider sends no timestamps", () => { + const t = createTelemetry() + recordTransport(t, "open") + recordSnapshot(t, T0, 1) + recordValidUpdate(t, POLICY, { receivedAt: T0 + 100, revision: 2 }) + const f = deriveFreshness(t, T0 + 200) + expect(f).toMatchObject({ state: "fresh", providerLagMs: null, clockOffsetMs: null }) + }) +}) + +describe("clock skew between us and the provider", () => { + it.each([ + ["our clock 10 minutes ahead", 600_000], + ["our clock 10 minutes behind", -600_000], + ["in step", 0], + ])("a constant offset is not lag (%s)", (_label, skew) => { + const t = createTelemetry() + recordTransport(t, "open") + recordSnapshot(t, T0, 1) + for (let i = 0; i < 10; i++) { + const received = T0 + 100 + i * 100 + // Provider timestamps are `skew` ms away from our clock, plus ~40ms transit. + recordValidUpdate(t, POLICY, { receivedAt: received, revision: 2 + i, providerTimestamp: received - skew - 40 }) + } + const f = deriveFreshness(t, T0 + 1_200) + expect(f).toMatchObject({ state: "fresh", providerLagMs: 0, canSeedQuote: true }) + expect(f.clockOffsetMs).toBe(skew + 40) + }) + + it("still detects real lag under a large skew", () => { + const skew = 600_000 + const t = createTelemetry() + recordTransport(t, "open") + recordSnapshot(t, T0, 1) + for (let i = 0; i < 5; i++) { + const received = T0 + 100 + i * 100 + recordValidUpdate(t, POLICY, { receivedAt: received, revision: 2 + i, providerTimestamp: received - skew - 40 }) + } + const received = T0 + 1_000 + recordValidUpdate(t, POLICY, { + receivedAt: received, + revision: 50, + providerTimestamp: received - skew - 40 - 8_000, + }) + expect(deriveFreshness(t, received + 1)).toMatchObject({ state: "stale", reason: "provider-lagging" }) + }) + + it("re-baselines when the offset drifts, because only a bounded window is kept", () => { + const t = createTelemetry() + recordTransport(t, "open") + recordSnapshot(t, T0, 1) + for (let i = 0; i < POLICY.skewWindow; i++) { + recordValidUpdate(t, POLICY, { receivedAt: T0 + i, revision: 2 + i, providerTimestamp: T0 + i - 40 }) + } + // The provider clock steps 5 minutes: all later samples share the new offset. + const shift = 300_000 + for (let i = 0; i < POLICY.skewWindow; i++) { + const received = T0 + 1_000 + i + recordValidUpdate(t, POLICY, { receivedAt: received, revision: 1_000 + i, providerTimestamp: received - shift - 40 }) + } + const f = deriveFreshness(t, T0 + 1_000 + POLICY.skewWindow) + expect(t.deliveryDelays).toHaveLength(POLICY.skewWindow) + expect(f).toMatchObject({ state: "fresh", providerLagMs: 0 }) + }) + + it("ignores a non-finite provider timestamp", () => { + const t = liveFeed() + const before = t.deliveryDelays.length + recordValidUpdate(t, POLICY, { receivedAt: T0 + 200, revision: 200, providerTimestamp: Number.NaN }) + expect(t.deliveryDelays).toHaveLength(before) + expect(deriveFreshness(t, T0 + 300).state).toBe("fresh") + }) +}) + +describe("revision handling", () => { + it("rejects duplicate and out-of-order revisions without freshening the book", () => { + const t = liveFeed() + const revision = t.lastValidRevision + const at = t.lastValidUpdateAt + + expect(recordValidUpdate(t, POLICY, { receivedAt: T0 + 5_000, revision: 101 })).toBe(false) + expect(recordValidUpdate(t, POLICY, { receivedAt: T0 + 6_000, revision: 50 })).toBe(false) + + expect(t.lastValidRevision).toBe(revision) + expect(t.lastValidUpdateAt).toBe(at) + // The frames still prove the transport is alive. + expect(t.lastMessageAt).toBe(T0 + 6_000) + }) + + it("accepts a strictly newer revision", () => { + const t = liveFeed() + expect(recordValidUpdate(t, POLICY, { receivedAt: T0 + 500, revision: 102 })).toBe(true) + expect(t.lastValidRevision).toBe(102) + }) + + it("a replayed old revision does not clear a malformed streak", () => { + const t = liveFeed() + for (let i = 0; i < 3; i++) recordMalformed(t, T0 + 200 + i) + recordValidUpdate(t, POLICY, { receivedAt: T0 + 300, revision: 100 }) + expect(deriveFreshness(t, T0 + 301).reason).toBe("malformed-updates") + }) +}) + +describe("stale depth cannot seed an actionable quote", () => { + it("quotes from fresh depth", () => { + expect(quoteFromDepth(book, deriveFreshness(liveFeed(), T0 + 200))).toEqual({ + bid: 99, + ask: 101, + mid: 100, + }) + }) + + it.each([ + ["transport silent", (t: FeedTelemetry) => void t, T0 + 100 + POLICY.heartbeatTimeoutMs + 1], + ["malformed", (t: FeedTelemetry) => { for (let i = 0; i < 3; i++) recordMalformed(t, T0 + 200 + i) }, T0 + 300], + ["reconnecting", (t: FeedTelemetry) => recordTransport(t, "closed", 1), T0 + 300], + ])("refuses to quote when %s", (_label, degrade, at) => { + const t = liveFeed() + degrade(t) + expect(quoteFromDepth(book, deriveFreshness(t, at))).toBeNull() + }) + + it("refuses a crossed, empty or nonsensical book even from a fresh feed", () => { + const fresh = deriveFreshness(liveFeed(), T0 + 200) + expect(quoteFromDepth({ bids: [{ price: 101, size: 1 }], asks: [{ price: 99, size: 1 }] }, fresh)).toBeNull() + expect(quoteFromDepth({ bids: [], asks: book.asks }, fresh)).toBeNull() + expect(quoteFromDepth({ bids: book.bids, asks: [] }, fresh)).toBeNull() + expect(quoteFromDepth({ bids: [{ price: 0, size: 1 }], asks: book.asks }, fresh)).toBeNull() + expect(quoteFromDepth({ bids: [{ price: Number.NaN, size: 1 }], asks: book.asks }, fresh)).toBeNull() + }) + + it("refuses before a snapshot even if updates are flowing", () => { + const t = createTelemetry() + recordTransport(t, "open") + recordValidUpdate(t, POLICY, { receivedAt: T0, revision: 9, providerTimestamp: T0 - 10 }) + expect(quoteFromDepth(book, deriveFreshness(t, T0 + 1))).toBeNull() + }) +}) + +describe("toSourceHealth", () => { + it("maps each state onto the statuses the badge renders", () => { + const now = T0 + 300 + const fresh = liveFeed() + expect(toSourceHealth(deriveFreshness(fresh, now), fresh, now)).toMatchObject({ + status: "connected", + isExecutable: true, + }) + + const silent = liveFeed() + const later = T0 + 100 + POLICY.heartbeatTimeoutMs + 5_000 + expect(toSourceHealth(deriveFreshness(silent, later), silent, later)).toMatchObject({ + status: "stale", + isExecutable: false, + }) + + const closed = liveFeed() + recordTransport(closed, "closed", 2) + expect(toSourceHealth(deriveFreshness(closed, now), closed, now)).toMatchObject({ + status: "reconnecting", + reconnectAttempt: 2, + message: "Reconnecting (attempt 2)…", + }) + + const fresh0 = createTelemetry() + expect(toSourceHealth(deriveFreshness(fresh0, now), fresh0, now).status).toBe("initial-load") + + const expired = liveFeed() + recordTransport(expired, "closed", 6) + const gone = T0 + 100 + POLICY.expireAfterMs + 1 + expect(toSourceHealth(deriveFreshness(expired, gone), expired, gone).status).toBe("error") + }) + + it("explains why a feed is stale", () => { + const t = liveFeed() + for (let i = 0; i < 3; i++) recordMalformed(t, T0 + 200 + i) + expect(toSourceHealth(deriveFreshness(t, T0 + 300), t, T0 + 300).message).toMatch(/invalid updates/) + }) + + it("does not call a quiet market a problem", () => { + const t = liveFeed() + recordMessage(t, T0 + 40_000) + const h = toSourceHealth(deriveFreshness(t, T0 + 41_000), t, T0 + 41_000) + expect(h).toMatchObject({ status: "connected", isExecutable: true }) + expect(h.message).toMatch(/no recent book changes/) + }) +}) diff --git a/apps/web/src/features/trade/lib/source-freshness.ts b/apps/web/src/features/trade/lib/source-freshness.ts new file mode 100644 index 00000000..f77a87ae --- /dev/null +++ b/apps/web/src/features/trade/lib/source-freshness.ts @@ -0,0 +1,381 @@ +/** + * OB-049: Market-data source health and freshness. + * + * A market feed has several independent clocks, and mixing them up is how a + * quiet market gets reported as a dead connection (or a dead connection as a + * quiet market). They are tracked separately here: + * + * - connection health: the transport state, and `lastMessageAt`, the local + * time ANY frame arrived (data, heartbeat or ack). Silence on the transport + * is what proves it dead. + * - last valid update: the local time and revision of the last book update + * that parsed and advanced the revision. A market with no trades still + * has a healthy transport, so this alone never marks a feed stale. + * - provider timestamps: the provider's own event time on those updates. + * Comparing them with local receipt time measures how far the provider + * is lagging, after removing the constant offset between the two clocks. + * + * This module is pure: every function takes `now` explicitly and mutates + * nothing but the telemetry it is given, so behaviour is fully deterministic + * under test. + */ + +import type { SourceHealth } from "../hooks/useSourceHealth" + +export type FeedTransport = "connecting" | "open" | "closed" | "error" + +export type FreshnessState = "fresh" | "stale" | "reconnecting" | "unavailable" + +/** Why the feed is in its current state. Distinct so the UI and tests can tell them apart. */ +export type FreshnessReason = + | "live" // updates are arriving + | "quiet-market" // transport healthy and heartbeating, but no book change lately + | "connecting" // first connection attempt, nothing to show yet + | "awaiting-snapshot" // transport open but no valid book yet + | "transport-silent" // transport claims to be open but nothing has arrived for too long + | "transport-closed" // transport dropped; a retry is under way + | "transport-error" // transport failed + | "malformed-updates" // frames arrive but keep failing validation + | "provider-lagging" // provider delivers updates far later than its own timestamps + | "data-expired" // last valid book is too old to show at all + +export type FreshnessPolicy = { + /** Longest silence (no frame of any kind) tolerated on an open transport. */ + heartbeatTimeoutMs: number + /** Extra delivery delay, beyond the fastest recently seen, that marks the provider as lagging. */ + maxProviderLagMs: number + /** Consecutive malformed frames, with no valid update since, that mark the feed degraded. */ + malformedThreshold: number + /** After this long without a valid update the last book is discarded as unusable. */ + expireAfterMs: number + /** Delivery-delay samples kept for the baseline (clock offset) estimate. */ + skewWindow: number +} + +export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = { + heartbeatTimeoutMs: 20_000, // matches the stream's heartbeat watchdog + maxProviderLagMs: 5_000, + malformedThreshold: 3, + expireAfterMs: 5 * 60_000, + skewWindow: 32, +} + +export type FeedTelemetry = { + transport: FeedTransport + reconnectAttempt: number + /** A book snapshot has been applied, so depth exists to show. */ + hasSnapshot: boolean + /** Local time the last frame of ANY kind arrived. */ + lastMessageAt: number | null + /** Local time the last valid book update (or snapshot) was applied. */ + lastValidUpdateAt: number | null + /** Revision of the last valid update; only ever increases. */ + lastValidRevision: number | null + /** Provider event time of the last valid update. */ + lastProviderTimestamp: number | null + /** Recent `receivedAt - providerTimestamp` samples, used to remove the clock offset. */ + deliveryDelays: Array + /** Delivery delay of the most recent valid update. */ + lastDeliveryDelay: number | null + consecutiveMalformed: number + malformedTotal: number +} + +export function createTelemetry(): FeedTelemetry { + return { + transport: "connecting", + reconnectAttempt: 0, + hasSnapshot: false, + lastMessageAt: null, + lastValidUpdateAt: null, + lastValidRevision: null, + lastProviderTimestamp: null, + deliveryDelays: [], + lastDeliveryDelay: null, + consecutiveMalformed: 0, + malformedTotal: 0, + } +} + +export function recordTransport( + t: FeedTelemetry, + transport: FeedTransport, + reconnectAttempt = t.reconnectAttempt +): void { + t.transport = transport + t.reconnectAttempt = reconnectAttempt +} + +/** Any frame arrived: data, heartbeat, subscription ack. Proves the transport is alive. */ +export function recordMessage(t: FeedTelemetry, receivedAt: number): void { + t.lastMessageAt = receivedAt +} + +/** A frame arrived but could not be used. Also counts as proof of a live transport. */ +export function recordMalformed(t: FeedTelemetry, receivedAt: number): void { + t.lastMessageAt = receivedAt + t.consecutiveMalformed += 1 + t.malformedTotal += 1 +} + +type ValidUpdate = { + receivedAt: number + revision: number + /** Provider event time in ms. Omit when the update carries none. */ + providerTimestamp?: number | null +} + +/** + * A book update that parsed. Returns false, and leaves the book bookkeeping + * untouched, for a duplicate or out-of-order revision: replaying an old + * revision must not make a feed look fresher than it is. + */ +export function recordValidUpdate( + t: FeedTelemetry, + policy: FreshnessPolicy, + update: ValidUpdate +): boolean { + t.lastMessageAt = update.receivedAt + if (t.lastValidRevision !== null && update.revision <= t.lastValidRevision) { + return false + } + t.lastValidRevision = update.revision + t.lastValidUpdateAt = update.receivedAt + t.consecutiveMalformed = 0 + + const ts = update.providerTimestamp + if (typeof ts === "number" && Number.isFinite(ts)) { + const delay = update.receivedAt - ts + t.lastProviderTimestamp = ts + t.lastDeliveryDelay = delay + t.deliveryDelays.push(delay) + if (t.deliveryDelays.length > policy.skewWindow) t.deliveryDelays.shift() + } + return true +} + +/** A full snapshot was applied. It establishes the book and its revision. */ +export function recordSnapshot( + t: FeedTelemetry, + receivedAt: number, + revision: number +): void { + t.lastMessageAt = receivedAt + t.hasSnapshot = true + t.lastValidUpdateAt = receivedAt + t.lastValidRevision = revision + t.consecutiveMalformed = 0 +} + +/** The book was discarded (e.g. resync after reconnect); depth must be rebuilt before use. */ +export function resetSnapshot(t: FeedTelemetry): void { + t.hasSnapshot = false + t.lastValidRevision = null + t.lastValidUpdateAt = null +} + +export type FreshnessResult = { + state: FreshnessState + reason: FreshnessReason + /** + * Whether depth from this feed may seed an actionable quote (a price + * suggested into an order ticket). Only a fresh feed with an applied + * snapshot may; stale depth must never. + */ + canSeedQuote: boolean + /** ms since any frame arrived, or null if none has. */ + lastMessageAgeMs: number | null + /** ms since the last valid book update, or null if none has been applied. */ + lastValidUpdateAgeMs: number | null + /** + * How much later than usual the latest update reached us, in ms, with the + * fixed offset between our clock and the provider's removed. Null without + * provider timestamps. + */ + providerLagMs: number | null + /** Estimated `local clock - provider clock` in ms (includes minimum network latency), or null. */ + clockOffsetMs: number | null +} + +function result( + t: FeedTelemetry, + now: number, + state: FreshnessState, + reason: FreshnessReason, + extra: { providerLagMs: number | null; clockOffsetMs: number | null } +): FreshnessResult { + return { + state, + reason, + canSeedQuote: state === "fresh" && t.hasSnapshot && t.lastValidRevision !== null, + lastMessageAgeMs: t.lastMessageAt === null ? null : Math.max(0, now - t.lastMessageAt), + lastValidUpdateAgeMs: + t.lastValidUpdateAt === null ? null : Math.max(0, now - t.lastValidUpdateAt), + ...extra, + } +} + +/** + * Fresh / stale / reconnecting / unavailable, with the reason. + * + * Order matters and encodes the policy: + * 1. No usable book at all -> unavailable (or reconnecting if a retry is under way). + * 2. Transport down -> reconnecting, and after `expireAfterMs` unavailable. + * 3. Open transport, checked from most to least severe: silence beyond the + * heartbeat timeout, then a stream of malformed frames, then a lagging + * provider. Any of them is stale. + * 4. Otherwise fresh. A long gap since the last book change is NOT a reason + * to distrust the feed: `quiet-market` is reported so it is visible, but + * stays fresh. + */ +export function deriveFreshness( + t: FeedTelemetry, + now: number, + policy: FreshnessPolicy = DEFAULT_FRESHNESS_POLICY +): FreshnessResult { + const clockOffsetMs = t.deliveryDelays.length > 0 ? Math.min(...t.deliveryDelays) : null + const providerLagMs = + t.lastDeliveryDelay !== null && clockOffsetMs !== null + ? Math.max(0, t.lastDeliveryDelay - clockOffsetMs) + : null + const extra = { providerLagMs, clockOffsetMs } + + const validAge = t.lastValidUpdateAt === null ? null : Math.max(0, now - t.lastValidUpdateAt) + const hasBook = t.hasSnapshot && t.lastValidRevision !== null + + if (t.transport === "error" || t.transport === "closed") { + if (!hasBook) return result(t, now, "unavailable", t.transport === "error" ? "transport-error" : "transport-closed", extra) + if (validAge !== null && validAge > policy.expireAfterMs) { + return result(t, now, "unavailable", "data-expired", extra) + } + return result( + t, + now, + "reconnecting", + t.transport === "error" ? "transport-error" : "transport-closed", + extra + ) + } + + if (t.transport === "connecting") { + if (!hasBook) { + return result( + t, + now, + t.reconnectAttempt > 0 ? "reconnecting" : "unavailable", + "connecting", + extra + ) + } + if (validAge !== null && validAge > policy.expireAfterMs) { + return result(t, now, "unavailable", "data-expired", extra) + } + return result(t, now, "reconnecting", "transport-closed", extra) + } + + // transport === "open" + if (!hasBook) return result(t, now, "unavailable", "awaiting-snapshot", extra) + + if (validAge !== null && validAge > policy.expireAfterMs) { + return result(t, now, "unavailable", "data-expired", extra) + } + + const silence = t.lastMessageAt === null ? Infinity : now - t.lastMessageAt + if (silence > policy.heartbeatTimeoutMs) { + return result(t, now, "stale", "transport-silent", extra) + } + + if (t.consecutiveMalformed >= policy.malformedThreshold) { + return result(t, now, "stale", "malformed-updates", extra) + } + + if (providerLagMs !== null && providerLagMs > policy.maxProviderLagMs) { + return result(t, now, "stale", "provider-lagging", extra) + } + + // Healthy transport. Distinguish a quiet market from a busy one for display only. + const quiet = validAge !== null && validAge > policy.heartbeatTimeoutMs + return result(t, now, "fresh", quiet ? "quiet-market" : "live", extra) +} + +/** Maps a freshness result onto the health model the existing badge renders. */ +export function toSourceHealth( + freshness: FreshnessResult, + t: FeedTelemetry, + now: number +): SourceHealth { + const lastUpdateTime = t.lastValidUpdateAt + const staleDuration = freshness.lastValidUpdateAgeMs + const base = { lastUpdateTime, reconnectAttempt: t.reconnectAttempt } + + switch (freshness.state) { + case "fresh": + return { + ...base, + status: "connected", + staleDuration: null, + isExecutable: freshness.canSeedQuote, + message: freshness.reason === "quiet-market" ? "Live — no recent book changes" : "Live market data", + } + case "stale": + return { + ...base, + status: "stale", + staleDuration, + isExecutable: false, + message: STALE_MESSAGES[freshness.reason] ?? "Market data is stale", + } + case "reconnecting": + return { + ...base, + status: "reconnecting", + staleDuration, + isExecutable: false, + message: + t.reconnectAttempt > 0 + ? `Reconnecting (attempt ${t.reconnectAttempt})…` + : "Reconnecting to market data…", + } + case "unavailable": + return { + ...base, + status: freshness.reason === "connecting" || freshness.reason === "awaiting-snapshot" ? "initial-load" : "error", + staleDuration: now === 0 ? null : staleDuration, + isExecutable: false, + message: + freshness.reason === "data-expired" + ? "Market data expired — waiting for a fresh book" + : freshness.reason === "connecting" || freshness.reason === "awaiting-snapshot" + ? "Connecting to market data feed…" + : "Market data unavailable", + } + } +} + +const STALE_MESSAGES: Partial> = { + "transport-silent": "No data received — connection may be dead", + "malformed-updates": "Receiving invalid updates — showing last valid book", + "provider-lagging": "Provider is delivering updates late", +} + +type Level = { price: number; size: number } +type Book = { bids: ReadonlyArray; asks: ReadonlyArray } + +/** + * The only sanctioned way to turn feed depth into a suggested price for an + * order ticket. Returns null unless the feed is fresh with an applied + * snapshot, and unless the book is uncrossed, so stale, unsynced or broken + * depth can never seed an actionable quote. + */ +export function quoteFromDepth( + book: Book, + freshness: FreshnessResult +): { bid: number; ask: number; mid: number } | null { + if (!freshness.canSeedQuote) return null + const bid = book.bids.at(0)?.price + const ask = book.asks.at(0)?.price + if (bid === undefined || ask === undefined) return null + if (!Number.isFinite(bid) || !Number.isFinite(ask) || bid <= 0 || ask <= 0) return null + if (bid >= ask) return null + return { bid, ask, mid: (bid + ask) / 2 } +} diff --git a/apps/web/src/features/trade/store/orderbook-display-store.test.ts b/apps/web/src/features/trade/store/orderbook-display-store.test.ts new file mode 100644 index 00000000..3f527a0a --- /dev/null +++ b/apps/web/src/features/trade/store/orderbook-display-store.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { + MAX_REMEMBERED_MARKETS, + ORDERBOOK_DISPLAY_STORAGE_KEY, + selectDisplayPrefs, + useOrderBookDisplayStore, +} from "./orderbook-display-store" +import type { MarketDisplayMetadata } from "../lib/orderbook/display-prefs" + +const meta = (marketId: string, tickSize = 1n): MarketDisplayMetadata => ({ + marketId, + baseSymbol: marketId.split("-")[0], + quoteSymbol: "USD", + priceScale: 2, + sizeScale: 4, + tickSize, +}) + +const BTC = meta("BTC-USD") +const XLM = meta("XLM-USD", 10n) + +const store = () => useOrderBookDisplayStore.getState() + +beforeEach(() => { + localStorage.clear() + useOrderBookDisplayStore.setState({ layout: "both", byMarket: {} }) +}) + +describe("defaults", () => { + it("starts with both sides, native grouping and base units", () => { + expect(store().layout).toBe("both") + expect(selectDisplayPrefs(store(), BTC)).toEqual({ groupingMultiplier: 1, unit: "base" }) + }) +}) + +describe("per-market preferences", () => { + it("remembers grouping and unit separately for each market", () => { + store().setGrouping(BTC, 10) + store().setUnit("BTC-USD", "quote") + store().setGrouping(XLM, 5) + + expect(selectDisplayPrefs(store(), BTC)).toEqual({ groupingMultiplier: 10, unit: "quote" }) + expect(selectDisplayPrefs(store(), XLM)).toEqual({ groupingMultiplier: 5, unit: "base" }) + }) + + it("survives switching between markets with different tick sizes", () => { + store().setGrouping(BTC, 100) + store().setGrouping(XLM, 2) + // Back and forth: each keeps its own multiplier and the other is not disturbed. + expect(selectDisplayPrefs(store(), BTC).groupingMultiplier).toBe(100) + expect(selectDisplayPrefs(store(), XLM).groupingMultiplier).toBe(2) + expect(selectDisplayPrefs(store(), BTC).groupingMultiplier).toBe(100) + }) + + it("setting the unit keeps the grouping, and vice versa", () => { + store().setGrouping(BTC, 50) + store().setUnit("BTC-USD", "quote") + store().setGrouping(BTC, 5) + expect(selectDisplayPrefs(store(), BTC)).toEqual({ groupingMultiplier: 5, unit: "quote" }) + }) + + it("refuses a grouping the market does not offer", () => { + store().setGrouping(BTC, 7) + store().setGrouping({ ...BTC, tickSize: 0n }, 10) + expect(store().byMarket["BTC-USD"]).toBeUndefined() + }) + + it("refuses an unknown unit or layout", () => { + store().setUnit("BTC-USD", "lots" as never) + store().setLayout("sideways" as never) + expect(store().byMarket).toEqual({}) + expect(store().layout).toBe("both") + }) +}) + +describe("reconcileMarket", () => { + it("resets a stored grouping the market stopped offering, and reports it", () => { + store().setGrouping(BTC, 100) + // The market's capabilities change: no metadata usable any more. + const reset = store().reconcileMarket({ ...BTC, tickSize: 0n }) + + expect(reset).toEqual({ grouping: true, unit: false }) + expect(store().byMarket["BTC-USD"]).toEqual({ groupingMultiplier: 1, unit: "base" }) + }) + + it("leaves valid preferences alone and reports no reset", () => { + store().setGrouping(BTC, 10) + const before = store().byMarket + expect(store().reconcileMarket(BTC)).toEqual({ grouping: false, unit: false }) + expect(store().byMarket).toBe(before) + }) + + it("does nothing for a market with no stored preferences", () => { + expect(store().reconcileMarket(BTC)).toEqual({ grouping: false, unit: false }) + expect(store().byMarket).toEqual({}) + }) + + it("selectDisplayPrefs never returns an invalid value even before reconcile runs", () => { + useOrderBookDisplayStore.setState({ byMarket: { "BTC-USD": { groupingMultiplier: 7, unit: "quote" } } }) + expect(selectDisplayPrefs(store(), BTC)).toEqual({ groupingMultiplier: 1, unit: "quote" }) + }) +}) + +describe("layout", () => { + it("stores the layout globally", () => { + store().setLayout("bids") + expect(store().layout).toBe("bids") + store().setGrouping(BTC, 10) + expect(store().layout).toBe("bids") + }) + + it("changing the layout does not touch grouping or unit", () => { + store().setGrouping(BTC, 10) + store().setUnit("BTC-USD", "quote") + store().setLayout("asks") + expect(selectDisplayPrefs(store(), BTC)).toEqual({ groupingMultiplier: 10, unit: "quote" }) + }) +}) + +describe("bounded memory", () => { + it("forgets the least recently changed markets past the cap", () => { + for (let i = 0; i < MAX_REMEMBERED_MARKETS + 5; i++) { + store().setUnit(`M${i}-USD`, "quote") + } + const ids = Object.keys(store().byMarket) + expect(ids).toHaveLength(MAX_REMEMBERED_MARKETS) + expect(ids).not.toContain("M0-USD") + expect(ids).toContain(`M${MAX_REMEMBERED_MARKETS + 4}-USD`) + }) + + it("changing a market again makes it the newest, not the oldest", () => { + store().setUnit("OLD-USD", "quote") + for (let i = 0; i < MAX_REMEMBERED_MARKETS - 1; i++) store().setUnit(`M${i}-USD`, "quote") + store().setUnit("OLD-USD", "base") // refreshed + store().setUnit("NEW-USD", "quote") // would have evicted OLD if it were still oldest + expect(Object.keys(store().byMarket)).toContain("OLD-USD") + }) +}) + +describe("persistence and recovery", () => { + it("writes only layout and per-market preferences", () => { + store().setLayout("bids") + store().setGrouping(BTC, 10) + const saved = JSON.parse(localStorage.getItem(ORDERBOOK_DISPLAY_STORAGE_KEY)!) + expect(saved.version).toBe(1) + expect(Object.keys(saved.state).sort()).toEqual(["byMarket", "layout"]) + expect(saved.state.byMarket["BTC-USD"]).toEqual({ groupingMultiplier: 10, unit: "base" }) + }) + + it("rehydrates saved preferences", async () => { + localStorage.setItem( + ORDERBOOK_DISPLAY_STORAGE_KEY, + JSON.stringify({ + version: 1, + state: { layout: "asks", byMarket: { "BTC-USD": { groupingMultiplier: 50, unit: "quote" } } }, + }), + ) + await useOrderBookDisplayStore.persist.rehydrate() + expect(store().layout).toBe("asks") + expect(selectDisplayPrefs(store(), BTC)).toEqual({ groupingMultiplier: 50, unit: "quote" }) + }) + + it.each([ + ["not an object", JSON.stringify({ version: 1, state: "garbage" })], + ["unknown layout", JSON.stringify({ version: 1, state: { layout: "diagonal", byMarket: {} } })], + ["byMarket is an array", JSON.stringify({ version: 1, state: { layout: "bids", byMarket: [1, 2] } })], + ])("recovers from corrupt storage: %s", async (_label, raw) => { + localStorage.setItem(ORDERBOOK_DISPLAY_STORAGE_KEY, raw) + await useOrderBookDisplayStore.persist.rehydrate() + expect(["both", "bids"]).toContain(store().layout) + expect(store().byMarket).toEqual({}) + }) + + it("drops malformed market entries but keeps the good ones", async () => { + localStorage.setItem( + ORDERBOOK_DISPLAY_STORAGE_KEY, + JSON.stringify({ + version: 1, + state: { + layout: "both", + byMarket: { + "BTC-USD": { groupingMultiplier: 10, unit: "quote" }, + "BAD-1": { groupingMultiplier: "10", unit: "quote" }, + "BAD-2": { groupingMultiplier: 0, unit: "base" }, + "BAD-3": { groupingMultiplier: 5, unit: "lots" }, + "BAD-4": null, + }, + }, + }), + ) + await useOrderBookDisplayStore.persist.rehydrate() + expect(Object.keys(store().byMarket)).toEqual(["BTC-USD"]) + }) + + it("caps an oversized stored list on load", async () => { + const byMarket = Object.fromEntries( + Array.from({ length: MAX_REMEMBERED_MARKETS + 20 }, (_, i) => [`M${i}`, { groupingMultiplier: 1, unit: "base" }]), + ) + localStorage.setItem(ORDERBOOK_DISPLAY_STORAGE_KEY, JSON.stringify({ version: 1, state: { layout: "both", byMarket } })) + await useOrderBookDisplayStore.persist.rehydrate() + expect(Object.keys(store().byMarket)).toHaveLength(MAX_REMEMBERED_MARKETS) + }) + + it("reset clears everything", () => { + store().setLayout("bids") + store().setGrouping(BTC, 10) + store().reset() + expect(store().layout).toBe("both") + expect(store().byMarket).toEqual({}) + }) +}) diff --git a/apps/web/src/features/trade/store/orderbook-display-store.ts b/apps/web/src/features/trade/store/orderbook-display-store.ts new file mode 100644 index 00000000..acdc145a --- /dev/null +++ b/apps/web/src/features/trade/store/orderbook-display-store.ts @@ -0,0 +1,146 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" +import { + DEFAULT_DISPLAY_PREFS, + + + + groupingOptions, + isSizeUnit, + sanitizeDisplayPrefs +} from "../lib/orderbook/display-prefs" +import { DEFAULT_BOOK_LAYOUT, isBookLayout, toBookLayout } from "../lib/orderbook/layout" +import type {DisplayPrefs, MarketDisplayMetadata, SizeUnit} from "../lib/orderbook/display-prefs"; +import type {BookLayout} from "../lib/orderbook/layout"; + +// OB-052 / OB-053: order book display preferences. +// +// Grouping and size unit are remembered PER MARKET, as a grouping multiplier of +// the market's native tick, so switching between markets with different tick +// sizes keeps each market's own choice. The layout is one global preference. +// Nothing here is market data: no prices, sizes or orders are stored. +// +// Stored values are never trusted. Reads go through `sanitizeDisplayPrefs` +// against the market's current metadata, and hydration drops anything malformed, +// so corrupt or outdated storage degrades to defaults instead of breaking the book. + +export const ORDERBOOK_DISPLAY_VERSION = 1 +export const ORDERBOOK_DISPLAY_STORAGE_KEY = "orderbook-display" +/** Markets remembered at once; the least recently changed are dropped past this. */ +export const MAX_REMEMBERED_MARKETS = 50 + +type PerMarket = Record + +type PersistedShape = { + layout: BookLayout + byMarket: PerMarket +} + +export interface OrderBookDisplayState extends PersistedShape { + setLayout: (layout: BookLayout) => void + setGrouping: (meta: MarketDisplayMetadata, multiplier: number) => void + setUnit: (marketId: string, unit: SizeUnit) => void + /** + * Call when a market's metadata is known or changes. Replaces stored values + * that are no longer valid for it; returns which were reset (nothing if none). + */ + reconcileMarket: (meta: MarketDisplayMetadata) => { grouping: boolean; unit: boolean } + reset: () => void +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +/** Keep only well-formed entries, capped to the newest MAX_REMEMBERED_MARKETS. */ +function recoverByMarket(raw: unknown): PerMarket { + if (!isRecord(raw)) return {} + const entries: Array<[string, DisplayPrefs]> = [] + for (const [marketId, value] of Object.entries(raw)) { + if (!marketId || !isRecord(value)) continue + const { groupingMultiplier, unit } = value + if ( + typeof groupingMultiplier === "number" && + Number.isInteger(groupingMultiplier) && + groupingMultiplier > 0 && + isSizeUnit(unit) + ) { + entries.push([marketId, { groupingMultiplier, unit }]) + } + } + // Insertion order is age order (oldest first), so keep the tail. + return Object.fromEntries(entries.slice(-MAX_REMEMBERED_MARKETS)) +} + +function recover(persisted: unknown): PersistedShape { + if (!isRecord(persisted)) return { layout: DEFAULT_BOOK_LAYOUT, byMarket: {} } + return { + layout: toBookLayout(persisted.layout), + byMarket: recoverByMarket(persisted.byMarket), + } +} + +/** Writes `prefs` as the newest entry, evicting the oldest past the cap. */ +function withMarket(byMarket: PerMarket, marketId: string, prefs: DisplayPrefs): PerMarket { + const { [marketId]: _previous, ...rest } = byMarket + const next = { ...rest, [marketId]: prefs } + const ids = Object.keys(next) + if (ids.length <= MAX_REMEMBERED_MARKETS) return next + return Object.fromEntries(ids.slice(ids.length - MAX_REMEMBERED_MARKETS).map((id) => [id, next[id]!])) +} + +export const useOrderBookDisplayStore = create()( + persist( + (set, get) => ({ + layout: DEFAULT_BOOK_LAYOUT, + byMarket: {}, + + setLayout: (layout) => { + // Ignore unknown layouts rather than storing something the UI cannot render. + if (!isBookLayout(layout)) return + set({ layout }) + }, + + setGrouping: (meta, multiplier) => { + // Only a grouping this market offers can be chosen. + if (!groupingOptions(meta).some((option) => option.multiplier === multiplier)) return + const current = sanitizeDisplayPrefs(get().byMarket[meta.marketId], meta).prefs + set({ byMarket: withMarket(get().byMarket, meta.marketId, { ...current, groupingMultiplier: multiplier }) }) + }, + + setUnit: (marketId, unit) => { + if (!isSizeUnit(unit)) return + const stored = get().byMarket[marketId] + set({ + byMarket: withMarket(get().byMarket, marketId, { + groupingMultiplier: stored?.groupingMultiplier ?? DEFAULT_DISPLAY_PREFS.groupingMultiplier, + unit, + }), + }) + }, + + reconcileMarket: (meta) => { + const stored = get().byMarket[meta.marketId] + if (stored === undefined) return { grouping: false, unit: false } + const { prefs, reset } = sanitizeDisplayPrefs(stored, meta) + if (reset.grouping || reset.unit) { + set({ byMarket: { ...get().byMarket, [meta.marketId]: prefs } }) + } + return reset + }, + + reset: () => set({ layout: DEFAULT_BOOK_LAYOUT, byMarket: {} }), + }), + { + name: ORDERBOOK_DISPLAY_STORAGE_KEY, + version: ORDERBOOK_DISPLAY_VERSION, + partialize: (state): PersistedShape => ({ layout: state.layout, byMarket: state.byMarket }), + merge: (persisted, current) => ({ ...current, ...recover(persisted) }), + }, + ), +) + +/** The preferences to use for a market right now: stored choice, validated against its metadata. */ +export function selectDisplayPrefs(state: Pick, meta: MarketDisplayMetadata): DisplayPrefs { + return sanitizeDisplayPrefs(state.byMarket[meta.marketId], meta).prefs +}