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