feat(visualizer): add DLOB dashboard and SOL default
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
||||
import AppShell from './layout/AppShell';
|
||||
@@ -11,6 +12,11 @@ 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';
|
||||
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const v = (import.meta as any).env?.[name];
|
||||
@@ -36,6 +42,11 @@ 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;
|
||||
}
|
||||
|
||||
type WhoamiResponse = {
|
||||
ok?: boolean;
|
||||
user?: string | null;
|
||||
@@ -99,9 +110,9 @@ export default function App() {
|
||||
}
|
||||
|
||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||
|
||||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP'));
|
||||
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 [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
||||
@@ -110,7 +121,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false);
|
||||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||||
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||
'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||
>('trade.bottomTab', 'positions');
|
||||
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
||||
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
||||
@@ -120,6 +131,16 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
||||
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol === 'BONK-PERP') {
|
||||
setSymbol('1MBONK-PERP');
|
||||
return;
|
||||
}
|
||||
if (!markets.includes(symbol)) {
|
||||
setSymbol('SOL-PERP');
|
||||
}
|
||||
}, [markets, setSymbol, symbol]);
|
||||
|
||||
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
||||
symbol,
|
||||
source: source.trim() ? source : undefined,
|
||||
@@ -128,12 +149,18 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
pollMs,
|
||||
});
|
||||
|
||||
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
|
||||
const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 14 });
|
||||
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 };
|
||||
if (!latest) return { asks: [], bids: [], mid: null as number | null };
|
||||
const mid = latest.close;
|
||||
const step = Math.max(mid * 0.00018, 0.0001);
|
||||
@@ -164,7 +191,19 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
});
|
||||
|
||||
return { asks, bids, mid };
|
||||
}, [latest]);
|
||||
}, [dlobL2, latest]);
|
||||
|
||||
const maxAskTotal = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.asks) max = Math.max(max, r.total || 0);
|
||||
return max;
|
||||
}, [orderbook.asks]);
|
||||
|
||||
const maxBidTotal = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.bids) max = Math.max(max, r.total || 0);
|
||||
return max;
|
||||
}, [orderbook.bids]);
|
||||
|
||||
const trades = useMemo(() => {
|
||||
const slice = candles.slice(-24).reverse();
|
||||
@@ -184,6 +223,24 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
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 },
|
||||
@@ -209,12 +266,28 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
),
|
||||
},
|
||||
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
|
||||
{ key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' },
|
||||
{ key: 'oi', label: 'Open Interest', value: '—' },
|
||||
{ key: 'vol', label: '24h Volume', value: '—' },
|
||||
{ key: 'details', label: 'Market Details', value: <a href="#">View</a> },
|
||||
{ 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]);
|
||||
}, [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]);
|
||||
@@ -292,11 +365,30 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
showBuild={showBuild}
|
||||
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||
seriesLabel={seriesLabel}
|
||||
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ 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> },
|
||||
@@ -323,7 +415,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
title={
|
||||
<div className="sideHead">
|
||||
<div className="sideHead__title">Orderbook</div>
|
||||
<div className="sideHead__subtitle">{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}</div>
|
||||
<div className="sideHead__subtitle">{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -341,18 +433,26 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
</div>
|
||||
<div className="orderbook__rows">
|
||||
{orderbook.asks.map((r) => (
|
||||
<div key={`a-${r.price}`} className="orderbookRow orderbookRow--ask">
|
||||
<div
|
||||
key={`a-${r.price}`}
|
||||
className="orderbookRow orderbookRow--ask"
|
||||
style={orderbookBarStyle(maxAskTotal > 0 ? r.total / maxAskTotal : 0)}
|
||||
>
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="orderbookMid">
|
||||
<span className="orderbookMid__price">{latest ? formatQty(latest.close, 3) : '—'}</span>
|
||||
<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">
|
||||
<div
|
||||
key={`b-${r.price}`}
|
||||
className="orderbookRow orderbookRow--bid"
|
||||
style={orderbookBarStyle(maxBidTotal > 0 ? r.total / maxBidTotal : 0)}
|
||||
>
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
||||
@@ -487,7 +587,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
</div>
|
||||
<div className="tradeMeta__row">
|
||||
<span className="tradeMeta__label">Slippage (Dynamic)</span>
|
||||
<span className="tradeMeta__value">—</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>
|
||||
|
||||
Reference in New Issue
Block a user