import type { CSSProperties } 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'; import { useChartData } from './features/chart/useChartData'; import TickerBar from './features/tickerbar/TickerBar'; import Card from './ui/Card'; import Tabs from './ui/Tabs'; import MarketHeader from './features/market/MarketHeader'; import Button from './ui/Button'; import TopNav from './layout/TopNav'; import AuthStatus from './layout/AuthStatus'; import LoginScreen from './layout/LoginScreen'; import { useDlobStats } from './features/market/useDlobStats'; 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]; if (v == null) return fallback; const n = Number(v); return Number.isFinite(n) ? n : fallback; } function envString(name: string, fallback: string): string { const v = (import.meta as any).env?.[name]; return v == null ? fallback : String(v); } function formatUsd(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return '—'; 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)}`; } function formatQty(v: number | null | undefined, decimals: number): string { if (v == null || !Number.isFinite(v)) return '—'; return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } 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 = { ok?: boolean; user?: string | null; mode?: string; }; export default function App() { const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); useEffect(() => { let cancelled = false; setAuthLoading(true); fetch('/whoami', { cache: 'no-store' }) .then(async (res) => { const json = (await res.json().catch(() => null)) as WhoamiResponse | null; const u = typeof json?.user === 'string' ? json.user.trim() : ''; return u || null; }) .then((u) => { if (cancelled) return; setUser(u); }) .catch(() => { if (cancelled) return; setUser(null); }) .finally(() => { if (cancelled) return; setAuthLoading(false); }); return () => { cancelled = true; }; }, []); const logout = async () => { try { await fetch('/auth/logout', { method: 'POST' }); } finally { setUser(null); } }; if (authLoading) { return (
Ładowanie…
); } if (!user) { return ; } return void logout()} />; } 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 [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', true); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [bottomTab, setBottomTab] = useLocalStorageState< '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', 'market' ); 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'); return; } if (!markets.includes(symbol)) { setSymbol('SOL-PERP'); } }, [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, tf, limit, pollMs, }); const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol); 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); const latest = candles.length ? candles[candles.length - 1] : null; const first = candles.length ? candles[0] : null; const changePct = 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, 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 = 10; const asksRaw = Array.from({ length: levels }, (_, i) => ({ price: mid + (i + 1) * step, sizeBase: 0.1 + ((i * 7) % 15) * 0.1, })); const bidsRaw = Array.from({ length: levels }, (_, i) => ({ price: mid - (i + 1) * step, sizeBase: 0.1 + ((i * 5) % 15) * 0.1, })); let askTotalBase = 0; let askTotalUsd = 0; const asks = asksRaw .slice() .reverse() .map((r) => { const sizeUsd = r.sizeBase * r.price; askTotalBase += r.sizeBase; askTotalUsd += sizeUsd; return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd }; }); let bidTotalBase = 0; let bidTotalUsd = 0; const bids = bidsRaw.map((r) => { const sizeUsd = r.sizeBase * r.price; bidTotalBase += r.sizeBase; bidTotalUsd += sizeUsd; return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd }; }); 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 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 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) => { const isBuy = c.close >= c.open; return { time: c.time, price: c.close, size: c.volume ?? null, side: isBuy ? ('buy' as const) : ('sell' as const), }; }); }, [candles]); const effectiveTradePrice = useMemo(() => { if (tradeOrderType === 'limit') return tradePrice; return latest?.close ?? tradePrice; }, [latest?.close, tradeOrderType, tradePrice]); const orderValueUsd = useMemo(() => { if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null; if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null; const v = effectiveTradePrice * tradeSize; return Number.isFinite(v) && v > 0 ? v : null; }, [effectiveTradePrice, tradeSize]); const dynamicSlippage = useMemo(() => { if (orderValueUsd == null) return null; const side = tradeSide === 'short' ? 'sell' : 'buy'; const rows = slippageRows.filter((r) => r.side === side).slice(); rows.sort((a, b) => a.sizeUsd - b.sizeUsd); if (!rows.length) return null; const biggest = rows[rows.length - 1]; const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest; return match; }, [orderValueUsd, slippageRows, tradeSide]); const topItems = useMemo( () => [ { key: 'BTC', label: 'BTC', changePct: 1.28, active: false }, { key: 'SOL', label: 'SOL', changePct: 1.89, active: false }, ], [] ); const stats = useMemo(() => { return [ { key: 'last', label: 'Last', value: formatUsd(latest?.close), sub: changePct == null ? ( '—' ) : ( = 0 ? 'pos' : 'neg'}> {changePct >= 0 ? '+' : ''} {changePct.toFixed(2)}% ), }, { key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) }, { key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) }, { key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) }, { key: 'spread', label: 'Spread', value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`, sub: formatUsd(dlob?.spreadAbs ?? null), }, { key: 'dlob', label: 'DLOB', value: dlobConnected ? 'live' : '—', sub: dlobError ? {dlobError} : dlob?.updatedAt || '—', }, { key: 'l2', label: 'L2', value: dlobL2Connected ? 'live' : '—', sub: dlobL2Error ? {dlobL2Error} : dlobL2?.updatedAt || '—', }, ]; }, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error]); const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []); const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]); const bucketSeconds = meta?.bucketSeconds ?? 60; return ( <> } />} top={} main={
Source setSource(e.target.value)} placeholder="(any)" /> } stats={stats} rightSlot={
} /> } > {error ?
{error}
: null}
setShowIndicators((v) => !v)} showBuild={showBuild} 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 } /> 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)
}, { id: 'orderHistory', label: 'Order History', content:
Order history (next)
, }, { id: 'positionHistory', label: 'Position History', content:
Position history (next)
, }, ]} activeId={bottomTab} onChange={setBottomTab} />
} sidebar={
Orderbook
{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}
} >
Price Size (USD) Total (USD)
{orderbook.asks.map((r) => (
0 ? (r as any).totalUsd / maxAskTotal : 0, maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0 )} > {formatQty(r.price, 3)} {formatCompact((r as any).sizeUsd)} {formatCompact((r as any).totalUsd)}
))}
{formatQty(orderbook.mid, 3)} mid
{orderbook.bids.map((r) => (
0 ? (r as any).totalUsd / maxBidTotal : 0, maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0 )} > {formatQty(r.price, 3)} {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)}
), }, { id: 'trades', label: 'Recent Trades', content: (
Time Price Size
{trades.map((t) => (
{new Date(t.time * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} {formatQty(t.price, 3)} {t.size == null ? '—' : formatQty(t.size, 2)}
))}
), }, ]} activeId={tab} onChange={setTab} /> } rightbar={
{symbol}
} >
Order Value {effectiveTradePrice ? formatUsd(effectiveTradePrice * tradeSize) : '—'}
Slippage (Dynamic) {slippageError ? ( {slippageError} ) : dynamicSlippage?.impactBps == null ? ( slippageConnected ? ( '—' ) : ( 'offline' ) ) : ( <> {dynamicSlippage.impactBps.toFixed(1)} bps{' '} ({dynamicSlippage.sizeUsd.toLocaleString()} USD) {dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9 ? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill` : ''} )}
Margin Required
Liq. Price
} /> {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' ?