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%.