From 2e6bcdfd929012a2197162828c099771eec80ceb Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:15:06 +0100 Subject: [PATCH 1/6] feat(trade): expose order book source health and freshness (OB-049) The order book feed had only a connection status, so a healthy but quiet market and a dead transport were indistinguishable, and nothing stopped stale depth from being treated as current. Track separately: transport state, the time of the last frame of any kind (heartbeats and acks count), the last valid book revision, and the provider timestamp of each update. Derive fresh / stale / reconnecting / unavailable with an explicit reason: silence past the heartbeat timeout, a run of malformed frames, or a provider delivering updates late are stale; a long gap with no book change while frames keep arriving is a quiet market and stays fresh. Provider lag is measured against a rolling baseline of delivery delays, so a constant clock offset in either direction is not lag. Duplicate or out-of-order revisions never make a book look fresher. quoteFromDepth() is the only sanctioned way to derive a suggested price from depth and returns null unless the feed is fresh with an applied snapshot. The shared subscription now records this from the frames it receives, and the order book panel shows the existing source health badge. Verification: 42 pure-logic tests, 11 stream tests through a fake WebSocket with an explicit MSW snapshot handler, 9 hook tests and 3 panel tests, all passing. These are mocked-transport tests; no live Binance verification. The repository gate (lint, typecheck, check:tokens, tests, build) does not pass on upstream main independent of this change: a syntax error in useOrderEventPolling.ts breaks typecheck and build, and pre-existing lint, token and test failures remain. The failing test set is identical with and without this change. --- .../orderbook/OrderBookPanel.test.tsx | 56 +++ .../components/orderbook/OrderBookPanel.tsx | 8 +- .../trade/hooks/useSourceFreshness.test.ts | 156 +++++++ .../trade/hooks/useSourceFreshness.ts | 59 +++ .../lib/market-data-stream.freshness.test.ts | 240 ++++++++++ .../features/trade/lib/market-data-stream.ts | 80 +++- .../trade/lib/source-freshness.test.ts | 441 ++++++++++++++++++ .../features/trade/lib/source-freshness.ts | 381 +++++++++++++++ 8 files changed, 1418 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/features/trade/hooks/useSourceFreshness.test.ts create mode 100644 apps/web/src/features/trade/hooks/useSourceFreshness.ts create mode 100644 apps/web/src/features/trade/lib/market-data-stream.freshness.test.ts create mode 100644 apps/web/src/features/trade/lib/source-freshness.test.ts create mode 100644 apps/web/src/features/trade/lib/source-freshness.ts 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/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 } +} From c1401d08db08bf9c2cee409d2ab5358cbbdb67ac Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:17:54 +0100 Subject: [PATCH 2/6] docs(changelog): note order book source health (OB-049) --- .changelog/unreleased/818-ob-049-source-freshness.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changelog/unreleased/818-ob-049-source-freshness.md 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. From eb6c54627cfdc47b3fe851d90eec7eb16814ffcb Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:44:33 +0100 Subject: [PATCH 3/6] feat(trade): add order book grouping, size unit and layout preferences (OB-052, OB-053) Pure, exact-integer logic for the depth view's display controls, plus a persisted store. Grouping is offered as multiples of the market's native tick and stored per market as a multiplier, so switching between markets with different tick sizes keeps each market's own choice. Stored values are never trusted: they are sanitized against the market's current metadata, a value the market no longer offers is reset (and reported), and unusable metadata offers no grouping at all. Quote-unit sizes are exact (price x size carries priceScale + sizeScale decimals), and bucket prices never appear better than a level they contain. Layouts (both, bids only, asks only) allocate rows deterministically, keep rows best-first from the spread, and scale depth bars against what is actually visible. The store caps remembered markets, recovers from corrupt storage and holds no market data. There is no market-metadata source in the repo yet, so these take metadata as input rather than inventing tick sizes. --- .../trade/lib/orderbook/display-prefs.test.ts | 273 ++++++++++++++++++ .../trade/lib/orderbook/display-prefs.ts | 177 ++++++++++++ .../trade/lib/orderbook/layout.test.ts | 143 +++++++++ .../features/trade/lib/orderbook/layout.ts | 85 ++++++ .../store/orderbook-display-store.test.ts | 211 ++++++++++++++ .../trade/store/orderbook-display-store.ts | 146 ++++++++++ 6 files changed, 1035 insertions(+) create mode 100644 apps/web/src/features/trade/lib/orderbook/display-prefs.test.ts create mode 100644 apps/web/src/features/trade/lib/orderbook/display-prefs.ts create mode 100644 apps/web/src/features/trade/lib/orderbook/layout.test.ts create mode 100644 apps/web/src/features/trade/lib/orderbook/layout.ts create mode 100644 apps/web/src/features/trade/store/orderbook-display-store.test.ts create mode 100644 apps/web/src/features/trade/store/orderbook-display-store.ts 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/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 +} From 700279b2270fe9b272773222e4764a1f8407d3c5 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:44:33 +0100 Subject: [PATCH 4/6] feat(trade): add depth controls and depth panel (OB-052, OB-053) DepthControls uses the shared Select and RadioGroup, with explicit accessible names carried in visually hidden label text (the shared radio links itself to its wrapping label, which overrides aria-label). DepthPanel shows the market identity, spread and freshness badge in every layout, keys rows by price, and has no transitions on the ladder so re-sorting rows does not shimmer. The panel receives levels as props from whatever owns the live subscription, so switching layout, grouping or unit only recomputes a display view and cannot remount the feed; a test drives 20 rapid switches and asserts one mount. When a market's metadata becomes unusable a stored grouping is reset rather than kept. Not wired into OrderBookPanel: that panel intentionally shows reference data only until a verified matching source exists, and there is no market-metadata source yet. No new motion was added, so the animation skills named in the issue (not present in the repository) were not needed. --- .../components/orderbook/DepthControls.tsx | 118 +++++++ .../components/orderbook/DepthPanel.test.tsx | 311 ++++++++++++++++++ .../trade/components/orderbook/DepthPanel.tsx | 170 ++++++++++ 3 files changed, 599 insertions(+) create mode 100644 apps/web/src/features/trade/components/orderbook/DepthControls.tsx create mode 100644 apps/web/src/features/trade/components/orderbook/DepthPanel.test.tsx create mode 100644 apps/web/src/features/trade/components/orderbook/DepthPanel.tsx diff --git a/apps/web/src/features/trade/components/orderbook/DepthControls.tsx b/apps/web/src/features/trade/components/orderbook/DepthControls.tsx new file mode 100644 index 00000000..4cd7dc3c --- /dev/null +++ b/apps/web/src/features/trade/components/orderbook/DepthControls.tsx @@ -0,0 +1,118 @@ +import { RadioGroup, RadioGroupItem } from "@workspace/ui/components/radio-group" +import { VisuallyHidden } from "@workspace/ui/components/visually-hidden" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/select" +import { + + + groupingOptions +} from "../../lib/orderbook/display-prefs" +import { isBookLayout } from "../../lib/orderbook/layout" +import type {MarketDisplayMetadata, SizeUnit} from "../../lib/orderbook/display-prefs"; +import type {BookLayout} from "../../lib/orderbook/layout"; + +type Props = { + /** Null or unusable metadata disables the controls that depend on it. */ + metadata: MarketDisplayMetadata | null + groupingMultiplier: number + unit: SizeUnit + layout: BookLayout + onGroupingChange: (multiplier: number) => void + onUnitChange: (unit: SizeUnit) => void + onLayoutChange: (layout: BookLayout) => void +} + +const LAYOUT_LABELS: ReadonlyArray<{ value: BookLayout; text: string; name: string }> = [ + { value: "both", text: "Both", name: "Show bids and asks" }, + { value: "bids", text: "Bids", name: "Show bids only" }, + { value: "asks", text: "Asks", name: "Show asks only" }, +] + +/** + * OB-052 / OB-053: grouping, size unit and layout controls for the depth view. + * + * All three are real form controls with explicit accessible names, so they work + * with a keyboard (arrow keys move within a radio group; the select opens with + * Enter/Space/arrow keys) and announce the unit rather than just a number. + */ +export function DepthControls({ + metadata, + groupingMultiplier, + unit, + layout, + onGroupingChange, + onUnitChange, + onLayoutChange, +}: Props) { + const options = groupingOptions(metadata) + const canGroup = options.length > 0 && metadata !== null + + return ( +
+ + + { + if (value === "base" || value === "quote") onUnitChange(value) + }} + > + + + + + { + if (isBookLayout(value)) onLayoutChange(value) + }} + > + {LAYOUT_LABELS.map(({ value, text, name }) => ( + + ))} + +
+ ) +} diff --git a/apps/web/src/features/trade/components/orderbook/DepthPanel.test.tsx b/apps/web/src/features/trade/components/orderbook/DepthPanel.test.tsx new file mode 100644 index 00000000..6798364a --- /dev/null +++ b/apps/web/src/features/trade/components/orderbook/DepthPanel.test.tsx @@ -0,0 +1,311 @@ +import { useEffect } from "react" +import { cleanup, render, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { useOrderBookDisplayStore } from "../../store/orderbook-display-store" +import { DepthPanel } from "./DepthPanel" +import type { SourceHealth } from "../../hooks/useSourceHealth" +import type { BookLevel } from "../../lib/orderbook/book-reducer" +import type { MarketDisplayMetadata } from "../../lib/orderbook/display-prefs" + +const BTC: MarketDisplayMetadata = { + marketId: "BTC-USD", + baseSymbol: "BTC", + quoteSymbol: "USD", + priceScale: 2, + sizeScale: 4, + tickSize: 1n, +} +const XLM: MarketDisplayMetadata = { + marketId: "XLM-USD", + baseSymbol: "XLM", + quoteSymbol: "USD", + priceScale: 5, + sizeScale: 1, + tickSize: 10n, +} + +const L = (price: bigint, size: bigint): BookLevel => ({ price, size }) + +// 0.01 price atoms, 0.0001 size atoms. +const BIDS = [L(10_007n, 10_000n), L(10_005n, 20_000n), L(10_004n, 5_000n), L(9_996n, 30_000n)] +const ASKS = [L(10_008n, 10_000n), L(10_010n, 20_000n), L(10_013n, 5_000n), L(10_020n, 30_000n)] + +const LIVE: SourceHealth = { + status: "connected", + lastUpdateTime: 1, + staleDuration: null, + reconnectAttempt: 0, + isExecutable: true, + message: "Live market data", +} + +function priceCells(): Array { + return screen + .getAllByRole("row") + .filter((row) => within(row).queryAllByRole("cell").length > 0) + .map((row) => within(row).getAllByRole("cell")[0].textContent) +} + +beforeEach(() => { + localStorage.clear() + useOrderBookDisplayStore.setState({ layout: "both", byMarket: {} }) +}) + +afterEach(cleanup) + +describe("layouts (OB-053)", () => { + it("both: asks above the spread worst-first, bids below best-first", () => { + render() + expect(priceCells()).toEqual([ + "100.20", "100.13", "100.10", "100.08", // asks, best ask next to the spread + "100.07", "100.05", "100.04", "99.96", // bids, best bid first + ]) + }) + + it("bids only: only bids, best-first", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name: "Show bids only" })) + expect(priceCells()).toEqual(["100.07", "100.05", "100.04"]) + }) + + it("asks only: only asks, still worst-first toward the spread", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name: "Show asks only" })) + expect(priceCells()).toEqual(["100.13", "100.10", "100.08"]) + }) + + it.each(["Show bids and asks", "Show bids only", "Show asks only"])( + "%s keeps the market identity, the spread and the freshness badge visible", + async (name) => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name })) + + expect(screen.getByRole("heading", { name: "BTC/USD" })).toBeInTheDocument() + expect(screen.getByRole("separator", { name: "Spread" })).toHaveTextContent("0.01") + expect(screen.getByText("Live")).toBeInTheDocument() + }, + ) + + it("exposes the layout controls with explicit accessible names", () => { + render() + const group = screen.getByRole("radiogroup", { name: "Order book layout" }) + expect(within(group).getAllByRole("radio")).toHaveLength(3) + // Each option is announced by its full action, not the short visible text. + for (const name of ["Show bids and asks", "Show bids only", "Show asks only"]) { + expect(within(group).getByRole("radio", { name })).toBeInTheDocument() + } + }) + + it("the layout is reachable and changeable by keyboard alone", async () => { + const user = userEvent.setup({ delay: null }) + render() + screen.getByRole("radio", { name: "Show bids and asks" }).focus() + await user.keyboard("{ArrowRight}") + expect(screen.getByRole("radio", { name: "Show bids only" })).toBeChecked() + expect(useOrderBookDisplayStore.getState().layout).toBe("bids") + }) + + it("preserves the selected grouping and unit across layouts", async () => { + const user = userEvent.setup({ delay: null }) + useOrderBookDisplayStore.getState().setGrouping(BTC, 5) + useOrderBookDisplayStore.getState().setUnit("BTC-USD", "quote") + render() + + await user.click(screen.getByRole("radio", { name: "Show bids only" })) + await user.click(screen.getByRole("radio", { name: "Show bids and asks" })) + + // 0.05 grouping: asks round up (100.08 -> 100.10), bids round down (100.07 -> 100.05). + expect(priceCells()).toContain("100.10") + expect(priceCells()).toContain("100.05") + expect(screen.getByRole("radio", { name: "Size in USD" })).toBeChecked() + expect(screen.getByRole("table", { name: /size in USD/ })).toBeInTheDocument() + }) +}) + +describe("grouping (OB-052)", () => { + it("aggregates rows to the chosen grouping using the exact fixtures", async () => { + const user = userEvent.setup({ delay: null }) + render() + + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "0.05" })) + + await waitFor(() => + expect(priceCells()).toEqual([ + "100.20", "100.15", "100.10", // asks: 100.08+100.10 -> 100.10, 100.13 -> 100.15 + "100.05", "100.00", "99.95", // bids: 100.07+100.05 -> 100.05, 100.04 -> 100.00, 99.96 -> 99.95 + ]), + ) + }) + + it("does not change the spread, which comes from the canonical book", async () => { + const user = userEvent.setup({ delay: null }) + render() + const before = screen.getByRole("separator", { name: "Spread" }).textContent + + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "10" })) + + await waitFor(() => expect(useOrderBookDisplayStore.getState().byMarket["BTC-USD"]?.groupingMultiplier).toBe(1000)) + expect(screen.getByRole("separator", { name: "Spread" }).textContent).toBe(before) + }) + + it("does not modify the levels it was given", async () => { + const bids = Object.freeze(BIDS.map((l) => Object.freeze({ ...l }))) + const asks = Object.freeze(ASKS.map((l) => Object.freeze({ ...l }))) + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "0.5" })) + expect(bids).toEqual(BIDS) + expect(asks).toEqual(ASKS) + }) + + it("offers groupings from the market's own tick size", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + const options = await screen.findAllByRole("option") + expect(options.map((o) => o.textContent)).toEqual(["0.0001", "0.0002", "0.0005", "0.001", "0.005", "0.01", "0.05", "0.1"]) + }) + + it("keeps each market's grouping when switching between markets", async () => { + const user = userEvent.setup({ delay: null }) + const { rerender } = render() + await user.click(screen.getByRole("combobox", { name: "Price grouping" })) + await user.click(await screen.findByRole("option", { name: "0.5" })) + await waitFor(() => expect(useOrderBookDisplayStore.getState().byMarket["BTC-USD"]?.groupingMultiplier).toBe(50)) + + rerender() + expect(screen.getByRole("heading", { name: "XLM/USD" })).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toHaveTextContent("0.0001") // XLM untouched: native + + rerender() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toHaveTextContent("0.5") + }) + + it("resets a stored grouping the market stops offering", async () => { + useOrderBookDisplayStore.getState().setGrouping(BTC, 100) + const { rerender } = render() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toHaveTextContent("1") + + // Precision metadata becomes unusable (capabilities change). + rerender() + await waitFor(() => + expect(useOrderBookDisplayStore.getState().byMarket["BTC-USD"]?.groupingMultiplier).toBe(1), + ) + }) +}) + +describe("units (OB-052)", () => { + it("shows base sizes with the base symbol by default", () => { + render() + expect(screen.getByRole("columnheader", { name: "Size (BTC)" })).toBeInTheDocument() + expect(screen.getByRole("radio", { name: "Size in BTC" })).toBeChecked() + }) + + it("switches to exact quote values and labels them in the quote symbol", async () => { + const user = userEvent.setup({ delay: null }) + render() + await user.click(screen.getByRole("radio", { name: "Size in USD" })) + + expect(screen.getByRole("columnheader", { name: "Size (USD)" })).toBeInTheDocument() + // Best bid: 100.07 * 1.0000 BTC = 100.07 USD. + const bidRow = screen.getAllByRole("row").at(-1)! + expect(within(bidRow).getAllByRole("cell")[1]).toHaveTextContent("100.07") + }) + + it("the unit is remembered per market", async () => { + const user = userEvent.setup({ delay: null }) + const { rerender } = render() + await user.click(screen.getByRole("radio", { name: "Size in USD" })) + + rerender() + expect(screen.getByRole("radio", { name: "Size in XLM" })).toBeChecked() + rerender() + expect(screen.getByRole("radio", { name: "Size in USD" })).toBeChecked() + }) +}) + +describe("stability while the feed is live (OB-053)", () => { + it("rapid layout, grouping and unit switches never remount the feed that owns the levels", async () => { + let mounts = 0 + let unmounts = 0 + function Feed() { + // Stands in for the component that holds the live subscription. + useEffect(() => { + mounts++ + return () => { + unmounts++ + } + }, []) + return + } + const user = userEvent.setup({ delay: null }) + render() + + for (let i = 0; i < 4; i++) { + await user.click(screen.getByRole("radio", { name: "Show bids only" })) + await user.click(screen.getByRole("radio", { name: "Show asks only" })) + await user.click(screen.getByRole("radio", { name: "Show bids and asks" })) + await user.click(screen.getByRole("radio", { name: "Size in USD" })) + await user.click(screen.getByRole("radio", { name: "Size in BTC" })) + } + + expect(mounts).toBe(1) + expect(unmounts).toBe(0) + expect(screen.getByRole("heading", { name: "BTC/USD" })).toBeInTheDocument() + }, 20_000) + + it("keeps the same DOM nodes for rows that persist across a book update", () => { + const { rerender } = render() + const rows = () => screen.getAllByRole("row") + const beforeRow = rows().find((r) => r.textContent.startsWith("100.07"))! + + // A new update: same levels plus one more bid deeper in the book. + rerender() + + const afterRow = rows().find((r) => r.textContent.startsWith("100.07"))! + expect(afterRow).toBe(beforeRow) + }) + + it("does not animate row movement or fills (no transitions on the ladder)", () => { + render() + // The ladder itself; the shared select and radio controls carry their own focus styling. + const ladder = screen.getByRole("table") + for (const el of [ladder, ...Array.from(ladder.querySelectorAll("*"))]) { + expect(el.className.toString()).not.toMatch(/transition|animate|duration/) + } + }) +}) + +describe("unusable market metadata", () => { + it("explains why depth is unavailable and disables the precision controls", () => { + render() + expect(screen.getByText(/precision is unavailable/i)).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: "Price grouping" })).toBeDisabled() + expect(screen.queryAllByRole("row")).toHaveLength(0) + }) + + it("handles no metadata at all", () => { + render() + expect(screen.getByRole("heading", { name: "Market unavailable" })).toBeInTheDocument() + }) +}) + +describe("empty and crossed books", () => { + it("says there is no depth to show", () => { + render() + expect(screen.getByText("No depth to show.")).toBeInTheDocument() + expect(screen.getByRole("separator", { name: "Spread" })).toHaveTextContent("—") + }) + + it("marks a crossed book instead of showing a negative spread", () => { + render() + expect(screen.getByRole("separator", { name: "Spread" })).toHaveTextContent("crossed") + }) +}) diff --git a/apps/web/src/features/trade/components/orderbook/DepthPanel.tsx b/apps/web/src/features/trade/components/orderbook/DepthPanel.tsx new file mode 100644 index 00000000..ffb4205b --- /dev/null +++ b/apps/web/src/features/trade/components/orderbook/DepthPanel.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo } from "react" +import { cn } from "@workspace/ui/lib/utils" +import { buildDepth } from "../../lib/orderbook/depth" +import { + + formatBucketPrice, + formatScaled, + isValidMetadata, + rowSizeDisplay, + tickFor +} from "../../lib/orderbook/display-prefs" +import { depthFraction, selectVisibleDepth } from "../../lib/orderbook/layout" +import { selectDisplayPrefs, useOrderBookDisplayStore } from "../../store/orderbook-display-store" +import { DepthControls } from "./DepthControls" +import { SourceHealthBadge } from "./SourceHealthBadge" +import type {VisibleDepth} from "../../lib/orderbook/layout"; +import type {MarketDisplayMetadata} from "../../lib/orderbook/display-prefs"; +import type {DepthRow} from "../../lib/orderbook/depth"; +import type { BookLevel } from "../../lib/orderbook/book-reducer" +import type { SourceHealth } from "../../hooks/useSourceHealth" + +type Props = { + metadata: MarketDisplayMetadata | null + /** Canonical levels, in any order. Never modified here. */ + bids: ReadonlyArray + asks: ReadonlyArray + /** Rows available for depth, excluding the spread row. */ + rows: number + /** Freshness of the feed the levels came from; shown in every layout. */ + health?: SourceHealth | null + className?: string +} + +function Row({ + row, + side, + visible, + metadata, + unit, +}: { + row: DepthRow + side: "bid" | "ask" + visible: VisibleDepth + metadata: MarketDisplayMetadata + unit: "base" | "quote" +}) { + const display = rowSizeDisplay(row, unit, metadata) + const fill = `${(depthFraction(row, visible) * 100).toFixed(1)}%` + return ( +
+ {/* Depth bar. Deliberately not animated: rows re-sort as the book moves and a + transition here would make the ladder shimmer rather than clarify anything. */} + + ) +} + +/** + * OB-052 / OB-053: the depth ladder with grouping, unit and layout controls. + * + * The live feed is NOT owned here: levels arrive as props from a parent that + * holds the subscription, so switching layout, grouping or unit only recomputes + * a display view and can never remount or restart the feed. + * + * Display only. Bucket prices are labels, not executable prices, and nothing + * here feeds a price into an order ticket. + */ +export function DepthPanel({ metadata, bids, asks, rows, health = null, className }: Props) { + const layout = useOrderBookDisplayStore((s) => s.layout) + const setLayout = useOrderBookDisplayStore((s) => s.setLayout) + const setGrouping = useOrderBookDisplayStore((s) => s.setGrouping) + const setUnit = useOrderBookDisplayStore((s) => s.setUnit) + const reconcileMarket = useOrderBookDisplayStore((s) => s.reconcileMarket) + const byMarket = useOrderBookDisplayStore((s) => s.byMarket) + + const usable = isValidMetadata(metadata) ? metadata : null + + // Replace stored choices the market no longer supports (precision changed, etc.). + // Runs for unusable metadata too: a market that lost its precision can no longer + // honour a stored grouping, and that has to be reset rather than kept. + useEffect(() => { + if (metadata?.marketId) reconcileMarket(metadata) + }, [metadata, reconcileMarket]) + + const prefs = usable ? selectDisplayPrefs({ byMarket }, usable) : { groupingMultiplier: 1, unit: "base" as const } + const { groupingMultiplier } = prefs + + // Depends on the grouping only: the unit changes how a row is printed, not what it contains. + const depth = useMemo( + () => (usable ? buildDepth(bids, asks, { tick: tickFor({ groupingMultiplier, unit: "base" }, usable) }) : null), + [usable, bids, asks, groupingMultiplier], + ) + const visible = useMemo(() => (depth ? selectVisibleDepth(depth, layout, rows) : null), [depth, layout, rows]) + + return ( +
+
+ {/* Market identity is part of every layout. */} +

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

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

No depth to show.

+ ) : null} +
+ ) : ( +

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

+ )} +
+ ) +} From e3d247bd7ba6e228c810be0664baea4dd34c4d83 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:44:33 +0100 Subject: [PATCH 5/6] test(trade): add feed replay and execution conformance suite (OB-050) 47 deterministic scenarios over the production book reducer and order lifecycle derivation: duplicates, disconnect and reconnect, missing revisions, partial fills and cancellation races, plus seeded randomized convergence. The expected book comes from an independent oracle rather than the reducer under test, and every arrival permutation of each cancel/fill race is checked. Mutation checks confirm it has teeth: breaking the reducer's gap check, duplicate check or reset, or making the stage derivation call a partial fill filled, each fails the suite. README.md names the tested modules and their revisions and separates what is mocked from what is not verified. All fixtures are hand-built; nothing was verified against a live venue or indexer, and order/fill events go through a test-support reference fold because the app does not yet consume them as a stream. --- .../trade/lib/orderbook/conformance/README.md | 78 ++++ .../orderbook/conformance/conformance.test.ts | 354 ++++++++++++++++++ .../lib/orderbook/conformance/feed-replay.ts | 126 +++++++ .../lib/orderbook/conformance/order-fold.ts | 91 +++++ 4 files changed, 649 insertions(+) create mode 100644 apps/web/src/features/trade/lib/orderbook/conformance/README.md create mode 100644 apps/web/src/features/trade/lib/orderbook/conformance/conformance.test.ts create mode 100644 apps/web/src/features/trade/lib/orderbook/conformance/feed-replay.ts create mode 100644 apps/web/src/features/trade/lib/orderbook/conformance/order-fold.ts 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 } +} From c09073cc41585f97c9a6776d5dd2f6345ad08f30 Mon Sep 17 00:00:00 2001 From: Precious Akpan <49040483+precious-akpan@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:45:20 +0100 Subject: [PATCH 6/6] docs(changelog): note order book depth controls (OB-052, OB-053) --- .changelog/unreleased/819-ob-052-053-depth-controls.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changelog/unreleased/819-ob-052-053-depth-controls.md 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.