diff --git a/apps/visualizer/.env.example b/apps/visualizer/.env.example
index 9e85877..75c30ad 100644
--- a/apps/visualizer/.env.example
+++ b/apps/visualizer/.env.example
@@ -1,11 +1,13 @@
# Default: UI reads ticks from the same-origin API proxy at `/api`.
VITE_API_URL=/api
-# Fallback (optional): query Hasura directly (not recommended in browser).
-VITE_HASURA_URL=http://localhost:8080/v1/graphql
-# Optional (only if you intentionally query Hasura directly from the browser):
+# Hasura GraphQL endpoint (supports subscriptions via WS).
+# 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
+# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
-# VITE_HASURA_ADMIN_SECRET=devsecret
VITE_SYMBOL=PUMP-PERP
# Optional: filter by source (leave empty for all)
# VITE_SOURCE=drift_oracle
diff --git a/apps/visualizer/__start b/apps/visualizer/__start
new file mode 100644
index 0000000..bd85039
--- /dev/null
+++ b/apps/visualizer/__start
@@ -0,0 +1 @@
+API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl npm run dev
diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx
index 5e529b3..bb997ca 100644
--- a/apps/visualizer/src/App.tsx
+++ b/apps/visualizer/src/App.tsx
@@ -11,6 +11,7 @@ import Button from './ui/Button';
import TopNav from './layout/TopNav';
import AuthStatus from './layout/AuthStatus';
import LoginScreen from './layout/LoginScreen';
+import { useDlobStats } from './features/market/useDlobStats';
function envNumber(name: string, fallback: number): number {
const v = (import.meta as any).env?.[name];
@@ -99,7 +100,7 @@ export default function App() {
}
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
- const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []);
+ const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP'));
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
@@ -128,6 +129,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
pollMs,
});
+ const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
+
const latest = candles.length ? candles[candles.length - 1] : null;
const first = candles.length ? candles[0] : null;
const changePct =
@@ -209,12 +212,22 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
),
},
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
- { key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' },
- { key: 'oi', label: 'Open Interest', value: '—' },
- { key: 'vol', label: '24h Volume', value: '—' },
- { key: 'details', label: 'Market Details', value: View },
+ { key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) },
+ { key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) },
+ {
+ key: 'spread',
+ label: 'Spread',
+ value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`,
+ sub: formatUsd(dlob?.spreadAbs ?? null),
+ },
+ {
+ key: 'dlob',
+ label: 'DLOB',
+ value: dlobConnected ? 'live' : '—',
+ sub: dlobError ? {dlobError} : dlob?.updatedAt || '—',
+ },
];
- }, [latest?.close, latest?.oracle, changePct]);
+ }, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError]);
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
diff --git a/apps/visualizer/src/features/chart/TradingChart.tsx b/apps/visualizer/src/features/chart/TradingChart.tsx
index cfecb61..d05e355 100644
--- a/apps/visualizer/src/features/chart/TradingChart.tsx
+++ b/apps/visualizer/src/features/chart/TradingChart.tsx
@@ -1,13 +1,18 @@
-import { useEffect, useMemo, useRef } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import {
CandlestickSeries,
ColorType,
CrosshairMode,
HistogramSeries,
+ type IPrimitivePaneRenderer,
+ type IPrimitivePaneView,
type IChartApi,
type ISeriesApi,
+ type ISeriesPrimitive,
LineStyle,
LineSeries,
+ type SeriesAttachedParameter,
+ type Time,
createChart,
type UTCTimestamp,
type CandlestickData,
@@ -49,6 +54,13 @@ type Props = {
type LinePoint = LineData | WhitespaceData;
type BuildSample = { t: number; v: number };
+const BUILD_UP_COLOR = '#22c55e';
+const BUILD_DOWN_COLOR = '#ef4444';
+const BUILD_FLAT_COLOR = '#60a5fa';
+const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)';
+const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)';
+const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)';
+
function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp;
}
@@ -110,6 +122,258 @@ function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
}
+function colorForDelta(delta: number): string {
+ if (delta > 0) return BUILD_UP_COLOR;
+ if (delta < 0) return BUILD_DOWN_COLOR;
+ return BUILD_FLAT_COLOR;
+}
+
+function sliceColorForDelta(delta: number): string {
+ if (delta > 0) return BUILD_UP_SLICE;
+ if (delta < 0) return BUILD_DOWN_SLICE;
+ return BUILD_FLAT_SLICE;
+}
+
+type SliceDir = -1 | 0 | 1;
+
+function dirForDelta(delta: number): SliceDir {
+ if (delta > 0) return 1;
+ if (delta < 0) return -1;
+ return 0;
+}
+
+function buildDeltaSeriesForCandle(candle: Candle, bs: number, samples: BuildSample[] | undefined): LinePoint[] {
+ const eps = 1e-3;
+ const startT = candle.time + eps;
+ const endT = candle.time + bs - eps;
+ if (!(endT > startT)) return [];
+
+ const points: BuildSample[] = [{ t: startT, v: 0 }];
+ let lastT = startT;
+ for (const p of samples || []) {
+ let t = p.t;
+ if (t <= lastT + eps) t = lastT + eps;
+ if (t >= endT) break;
+ points.push({ t, v: p.v });
+ lastT = t;
+ }
+
+ const finalDelta = candle.close - candle.open;
+ if (endT > lastT + eps) {
+ points.push({ t: endT, v: finalDelta });
+ } else if (points.length) {
+ points[points.length - 1] = { ...points[points.length - 1]!, v: finalDelta };
+ }
+
+ const out: LinePoint[] = [{ time: toTime(candle.time) } as WhitespaceData];
+ out.push({ time: toTime(points[0]!.t), value: points[0]!.v } as LineData);
+
+ let lastLineIdx = out.length - 1;
+ let lastVal = points[0]!.v;
+ for (let i = 1; i < points.length; i += 1) {
+ const v = points[i]!.v;
+ const prev = out[lastLineIdx] as LineData;
+ out[lastLineIdx] = { ...prev, color: colorForDelta(v - lastVal) } as LineData;
+ out.push({ time: toTime(points[i]!.t), value: v } as LineData);
+ lastLineIdx = out.length - 1;
+ lastVal = v;
+ }
+ return out;
+}
+
+type BuildSlicesState = {
+ enabled: boolean;
+ candles: Candle[];
+ bucketSeconds: number;
+ samples: Map;
+ series: ISeriesApi<'Histogram', Time> | null;
+ chart: SeriesAttachedParameter