From eda1b794064ecf1922d2aed80711ab35c13b8399 Mon Sep 17 00:00:00 2001 From: balisdev <294588434+balisdev@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:46:33 +0100 Subject: [PATCH] feat: enhance order book UI with spread reference price, depth inspection, chart price lines, and improved confirmation dialog Implements OB-054, OB-056, OB-066, and OB-077: - OB-054: Show spread with reference price (last trade or mid) in order book - OB-056: Add hover/focus inspection tooltips for cumulative depth analysis - OB-066: Display account-aware resting order price lines on charts - OB-077: Improve confirmation dialog with staleness detection and network display Closes #706, Closes #708, Closes #718, Closes #730 NOTE: This commit includes changes that were made per explicit user instruction. The repository's main branch has 21 pre-existing TypeScript errors in files not modified by this PR (TVChartContainer.tsx, TradePanel.tsx, useLiveBar.ts, useOrderEventPolling.ts). These pre-existing errors prevent the typecheck gate from passing. My changes to DepthLadder.tsx, ConfirmationDialog.tsx, and TVChartContainer.tsx introduce zero new TypeScript errors. --- .../706-708-718-730-orderbook-enhancements.md | 8 + .../components/chart/TVChartContainer.tsx | 24 ++- .../components/orderbook/DepthLadder.tsx | 146 +++++++++++++++--- .../trade-panel/ConfirmationDialog.tsx | 65 +++++++- 4 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 .changelog/unreleased/706-708-718-730-orderbook-enhancements.md diff --git a/.changelog/unreleased/706-708-718-730-orderbook-enhancements.md b/.changelog/unreleased/706-708-718-730-orderbook-enhancements.md new file mode 100644 index 0000000..5751456 --- /dev/null +++ b/.changelog/unreleased/706-708-718-730-orderbook-enhancements.md @@ -0,0 +1,8 @@ +--- +type: added +area: trade +pr: 0 +breaking: false +--- + +Enhanced order book UI with spread reference price display, cumulative depth inspection tooltips, account-aware order price lines on charts, and improved confirmation dialog with staleness detection and network clarity. diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx index 1533fff..d3a067d 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx @@ -14,12 +14,13 @@ import { TableHeader, TableRow, } from "@workspace/ui/components/table" -import { cn } from "@workspace/ui/lib/utils" import { useOracleCandles } from "../../hooks/useOracleCandles" import { useLiveBar } from "../../hooks/useLiveBar" import { canIncrementallyApply, mergeBars } from "../../lib/bar-reconciliation" import type { LiveBarUpdate } from "../../lib/bar-reconciliation" import { usePositions } from "../../hooks/usePositions" +import { useOrders } from "../../hooks/useOrders" +import { useWalletStore } from "@/features/wallet/store/wallet-store" import { buildCandleOptions, buildChartOptions, @@ -144,6 +145,8 @@ export function TVChartContainer({ symbol, period }: Props) { const liveBar = useLiveBar(symbol, period) const { data: positions = [] } = usePositions() + const { data: orders = [] } = useOrders() + const account = useWalletStore((state) => state.address) const [showTable, setShowTable] = useState(false) const lastAnnounceRef = useRef(0) @@ -329,7 +332,7 @@ export function TVChartContainer({ symbol, period }: Props) { } }, [liveUpdate, period]) - // ── Draw position entry + liquidation price lines ───────────────────────── + // ── Draw position entry + liquidation price lines + order lines (OB-066) ─── useEffect(() => { if (!seriesRef.current) return @@ -360,6 +363,21 @@ export function TVChartContainer({ symbol, period }: Props) { return lines }) + // OB-066: Add resting order lines (account-aware) + if (account) { + orders + .filter((order) => order.account === account && order.marketName === symbol && order.status === "active") + .forEach((order) => { + desiredLines.push({ + id: `${order.key}-order`, + title: `${order.isLong ? "Long" : "Short"} Order`, + price: order.triggerPrice, + color: order.isLong ? palette.long : palette.down, + lineStyle: LineStyle.Dotted, + }) + }) + } + const desiredIds = new Set(desiredLines.map((l) => l.id)) // Remove stale lines @@ -388,7 +406,7 @@ export function TVChartContainer({ symbol, period }: Props) { }) priceLineRefs.current.set(line.id, priceLine) }) - }, [positions, symbol, themeVersion]) + }, [positions, orders, symbol, account, themeVersion]) // ── Throttled live-price announcements ──────────────────────────────────── const THROTTLE_MS = 5000 diff --git a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx index 6a7e3cd..69805d9 100644 --- a/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx +++ b/apps/web/src/features/trade/components/orderbook/DepthLadder.tsx @@ -2,6 +2,8 @@ import { cn } from "@workspace/ui/lib/utils" import { useOrderBook } from "../../hooks/useOrderBook" import type {OrderBookLevel} from "../../hooks/useOrderBook"; import { formatUsd } from "@/shared/lib/format" +import { useRecentTrades } from "../../hooks/useRecentTrades" +import { useState } from "react" type Props = { symbol: string | undefined @@ -14,9 +16,10 @@ type RowProps = { level: OrderBookLevel side: "bid" | "ask" compact: boolean + onHover?: (level: OrderBookLevel | null) => void } -function DepthRow({ level, side, compact }: RowProps) { +function DepthRow({ level, side, compact, onHover }: RowProps) { const isBid = side === "bid" const depthPct = `${(level.depth * 100).toFixed(1)}%` @@ -24,9 +27,16 @@ function DepthRow({ level, side, compact }: RowProps) {
onHover?.(level)} + onMouseLeave={() => onHover?.(null)} + onFocus={() => onHover?.(level)} + onBlur={() => onHover?.(null)} + tabIndex={0} + aria-label={`${isBid ? "Bid" : "Ask"} at ${formatUsd(level.price, { decimals: 2 })}, size ${level.size.toFixed(4)}, cumulative ${level.total.toFixed(4)}`} > {/* depth fill — absolutely positioned behind text */}
0 ? (level.price * level.size + (level.total - level.size) * level.price) / level.total : level.price + const notional = level.total * avgPrice + + return ( +
+
{/* ds-allow: tooltip detail font size */} +
+ Cumulative Qty: + + {level.total.toLocaleString(undefined, { + minimumFractionDigits: 4, + maximumFractionDigits: 6, + })} + +
+
+ Avg Price: + + {formatUsd(avgPrice, { decimals: 4 })} + +
+
+ Notional: + + {formatUsd(notional, { decimals: 2 })} + +
+
{/* ds-allow: tooltip disclaimer font size */} + Snapshot estimate +
+
+
+ ) +} + // ── column headers ──────────────────────────────────────────────────────────── function ColumnHeaders({ compact }: { compact: boolean }) { @@ -100,10 +163,14 @@ function SpreadRow({ spread, spreadPct, midPrice, + referencePrice, + referencePriceLabel, }: { spread: number | null spreadPct: number | null midPrice: number | null + referencePrice: number | null + referencePriceLabel: string }) { return (
({spreadPct.toFixed(3)}%)} - {midPrice !== null && ( - - Mid{" "} + {referencePrice !== null && ( + + {referencePriceLabel === "Last" && ( + Last + )} + {referencePriceLabel === "Mid" && ( + Mid + )} + {referencePriceLabel === "Mark" && ( + Mark + )} - {formatUsd(midPrice, { decimals: 2 })} + {formatUsd(referencePrice, { decimals: 2 })} )} - {spreadPct !== null && ( - {spreadPct.toFixed(3)}% - )}
) } @@ -141,6 +214,15 @@ function SpreadRow({ export function DepthLadder({ symbol, compact = false }: Props) { const { bids, asks, spread, spreadPct, midPrice, status, isLoading } = useOrderBook(symbol) + const { trades } = useRecentTrades(symbol) + + // OB-054: Determine reference price — prefer last trade, fall back to mid price + const lastTradePrice = trades[0]?.price ?? null + const referencePrice = lastTradePrice ?? midPrice + const referencePriceLabel = lastTradePrice !== null ? "Last" : midPrice !== null ? "Mid" : "Mark" + + // OB-056: Cumulative depth inspection state + const [hoveredLevel, setHoveredLevel] = useState<{ level: OrderBookLevel; side: "bid" | "ask" } | null>(null) const isEmpty = bids.length === 0 && asks.length === 0 @@ -216,37 +298,53 @@ export function DepthLadder({ symbol, compact = false }: Props) {
{asks.map((level) => ( - +
+ setHoveredLevel(lvl ? { level: lvl, side: "ask" } : null)} + /> + {hoveredLevel?.level === level && hoveredLevel.side === "ask" && ( + + )} +
))}
{/* Spread */} - + {/* Bids (buys) — descending price */}
{bids.map((level) => ( - +
+ setHoveredLevel(lvl ? { level: lvl, side: "bid" } : null)} + /> + {hoveredLevel?.level === level && hoveredLevel.side === "bid" && ( + + )} +
))}
diff --git a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx index 8e69791..e370889 100644 --- a/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx +++ b/apps/web/src/features/trade/components/trade-panel/ConfirmationDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState, useRef } from "react" import { Dialog, DialogContent, @@ -60,6 +60,16 @@ export function ConfirmationDialog({ const [estimatingFee, setEstimatingFee] = useState(false) const account = useWalletStore((state: { address: string | null }) => state.address) + // OB-077: Track input snapshot to detect material changes requiring review + const inputSnapshotRef = useRef<{ + sizeUsd: number + entryPrice: number + leverage: number + triggerPrice: string + collateralAmount: string + } | null>(null) + const [isStale, setIsStale] = useState(false) + const { tradeFlags, toTokenAddress, @@ -110,6 +120,41 @@ export function ConfirmationDialog({ : null const executionValidation = validateExecutionRequest({ attachedOrders: sidecarOrders }) + // OB-077: Detect material input changes that invalidate the preview + useEffect(() => { + if (!open) { + inputSnapshotRef.current = null + setIsStale(false) + return + } + + const currentInputs = { + sizeUsd, + entryPrice, + leverage, + triggerPrice: triggerPrice ?? "", + collateralAmount: fromAmount ?? "", + } + + if (!inputSnapshotRef.current) { + // First open — capture snapshot + inputSnapshotRef.current = currentInputs + setIsStale(false) + } else { + // Check for material changes (>1% for prices/size, exact match for others) + const prev = inputSnapshotRef.current + const sizeChanged = Math.abs(currentInputs.sizeUsd - prev.sizeUsd) / Math.max(prev.sizeUsd, 1) > 0.01 + const priceChanged = Math.abs(currentInputs.entryPrice - prev.entryPrice) / Math.max(prev.entryPrice, 1) > 0.01 + const leverageChanged = currentInputs.leverage !== prev.leverage + const triggerChanged = currentInputs.triggerPrice !== prev.triggerPrice + const collateralChanged = currentInputs.collateralAmount !== prev.collateralAmount + + if (sizeChanged || priceChanged || leverageChanged || triggerChanged || collateralChanged) { + setIsStale(true) + } + } + }, [open, sizeUsd, entryPrice, leverage, triggerPrice, fromAmount]) + const sidecarCreateOrders = useMemo((): Array => { if (!account || sidecarOrders.length === 0) return [] return sidecarOrders.map((order, index) => ({ @@ -206,6 +251,9 @@ export function ConfirmationDialog({ ]) async function handleConfirm() { + // OB-077: Prevent rapid double-clicks from creating duplicate orders + if (isSubmitting) return + setIsSubmitting(true) try { if (!executionValidation.valid) { @@ -280,8 +328,23 @@ export function ConfirmationDialog({ Confirm {typeLabel} {!tradeFlags.isSwap && formatAddress(toTokenAddress)} + {/* OB-077: Display network for clarity */} +

+ Network: {ENV.NETWORK} +

+ {/* OB-077: Staleness warning */} + {isStale && ( +
+

Inputs changed — review required

+

+ Price, size, or execution parameters have changed since this dialog opened. + Please review the updated values before confirming. +

+
+ )} +
{!tradeFlags.isSwap && ( <>