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 (
+
+ {
+ const multiplier = Number(value)
+ if (Number.isInteger(multiplier)) onGroupingChange(multiplier)
+ }}
+ disabled={!canGroup}
+ >
+
+
+ {(value: string) => options.find((o) => String(o.multiplier) === value)?.label ?? "—"}
+
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {
+ if (value === "base" || value === "quote") onUnitChange(value)
+ }}
+ >
+
+
+ {metadata?.baseSymbol ?? "Base"}
+ {`Size in ${metadata?.baseSymbol ?? "base asset"}`}
+
+
+
+ {metadata?.quoteSymbol ?? "Quote"}
+ {`Size in ${metadata?.quoteSymbol ?? "quote asset"}`}
+
+
+
+ {
+ if (isBookLayout(value)) onLayoutChange(value)
+ }}
+ >
+ {LAYOUT_LABELS.map(({ value, text, name }) => (
+
+
+ {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. */}
+
+
+ {formatBucketPrice(row.price, metadata)}
+
+
+ {display.size}
+
+
+ {display.cumulative}
+
+
+ )
+}
+
+/**
+ * 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
+}