diff --git a/.gitignore b/.gitignore index ea41b5d..8f58734 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,20 @@ tokens/* !tokens/*.example.yml !tokens/*.example.yaml +# Local secrets (never commit) +pass/ +argo/pass + node_modules/ dist/ .env *.log +# Baremetal IaC local overlays (do not commit) +infra/baremetal-solana-rpc/ansible/inventory/hosts.ini +infra/baremetal-solana-rpc/ansible/group_vars/solana_rpc.yml +infra/baremetal-solana-rpc/ansible/group_vars/vault.yml + # Local scratch / build output _tmp/ apps/visualizer/dist/ diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index 38f842f..c82af9a 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -32,7 +32,8 @@ function envString(name: string, fallback: string): string { function formatUsd(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return '—'; - if (v >= 1000) return `$${v.toFixed(0)}`; + if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`; if (v >= 1) return `$${v.toFixed(2)}`; return `$${v.toPrecision(4)}`; } @@ -42,9 +43,36 @@ function formatQty(v: number | null | undefined, decimals: number): string { return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } -function orderbookBarStyle(scale: number): CSSProperties { - const s = Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0; - return { ['--ob-bar-scale' as any]: s } as CSSProperties; +function formatCompact(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + const abs = Math.abs(v); + if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`; + if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`; + if (abs >= 1) return v.toFixed(2); + return v.toPrecision(4); +} + +function clamp01(scale: number): number { + return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0; +} + +function barCurve(scale01: number): number { + // Makes small rows visible without letting a single wall dominate. + return Math.sqrt(clamp01(scale01)); +} + +function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties { + return { + ['--ob-total-scale' as any]: barCurve(totalScale), + ['--ob-level-scale' as any]: barCurve(levelScale), + } as CSSProperties; +} + +function liquidityStyle(bid: number, ask: number): CSSProperties { + const max = Math.max(1e-9, bid, ask); + const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0; + const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0; + return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties; } type WhoamiResponse = { @@ -118,7 +146,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000)); const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300)); const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true); - const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false); + const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [bottomTab, setBottomTab] = useLocalStorageState< 'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' @@ -150,7 +178,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { }); const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol); - const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 14 }); + const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 10 }); const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol); const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol); @@ -160,51 +188,88 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null; const orderbook = useMemo(() => { - if (dlobL2) return { asks: dlobL2.asks, bids: dlobL2.bids, mid: dlobL2.mid as number | null }; - if (!latest) return { asks: [], bids: [], mid: null as number | null }; + if (dlobL2) { + return { + asks: dlobL2.asks, + bids: dlobL2.bids, + mid: dlobL2.mid as number | null, + bestBid: dlobL2.bestBid, + bestAsk: dlobL2.bestAsk, + }; + } + if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: null as number | null }; const mid = latest.close; const step = Math.max(mid * 0.00018, 0.0001); - const levels = 14; + const levels = 10; const asksRaw = Array.from({ length: levels }, (_, i) => ({ price: mid + (i + 1) * step, - size: 0.1 + ((i * 7) % 15) * 0.1, + sizeBase: 0.1 + ((i * 7) % 15) * 0.1, })); const bidsRaw = Array.from({ length: levels }, (_, i) => ({ price: mid - (i + 1) * step, - size: 0.1 + ((i * 5) % 15) * 0.1, + sizeBase: 0.1 + ((i * 5) % 15) * 0.1, })); - let askTotal = 0; + let askTotalBase = 0; + let askTotalUsd = 0; const asks = asksRaw .slice() .reverse() .map((r) => { - askTotal += r.size; - return { ...r, total: askTotal }; + const sizeUsd = r.sizeBase * r.price; + askTotalBase += r.sizeBase; + askTotalUsd += sizeUsd; + return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd }; }); - let bidTotal = 0; + let bidTotalBase = 0; + let bidTotalUsd = 0; const bids = bidsRaw.map((r) => { - bidTotal += r.size; - return { ...r, total: bidTotal }; + const sizeUsd = r.sizeBase * r.price; + bidTotalBase += r.sizeBase; + bidTotalUsd += sizeUsd; + return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd }; }); - return { asks, bids, mid }; + return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null }; }, [dlobL2, latest]); const maxAskTotal = useMemo(() => { let max = 0; - for (const r of orderbook.asks) max = Math.max(max, r.total || 0); + for (const r of orderbook.asks) max = Math.max(max, (r as any).totalUsd || 0); return max; }, [orderbook.asks]); const maxBidTotal = useMemo(() => { let max = 0; - for (const r of orderbook.bids) max = Math.max(max, r.total || 0); + for (const r of orderbook.bids) max = Math.max(max, (r as any).totalUsd || 0); return max; }, [orderbook.bids]); + const maxAskSize = useMemo(() => { + let max = 0; + for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0); + return max; + }, [orderbook.asks]); + + const maxBidSize = useMemo(() => { + let max = 0; + for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0); + return max; + }, [orderbook.bids]); + + const liquidity = useMemo(() => { + const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0; + const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0; + const bestBid = orderbook.bestBid; + const bestAsk = orderbook.bestAsk; + const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null; + const spreadPct = + spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null; + return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct }; + }, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]); + const trades = useMemo(() => { const slice = candles.slice(-24).reverse(); return slice.map((c) => { @@ -428,19 +493,22 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
Price - Size - Total + Size (USD) + Total (USD)
{orderbook.asks.map((r) => (
0 ? r.total / maxAskTotal : 0)} + style={orderbookRowBarStyle( + maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0, + maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0 + )} > {formatQty(r.price, 3)} - {formatQty(r.size, 2)} - {formatQty(r.total, 2)} + {formatCompact((r as any).sizeUsd)} + {formatCompact((r as any).totalUsd)}
))}
@@ -451,13 +519,37 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
0 ? r.total / maxBidTotal : 0)} + style={orderbookRowBarStyle( + maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0, + maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0 + )} > {formatQty(r.price, 3)} - {formatQty(r.size, 2)} - {formatQty(r.total, 2)} + {formatCompact((r as any).sizeUsd)} + {formatCompact((r as any).totalUsd)}
))} +
+
+ Spread + + {liquidity.spreadAbs == null || liquidity.spreadPct == null + ? '—' + : `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`} + +
+
+ Liquidity (L1–L10) + + {formatUsd(liquidity.bidUsd)} /{' '} + {formatUsd(liquidity.askUsd)} + +
+
+
+
+
+
), diff --git a/apps/visualizer/src/features/chart/ChartToolbar.tsx b/apps/visualizer/src/features/chart/ChartToolbar.tsx index 6e9328a..4320df7 100644 --- a/apps/visualizer/src/features/chart/ChartToolbar.tsx +++ b/apps/visualizer/src/features/chart/ChartToolbar.tsx @@ -14,7 +14,7 @@ type Props = { onToggleFullscreen: () => void; }; -const timeframes = ['5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const; +const timeframes = ['3s', '5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const; export default function ChartToolbar({ timeframe, diff --git a/apps/visualizer/src/features/chart/TradingChart.tsx b/apps/visualizer/src/features/chart/TradingChart.tsx index 466cbe2..6da52e7 100644 --- a/apps/visualizer/src/features/chart/TradingChart.tsx +++ b/apps/visualizer/src/features/chart/TradingChart.tsx @@ -639,7 +639,7 @@ export default function TradingChart({ const buildSlicesPrimitive = new BuildSlicesPrimitive(); volumeSeries.attachPrimitive(buildSlicesPrimitive); buildSlicesPrimitiveRef.current = buildSlicesPrimitive; - buildSlicesPrimitive.setEnabled(!showBuildRef.current); + buildSlicesPrimitive.setEnabled(showBuildRef.current); const buildHoverSeries = chart.addSeries(LineSeries, { color: BUILD_FLAT_COLOR, @@ -1125,7 +1125,7 @@ export default function TradingChart({ const buildPrimitive = buildSlicesPrimitiveRef.current; buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map }); - buildPrimitive?.setEnabled(!showBuild); + buildPrimitive?.setEnabled(showBuild); if (showBuild) { const hoverTime = hoverCandleTime; diff --git a/apps/visualizer/src/features/market/useDlobL2.ts b/apps/visualizer/src/features/market/useDlobL2.ts index e981faa..07d00f0 100644 --- a/apps/visualizer/src/features/market/useDlobL2.ts +++ b/apps/visualizer/src/features/market/useDlobL2.ts @@ -3,8 +3,10 @@ import { subscribeGraphqlWs } from '../../lib/graphqlWs'; export type OrderbookRow = { price: number; - size: number; - total: number; + sizeBase: number; + sizeUsd: number; + totalBase: number; + totalUsd: number; }; export type DlobL2 = { @@ -66,11 +68,22 @@ function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number return out; } -function withTotals(levels: Array<{ price: number; size: number }>): OrderbookRow[] { - let total = 0; +function withTotals(levels: Array<{ price: number; sizeBase: number }>): OrderbookRow[] { + let totalBase = 0; + let totalUsd = 0; + return levels.map((l) => { - total += l.size; - return { ...l, total }; + const sizeUsd = l.sizeBase * l.price; + totalBase += l.sizeBase; + totalUsd += sizeUsd; + + return { + price: l.price, + sizeBase: l.sizeBase, + sizeUsd, + totalBase, + totalUsd, + }; }); } @@ -129,14 +142,25 @@ export function useDlobL2( 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 bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision) + .slice() + .sort((a, b) => b.price - a.price) + .slice(0, levels) + .map((l) => ({ price: l.price, sizeBase: l.size })); - const bids = withTotals(bidsRaw); - const asks = withTotals(asksRaw).slice().reverse(); + const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision) + .slice() + .sort((a, b) => a.price - b.price) + .slice(0, levels) + .map((l) => ({ price: l.price, sizeBase: l.size })); - const bestBid = bidsRaw.length ? bidsRaw[0].price : null; - const bestAsk = asksRaw.length ? asksRaw[0].price : null; + // We compute totals from best -> worse. + // For UI we display asks with best ask closest to mid (at the bottom), so we reverse. + const bids = withTotals(bidsSorted); + const asks = withTotals(asksSorted).slice().reverse(); + + const bestBid = bidsSorted.length ? bidsSorted[0].price : null; + const bestAsk = asksSorted.length ? asksSorted[0].price : null; const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null; setL2({ @@ -156,4 +180,3 @@ export function useDlobL2( return { l2, connected, error }; } - diff --git a/apps/visualizer/src/lib/api.ts b/apps/visualizer/src/lib/api.ts index 87d16a4..fe83060 100644 --- a/apps/visualizer/src/lib/api.ts +++ b/apps/visualizer/src/lib/api.ts @@ -79,8 +79,16 @@ export async function fetchChart(params: { 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, + flowRows: Array.isArray((c as any)?.flowRows) + ? (c as any).flowRows.map((x: any) => Number(x)) + : Array.isArray((c as any)?.flow_rows) + ? (c as any).flow_rows.map((x: any) => Number(x)) + : undefined, + flowMoves: Array.isArray((c as any)?.flowMoves) + ? (c as any).flowMoves.map((x: any) => Number(x)) + : Array.isArray((c as any)?.flow_moves) + ? (c as any).flow_moves.map((x: any) => Number(x)) + : undefined, })), indicators: json.indicators || {}, meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) }, diff --git a/apps/visualizer/src/styles.css b/apps/visualizer/src/styles.css index f8f1ecc..8900fc2 100644 --- a/apps/visualizer/src/styles.css +++ b/apps/visualizer/src/styles.css @@ -1286,7 +1286,20 @@ body.chartFullscreen { border-radius: inherit; pointer-events: none; z-index: 0; - transform: scaleX(var(--ob-bar-scale, 0)); + opacity: 0.35; + transform: scaleX(var(--ob-total-scale, 0)); + transition: transform 220ms ease-out; + will-change: transform; +} + +.orderbookRow::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + z-index: 0; + transform: scaleX(var(--ob-level-scale, 0)); transition: transform 220ms ease-out; will-change: transform; } @@ -1311,6 +1324,26 @@ body.chartFullscreen { ); } +.orderbookRow--ask::after { + transform-origin: left center; + background: linear-gradient( + 90deg, + rgba(239, 68, 68, 0.36) 0%, + rgba(239, 68, 68, 0.12) 55%, + rgba(239, 68, 68, 0) 100% + ); +} + +.orderbookRow--bid::after { + transform-origin: right center; + background: linear-gradient( + 90deg, + rgba(34, 197, 94, 0) 0%, + rgba(34, 197, 94, 0.12) 45%, + rgba(34, 197, 94, 0.36) 100% + ); +} + .orderbookRow__num { text-align: right; color: rgba(230, 233, 239, 0.75); @@ -1344,6 +1377,69 @@ body.chartFullscreen { color: var(--muted); } +.orderbookMeta { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 2px 2px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.orderbookMeta__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + font-size: 11px; +} + +.orderbookMeta__val { + font-variant-numeric: tabular-nums; +} + +.liquidityBar { + position: relative; + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; +} + +.liquidityBar::after { + content: ''; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: rgba(0, 0, 0, 0.55); + transform: translateX(-0.5px); + pointer-events: none; +} + +.liquidityBar__bid, +.liquidityBar__ask { + height: 100%; + transform: scaleX(0); + transform-origin: center; + transition: transform 180ms ease-out; +} + +.liquidityBar__bid { + background: linear-gradient(90deg, rgba(34, 197, 94, 0.0) 0%, rgba(34, 197, 94, 0.35) 55%, rgba(34, 197, 94, 0.85) 100%); + transform-origin: right center; + transform: scaleX(var(--liq-bid, 0)); +} + +.liquidityBar__ask { + background: linear-gradient(90deg, rgba(239, 68, 68, 0.85) 0%, rgba(239, 68, 68, 0.35) 45%, rgba(239, 68, 68, 0.0) 100%); + transform-origin: left center; + transform: scaleX(var(--liq-ask, 0)); +} + .trades { display: flex; flex-direction: column; diff --git a/doc/dlob-services.md b/doc/dlob-services.md index 1eb4af8..bff964d 100644 --- a/doc/dlob-services.md +++ b/doc/dlob-services.md @@ -2,6 +2,21 @@ Ten dokument opisuje rolę serwisów “DLOB” uruchomionych w namespace `trade-staging` oraz ich przepływ danych. +## Czy `dlob-worker` pracuje na VPS? + +Tak — wszystkie serwisy wymienione niżej działają **na VPS** jako Deploymenty w klastrze k3s, w namespace `trade-staging`. + +## Czy na VPS jest GraphQL/WS dla stats i orderbook? + +Tak — **GraphQL wystawia Hasura** (na VPS w k3s), a nie `dlob-server`. + +- Dane L2 i liczone statsy są zapisane do Postgresa jako tabele `dlob_*_latest` i są dostępne przez Hasurę jako GraphQL (query + subscriptions). +- Z zewnątrz korzystamy przez frontend (proxy) pod: + - HTTP: `https://trade.rv32i.pl/graphql` + - WS: `wss://trade.rv32i.pl/graphql` (subskrypcje, protokół `graphql-ws`) + +`dlob-server` wystawia **REST** (np. `/l2`, `/l3`) w klastrze; to jest źródło danych dla workerów albo do debugowania. + ## TL;DR: kto co robi ### `dlob-worker` @@ -70,6 +85,12 @@ W tym stacku: - `base` = instrument bazowy (np. SOL), - `quote` = waluta wyceny (często USD). +## Kolory w UI (visualizer) + +- `bid` / “buy side” = zielony (`.pos`, `#22c55e`) +- `ask` / “sell side” = czerwony (`.neg`, `#ef4444`) +- “flat” / brak zmiany = niebieski (`#60a5fa`) — używany m.in. w “brick stack” pod świecami + ### Jednostki i skróty - **bps (basis points)**: 1 bps = 0.01% = `0.0001`. Np. 25 bps = 0.25%.