"use client"; import { motion, useReducedMotion } from "motion/react"; import { createContext, useContext, type ReactNode } from "react"; import { SPRING_PANEL } from "@/lib/ease"; import { OrderBookDepthRow } from "./order-book/depth-row"; import { cn } from "@/lib/utils"; import { buildOrderBook } from "./order-book/model"; export type OrderBookLevel = { price: number; size: number }; export interface OrderBookProps { /** Snapshot of buy levels. Equal prices are combined; invalid and zero sizes are omitted. */ bids: readonly OrderBookLevel[]; /** Snapshot of sell levels. Supply new arrays when the book changes. */ asks: readonly OrderBookLevel[]; /** Number of nearest levels to show on each side. */ levels?: number; /** Last traded price. Omit to show the midpoint of the best bid and ask. */ lastPrice?: number; label?: string; baseSymbol?: string; quoteSymbol?: string; formatPrice?: (value: number) => string; formatSize?: (value: number) => string; children?: ReactNode; className?: string; } const priceFormatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); const sizeFormatter = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1, }); const defaultPrice = (value: number) => priceFormatter.format(value); const defaultSize = (value: number) => sizeFormatter.format(value); function useOrderBookModel({ bids, asks, levels = 9, lastPrice, baseSymbol = "", quoteSymbol = "USD", formatPrice = defaultPrice, formatSize = defaultSize, }: OrderBookProps) { const book = buildOrderBook(bids, asks, levels); return { ...book, price: lastPrice != null && Number.isFinite(lastPrice) && lastPrice > 0 ? lastPrice : book.midpoint, baseSymbol, quoteSymbol, formatPrice, formatSize, reduce: useReducedMotion(), }; } const OrderBookContext = createContext | null>(null); export function useOrderBook() { const context = useContext(OrderBookContext); if (!context) throw new Error("Order book parts must be rendered inside OrderBook."); return context; } /** A snapshot-driven depth ladder. No timers or invented market data live in the chart. */ export function OrderBook({ children, className, label = "Order book", ...props }: OrderBookProps) { const model = useOrderBookModel(props); return (
{children === undefined ? ( <> ) : ( children )}
); } export function OrderBookHeader({ className }: { className?: string }) { const { baseSymbol, quoteSymbol } = useOrderBook(); return ( ); } export function OrderBookSide({ side, className }: { side: "bid" | "ask"; className?: string }) { const { bids, asks, maxTotal, formatPrice, formatSize, reduce, baseSymbol, quoteSymbol } = useOrderBook(); const rows = side === "ask" ? [...asks].reverse() : bids; const color = side === "ask" ? "text-rose-600 dark:text-rose-400" : "text-emerald-700 dark:text-emerald-400"; return ( {rows.length === 0 ? ( ) : ( rows.map((row, index) => ( )) )}
Price {quoteSymbol} Size {baseSymbol} Cumulative total {baseSymbol}
No {side === "ask" ? "asks" : "bids"}
); } export function OrderBookSpread({ className }: { className?: string }) { const { price, spread, midpoint, formatPrice } = useOrderBook(); return (
Reference price: {price == null ? "—" : formatPrice(price)} {spread == null || midpoint == null ? "Spread unavailable" : spread < 0 ? "Crossed book" : `Spread ${formatPrice(spread)} · ${((spread / midpoint) * 100).toFixed(3)}%`}
); } /** Balance of the displayed depth, not the entire exchange order book. */ export function OrderBookBalance({ className }: { className?: string }) { const { bidTotal, askTotal, reduce } = useOrderBook(); const total = bidTotal + askTotal; const ratio = total ? bidTotal / total : 0.5; return (
Bids {total ? `${(ratio * 100).toFixed(1)}%` : "—"} Visible depth Asks {total ? `${((1 - ratio) * 100).toFixed(1)}%` : "—"}
); }