diff --git a/.gitignore b/.gitignore index ea41b5d..936ff7f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ tokens/* !tokens/*.example.json !tokens/*.example.yml !tokens/*.example.yaml +gitea/token node_modules/ dist/ diff --git a/apps/visualizer/__start b/apps/visualizer/__start index 8b9ad5f..501daac 100644 --- a/apps/visualizer/__start +++ b/apps/visualizer/__start @@ -6,19 +6,17 @@ ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" cd "${SCRIPT_DIR}" -export API_PROXY_TARGET="${API_PROXY_TARGET:-https://trade.mpabi.pl}" -export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}" +DEFAULT_PROXY_TARGET="${VISUALIZER_PROXY_TARGET:-${TRADE_UI_URL:-${TRADE_VPS_URL:-https://trade.mpabi.pl}}}" +export API_PROXY_TARGET="${API_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}" +export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}" export VITE_API_URL="${VITE_API_URL:-/api}" export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}" export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}" -if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then - if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then - export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json" - else - echo "Missing basic auth config for VPS proxy." - echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2 - fi +# Safety: avoid passing stale auth env vars into Hasura WS unless explicitly enabled. +if [[ "${VISUALIZER_USE_HASURA_AUTH:-}" != "1" ]]; then + unset VITE_HASURA_AUTH_TOKEN + unset VITE_HASURA_ADMIN_SECRET fi npm run dev diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index 38f842f..a622480 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from 'react'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocalStorageState } from './app/hooks/useLocalStorageState'; import AppShell from './layout/AppShell'; import ChartPanel from './features/chart/ChartPanel'; @@ -17,6 +17,78 @@ import { useDlobL2 } from './features/market/useDlobL2'; import { useDlobSlippage } from './features/market/useDlobSlippage'; import { useDlobDepthBands } from './features/market/useDlobDepthBands'; import DlobDashboard from './features/market/DlobDashboard'; +import ContractCostsPanel from './features/contracts/ContractCostsPanel'; + +type PaneId = 'chart' | 'dlob' | 'costsActive' | 'costsNew'; + +// Order matters: missing panes are appended in this order; last is default "top". +const ALL_PANES: PaneId[] = ['chart', 'dlob', 'costsActive', 'costsNew']; + +function makePaneRecord(factory: (id: PaneId) => T): Record { + const out: any = {}; + for (const id of ALL_PANES) out[id] = factory(id); + return out as Record; +} + +function normalizePaneOrder(raw: unknown): PaneId[] { + const out: PaneId[] = []; + const arr = Array.isArray(raw) ? raw : []; + for (const v of arr) { + if (v === 'chart' || v === 'dlob' || v === 'costsActive' || v === 'costsNew') { + if (!out.includes(v)) out.push(v); + } + } + for (const id of ALL_PANES) { + if (!out.includes(id)) out.push(id); + } + return out; +} + +function normalizeLayerOpacity(raw: unknown): Record { + const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial>; + const clamp = (v: unknown, fallback: number) => { + const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(1, Math.max(0, n)); + }; + return makePaneRecord((id) => clamp((input as any)[id], 1)); +} + +function normalizeLayerToggle(raw: unknown, fallback: boolean): Record { + const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial>; + const toBool = (v: unknown) => (typeof v === 'boolean' ? v : fallback); + return makePaneRecord((id) => toBool((input as any)[id])); +} + +function normalizeLayerFactor(raw: unknown, fallback: number, min: number, max: number): Record { + const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial>; + const clamp = (v: unknown) => { + const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); + }; + return makePaneRecord((id) => clamp((input as any)[id])); +} + +function reorderList(items: T[], from: T, to: T): T[] { + if (from === to) return items.slice(); + const next = items.filter((x) => x !== from); + const idx = next.indexOf(to); + if (idx < 0) return next.concat(from); + next.splice(idx, 0, from); + return next; +} + +function clampNumber(v: number, min: number, max: number): number { + if (!Number.isFinite(v)) return min; + return Math.min(max, Math.max(min, v)); +} + +function stepByWheel(e: React.WheelEvent, step: number): number { + // Wheel up => increase, wheel down => decrease + if (e.deltaY === 0) return 0; + return e.deltaY < 0 ? step : -step; +} function envNumber(name: string, fallback: number): number { const v = (import.meta as any).env?.[name]; @@ -32,7 +104,8 @@ function envString(name: string, fallback: string): string { function formatUsd(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return '—'; - if (v >= 1000) return `$${v.toFixed(0)}`; + if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`; if (v >= 1) return `$${v.toFixed(2)}`; return `$${v.toPrecision(4)}`; } @@ -42,9 +115,36 @@ function formatQty(v: number | null | undefined, decimals: number): string { return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } -function orderbookBarStyle(scale: number): CSSProperties { - const s = Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0; - return { ['--ob-bar-scale' as any]: s } as CSSProperties; +function formatCompact(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + const abs = Math.abs(v); + if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`; + if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`; + if (abs >= 1) return v.toFixed(2); + return v.toPrecision(4); +} + +function clamp01(scale: number): number { + return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0; +} + +function barCurve(scale01: number): number { + // Makes small rows visible without letting a single wall dominate. + return Math.sqrt(clamp01(scale01)); +} + +function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties { + return { + ['--ob-total-scale' as any]: barCurve(totalScale), + ['--ob-level-scale' as any]: barCurve(levelScale), + } as CSSProperties; +} + +function liquidityStyle(bid: number, ask: number): CSSProperties { + const max = Math.max(1e-9, bid, ask); + const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0; + const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0; + return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties; } type WhoamiResponse = { @@ -112,17 +212,28 @@ export default function App() { function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []); + const normalizeTf = (raw: string): string => { + const v = String(raw || '').trim(); + if (!v) return '1m'; + const lower = v.toLowerCase(); + // keep backwards compatibility with older saved values (e.g. "1D") + if (lower === '1d') return '1d'; + return lower; + }; + const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP')); const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', '')); - const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m')); + const [tfRaw, setTfRaw] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m')); + const tf = useMemo(() => normalizeTf(tfRaw), [tfRaw]); + const setTf = (next: string) => setTfRaw(normalizeTf(next)); const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000)); const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300)); const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true); - const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false); + const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [bottomTab, setBottomTab] = useLocalStorageState< - 'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' - >('trade.bottomTab', 'positions'); + 'dlob' | 'costs' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' + >('trade.bottomTab', envString('VITE_BOTTOM_TAB', 'dlob') as any); const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long'); const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>( 'trade.form.type', @@ -131,6 +242,213 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const [tradePrice, setTradePrice] = useLocalStorageState('trade.form.price', 0); const [tradeSize, setTradeSize] = useLocalStorageState('trade.form.size', 0.1); + const [layoutMode, setLayoutMode] = useLocalStorageState<'grid' | 'stack'>('trade.layoutMode', 'grid'); + const [stackOrderRaw, setStackOrder] = useLocalStorageState('trade.stackOrder', ALL_PANES); + const stackOrder = useMemo(() => normalizePaneOrder(stackOrderRaw), [stackOrderRaw]); + const activePane = stackOrder[stackOrder.length - 1] ?? 'chart'; + const [stackOrderManual, setStackOrderManual] = useLocalStorageState('trade.stackOrderManual', false); + const escRef = useRef(0); + const [stackPanelLocked, setStackPanelLocked] = useLocalStorageState('trade.stackPanelLocked', false); + const [stackPanelOpen, setStackPanelOpen] = useState(true); + const stackPanelHideTimerRef = useRef(null); + const [stackDrawerOpacity, setStackDrawerOpacity] = useLocalStorageState('trade.stackDrawerOpacity', 0.92); + const [stackBackdropOpacity, setStackBackdropOpacity] = useLocalStorageState('trade.stackBackdropOpacity', 0.55); + const [layerOpacityRaw, setLayerOpacity] = useLocalStorageState>('trade.layerOpacity', { + chart: 1, + dlob: 1, + costsActive: 1, + costsNew: 1, + }); + const layerOpacity = useMemo(() => normalizeLayerOpacity(layerOpacityRaw), [layerOpacityRaw]); + const [layerVisibleRaw, setLayerVisible] = useLocalStorageState>('trade.layerVisible', { + chart: true, + dlob: true, + costsActive: false, + costsNew: true, + }); + const layerVisible = useMemo(() => normalizeLayerToggle(layerVisibleRaw, true), [layerVisibleRaw]); + const [layerLockedRaw, setLayerLocked] = useLocalStorageState>('trade.layerLocked', { + chart: false, + dlob: false, + costsActive: false, + costsNew: false, + }); + const layerLocked = useMemo(() => normalizeLayerToggle(layerLockedRaw, false), [layerLockedRaw]); + const [layerBrightnessRaw, setLayerBrightness] = useLocalStorageState>('trade.layerBrightness', { + chart: 1, + dlob: 1, + costsActive: 1, + costsNew: 1, + }); + const layerBrightness = useMemo(() => normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8), [layerBrightnessRaw]); + const [activeContractIdSeen, setActiveContractIdSeen] = useLocalStorageState('trade.activeContractIdSeen', ''); + const [activeContractId] = useLocalStorageState('trade.contractId', ''); + const hasActiveContract = Boolean(activeContractId.trim()); + + useEffect(() => { + const normalized = normalizePaneOrder(stackOrderRaw); + if (normalized.join('|') !== (Array.isArray(stackOrderRaw) ? stackOrderRaw.join('|') : '')) { + setStackOrder(normalized); + } + }, [setStackOrder, stackOrderRaw]); + + useEffect(() => { + const normalized = normalizeLayerOpacity(layerOpacityRaw); + const raw = layerOpacityRaw as any; + if (!raw || normalized.chart !== raw.chart || normalized.dlob !== raw.dlob) setLayerOpacity(normalized); + }, [layerOpacityRaw, setLayerOpacity]); + + useEffect(() => { + const normalized = normalizeLayerToggle(layerVisibleRaw, true); + const raw = layerVisibleRaw as any; + const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]); + if (needsFix) setLayerVisible(normalized); + }, [layerVisibleRaw, setLayerVisible]); + + useEffect(() => { + const normalized = normalizeLayerToggle(layerLockedRaw, false); + const raw = layerLockedRaw as any; + const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]); + if (needsFix) setLayerLocked(normalized); + }, [layerLockedRaw, setLayerLocked]); + + useEffect(() => { + const normalized = normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8); + const raw = layerBrightnessRaw as any; + const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]); + if (needsFix) setLayerBrightness(normalized); + }, [layerBrightnessRaw, setLayerBrightness]); + + // When a contract is "pushed" (contractId becomes non-empty), ensure Active layer is visible and placed + // directly below Costs(New) (unless user reorders later). We do this once per contract id. + useEffect(() => { + const id = activeContractId.trim(); + if (!id) return; + if (id === activeContractIdSeen) return; + + setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), costsActive: true })); + + if (!stackOrderManual) { + setStackOrder((prev) => { + const normalized = normalizePaneOrder(prev); + const without = normalized.filter((p) => p !== 'costsActive'); + const idxNew = without.indexOf('costsNew'); + const insertAt = idxNew >= 0 ? idxNew : Math.max(0, without.length - 1); + without.splice(insertAt, 0, 'costsActive'); + return without; + }); + } + + setActiveContractIdSeen(id); + }, [activeContractId, activeContractIdSeen, setActiveContractIdSeen, setLayerVisible, setStackOrder, stackOrderManual]); + + useEffect(() => { + document.body.classList.toggle('stackMode', layoutMode === 'stack'); + return () => document.body.classList.remove('stackMode'); + }, [layoutMode]); + + useEffect(() => { + const drawer = clampNumber(stackDrawerOpacity, 0.05, 1); + const backdrop = clampNumber(stackBackdropOpacity, 0, 0.95); + document.body.style.setProperty('--stack-drawer-opacity', String(drawer)); + document.body.style.setProperty('--stack-backdrop-opacity', String(backdrop)); + return () => { + document.body.style.removeProperty('--stack-drawer-opacity'); + document.body.style.removeProperty('--stack-backdrop-opacity'); + }; + }, [stackBackdropOpacity, stackDrawerOpacity]); + + useEffect(() => { + if (layoutMode !== 'stack') return; + // Default focus order in stack mode unless the user manually reordered (DnD). + if (!stackOrderManual) { + setStackOrder(() => normalizePaneOrder(ALL_PANES)); + } + setStackPanelOpen(true); + if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current); + stackPanelHideTimerRef.current = null; + }, [layoutMode, setStackOrder, stackOrderManual]); + + useEffect(() => { + return () => { + if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current); + stackPanelHideTimerRef.current = null; + }; + }, []); + + useEffect(() => { + if (layoutMode !== 'stack') return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + const now = Date.now(); + if (now - escRef.current < 800) { + escRef.current = 0; + setLayoutMode('grid'); + return; + } + escRef.current = now; + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [layoutMode, setLayoutMode]); + + const openStackPanel = () => { + if (layoutMode !== 'stack') return; + if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current); + stackPanelHideTimerRef.current = null; + setStackPanelOpen(true); + }; + + const scheduleHideStackPanel = () => { + if (layoutMode !== 'stack') return; + if (stackPanelLocked) return; + if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current); + stackPanelHideTimerRef.current = window.setTimeout(() => { + setStackPanelOpen(false); + stackPanelHideTimerRef.current = null; + }, 1000); + }; + + const enterStack = (pane: PaneId) => { + if (pane === 'dlob') setBottomTab('dlob'); + setLayoutMode('stack'); + setStackOrder((prev) => { + const normalized = normalizePaneOrder(prev); + return normalized.filter((p) => p !== pane).concat(pane); + }); + openStackPanel(); + }; + + const exitStack = () => setLayoutMode('grid'); + + const togglePaneFullscreen = (pane: PaneId) => { + if (layoutMode === 'stack' && activePane === pane) { + exitStack(); + return; + } + enterStack(pane); + }; + + const stackTopFirst = useMemo(() => stackOrder.slice().reverse(), [stackOrder]); + const stackZ = useMemo(() => { + const z: Record = makePaneRecord(() => 2600); + const base = 2600; + const step = 20; + for (let i = 0; i < stackOrder.length; i++) { + const id = stackOrder[i]; + z[id] = base + i * step; + } + return z; + }, [stackOrder]); + + const effectiveLayerOpacity = useMemo(() => { + const out: Record = makePaneRecord(() => 0); + for (const id of ALL_PANES) { + out[id] = layerVisible[id] ? clampNumber(layerOpacity[id] ?? 1, 0, 1) : 0; + } + return out; + }, [layerOpacity, layerVisible]); + useEffect(() => { if (symbol === 'BONK-PERP') { setSymbol('1MBONK-PERP'); @@ -141,6 +459,13 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { } }, [markets, setSymbol, symbol]); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const wanted = (params.get('bottomTab') || params.get('tab') || '').trim(); + const allowed = new Set(['dlob', 'costs', 'positions', 'orders', 'balances', 'orderHistory', 'positionHistory']); + if (wanted && allowed.has(wanted)) setBottomTab(wanted as any); + }, [setBottomTab]); + const { candles, indicators, meta, loading, error, refresh } = useChartData({ symbol, source: source.trim() ? source : undefined, @@ -150,7 +475,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { }); const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol); - const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 14 }); + const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 10 }); const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol); const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol); @@ -160,51 +485,88 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null; const orderbook = useMemo(() => { - if (dlobL2) return { asks: dlobL2.asks, bids: dlobL2.bids, mid: dlobL2.mid as number | null }; - if (!latest) return { asks: [], bids: [], mid: null as number | null }; + if (dlobL2) { + return { + asks: dlobL2.asks, + bids: dlobL2.bids, + mid: dlobL2.mid as number | null, + bestBid: dlobL2.bestBid, + bestAsk: dlobL2.bestAsk, + }; + } + if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: null as number | null }; const mid = latest.close; const step = Math.max(mid * 0.00018, 0.0001); - const levels = 14; + const levels = 10; const asksRaw = Array.from({ length: levels }, (_, i) => ({ price: mid + (i + 1) * step, - size: 0.1 + ((i * 7) % 15) * 0.1, + sizeBase: 0.1 + ((i * 7) % 15) * 0.1, })); const bidsRaw = Array.from({ length: levels }, (_, i) => ({ price: mid - (i + 1) * step, - size: 0.1 + ((i * 5) % 15) * 0.1, + sizeBase: 0.1 + ((i * 5) % 15) * 0.1, })); - let askTotal = 0; + let askTotalBase = 0; + let askTotalUsd = 0; const asks = asksRaw .slice() .reverse() .map((r) => { - askTotal += r.size; - return { ...r, total: askTotal }; + const sizeUsd = r.sizeBase * r.price; + askTotalBase += r.sizeBase; + askTotalUsd += sizeUsd; + return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd }; }); - let bidTotal = 0; + let bidTotalBase = 0; + let bidTotalUsd = 0; const bids = bidsRaw.map((r) => { - bidTotal += r.size; - return { ...r, total: bidTotal }; + const sizeUsd = r.sizeBase * r.price; + bidTotalBase += r.sizeBase; + bidTotalUsd += sizeUsd; + return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd }; }); - return { asks, bids, mid }; + return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null }; }, [dlobL2, latest]); const maxAskTotal = useMemo(() => { let max = 0; - for (const r of orderbook.asks) max = Math.max(max, r.total || 0); + for (const r of orderbook.asks) max = Math.max(max, (r as any).totalUsd || 0); return max; }, [orderbook.asks]); const maxBidTotal = useMemo(() => { let max = 0; - for (const r of orderbook.bids) max = Math.max(max, r.total || 0); + for (const r of orderbook.bids) max = Math.max(max, (r as any).totalUsd || 0); return max; }, [orderbook.bids]); + const maxAskSize = useMemo(() => { + let max = 0; + for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0); + return max; + }, [orderbook.asks]); + + const maxBidSize = useMemo(() => { + let max = 0; + for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0); + return max; + }, [orderbook.bids]); + + const liquidity = useMemo(() => { + const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0; + const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0; + const bestBid = orderbook.bestBid; + const bestAsk = orderbook.bestAsk; + const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null; + const spreadPct = + spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null; + return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct }; + }, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]); + const trades = useMemo(() => { const slice = candles.slice(-24).reverse(); return slice.map((c) => { @@ -294,11 +656,12 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const bucketSeconds = meta?.bucketSeconds ?? 60; return ( - } />} - top={} - main={ -
+ <> + } />} + top={} + main={ +
void }) { onToggleBuild={() => setShowBuild((v) => !v)} seriesLabel={seriesLabel} dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }} + fullscreenOverride={layoutMode === 'stack'} + onToggleFullscreenOverride={() => togglePaneFullscreen('chart')} + fullscreenStyle={ + layoutMode === 'stack' + ? { + zIndex: stackZ.chart, + opacity: effectiveLayerOpacity.chart, + filter: `brightness(${layerBrightness.chart})`, + pointerEvents: activePane === 'chart' ? 'auto' : 'none', + } + : undefined + } /> @@ -386,9 +761,16 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { slippageRows={slippageRows} slippageConnected={slippageConnected} slippageError={slippageError} + isFullscreen={layoutMode === 'stack' && activePane === 'dlob'} + onToggleFullscreen={() => togglePaneFullscreen('dlob')} /> ), }, + { + id: 'costs', + label: 'Costs', + content: , + }, { id: 'positions', label: 'Positions', content:
Positions (next)
}, { id: 'orders', label: 'Orders', content:
Orders (next)
}, { id: 'balances', label: 'Balances', content:
Balances (next)
}, @@ -407,9 +789,9 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { onChange={setBottomTab} />
-
- } - sidebar={ +
+ } + sidebar={ void }) {
Price - Size - Total + Size (USD) + Total (USD)
{orderbook.asks.map((r) => (
0 ? r.total / maxAskTotal : 0)} + style={orderbookRowBarStyle( + maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0, + maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0 + )} > {formatQty(r.price, 3)} - {formatQty(r.size, 2)} - {formatQty(r.total, 2)} + {formatCompact((r as any).sizeUsd)} + {formatCompact((r as any).totalUsd)}
))}
@@ -451,13 +836,37 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
0 ? r.total / maxBidTotal : 0)} + style={orderbookRowBarStyle( + maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0, + maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0 + )} > {formatQty(r.price, 3)} - {formatQty(r.size, 2)} - {formatQty(r.total, 2)} + {formatCompact((r as any).sizeUsd)} + {formatCompact((r as any).totalUsd)}
))} +
+
+ Spread + + {liquidity.spreadAbs == null || liquidity.spreadPct == null + ? '—' + : `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`} + +
+
+ Liquidity (L1–L10) + + {formatUsd(liquidity.bidUsd)} /{' '} + {formatUsd(liquidity.askUsd)} + +
+
+
+
+
+
), @@ -493,8 +902,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { onChange={setTab} /> - } - rightbar={ + } + rightbar={ void }) {
- } - /> + } + /> + + {layoutMode === 'stack' ? ( + <> +
+ + Esc ×2 + + + +
+ } + onMouseEnter={openStackPanel} + onMouseLeave={scheduleHideStackPanel} + > +
UI Opacity
+
+ + +
+ +
+ Drag to reorder, click to focus (top = active). +
+
+ {stackTopFirst.map((pane) => { + const isActive = pane === activePane; + const label = + pane === 'chart' + ? 'Chart' + : pane === 'dlob' + ? 'DLOB' + : pane === 'costsNew' + ? 'Costs (New)' + : 'Costs (Active)'; + const pct = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100); + const isVisible = layerVisible[pane]; + const isLocked = layerLocked[pane]; + const brightPct = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100); + return ( +
{ + if (isLocked) return; + enterStack(pane); + }} + onDragStart={(e) => { + if (isLocked) return; + e.dataTransfer.setData('text/plain', pane); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + if (isLocked) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }} + onDrop={(e) => { + e.preventDefault(); + const dragged = e.dataTransfer.getData('text/plain') as PaneId; + if (!ALL_PANES.includes(dragged)) return; + if (layerLocked[dragged]) return; + if (isLocked) return; + const nextTop = reorderList(stackTopFirst, dragged, pane); + setStackOrder(nextTop.slice().reverse() as any); + setStackOrderManual(true); + }} + > + ⋮⋮ + {label} + + + e.stopPropagation()} + onWheel={(e) => { + e.preventDefault(); + const cur = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100); + const step = e.shiftKey ? 10 : 2; + const next = clampNumber(cur + stepByWheel(e, step), 60, 180); + setLayerBrightness((prev) => ({ ...normalizeLayerFactor(prev, 1, 0.6, 1.8), [pane]: next / 100 })); + }} + disabled={isLocked} + onChange={(e) => + setLayerBrightness((prev) => ({ + ...normalizeLayerFactor(prev, 1, 0.6, 1.8), + [pane]: Number(e.target.value) / 100, + })) + } + /> + {brightPct}% + e.stopPropagation()} + onWheel={(e) => { + e.preventDefault(); + const cur = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100); + const step = e.shiftKey ? 5 : 1; + const next = clampNumber(cur + stepByWheel(e, step), 0, 100); + setLayerOpacity((prev) => ({ ...normalizeLayerOpacity(prev), [pane]: next / 100 })); + }} + disabled={isLocked} + onChange={(e) => + setLayerOpacity((prev) => ({ + ...normalizeLayerOpacity(prev), + [pane]: Number(e.target.value) / 100, + })) + } + /> + {pct}% + {isActive ? active : null} +
+ ); + })} +
+ + + ) : null} + + {layoutMode === 'stack' ?