Files
trade-frontend/apps/visualizer/src/App.tsx

1339 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T>(factory: (id: PaneId) => T): Record<PaneId, T> {
const out: any = {};
for (const id of ALL_PANES) out[id] = factory(id);
return out as Record<PaneId, T>;
}
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<PaneId, number> {
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
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<PaneId, boolean> {
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
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<PaneId, number> {
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
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<T>(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<string | null>(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 (
<div className="loginScreen">
<div className="loginCard" role="status" aria-label="Ładowanie">
Ładowanie
</div>
</div>
);
}
if (!user) {
return <LoginScreen onLoggedIn={setUser} />;
}
return <TradeApp user={user} onLogout={() => 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<number>('trade.form.price', 0);
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
const [layoutMode, setLayoutMode] = useLocalStorageState<'grid' | 'stack'>('trade.layoutMode', 'grid');
const [stackOrderRaw, setStackOrder] = useLocalStorageState<PaneId[]>('trade.stackOrder', ALL_PANES);
const stackOrder = useMemo(() => normalizePaneOrder(stackOrderRaw), [stackOrderRaw]);
const activePane = stackOrder[stackOrder.length - 1] ?? 'chart';
const [stackOrderManual, setStackOrderManual] = useLocalStorageState<boolean>('trade.stackOrderManual', false);
const escRef = useRef<number>(0);
const [stackPanelLocked, setStackPanelLocked] = useLocalStorageState<boolean>('trade.stackPanelLocked', false);
const [stackPanelOpen, setStackPanelOpen] = useState(true);
const stackPanelHideTimerRef = useRef<number | null>(null);
const [stackDrawerOpacity, setStackDrawerOpacity] = useLocalStorageState<number>('trade.stackDrawerOpacity', 0.92);
const [stackBackdropOpacity, setStackBackdropOpacity] = useLocalStorageState<number>('trade.stackBackdropOpacity', 0.55);
const [layerOpacityRaw, setLayerOpacity] = useLocalStorageState<Record<PaneId, number>>('trade.layerOpacity', {
chart: 1,
dlob: 1,
costsActive: 1,
costsNew: 1,
});
const layerOpacity = useMemo(() => normalizeLayerOpacity(layerOpacityRaw), [layerOpacityRaw]);
const [layerVisibleRaw, setLayerVisible] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerVisible', {
chart: true,
dlob: true,
costsActive: false,
costsNew: true,
});
const layerVisible = useMemo(() => normalizeLayerToggle(layerVisibleRaw, true), [layerVisibleRaw]);
const [layerLockedRaw, setLayerLocked] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerLocked', {
chart: false,
dlob: false,
costsActive: false,
costsNew: false,
});
const layerLocked = useMemo(() => normalizeLayerToggle(layerLockedRaw, false), [layerLockedRaw]);
const [layerBrightnessRaw, setLayerBrightness] = useLocalStorageState<Record<PaneId, number>>('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<string>('trade.activeContractIdSeen', '');
const [activeContractId] = useLocalStorageState<string>('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<PaneId, number> = 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<PaneId, number> = 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 ? (
'—'
) : (
<span className={changePct >= 0 ? 'pos' : 'neg'}>
{changePct >= 0 ? '+' : ''}
{changePct.toFixed(2)}%
</span>
),
},
{ 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 ? <span className="neg">{dlobError}</span> : dlob?.updatedAt || '—',
},
{
key: 'l2',
label: 'L2',
value: dlobL2Connected ? 'live' : '—',
sub: dlobL2Error ? <span className="neg">{dlobL2Error}</span> : 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 (
<>
<AppShell
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
top={<TickerBar items={topItems} />}
main={
<div className="tradeMain">
<Card
className="marketCard"
title={
<MarketHeader
market={symbol}
markets={markets}
onMarketChange={setSymbol}
leftSlot={
<label className="inlineField">
<span className="inlineField__label">Source</span>
<input
className="inlineField__input"
value={source}
onChange={(e) => setSource(e.target.value)}
placeholder="(any)"
/>
</label>
}
stats={stats}
rightSlot={
<div className="marketHeader__actions">
<label className="inlineField">
<span className="inlineField__label">Poll</span>
<input
className="inlineField__input"
value={pollMs}
type="number"
min={250}
step={250}
onChange={(e) => setPollMs(Number(e.target.value))}
/>
</label>
<label className="inlineField">
<span className="inlineField__label">Limit</span>
<input
className="inlineField__input"
value={limit}
type="number"
min={50}
step={50}
onChange={(e) => setLimit(Number(e.target.value))}
/>
</label>
<Button onClick={() => void refresh()} disabled={loading} type="button">
Refresh
</Button>
</div>
}
/>
}
>
{error ? <div className="uiError">{error}</div> : null}
</Card>
<ChartPanel
candles={candles}
indicators={indicators}
timeframe={tf}
bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
onTimeframeChange={setTf}
showIndicators={showIndicators}
onToggleIndicators={() => 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
}
/>
<Card className="bottomCard">
<Tabs
items={[
{
id: 'dlob',
label: 'DLOB',
content: (
<DlobDashboard
market={symbol}
stats={dlob}
statsConnected={dlobConnected}
statsError={dlobError}
depthBands={depthBands}
depthBandsConnected={depthBandsConnected}
depthBandsError={depthBandsError}
slippageRows={slippageRows}
slippageConnected={slippageConnected}
slippageError={slippageError}
isFullscreen={layoutMode === 'stack' && activePane === 'dlob'}
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
/>
),
},
{
id: 'costs',
label: 'Costs',
content: <ContractCostsPanel market={symbol} />,
},
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
{
id: 'orderHistory',
label: 'Order History',
content: <div className="placeholder">Order history (next)</div>,
},
{
id: 'positionHistory',
label: 'Position History',
content: <div className="placeholder">Position history (next)</div>,
},
]}
activeId={bottomTab}
onChange={setBottomTab}
/>
</Card>
</div>
}
sidebar={
<Card
className="orderbookCard"
title={
<div className="sideHead">
<div className="sideHead__title">Orderbook</div>
<div className="sideHead__subtitle">{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}</div>
</div>
}
>
<Tabs
items={[
{
id: 'orderbook',
label: 'Orderbook',
content: (
<div className="orderbook">
<div className="orderbook__header">
<span>Price</span>
<span className="orderbook__num">Size (USD)</span>
<span className="orderbook__num">Total (USD)</span>
</div>
<div className="orderbook__rows">
{orderbook.asks.map((r) => (
<div
key={`a-${r.price}`}
className="orderbookRow orderbookRow--ask"
style={orderbookRowBarStyle(
maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0,
maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0
)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
</div>
))}
<div className="orderbookMid">
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
<span className="orderbookMid__label">mid</span>
</div>
{orderbook.bids.map((r) => (
<div
key={`b-${r.price}`}
className="orderbookRow orderbookRow--bid"
style={orderbookRowBarStyle(
maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0,
maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0
)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
</div>
))}
<div className="orderbookMeta">
<div className="orderbookMeta__row">
<span className="muted">Spread</span>
<span className="orderbookMeta__val">
{liquidity.spreadAbs == null || liquidity.spreadPct == null
? '—'
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
</span>
</div>
<div className="orderbookMeta__row">
<span className="muted">Liquidity (L1L10)</span>
<span className="orderbookMeta__val">
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
</span>
</div>
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
<div className="liquidityBar__bid" />
<div className="liquidityBar__ask" />
</div>
</div>
</div>
</div>
),
},
{
id: 'trades',
label: 'Recent Trades',
content: (
<div className="trades">
<div className="trades__header">
<span>Time</span>
<span className="trades__num">Price</span>
<span className="trades__num">Size</span>
</div>
<div className="trades__rows">
{trades.map((t) => (
<div key={`${t.time}-${t.price}`} className="tradeRow">
<span className="tradeRow__time">
{new Date(t.time * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className={['tradeRow__price', t.side === 'buy' ? 'pos' : 'neg'].join(' ')}>
{formatQty(t.price, 3)}
</span>
<span className="tradeRow__num">{t.size == null ? '—' : formatQty(t.size, 2)}</span>
</div>
))}
</div>
</div>
),
},
]}
activeId={tab}
onChange={setTab}
/>
</Card>
}
rightbar={
<Card
className="tradeFormCard"
title={
<div className="tradeFormHead">
<div className="tradeFormHead__left">
<button className="chipBtn" type="button">
Cross
</button>
<button className="chipBtn" type="button">
20x
</button>
</div>
<div className="tradeFormHead__right">{symbol}</div>
</div>
}
>
<div className="tradeForm">
<div className="segmented">
<button
className={['segmented__btn', tradeSide === 'long' ? 'segmented__btn--activeLong' : ''].filter(Boolean).join(' ')}
type="button"
onClick={() => setTradeSide('long')}
>
Long
</button>
<button
className={['segmented__btn', tradeSide === 'short' ? 'segmented__btn--activeShort' : ''].filter(Boolean).join(' ')}
type="button"
onClick={() => setTradeSide('short')}
>
Short
</button>
</div>
<div className="tradeTabs">
<button
type="button"
className={['tradeTabs__btn', tradeOrderType === 'market' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
onClick={() => setTradeOrderType('market')}
>
Market
</button>
<button
type="button"
className={['tradeTabs__btn', tradeOrderType === 'limit' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
onClick={() => setTradeOrderType('limit')}
>
Limit
</button>
<button
type="button"
className={['tradeTabs__btn', tradeOrderType === 'other' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
onClick={() => setTradeOrderType('other')}
>
Others
</button>
</div>
<div className="tradeFields">
<label className="formField">
<span className="formField__label">Price</span>
<input
className="formField__input"
value={tradeOrderType === 'market' ? '' : String(tradePrice)}
placeholder={tradeOrderType === 'market' ? formatQty(latest?.close ?? null, 3) : '0'}
disabled={tradeOrderType !== 'limit'}
onChange={(e) => setTradePrice(Number(e.target.value))}
inputMode="decimal"
/>
</label>
<label className="formField">
<span className="formField__label">Size</span>
<input
className="formField__input"
value={String(tradeSize)}
onChange={(e) => setTradeSize(Number(e.target.value))}
inputMode="decimal"
/>
</label>
</div>
<Button className="tradeCta" type="button" disabled>
Enable Trading
</Button>
<div className="tradeMeta">
<div className="tradeMeta__row">
<span className="tradeMeta__label">Order Value</span>
<span className="tradeMeta__value">{effectiveTradePrice ? formatUsd(effectiveTradePrice * tradeSize) : '—'}</span>
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Slippage (Dynamic)</span>
<span className="tradeMeta__value">
{slippageError ? (
<span className="neg">{slippageError}</span>
) : dynamicSlippage?.impactBps == null ? (
slippageConnected ? (
'—'
) : (
'offline'
)
) : (
<>
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
<span className="muted">
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
: ''}
</span>
</>
)}
</span>
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Margin Required</span>
<span className="tradeMeta__value"></span>
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Liq. Price</span>
<span className="tradeMeta__value"></span>
</div>
</div>
</div>
</Card>
}
/>
{layoutMode === 'stack' ? (
<>
<div className="stackDrawerHotspot" onMouseEnter={openStackPanel} aria-label="Layers hotspot" />
<Card
className={['stackDrawer', stackPanelOpen ? 'stackDrawer--open' : 'stackDrawer--closed'].join(' ')}
title="Layers"
right={
<div className="stackPanel__actions">
<span className="muted stackPanel__hint">Esc ×2</span>
<Button
size="sm"
variant={stackPanelLocked ? 'primary' : 'ghost'}
onClick={() => {
setStackPanelLocked((v) => !v);
openStackPanel();
}}
type="button"
title={stackPanelLocked ? 'Auto-hide disabled' : 'Auto-hide enabled'}
>
{stackPanelLocked ? 'Locked' : 'Auto'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setStackPanelOpen(false);
scheduleHideStackPanel();
}}
type="button"
>
Hide
</Button>
<Button size="sm" variant="primary" onClick={exitStack} type="button">
Back
</Button>
</div>
}
onMouseEnter={openStackPanel}
onMouseLeave={scheduleHideStackPanel}
>
<div className="stackPanel__sub muted">UI Opacity</div>
<div className="stackPanel__sliders" style={{ marginBottom: 14 }}>
<label className="stackPanel__sliderRow">
<span className="stackPanel__sliderLabel muted">Backdrop</span>
<input
className="stackPanel__slider"
type="range"
min={0}
max={95}
step={1}
value={Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}
onChange={(e) => setStackBackdropOpacity(Number(e.target.value) / 100)}
onWheel={(e) => {
e.preventDefault();
const cur = Math.round(clampNumber(stackBackdropOpacity, 0, 0.95) * 100);
const step = e.shiftKey ? 5 : 1;
const next = clampNumber(cur + stepByWheel(e, step), 0, 95);
setStackBackdropOpacity(next / 100);
}}
/>
<span className="stackPanel__sliderValue muted">
{Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}%
</span>
</label>
<label className="stackPanel__sliderRow">
<span className="stackPanel__sliderLabel muted">Panel</span>
<input
className="stackPanel__slider"
type="range"
min={5}
max={100}
step={1}
value={Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}
onChange={(e) => setStackDrawerOpacity(Number(e.target.value) / 100)}
onWheel={(e) => {
e.preventDefault();
const cur = Math.round(clampNumber(stackDrawerOpacity, 0.05, 1) * 100);
const step = e.shiftKey ? 5 : 1;
const next = clampNumber(cur + stepByWheel(e, step), 5, 100);
setStackDrawerOpacity(next / 100);
}}
/>
<span className="stackPanel__sliderValue muted">
{Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}%
</span>
</label>
</div>
<div className="stackPanel__sub muted">
Drag to reorder, click to focus (top = active).
</div>
<div className="stackPanel__list" role="list">
{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 (
<div
key={pane}
role="listitem"
className={[
'stackPanel__item',
isActive ? 'stackPanel__item--active' : null,
isLocked ? 'stackPanel__item--locked' : null,
!isVisible ? 'stackPanel__item--hidden' : null,
]
.filter(Boolean)
.join(' ')}
draggable={!isLocked}
onClick={() => {
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);
}}
>
<span className="stackPanel__drag"></span>
<span className="stackPanel__label">{label}</span>
<button
type="button"
className="stackPanel__iconBtn"
title={isVisible ? 'Hide layer' : 'Show layer'}
onClick={(e) => {
e.stopPropagation();
setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), [pane]: !isVisible }));
}}
>
{isVisible ? '👁' : '🚫'}
</button>
<button
type="button"
className="stackPanel__iconBtn"
title={isLocked ? 'Unlock layer' : 'Lock layer'}
onClick={(e) => {
e.stopPropagation();
setLayerLocked((prev) => ({ ...normalizeLayerToggle(prev, false), [pane]: !isLocked }));
}}
>
{isLocked ? '🔒' : '🔓'}
</button>
<input
className="stackPanel__layerBrightness"
type="range"
min={60}
max={180}
step={1}
value={brightPct}
title={`${label} brightness`}
onClick={(e) => 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,
}))
}
/>
<span className="stackPanel__layerBrightnessValue muted">{brightPct}%</span>
<input
className="stackPanel__layerOpacity"
type="range"
min={0}
max={100}
step={1}
value={pct}
title={`${label} opacity`}
onClick={(e) => 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,
}))
}
/>
<span className="stackPanel__layerOpacityValue muted">{pct}%</span>
{isActive ? <span className="stackPanel__badge">active</span> : null}
</div>
);
})}
</div>
</Card>
</>
) : null}
{layoutMode === 'stack' ? <div className="stackBackdrop" aria-hidden="true" /> : null}
{layoutMode === 'stack' && effectiveLayerOpacity.dlob > 0 ? (
<div
className="stackLayer stackLayer--dlob"
role="dialog"
aria-label="DLOB layer"
style={{
zIndex: stackZ.dlob,
opacity: effectiveLayerOpacity.dlob,
filter: `brightness(${layerBrightness.dlob})`,
pointerEvents: activePane === 'dlob' ? 'auto' : 'none',
}}
>
<div className="stackLayer__body">
<Card className="stackLayer__card">
<DlobDashboard
market={symbol}
stats={dlob}
statsConnected={dlobConnected}
statsError={dlobError}
depthBands={depthBands}
depthBandsConnected={depthBandsConnected}
depthBandsError={depthBandsError}
slippageRows={slippageRows}
slippageConnected={slippageConnected}
slippageError={slippageError}
isFullscreen
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
/>
</Card>
</div>
</div>
) : null}
{layoutMode === 'stack' && effectiveLayerOpacity.costsActive > 0 && hasActiveContract ? (
<div
className="stackLayer stackLayer--costsActive"
role="dialog"
aria-label="Costs active layer"
style={{
zIndex: stackZ.costsActive,
opacity: effectiveLayerOpacity.costsActive,
filter: `brightness(${layerBrightness.costsActive})`,
pointerEvents: activePane === 'costsActive' ? 'auto' : 'none',
}}
>
<div className="stackLayer__body">
<Card className="stackLayer__card">
<ContractCostsPanel market={symbol} view="active" />
</Card>
</div>
</div>
) : null}
{layoutMode === 'stack' && effectiveLayerOpacity.costsNew > 0 ? (
<div
className="stackLayer stackLayer--costsNew"
role="dialog"
aria-label="Costs new layer"
style={{
zIndex: stackZ.costsNew,
opacity: effectiveLayerOpacity.costsNew,
filter: `brightness(${layerBrightness.costsNew})`,
pointerEvents: activePane === 'costsNew' ? 'auto' : 'none',
}}
>
<div className="stackLayer__body">
<Card className="stackLayer__card">
<ContractCostsPanel market={symbol} view="new" />
</Card>
</div>
</div>
) : null}
</>
);
}