feat(visualizer): brick-stack flow bars + DLOB dashboard
This commit is contained in:
@@ -5,10 +5,11 @@ VITE_API_URL=/api
|
||||
# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
|
||||
VITE_HASURA_URL=/graphql
|
||||
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
|
||||
# VITE_HASURA_WS_URL=wss://YOUR_HOST/graphql
|
||||
# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws).
|
||||
# VITE_HASURA_WS_URL=/graphql-ws
|
||||
# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
|
||||
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
||||
VITE_SYMBOL=PUMP-PERP
|
||||
VITE_SYMBOL=SOL-PERP
|
||||
# Optional: filter by source (leave empty for all)
|
||||
# VITE_SOURCE=drift_oracle
|
||||
VITE_POLL_MS=1000
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl npm run dev
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
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}"
|
||||
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
|
||||
fi
|
||||
|
||||
npm run dev
|
||||
|
||||
@@ -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';
|
||||
@@ -12,6 +13,10 @@ 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];
|
||||
@@ -37,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;
|
||||
@@ -100,9 +110,9 @@ export default function App() {
|
||||
}
|
||||
|
||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BONK-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));
|
||||
@@ -111,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'>(
|
||||
@@ -121,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,
|
||||
@@ -130,6 +150,9 @@ 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 { 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;
|
||||
@@ -137,6 +160,7 @@ 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 };
|
||||
const mid = latest.close;
|
||||
const step = Math.max(mid * 0.00018, 0.0001);
|
||||
@@ -167,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();
|
||||
@@ -187,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 },
|
||||
@@ -226,8 +280,14 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
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]);
|
||||
}, [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]);
|
||||
@@ -305,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> },
|
||||
@@ -336,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>
|
||||
}
|
||||
>
|
||||
@@ -354,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>
|
||||
@@ -500,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>
|
||||
|
||||
@@ -142,38 +142,30 @@ export default function ChartLayersPanel({
|
||||
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||
</div>
|
||||
|
||||
{drawingsLayer ? (
|
||||
<div className="chartLayersRow chartLayersRow--layer">
|
||||
{layers.map((layer) => (
|
||||
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton
|
||||
title="Toggle visible"
|
||||
active={drawingsLayer.visible}
|
||||
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
|
||||
>
|
||||
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
|
||||
<IconEye />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton
|
||||
title="Toggle lock"
|
||||
active={drawingsLayer.locked}
|
||||
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
|
||||
>
|
||||
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
|
||||
<IconLock />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">
|
||||
<div className="layersName layersName--layer">
|
||||
{drawingsLayer.name}
|
||||
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span>
|
||||
{layer.name}
|
||||
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">
|
||||
<OpacitySlider value={drawingsLayer.opacity} onChange={(next) => onSetLayerOpacity(drawingsLayer.id, next)} />
|
||||
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions" />
|
||||
</div>
|
||||
) : null}
|
||||
))}
|
||||
|
||||
{drawingsLayer && fibPresent ? (
|
||||
<div
|
||||
@@ -212,4 +204,3 @@ export default function ChartLayersPanel({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import ChartSideToolbar from './ChartSideToolbar';
|
||||
import ChartToolbar from './ChartToolbar';
|
||||
import TradingChart from './TradingChart';
|
||||
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
||||
import type { IChartApi } from 'lightweight-charts';
|
||||
import { LineStyle, type IChartApi } from 'lightweight-charts';
|
||||
import type { OverlayLayer } from './ChartPanel.types';
|
||||
|
||||
type Props = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
|
||||
timeframe: string;
|
||||
bucketSeconds: number;
|
||||
seriesKey: string;
|
||||
@@ -45,6 +46,7 @@ function isEditableTarget(t: EventTarget | null): boolean {
|
||||
export default function ChartPanel({
|
||||
candles,
|
||||
indicators,
|
||||
dlobQuotes,
|
||||
timeframe,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
@@ -61,6 +63,7 @@ export default function ChartPanel({
|
||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||
const [layers, setLayers] = useState<OverlayLayer[]>([
|
||||
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
|
||||
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
||||
]);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
@@ -196,6 +199,37 @@ export default function ChartPanel({
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
|
||||
const quotesVisible = Boolean(quotesLayer?.visible);
|
||||
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
|
||||
|
||||
const priceLines = useMemo(() => {
|
||||
if (!quotesVisible) return [];
|
||||
return [
|
||||
{
|
||||
id: 'dlob-bid',
|
||||
title: 'DLOB Bid',
|
||||
price: dlobQuotes?.bid ?? null,
|
||||
color: `rgba(34,197,94,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
},
|
||||
{
|
||||
id: 'dlob-mid',
|
||||
title: 'DLOB Mid',
|
||||
price: dlobQuotes?.mid ?? null,
|
||||
color: `rgba(230,233,239,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
},
|
||||
{
|
||||
id: 'dlob-ask',
|
||||
title: 'DLOB Ask',
|
||||
price: dlobQuotes?.ask ?? null,
|
||||
color: `rgba(239,68,68,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
},
|
||||
];
|
||||
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]);
|
||||
|
||||
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
|
||||
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
||||
}
|
||||
@@ -309,6 +343,7 @@ export default function ChartPanel({
|
||||
showBuild={showBuild}
|
||||
bucketSeconds={bucketSeconds}
|
||||
seriesKey={seriesKey}
|
||||
priceLines={priceLines}
|
||||
fib={fibRenderable}
|
||||
fibOpacity={fibEffectiveOpacity}
|
||||
fibSelected={fibSelected}
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
onToggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||
const timeframes = ['5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||
|
||||
export default function ChartToolbar({
|
||||
timeframe,
|
||||
|
||||
@@ -33,6 +33,15 @@ type Props = {
|
||||
showBuild: boolean;
|
||||
bucketSeconds: number;
|
||||
seriesKey: string;
|
||||
priceLines?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
price: number | null;
|
||||
color: string;
|
||||
lineWidth?: number;
|
||||
lineStyle?: LineStyle;
|
||||
axisLabelVisible?: boolean;
|
||||
}>;
|
||||
fib?: FibRetracement | null;
|
||||
fibOpacity?: number;
|
||||
fibSelected?: boolean;
|
||||
@@ -203,7 +212,6 @@ class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
|
||||
if (!candles.length || !series || !chart) return;
|
||||
|
||||
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||
const rows = Math.min(60, Math.max(12, Math.floor(bs)));
|
||||
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
|
||||
|
||||
const yBase = series.priceToCoordinate(0);
|
||||
@@ -221,7 +229,7 @@ class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
|
||||
if (!Number.isFinite(x)) continue;
|
||||
|
||||
const c = candles[i]!;
|
||||
const list = samples.get(c.time);
|
||||
|
||||
const start = c.time;
|
||||
const end = start + bs;
|
||||
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
|
||||
@@ -251,58 +259,159 @@ class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
|
||||
if (!(yBottomPx > yTopPx)) continue;
|
||||
const barHeightPx = yBottomPx - yTopPx;
|
||||
|
||||
const rowDirs: Array<SliceDir | null> = Array.from({ length: rows }, () => null);
|
||||
const overallDir = dirForDelta(c.close - c.open);
|
||||
|
||||
const points: BuildSample[] = [{ t: start, v: 0 }];
|
||||
if (list?.length) {
|
||||
for (const p of list) {
|
||||
if (!Number.isFinite(p.t) || !Number.isFinite(p.v)) continue;
|
||||
const t = Math.min(end, Math.max(start, p.t));
|
||||
points.push({ t, v: p.v });
|
||||
}
|
||||
}
|
||||
|
||||
const endV = c.close - c.open;
|
||||
const lastPt = points[points.length - 1]!;
|
||||
if (progressT > lastPt.t + 1e-3) {
|
||||
points.push({ t: progressT, v: endV });
|
||||
} else {
|
||||
lastPt.v = endV;
|
||||
}
|
||||
|
||||
for (let j = 1; j < points.length; j += 1) {
|
||||
const a = points[j - 1]!;
|
||||
const b = points[j]!;
|
||||
const dir = dirForDelta(b.v - a.v);
|
||||
|
||||
const from = Math.max(0, Math.min(rows - 1, Math.floor(((a.t - start) / bs) * rows)));
|
||||
const to = Math.max(0, Math.min(rows - 1, Math.floor(((b.t - start) / bs) * rows)));
|
||||
|
||||
for (let r = from; r <= to; r += 1) rowDirs[r] = dir;
|
||||
}
|
||||
|
||||
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
if (rowDirs[r] == null) rowDirs[r] = overallDir;
|
||||
}
|
||||
|
||||
const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx));
|
||||
const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx));
|
||||
const w = x1 - x0;
|
||||
if (!(w > 0)) continue;
|
||||
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const dir = rowDirs[r];
|
||||
if (dir == null) continue;
|
||||
// Prefer server-provided `flowRows`: brick-by-brick direction per time slice inside the candle.
|
||||
// Fallback to `flow` (3 shares) or net candle direction.
|
||||
const rowsFromApi = Array.isArray((c as any).flowRows) ? ((c as any).flowRows as any[]) : null;
|
||||
const rowDirs = rowsFromApi?.length ? rowsFromApi : null;
|
||||
|
||||
const rowTop = yBottomPx - Math.round(((r + 1) * barHeightPx) / rows);
|
||||
const rowBottom = yBottomPx - Math.round((r * barHeightPx) / rows);
|
||||
const h = rowBottom - rowTop;
|
||||
if (!(h > 0)) continue;
|
||||
if (rowDirs) {
|
||||
const rows = Math.max(1, rowDirs.length);
|
||||
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
|
||||
const movesFromApi = Array.isArray((c as any).flowMoves) ? ((c as any).flowMoves as any[]) : null;
|
||||
const rowMoves = movesFromApi && movesFromApi.length === rows ? movesFromApi : null;
|
||||
let maxMove = 0;
|
||||
if (rowMoves) {
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const v = Number(rowMoves[r]);
|
||||
if (Number.isFinite(v) && v > maxMove) maxMove = v;
|
||||
}
|
||||
}
|
||||
|
||||
context.fillStyle = sliceColorForDelta(dir);
|
||||
context.fillRect(x0, rowTop, w, h);
|
||||
const sepPx = 1; // black separator line between steps
|
||||
const sepColor = 'rgba(0,0,0,0.75)';
|
||||
|
||||
// Blue (flat) bricks have a constant pixel height (unit).
|
||||
// Up/down bricks are scaled by |Δ| in their dt.
|
||||
const minNonFlatPx = 1;
|
||||
let unitFlatPx = 2;
|
||||
|
||||
let flatCount = 0;
|
||||
let nonFlatCount = 0;
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const dirRaw = rowDirs[r];
|
||||
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||
if (dir === 0) flatCount += 1;
|
||||
else nonFlatCount += 1;
|
||||
}
|
||||
|
||||
const sepTotal = Math.max(0, progressRows - 1) * sepPx;
|
||||
if (flatCount > 0) {
|
||||
const maxUnit = Math.floor((barHeightPx - sepTotal - nonFlatCount * minNonFlatPx) / flatCount);
|
||||
unitFlatPx = Math.max(1, Math.min(unitFlatPx, maxUnit));
|
||||
} else {
|
||||
unitFlatPx = 0;
|
||||
}
|
||||
|
||||
let nonFlatAvailable = barHeightPx - sepTotal - flatCount * unitFlatPx;
|
||||
if (!Number.isFinite(nonFlatAvailable) || nonFlatAvailable < 0) nonFlatAvailable = 0;
|
||||
|
||||
const baseNonFlat = nonFlatCount * minNonFlatPx;
|
||||
let extra = nonFlatAvailable - baseNonFlat;
|
||||
if (!Number.isFinite(extra) || extra < 0) extra = 0;
|
||||
|
||||
let sumMove = 0;
|
||||
if (nonFlatCount > 0) {
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const dirRaw = rowDirs[r];
|
||||
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||
if (dir === 0) continue;
|
||||
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
||||
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
||||
sumMove += mv;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(flatCount + nonFlatCount > 0) || barHeightPx <= sepTotal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stack bricks bottom-up (earliest slices at the bottom).
|
||||
let usedExtra = 0;
|
||||
let nonFlatSeen = 0;
|
||||
let y = yBottomPx;
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const dirRaw = rowDirs[r];
|
||||
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||
const isLast = r === progressRows - 1;
|
||||
|
||||
let h = 0;
|
||||
if (dir === 0) {
|
||||
h = unitFlatPx;
|
||||
} else {
|
||||
nonFlatSeen += 1;
|
||||
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
||||
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
||||
const share = sumMove > 0 ? mv / sumMove : 1 / nonFlatCount;
|
||||
const wantExtra = Math.floor(extra * share);
|
||||
const isLastNonFlat = nonFlatSeen === nonFlatCount;
|
||||
const add = isLastNonFlat ? Math.max(0, extra - usedExtra) : Math.max(0, wantExtra);
|
||||
usedExtra += add;
|
||||
h = minNonFlatPx + add;
|
||||
}
|
||||
|
||||
if (h <= 0) continue;
|
||||
y -= h;
|
||||
context.fillStyle = sliceColorForDelta(dir);
|
||||
context.fillRect(x0, y, w, h);
|
||||
|
||||
if (!isLast) {
|
||||
y -= sepPx;
|
||||
context.fillStyle = sepColor;
|
||||
context.fillRect(x0, y, w, sepPx);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const f: any = (c as any).flow;
|
||||
let upShare = typeof f?.up === 'number' ? f.up : Number(f?.up);
|
||||
let downShare = typeof f?.down === 'number' ? f.down : Number(f?.down);
|
||||
let flatShare = typeof f?.flat === 'number' ? f.flat : Number(f?.flat);
|
||||
|
||||
if (!Number.isFinite(upShare)) upShare = 0;
|
||||
if (!Number.isFinite(downShare)) downShare = 0;
|
||||
if (!Number.isFinite(flatShare)) flatShare = 0;
|
||||
upShare = Math.max(0, upShare);
|
||||
downShare = Math.max(0, downShare);
|
||||
flatShare = Math.max(0, flatShare);
|
||||
|
||||
let sum = upShare + downShare + flatShare;
|
||||
if (!(sum > 0)) {
|
||||
const overallDir = dirForDelta(c.close - c.open);
|
||||
upShare = overallDir > 0 ? 1 : 0;
|
||||
downShare = overallDir < 0 ? 1 : 0;
|
||||
flatShare = overallDir === 0 ? 1 : 0;
|
||||
sum = 1;
|
||||
}
|
||||
|
||||
upShare /= sum;
|
||||
downShare /= sum;
|
||||
flatShare /= sum;
|
||||
|
||||
const downH = Math.floor(barHeightPx * downShare);
|
||||
const flatH = Math.floor(barHeightPx * flatShare);
|
||||
const upH = Math.max(0, barHeightPx - downH - flatH);
|
||||
|
||||
let y = yBottomPx;
|
||||
|
||||
if (downH > 0) {
|
||||
context.fillStyle = sliceColorForDelta(-1);
|
||||
context.fillRect(x0, y - downH, w, downH);
|
||||
y -= downH;
|
||||
}
|
||||
if (flatH > 0) {
|
||||
context.fillStyle = sliceColorForDelta(0);
|
||||
context.fillRect(x0, y - flatH, w, flatH);
|
||||
y -= flatH;
|
||||
}
|
||||
if (upH > 0) {
|
||||
context.fillStyle = sliceColorForDelta(1);
|
||||
context.fillRect(x0, y - upH, w, upH);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -384,6 +493,7 @@ export default function TradingChart({
|
||||
showBuild,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
priceLines,
|
||||
fib,
|
||||
fibOpacity = 1,
|
||||
fibSelected = false,
|
||||
@@ -412,6 +522,7 @@ export default function TradingChart({
|
||||
const lastBuildCandleStartRef = useRef<number | null>(null);
|
||||
const hoverCandleTimeRef = useRef<number | null>(null);
|
||||
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
|
||||
const priceLinesRef = useRef<Map<string, any>>(new Map());
|
||||
const seriesRef = useRef<{
|
||||
candles?: ISeriesApi<'Candlestick'>;
|
||||
volume?: ISeriesApi<'Histogram'>;
|
||||
@@ -881,12 +992,68 @@ export default function TradingChart({
|
||||
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
|
||||
buildSlicesPrimitiveRef.current = null;
|
||||
}
|
||||
const lines = priceLinesRef.current;
|
||||
for (const line of Array.from(lines.values())) {
|
||||
try {
|
||||
candleSeries.removePriceLine(line);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
lines.clear();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const candlesSeries = seriesRef.current.candles;
|
||||
if (!candlesSeries) return;
|
||||
|
||||
const desired = (priceLines || []).filter((l) => l.price != null && Number.isFinite(l.price));
|
||||
const desiredIds = new Set(desired.map((l) => l.id));
|
||||
|
||||
const map = priceLinesRef.current;
|
||||
for (const [id, line] of Array.from(map.entries())) {
|
||||
if (desiredIds.has(id)) continue;
|
||||
try {
|
||||
candlesSeries.removePriceLine(line);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
map.delete(id);
|
||||
}
|
||||
|
||||
for (const spec of desired) {
|
||||
const opts: any = {
|
||||
price: spec.price,
|
||||
color: spec.color,
|
||||
title: spec.title,
|
||||
lineWidth: spec.lineWidth ?? 1,
|
||||
lineStyle: spec.lineStyle ?? LineStyle.Dotted,
|
||||
axisLabelVisible: spec.axisLabelVisible ?? true,
|
||||
};
|
||||
|
||||
const existing = map.get(spec.id);
|
||||
if (!existing) {
|
||||
try {
|
||||
const created = candlesSeries.createPriceLine(opts);
|
||||
map.set(spec.id, created);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
existing.applyOptions(opts);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [priceLines]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles || !s.volume || !s.buildHover) return;
|
||||
|
||||
137
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
137
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { DlobStats } from './useDlobStats';
|
||||
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
||||
import DlobSlippageChart from './DlobSlippageChart';
|
||||
|
||||
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 formatBps(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${v.toFixed(1)} bps`;
|
||||
}
|
||||
|
||||
function formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function statusLabel(connected: boolean, error: string | null): ReactNode {
|
||||
if (error) return <span className="neg">{error}</span>;
|
||||
return connected ? <span className="pos">live</span> : <span className="muted">offline</span>;
|
||||
}
|
||||
|
||||
export default function DlobDashboard({
|
||||
market,
|
||||
stats,
|
||||
statsConnected,
|
||||
statsError,
|
||||
depthBands,
|
||||
depthBandsConnected,
|
||||
depthBandsError,
|
||||
slippageRows,
|
||||
slippageConnected,
|
||||
slippageError,
|
||||
}: {
|
||||
market: string;
|
||||
stats: DlobStats | null;
|
||||
statsConnected: boolean;
|
||||
statsError: string | null;
|
||||
depthBands: DlobDepthBandRow[];
|
||||
depthBandsConnected: boolean;
|
||||
depthBandsError: string | null;
|
||||
slippageRows: DlobSlippageRow[];
|
||||
slippageConnected: boolean;
|
||||
slippageError: string | null;
|
||||
}) {
|
||||
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
||||
|
||||
return (
|
||||
<div className="dlobDash">
|
||||
<div className="dlobDash__head">
|
||||
<div className="dlobDash__title">DLOB</div>
|
||||
<div className="dlobDash__meta">
|
||||
<span className="dlobDash__market">{market}</span>
|
||||
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__statuses">
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">stats</span>
|
||||
<span className="dlobStatus__value">{statusLabel(statsConnected, statsError)}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">depth bands</span>
|
||||
<span className="dlobStatus__value">{statusLabel(depthBandsConnected, depthBandsError)}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">slippage</span>
|
||||
<span className="dlobStatus__value">{statusLabel(slippageConnected, slippageError)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__grid">
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Bid</div>
|
||||
<div className="dlobKpi__value pos">{formatUsd(stats?.bestBid ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Ask</div>
|
||||
<div className="dlobKpi__value neg">{formatUsd(stats?.bestAsk ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Mid</div>
|
||||
<div className="dlobKpi__value">{formatUsd(stats?.mid ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Spread</div>
|
||||
<div className="dlobKpi__value">{formatBps(stats?.spreadBps ?? null)}</div>
|
||||
<div className="dlobKpi__sub muted">{formatUsd(stats?.spreadAbs ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Depth (bid/ask)</div>
|
||||
<div className="dlobKpi__value">
|
||||
<span className="pos">{formatUsd(stats?.depthBidUsd ?? null)}</span>{' '}
|
||||
<span className="muted">/</span> <span className="neg">{formatUsd(stats?.depthAskUsd ?? null)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Imbalance</div>
|
||||
<div className="dlobKpi__value">{formatPct(stats?.imbalance ?? null)}</div>
|
||||
<div className="dlobKpi__sub muted">[-1..1]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__panes">
|
||||
<div className="dlobDash__pane">
|
||||
<DlobDepthBandsPanel rows={depthBands} />
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__pane">
|
||||
<div className="dlobSlippage">
|
||||
<div className="dlobSlippage__head">
|
||||
<div className="dlobSlippage__title">Slippage (impact bps)</div>
|
||||
<div className="dlobSlippage__meta muted">by size (USD)</div>
|
||||
</div>
|
||||
{slippageRows.length ? (
|
||||
<div className="dlobSlippage__chartWrap">
|
||||
<DlobSlippageChart rows={slippageRows} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dlobSlippage__empty muted">No slippage rows yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||
|
||||
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 formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${v.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function bandRowStyle(askScale: number, bidScale: number): CSSProperties {
|
||||
const a = Number.isFinite(askScale) && askScale > 0 ? Math.min(1, askScale) : 0;
|
||||
const b = Number.isFinite(bidScale) && bidScale > 0 ? Math.min(1, bidScale) : 0;
|
||||
return { ['--ask-scale' as any]: a, ['--bid-scale' as any]: b } as CSSProperties;
|
||||
}
|
||||
|
||||
export default function DlobDepthBandsPanel({ rows }: { rows: DlobDepthBandRow[] }) {
|
||||
const sorted = useMemo(() => rows.slice().sort((a, b) => a.bandBps - b.bandBps), [rows]);
|
||||
|
||||
const maxUsd = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of sorted) {
|
||||
if (r.askUsd != null && Number.isFinite(r.askUsd)) max = Math.max(max, r.askUsd);
|
||||
if (r.bidUsd != null && Number.isFinite(r.bidUsd)) max = Math.max(max, r.bidUsd);
|
||||
}
|
||||
return max;
|
||||
}, [sorted]);
|
||||
|
||||
return (
|
||||
<div className="dlobDepth">
|
||||
<div className="dlobDepth__head">
|
||||
<div className="dlobDepth__title">Depth (bands)</div>
|
||||
<div className="dlobDepth__meta">±bps around mid</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDepth__table">
|
||||
<div className="dlobDepthRow dlobDepthRow--head">
|
||||
<span>Band</span>
|
||||
<span className="dlobDepthRow__num">Ask USD</span>
|
||||
<span className="dlobDepthRow__num">Bid USD</span>
|
||||
<span className="dlobDepthRow__num">Bid %</span>
|
||||
</div>
|
||||
|
||||
{sorted.length ? (
|
||||
sorted.map((r) => (
|
||||
<div
|
||||
key={r.bandBps}
|
||||
className="dlobDepthRow"
|
||||
style={bandRowStyle(maxUsd > 0 ? (r.askUsd || 0) / maxUsd : 0, maxUsd > 0 ? (r.bidUsd || 0) / maxUsd : 0)}
|
||||
title={`band=${r.bandBps}bps bid=${r.bidUsd ?? '—'} ask=${r.askUsd ?? '—'} imbalance=${r.imbalance ?? '—'}`}
|
||||
>
|
||||
<span className="dlobDepthRow__band">{r.bandBps} bps</span>
|
||||
<span className="dlobDepthRow__num neg">{formatUsd(r.askUsd)}</span>
|
||||
<span className="dlobDepthRow__num pos">{formatUsd(r.bidUsd)}</span>
|
||||
<span className="dlobDepthRow__num muted">
|
||||
{r.imbalance == null || !Number.isFinite(r.imbalance)
|
||||
? '—'
|
||||
: formatPct(((r.imbalance + 1) / 2) * 100)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="dlobDepth__empty muted">No depth band rows yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
112
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import Chart from 'chart.js/auto';
|
||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||
|
||||
type Point = { x: number; y: number | null };
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<Chart | null>(null);
|
||||
|
||||
const { buy, sell, maxImpact } = useMemo(() => {
|
||||
const buy: Point[] = [];
|
||||
const sell: Point[] = [];
|
||||
let maxImpact = 0;
|
||||
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.sizeUsd) || r.sizeUsd <= 0) continue;
|
||||
const y = r.impactBps == null || !Number.isFinite(r.impactBps) ? null : r.impactBps;
|
||||
if (y != null) maxImpact = Math.max(maxImpact, y);
|
||||
if (r.side === 'buy') buy.push({ x: r.sizeUsd, y });
|
||||
if (r.side === 'sell') sell.push({ x: r.sizeUsd, y });
|
||||
}
|
||||
|
||||
buy.sort((a, b) => a.x - b.x);
|
||||
sell.sort((a, b) => a.x - b.x);
|
||||
|
||||
return { buy, sell, maxImpact };
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
if (chartRef.current) return;
|
||||
|
||||
chartRef.current = new Chart(canvasRef.current, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Buy',
|
||||
data: [],
|
||||
borderColor: 'rgba(34,197,94,0.9)',
|
||||
backgroundColor: 'rgba(34,197,94,0.15)',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: 'Sell',
|
||||
data: [],
|
||||
borderColor: 'rgba(239,68,68,0.9)',
|
||||
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
parsing: false,
|
||||
interaction: { mode: 'nearest', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#e6e9ef' } },
|
||||
tooltip: { enabled: true },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
title: { display: true, text: 'Size (USD)', color: '#c7cbd4' },
|
||||
ticks: { color: '#c7cbd4' },
|
||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
beginAtZero: true,
|
||||
suggestedMax: Math.max(10, maxImpact * (1 + clamp01(0.15))),
|
||||
title: { display: true, text: 'Impact (bps)', color: '#c7cbd4' },
|
||||
ticks: { color: '#c7cbd4' },
|
||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [maxImpact]);
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
chart.data.datasets[0]!.data = buy as any;
|
||||
chart.data.datasets[1]!.data = sell as any;
|
||||
chart.update('none');
|
||||
}, [buy, sell]);
|
||||
|
||||
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
|
||||
}
|
||||
|
||||
134
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
134
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type DlobDepthBandRow = {
|
||||
marketName: string;
|
||||
bandBps: number;
|
||||
midPrice: number | null;
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
bidUsd: number | null;
|
||||
askUsd: number | null;
|
||||
bidBase: number | null;
|
||||
askBase: number | null;
|
||||
imbalance: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toInt(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number.parseInt(s, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type HasuraRow = {
|
||||
market_name: string;
|
||||
band_bps: unknown;
|
||||
mid_price?: unknown;
|
||||
best_bid_price?: unknown;
|
||||
best_ask_price?: unknown;
|
||||
bid_usd?: unknown;
|
||||
ask_usd?: unknown;
|
||||
bid_base?: unknown;
|
||||
ask_base?: unknown;
|
||||
imbalance?: unknown;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
dlob_depth_bps_latest: HasuraRow[];
|
||||
};
|
||||
|
||||
export function useDlobDepthBands(
|
||||
marketName: string
|
||||
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
|
||||
const [rows, setRows] = useState<DlobDepthBandRow[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setRows([]);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const query = `
|
||||
subscription DlobDepthBands($market: String!) {
|
||||
dlob_depth_bps_latest(
|
||||
where: { market_name: { _eq: $market } }
|
||||
order_by: [{ band_bps: asc }]
|
||||
) {
|
||||
market_name
|
||||
band_bps
|
||||
mid_price
|
||||
best_bid_price
|
||||
best_ask_price
|
||||
bid_usd
|
||||
ask_usd
|
||||
bid_base
|
||||
ask_base
|
||||
imbalance
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const out: DlobDepthBandRow[] = [];
|
||||
for (const r of data?.dlob_depth_bps_latest || []) {
|
||||
if (!r?.market_name) continue;
|
||||
const bandBps = toInt(r.band_bps);
|
||||
if (bandBps == null || bandBps <= 0) continue;
|
||||
out.push({
|
||||
marketName: r.market_name,
|
||||
bandBps,
|
||||
midPrice: toNum(r.mid_price),
|
||||
bestBid: toNum(r.best_bid_price),
|
||||
bestAsk: toNum(r.best_ask_price),
|
||||
bidUsd: toNum(r.bid_usd),
|
||||
askUsd: toNum(r.ask_usd),
|
||||
bidBase: toNum(r.bid_base),
|
||||
askBase: toNum(r.ask_base),
|
||||
imbalance: toNum(r.imbalance),
|
||||
updatedAt: r.updated_at ?? null,
|
||||
});
|
||||
}
|
||||
setRows(out);
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket]);
|
||||
|
||||
return { rows, connected, error };
|
||||
}
|
||||
|
||||
159
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
159
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type OrderbookRow = {
|
||||
price: number;
|
||||
size: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type DlobL2 = {
|
||||
marketName: string;
|
||||
bids: OrderbookRow[];
|
||||
asks: OrderbookRow[];
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
mid: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function envNumber(name: string): number | undefined {
|
||||
const raw = (import.meta as any).env?.[name];
|
||||
if (raw == null) return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeJson(v: unknown): unknown {
|
||||
if (typeof v !== 'string') return v;
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number): Array<{ price: number; size: number }> {
|
||||
const v = normalizeJson(raw);
|
||||
if (!Array.isArray(v)) return [];
|
||||
|
||||
const out: Array<{ price: number; size: number }> = [];
|
||||
for (const item of v) {
|
||||
const priceInt = toNum((item as any)?.price);
|
||||
const sizeInt = toNum((item as any)?.size);
|
||||
if (priceInt == null || sizeInt == null) continue;
|
||||
|
||||
const price = priceInt / pricePrecision;
|
||||
const size = sizeInt / basePrecision;
|
||||
if (!Number.isFinite(price) || !Number.isFinite(size)) continue;
|
||||
out.push({ price, size });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function withTotals(levels: Array<{ price: number; size: number }>): OrderbookRow[] {
|
||||
let total = 0;
|
||||
return levels.map((l) => {
|
||||
total += l.size;
|
||||
return { ...l, total };
|
||||
});
|
||||
}
|
||||
|
||||
type HasuraDlobL2Row = {
|
||||
market_name: string;
|
||||
bids?: unknown;
|
||||
asks?: unknown;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
dlob_l2_latest: HasuraDlobL2Row[];
|
||||
};
|
||||
|
||||
export function useDlobL2(
|
||||
marketName: string,
|
||||
opts?: { levels?: number }
|
||||
): { l2: DlobL2 | null; connected: boolean; error: string | null } {
|
||||
const [l2, setL2] = useState<DlobL2 | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]);
|
||||
|
||||
const pricePrecision = useMemo(() => envNumber('VITE_DLOB_PRICE_PRECISION') ?? 1_000_000, []);
|
||||
const basePrecision = useMemo(() => envNumber('VITE_DLOB_BASE_PRECISION') ?? 1_000_000_000, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setL2(null);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const query = `
|
||||
subscription DlobL2($market: String!) {
|
||||
dlob_l2_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||
market_name
|
||||
bids
|
||||
asks
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const row = data?.dlob_l2_latest?.[0];
|
||||
if (!row?.market_name) return;
|
||||
|
||||
const bidsRaw = parseLevels(row.bids, pricePrecision, basePrecision).slice(0, levels);
|
||||
const asksRaw = parseLevels(row.asks, pricePrecision, basePrecision).slice(0, levels);
|
||||
|
||||
const bids = withTotals(bidsRaw);
|
||||
const asks = withTotals(asksRaw).slice().reverse();
|
||||
|
||||
const bestBid = bidsRaw.length ? bidsRaw[0].price : null;
|
||||
const bestAsk = asksRaw.length ? asksRaw[0].price : null;
|
||||
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
|
||||
|
||||
setL2({
|
||||
marketName: row.market_name,
|
||||
bids,
|
||||
asks,
|
||||
bestBid,
|
||||
bestAsk,
|
||||
mid,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket, levels, pricePrecision, basePrecision]);
|
||||
|
||||
return { l2, connected, error };
|
||||
}
|
||||
|
||||
138
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
138
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type DlobSlippageRow = {
|
||||
marketName: string;
|
||||
side: 'buy' | 'sell';
|
||||
sizeUsd: number;
|
||||
midPrice: number | null;
|
||||
vwapPrice: number | null;
|
||||
worstPrice: number | null;
|
||||
filledUsd: number | null;
|
||||
filledBase: number | null;
|
||||
impactBps: number | null;
|
||||
levelsConsumed: number | null;
|
||||
fillPct: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toInt(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number.parseInt(s, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type HasuraRow = {
|
||||
market_name: string;
|
||||
side: string;
|
||||
size_usd: unknown;
|
||||
mid_price?: unknown;
|
||||
vwap_price?: unknown;
|
||||
worst_price?: unknown;
|
||||
filled_usd?: unknown;
|
||||
filled_base?: unknown;
|
||||
impact_bps?: unknown;
|
||||
levels_consumed?: unknown;
|
||||
fill_pct?: unknown;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
dlob_slippage_latest: HasuraRow[];
|
||||
};
|
||||
|
||||
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
||||
const [rows, setRows] = useState<DlobSlippageRow[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setRows([]);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const query = `
|
||||
subscription DlobSlippage($market: String!) {
|
||||
dlob_slippage_latest(
|
||||
where: { market_name: { _eq: $market } }
|
||||
order_by: [{ side: asc }, { size_usd: asc }]
|
||||
) {
|
||||
market_name
|
||||
side
|
||||
size_usd
|
||||
mid_price
|
||||
vwap_price
|
||||
worst_price
|
||||
filled_usd
|
||||
filled_base
|
||||
impact_bps
|
||||
levels_consumed
|
||||
fill_pct
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const out: DlobSlippageRow[] = [];
|
||||
for (const r of data?.dlob_slippage_latest || []) {
|
||||
if (!r?.market_name) continue;
|
||||
const side = String(r.side || '').trim();
|
||||
if (side !== 'buy' && side !== 'sell') continue;
|
||||
const sizeUsd = toInt(r.size_usd);
|
||||
if (sizeUsd == null || sizeUsd <= 0) continue;
|
||||
out.push({
|
||||
marketName: r.market_name,
|
||||
side,
|
||||
sizeUsd,
|
||||
midPrice: toNum(r.mid_price),
|
||||
vwapPrice: toNum(r.vwap_price),
|
||||
worstPrice: toNum(r.worst_price),
|
||||
filledUsd: toNum(r.filled_usd),
|
||||
filledBase: toNum(r.filled_base),
|
||||
impactBps: toNum(r.impact_bps),
|
||||
levelsConsumed: toInt(r.levels_consumed),
|
||||
fillPct: toNum(r.fill_pct),
|
||||
updatedAt: r.updated_at ?? null,
|
||||
});
|
||||
}
|
||||
setRows(out);
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket]);
|
||||
|
||||
return { rows, connected, error };
|
||||
}
|
||||
|
||||
@@ -3,13 +3,18 @@ import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type DlobStats = {
|
||||
marketName: string;
|
||||
markPrice: number | null;
|
||||
oraclePrice: number | null;
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
mid: number | null;
|
||||
spreadAbs: number | null;
|
||||
spreadBps: number | null;
|
||||
depthBidBase: number | null;
|
||||
depthAskBase: number | null;
|
||||
depthBidUsd: number | null;
|
||||
depthAskUsd: number | null;
|
||||
imbalance: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
@@ -27,13 +32,18 @@ function toNum(v: unknown): number | null {
|
||||
|
||||
type HasuraDlobStatsRow = {
|
||||
market_name: string;
|
||||
mark_price?: string | null;
|
||||
oracle_price?: string | null;
|
||||
best_bid_price?: string | null;
|
||||
best_ask_price?: string | null;
|
||||
mid_price?: string | null;
|
||||
spread_abs?: string | null;
|
||||
spread_bps?: string | null;
|
||||
depth_bid_base?: string | null;
|
||||
depth_ask_base?: string | null;
|
||||
depth_bid_usd?: string | null;
|
||||
depth_ask_usd?: string | null;
|
||||
imbalance?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
@@ -62,13 +72,18 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
|
||||
subscription DlobStats($market: String!) {
|
||||
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||
market_name
|
||||
mark_price
|
||||
oracle_price
|
||||
best_bid_price
|
||||
best_ask_price
|
||||
mid_price
|
||||
spread_abs
|
||||
spread_bps
|
||||
depth_bid_base
|
||||
depth_ask_base
|
||||
depth_bid_usd
|
||||
depth_ask_usd
|
||||
imbalance
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
@@ -84,13 +99,18 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
|
||||
if (!row?.market_name) return;
|
||||
setStats({
|
||||
marketName: row.market_name,
|
||||
markPrice: toNum(row.mark_price),
|
||||
oraclePrice: toNum(row.oracle_price),
|
||||
bestBid: toNum(row.best_bid_price),
|
||||
bestAsk: toNum(row.best_ask_price),
|
||||
mid: toNum(row.mid_price),
|
||||
spreadAbs: toNum(row.spread_abs),
|
||||
spreadBps: toNum(row.spread_bps),
|
||||
depthBidBase: toNum(row.depth_bid_base),
|
||||
depthAskBase: toNum(row.depth_ask_base),
|
||||
depthBidUsd: toNum(row.depth_bid_usd),
|
||||
depthAskUsd: toNum(row.depth_ask_usd),
|
||||
imbalance: toNum(row.imbalance),
|
||||
updatedAt: row.updated_at ?? null,
|
||||
});
|
||||
},
|
||||
@@ -101,4 +121,3 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
|
||||
|
||||
return { stats, connected, error };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ export type Candle = {
|
||||
close: number;
|
||||
volume?: number;
|
||||
oracle?: number | null;
|
||||
flow?: { up: number; down: number; flat: number };
|
||||
flowRows?: number[];
|
||||
flowMoves?: number[];
|
||||
};
|
||||
|
||||
export type SeriesPoint = {
|
||||
@@ -68,9 +71,18 @@ export async function fetchChart(params: {
|
||||
close: Number(c.close),
|
||||
volume: c.volume == null ? undefined : Number(c.volume),
|
||||
oracle: c.oracle == null ? null : Number(c.oracle),
|
||||
flow:
|
||||
(c as any)?.flow && typeof (c as any).flow === 'object'
|
||||
? {
|
||||
up: Number((c as any).flow.up),
|
||||
down: Number((c as any).flow.down),
|
||||
flat: Number((c as any).flow.flat),
|
||||
}
|
||||
: undefined,
|
||||
flowRows: Array.isArray((c as any)?.flowRows) ? (c as any).flowRows.map((x: any) => Number(x)) : undefined,
|
||||
flowMoves: Array.isArray((c as any)?.flowMoves) ? (c as any).flowMoves.map((x: any) => Number(x)) : undefined,
|
||||
})),
|
||||
indicators: json.indicators || {},
|
||||
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,16 @@ function resolveGraphqlHttpUrl(): string {
|
||||
|
||||
function resolveGraphqlWsUrl(): string {
|
||||
const explicit = envString('VITE_HASURA_WS_URL');
|
||||
if (explicit) return explicit;
|
||||
if (explicit) {
|
||||
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
|
||||
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
|
||||
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
|
||||
return `${proto}//${host}${path}`;
|
||||
}
|
||||
|
||||
const httpUrl = resolveGraphqlHttpUrl();
|
||||
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
|
||||
@@ -170,4 +179,3 @@ export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onSta
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ function getApiUrl(): string | undefined {
|
||||
}
|
||||
|
||||
function getHasuraUrl(): string {
|
||||
return (import.meta as any).env?.VITE_HASURA_URL || 'http://localhost:8080/v1/graphql';
|
||||
return (import.meta as any).env?.VITE_HASURA_URL || '/graphql';
|
||||
}
|
||||
|
||||
function getAuthToken(): string | undefined {
|
||||
|
||||
@@ -46,6 +46,10 @@ a:hover {
|
||||
color: var(--neg);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shell {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
@@ -981,6 +985,226 @@ body.chartFullscreen {
|
||||
padding: 10px 2px;
|
||||
}
|
||||
|
||||
.dlobDash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDash__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobDash__title {
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.dlobDash__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobDash__market {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dlobDash__statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobStatus {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.dlobStatus__label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dlobDash__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobKpi {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.dlobKpi__label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dlobKpi__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.dlobKpi__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dlobDash__panes {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDash__pane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDepth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDepth__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobDepth__title {
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobDepth__meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dlobDepth__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dlobDepthRow {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1fr 1fr 0.7fr;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dlobDepthRow > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dlobDepthRow::before,
|
||||
.dlobDepthRow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dlobDepthRow::before {
|
||||
transform: scaleX(var(--ask-scale, 0));
|
||||
transform-origin: left center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(239, 68, 68, 0.18) 0%,
|
||||
rgba(239, 68, 68, 0.06) 60%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dlobDepthRow::after {
|
||||
transform: scaleX(var(--bid-scale, 0));
|
||||
transform-origin: right center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 197, 94, 0) 0%,
|
||||
rgba(34, 197, 94, 0.06) 40%,
|
||||
rgba(34, 197, 94, 0.18) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dlobDepthRow--head {
|
||||
padding: 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dlobDepthRow--head::before,
|
||||
.dlobDepthRow--head::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dlobDepthRow__num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dlobDepth__empty {
|
||||
padding: 8px 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobSlippage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobSlippage__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobSlippage__title {
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobSlippage__chartWrap {
|
||||
height: 220px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.dlobSlippageChart {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.dlobSlippage__empty {
|
||||
padding: 8px 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bottomCard .uiCard__body {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
@@ -1046,6 +1270,45 @@ body.chartFullscreen {
|
||||
padding: 2px 2px;
|
||||
border-radius: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orderbookRow > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.orderbookRow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transform: scaleX(var(--ob-bar-scale, 0));
|
||||
transition: transform 220ms ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.orderbookRow--ask::before {
|
||||
transform-origin: left center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(239, 68, 68, 0.24) 0%,
|
||||
rgba(239, 68, 68, 0.09) 60%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.orderbookRow--bid::before {
|
||||
transform-origin: right center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 197, 94, 0) 0%,
|
||||
rgba(34, 197, 94, 0.09) 40%,
|
||||
rgba(34, 197, 94, 0.24) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.orderbookRow__num {
|
||||
|
||||
Reference in New Issue
Block a user