feat(v2): add Hasura /graphql WS proxy and live DLOB stats

This commit is contained in:
u1
2026-01-10 01:17:53 +01:00
parent a9ccc0b00e
commit fa79340cf5
9 changed files with 824 additions and 95 deletions

View File

@@ -1,11 +1,13 @@
# Default: UI reads ticks from the same-origin API proxy at `/api`. # Default: UI reads ticks from the same-origin API proxy at `/api`.
VITE_API_URL=/api VITE_API_URL=/api
# Fallback (optional): query Hasura directly (not recommended in browser). # Hasura GraphQL endpoint (supports subscriptions via WS).
VITE_HASURA_URL=http://localhost:8080/v1/graphql # On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
# Optional (only if you intentionally query Hasura directly from the browser): 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_AUTH_TOKEN=YOUR_JWT
# VITE_HASURA_ADMIN_SECRET=devsecret
VITE_SYMBOL=PUMP-PERP VITE_SYMBOL=PUMP-PERP
# Optional: filter by source (leave empty for all) # Optional: filter by source (leave empty for all)
# VITE_SOURCE=drift_oracle # VITE_SOURCE=drift_oracle

1
apps/visualizer/__start Normal file
View File

@@ -0,0 +1 @@
API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl npm run dev

View File

@@ -11,6 +11,7 @@ import Button from './ui/Button';
import TopNav from './layout/TopNav'; import TopNav from './layout/TopNav';
import AuthStatus from './layout/AuthStatus'; import AuthStatus from './layout/AuthStatus';
import LoginScreen from './layout/LoginScreen'; import LoginScreen from './layout/LoginScreen';
import { useDlobStats } from './features/market/useDlobStats';
function envNumber(name: string, fallback: number): number { function envNumber(name: string, fallback: number): number {
const v = (import.meta as any).env?.[name]; const v = (import.meta as any).env?.[name];
@@ -99,7 +100,7 @@ export default function App() {
} }
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { 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 [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP'));
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', '')); const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
@@ -128,6 +129,8 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
pollMs, pollMs,
}); });
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
const latest = candles.length ? candles[candles.length - 1] : null; const latest = candles.length ? candles[candles.length - 1] : null;
const first = candles.length ? candles[0] : null; const first = candles.length ? candles[0] : null;
const changePct = const changePct =
@@ -209,12 +212,22 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
), ),
}, },
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) }, { key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
{ key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' }, { key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) },
{ key: 'oi', label: 'Open Interest', value: '—' }, { key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) },
{ key: 'vol', label: '24h Volume', value: '—' }, {
{ key: 'details', label: 'Market Details', value: <a href="#">View</a> }, 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 ? <span className="neg">{dlobError}</span> : dlob?.updatedAt || '—',
},
]; ];
}, [latest?.close, latest?.oracle, changePct]); }, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError]);
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []); const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]); const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);

View File

@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
CandlestickSeries, CandlestickSeries,
ColorType, ColorType,
CrosshairMode, CrosshairMode,
HistogramSeries, HistogramSeries,
type IPrimitivePaneRenderer,
type IPrimitivePaneView,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type ISeriesPrimitive,
LineStyle, LineStyle,
LineSeries, LineSeries,
type SeriesAttachedParameter,
type Time,
createChart, createChart,
type UTCTimestamp, type UTCTimestamp,
type CandlestickData, type CandlestickData,
@@ -49,6 +54,13 @@ type Props = {
type LinePoint = LineData | WhitespaceData; type LinePoint = LineData | WhitespaceData;
type BuildSample = { t: number; v: number }; 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 { function toTime(t: number): UTCTimestamp {
return t as 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 })); 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<number, BuildSample[]>;
series: ISeriesApi<'Histogram', Time> | null;
chart: SeriesAttachedParameter<Time>['chart'] | null;
};
class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
private readonly _getState: () => BuildSlicesState;
constructor(getState: () => BuildSlicesState) {
this._getState = getState;
}
draw(target: any) {
const { enabled, candles, bucketSeconds, samples, series, chart } = this._getState();
if (!enabled) return;
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);
if (yBase == null) return;
const xs = candles.map((c) => chart.timeScale().timeToCoordinate(toTime(c.time)));
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
const yBottomPx = Math.round(yBase * verticalPixelRatio);
const lastIdx = xs.length - 1;
for (let i = 0; i < candles.length; i += 1) {
const x = xs[i];
if (x == null) continue;
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;
const now = Date.now() / 1000;
const progressT = isCurrent ? Math.min(end, Math.max(start, now)) : end;
let spacing = 0;
const prevX = i > 0 ? xs[i - 1] : null;
const nextX = i < lastIdx ? xs[i + 1] : null;
if (prevX != null && Number.isFinite(prevX)) spacing = x - prevX;
if (nextX != null && Number.isFinite(nextX)) {
const s2 = nextX - x;
spacing = spacing > 0 ? Math.min(spacing, s2) : s2;
}
if (!(spacing > 0)) spacing = 6;
const barWidthCss = Math.max(1, spacing * 0.9);
const barWidthPx = Math.max(1, Math.round(barWidthCss * horizontalPixelRatio));
const xCenterPx = Math.round(x * horizontalPixelRatio);
const xLeftPx = Math.round(xCenterPx - barWidthPx / 2);
const volumeValue = typeof c.volume === 'number' && Number.isFinite(c.volume) ? c.volume : 0;
if (!(volumeValue > 0)) continue;
const yTop = series.priceToCoordinate(volumeValue);
if (yTop == null) continue;
const yTopPx = Math.round(yTop * verticalPixelRatio);
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;
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;
context.fillStyle = sliceColorForDelta(dir);
context.fillRect(x0, rowTop, w, h);
}
}
});
}
}
class BuildSlicesPaneView implements IPrimitivePaneView {
private readonly _renderer: BuildSlicesPaneRenderer;
constructor(getState: () => BuildSlicesState) {
this._renderer = new BuildSlicesPaneRenderer(getState);
}
zOrder() {
return 'top';
}
renderer() {
return this._renderer;
}
}
class BuildSlicesPrimitive implements ISeriesPrimitive<Time> {
private _param: SeriesAttachedParameter<Time> | null = null;
private _series: ISeriesApi<'Histogram', Time> | null = null;
private _enabled = true;
private _candles: Candle[] = [];
private _bucketSeconds = 0;
private _samples: Map<number, BuildSample[]> = new Map();
private readonly _paneView: BuildSlicesPaneView;
private readonly _paneViews: readonly IPrimitivePaneView[];
constructor() {
this._paneView = new BuildSlicesPaneView(() => ({
enabled: this._enabled,
candles: this._candles,
bucketSeconds: this._bucketSeconds,
samples: this._samples,
series: this._series,
chart: this._param?.chart ?? null,
}));
this._paneViews = [this._paneView];
}
attached(param: SeriesAttachedParameter<Time>) {
this._param = param;
this._series = param.series as ISeriesApi<'Histogram', Time>;
}
detached() {
this._param = null;
this._series = null;
}
paneViews() {
return this._paneViews;
}
setEnabled(next: boolean) {
this._enabled = Boolean(next);
this._param?.requestUpdate();
}
setData(next: { candles: Candle[]; bucketSeconds: number; samples: Map<number, BuildSample[]> }) {
this._candles = Array.isArray(next.candles) ? next.candles : [];
this._bucketSeconds = Number.isFinite(next.bucketSeconds) ? next.bucketSeconds : 0;
this._samples = next.samples;
this._param?.requestUpdate();
}
}
export default function TradingChart({ export default function TradingChart({
candles, candles,
oracle, oracle,
@@ -136,18 +400,22 @@ export default function TradingChart({
const fibOpacityRef = useRef<number>(fibOpacity); const fibOpacityRef = useRef<number>(fibOpacity);
const priceAutoScaleRef = useRef<boolean>(priceAutoScale); const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale); const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
const showBuildRef = useRef<boolean>(showBuild);
const onReadyRef = useRef<Props['onReady']>(onReady); const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick); const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove); const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent); const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
const capturedOverlayPointerRef = useRef<number | null>(null); const capturedOverlayPointerRef = useRef<number | null>(null);
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map()); const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
const buildKeyRef = useRef<string | null>(null); const buildKeyRef = useRef<string | null>(null);
const lastBuildCandleStartRef = useRef<number | null>(null); const lastBuildCandleStartRef = useRef<number | null>(null);
const hoverCandleTimeRef = useRef<number | null>(null);
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
const seriesRef = useRef<{ const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>; candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>; volume?: ISeriesApi<'Histogram'>;
build?: ISeriesApi<'Line'>; buildHover?: ISeriesApi<'Line'>;
oracle?: ISeriesApi<'Line'>; oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>; sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>; ema20?: ISeriesApi<'Line'>;
@@ -198,6 +466,14 @@ export default function TradingChart({
priceAutoScaleRef.current = priceAutoScale; priceAutoScaleRef.current = priceAutoScale;
}, [priceAutoScale]); }, [priceAutoScale]);
useEffect(() => {
showBuildRef.current = showBuild;
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
hoverCandleTimeRef.current = null;
setHoverCandleTime(null);
}
}, [showBuild, hoverCandleTime]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
if (chartRef.current) return; if (chartRef.current) return;
@@ -249,8 +525,13 @@ export default function TradingChart({
scaleMargins: { top: 0.88, bottom: 0 }, scaleMargins: { top: 0.88, bottom: 0 },
}); });
const buildSeries = chart.addSeries(LineSeries, { const buildSlicesPrimitive = new BuildSlicesPrimitive();
color: '#60a5fa', volumeSeries.attachPrimitive(buildSlicesPrimitive);
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
buildSlicesPrimitive.setEnabled(!showBuildRef.current);
const buildHoverSeries = chart.addSeries(LineSeries, {
color: BUILD_FLAT_COLOR,
lineWidth: 2, lineWidth: 2,
priceFormat, priceFormat,
priceScaleId: 'build', priceScaleId: 'build',
@@ -258,7 +539,7 @@ export default function TradingChart({
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
buildSeries.priceScale().applyOptions({ buildHoverSeries.priceScale().applyOptions({
scaleMargins: { top: 0.72, bottom: 0.12 }, scaleMargins: { top: 0.72, bottom: 0.12 },
visible: false, visible: false,
borderVisible: false, borderVisible: false,
@@ -285,7 +566,7 @@ export default function TradingChart({
seriesRef.current = { seriesRef.current = {
candles: candleSeries, candles: candleSeries,
volume: volumeSeries, volume: volumeSeries,
build: buildSeries, buildHover: buildHoverSeries,
oracle: oracleSeries, oracle: oracleSeries,
sma20: smaSeries, sma20: smaSeries,
ema20: emaSeries, ema20: emaSeries,
@@ -335,7 +616,23 @@ export default function TradingChart({
chart.subscribeClick(onClick); chart.subscribeClick(onClick);
const onCrosshairMove = (param: any) => { const onCrosshairMove = (param: any) => {
if (!param?.point) return; if (!param?.point) {
if (showBuildRef.current && hoverCandleTimeRef.current != null) {
hoverCandleTimeRef.current = null;
setHoverCandleTime(null);
}
return;
}
if (showBuildRef.current) {
const t = typeof param?.time === 'number' ? Number(param.time) : null;
const next = t != null && Number.isFinite(t) ? t : null;
if (hoverCandleTimeRef.current !== next) {
hoverCandleTimeRef.current = next;
setHoverCandleTime(next);
}
}
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x); const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return; if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y); const price = candleSeries.coordinateToPrice(param.point.y);
@@ -580,6 +877,10 @@ export default function TradingChart({
candleSeries.detachPrimitive(fibPrimitiveRef.current); candleSeries.detachPrimitive(fibPrimitiveRef.current);
fibPrimitiveRef.current = null; fibPrimitiveRef.current = null;
} }
if (buildSlicesPrimitiveRef.current) {
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
buildSlicesPrimitiveRef.current = null;
}
chart.remove(); chart.remove();
chartRef.current = null; chartRef.current = null;
seriesRef.current = {}; seriesRef.current = {};
@@ -588,7 +889,7 @@ export default function TradingChart({
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles || !s.volume || !s.build) return; if (!s.candles || !s.volume || !s.buildHover) return;
s.candles.setData(candleData); s.candles.setData(candleData);
s.volume.setData(volumeData); s.volume.setData(volumeData);
s.oracle?.setData(oracleData); s.oracle?.setData(oracleData);
@@ -598,34 +899,18 @@ export default function TradingChart({
s.bbLower?.setData(bbLower); s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid); s.bbMid?.setData(bbMid);
s.build.applyOptions({ visible: showBuild });
if (!showBuild) {
buildSamplesRef.current.clear();
buildKeyRef.current = seriesKey;
lastBuildCandleStartRef.current = null;
s.build.setData([]);
}
if (buildKeyRef.current !== seriesKey) {
buildSamplesRef.current.clear();
buildKeyRef.current = seriesKey;
lastBuildCandleStartRef.current = null;
}
if (!showBuild) {
s.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators });
s.bbLower?.applyOptions({ visible: showIndicators });
s.bbMid?.applyOptions({ visible: showIndicators });
return;
}
const bs = resolveBucketSeconds(bucketSeconds, candles); const bs = resolveBucketSeconds(bucketSeconds, candles);
const eps = 1e-3; const eps = 1e-3;
const maxPointsPerCandle = 600; const maxPointsPerCandle = 600;
const minStep = Math.max(0.5, bs / maxPointsPerCandle); const minStep = Math.max(0.5, bs / maxPointsPerCandle);
const map = buildSamplesRef.current; const map = buildSamplesRef.current;
if (buildKeyRef.current !== seriesKey) {
map.clear();
buildKeyRef.current = seriesKey;
lastBuildCandleStartRef.current = null;
}
const visibleStarts = new Set(candles.map((c) => c.time)); const visibleStarts = new Set(candles.map((c) => c.time));
for (const start of Array.from(map.keys())) { for (const start of Array.from(map.keys())) {
if (!visibleStarts.has(start)) map.delete(start); if (!visibleStarts.has(start)) map.delete(start);
@@ -671,62 +956,26 @@ export default function TradingChart({
lastBuildCandleStartRef.current = start; lastBuildCandleStartRef.current = start;
} }
const buildRaw: LinePoint[] = []; const buildPrimitive = buildSlicesPrimitiveRef.current;
for (const c of candles) { buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
const list = map.get(c.time); buildPrimitive?.setEnabled(!showBuild);
if (!list?.length) continue;
const startT = c.time + eps; if (showBuild) {
const endT = c.time + bs - eps; const hoverTime = hoverCandleTime;
if (!(endT > startT)) continue; const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime);
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
buildRaw.push({ time: toTime(c.time) } as WhitespaceData); if (hoverData.length) {
buildRaw.push({ time: toTime(startT), value: 0 } as LineData); s.buildHover.applyOptions({ visible: true });
s.buildHover.setData(hoverData);
let lastT = startT; } else {
for (const p of list) { s.buildHover.applyOptions({ visible: false });
let t = p.t; s.buildHover.setData([]);
if (t <= lastT + eps) t = lastT + eps;
if (t >= endT) break;
buildRaw.push({ time: toTime(t), value: p.v } as LineData);
lastT = t;
} }
} else {
const finalDelta = c.close - c.open; s.buildHover.applyOptions({ visible: false });
if (endT > lastT + eps) { s.buildHover.setData([]);
buildRaw.push({ time: toTime(endT), value: finalDelta } as LineData);
} else if (buildRaw.length) {
const prev = buildRaw[buildRaw.length - 1];
if ('value' in prev) {
buildRaw[buildRaw.length - 1] = { ...prev, value: finalDelta } as LineData;
} }
}
}
const buildColored: LinePoint[] = [];
let lastLineIdx: number | null = null;
let lastLineVal: number | null = null;
for (const p of buildRaw) {
if (!('value' in p)) {
buildColored.push(p);
lastLineIdx = null;
lastLineVal = null;
continue;
}
if (lastLineIdx != null && lastLineVal != null) {
const delta = p.value - lastLineVal;
const color = delta > 0 ? '#22c55e' : delta < 0 ? '#ef4444' : '#60a5fa';
const prev = buildColored[lastLineIdx] as LineData;
buildColored[lastLineIdx] = { ...prev, color };
}
buildColored.push({ time: p.time, value: p.value } as LineData);
lastLineIdx = buildColored.length - 1;
lastLineVal = p.value;
}
s.build.setData(buildColored);
s.sma20?.applyOptions({ visible: showIndicators }); s.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators }); s.ema20?.applyOptions({ visible: showIndicators });
@@ -747,13 +996,14 @@ export default function TradingChart({
candles, candles,
bucketSeconds, bucketSeconds,
seriesKey, seriesKey,
hoverCandleTime,
]); ]);
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles) return; if (!s.candles) return;
s.candles.applyOptions({ priceFormat }); s.candles.applyOptions({ priceFormat });
s.build?.applyOptions({ priceFormat }); s.buildHover?.applyOptions({ priceFormat });
s.oracle?.applyOptions({ priceFormat }); s.oracle?.applyOptions({ priceFormat });
s.sma20?.applyOptions({ priceFormat }); s.sma20?.applyOptions({ priceFormat });
s.ema20?.applyOptions({ priceFormat }); s.ema20?.applyOptions({ priceFormat });

View File

@@ -0,0 +1,104 @@
import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
export type DlobStats = {
marketName: string;
bestBid: number | null;
bestAsk: number | null;
mid: number | null;
spreadAbs: number | null;
spreadBps: number | null;
depthBidUsd: number | null;
depthAskUsd: 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;
}
type HasuraDlobStatsRow = {
market_name: string;
best_bid_price?: string | null;
best_ask_price?: string | null;
mid_price?: string | null;
spread_abs?: string | null;
spread_bps?: string | null;
depth_bid_usd?: string | null;
depth_ask_usd?: string | null;
updated_at?: string | null;
};
type SubscriptionData = {
dlob_stats_latest: HasuraDlobStatsRow[];
};
export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } {
const [stats, setStats] = useState<DlobStats | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
useEffect(() => {
if (!normalizedMarket) {
setStats(null);
setError(null);
setConnected(false);
return;
}
setError(null);
const query = `
subscription DlobStats($market: String!) {
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
market_name
best_bid_price
best_ask_price
mid_price
spread_abs
spread_bps
depth_bid_usd
depth_ask_usd
updated_at
}
}
`;
const sub = subscribeGraphqlWs<SubscriptionData>({
query,
variables: { market: normalizedMarket },
onStatus: ({ connected }) => setConnected(connected),
onError: (e) => setError(e),
onData: (data) => {
const row = data?.dlob_stats_latest?.[0];
if (!row?.market_name) return;
setStats({
marketName: row.market_name,
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),
depthBidUsd: toNum(row.depth_bid_usd),
depthAskUsd: toNum(row.depth_ask_usd),
updatedAt: row.updated_at ?? null,
});
},
});
return () => sub.unsubscribe();
}, [normalizedMarket]);
return { stats, connected, error };
}

View File

@@ -0,0 +1,173 @@
type HeadersMap = Record<string, string>;
type SubscribeParams<T> = {
query: string;
variables?: Record<string, unknown>;
onData: (data: T) => void;
onError?: (err: string) => void;
onStatus?: (s: { connected: boolean }) => void;
};
function envString(name: string): string | undefined {
const v = (import.meta as any).env?.[name];
const s = v == null ? '' : String(v).trim();
return s ? s : undefined;
}
function resolveGraphqlHttpUrl(): string {
return envString('VITE_HASURA_URL') || '/graphql';
}
function resolveGraphqlWsUrl(): string {
const explicit = envString('VITE_HASURA_WS_URL');
if (explicit) return explicit;
const httpUrl = resolveGraphqlHttpUrl();
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`;
return `${proto}//${host}${path}`;
}
function resolveAuthHeaders(): HeadersMap | undefined {
const token = envString('VITE_HASURA_AUTH_TOKEN');
if (token) return { authorization: `Bearer ${token}` };
const secret = envString('VITE_HASURA_ADMIN_SECRET');
if (secret) return { 'x-hasura-admin-secret': secret };
return undefined;
}
type WsMessage =
| { type: 'connection_ack' | 'ka' | 'complete' }
| { type: 'connection_error'; payload?: any }
| { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } }
| { type: 'error'; id: string; payload?: any };
export type SubscriptionHandle = {
unsubscribe: () => void;
};
export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onStatus }: SubscribeParams<T>): SubscriptionHandle {
const wsUrl = resolveGraphqlWsUrl();
const headers = resolveAuthHeaders();
let ws: WebSocket | null = null;
let closed = false;
let started = false;
let reconnectTimer: number | null = null;
const subId = '1';
const emitError = (e: unknown) => {
const msg = typeof e === 'string' ? e : String((e as any)?.message || e);
onError?.(msg);
};
const setConnected = (connected: boolean) => onStatus?.({ connected });
const start = () => {
if (!ws || started) return;
started = true;
ws.send(
JSON.stringify({
id: subId,
type: 'start',
payload: { query, variables: variables ?? {} },
})
);
};
const connect = () => {
if (closed) return;
started = false;
try {
ws = new WebSocket(wsUrl, 'graphql-ws');
} catch (e) {
emitError(e);
reconnectTimer = window.setTimeout(connect, 1000);
return;
}
ws.onopen = () => {
setConnected(true);
const payload = headers ? { headers } : {};
ws?.send(JSON.stringify({ type: 'connection_init', payload }));
};
ws.onmessage = (ev) => {
let msg: WsMessage;
try {
msg = JSON.parse(String(ev.data));
} catch (e) {
emitError(e);
return;
}
if (msg.type === 'connection_ack') {
start();
return;
}
if (msg.type === 'connection_error') {
emitError(msg.payload || 'connection_error');
return;
}
if (msg.type === 'ka' || msg.type === 'complete') return;
if (msg.type === 'error') {
emitError(msg.payload || 'subscription_error');
return;
}
if (msg.type === 'data') {
const errors = msg.payload?.errors;
if (Array.isArray(errors) && errors.length) {
emitError(errors.map((e) => e.message).join(' | '));
return;
}
if (msg.payload?.data != null) onData(msg.payload.data as T);
}
};
ws.onerror = () => {
setConnected(false);
};
ws.onclose = () => {
setConnected(false);
if (closed) return;
reconnectTimer = window.setTimeout(connect, 1000);
};
};
connect();
return {
unsubscribe: () => {
closed = true;
setConnected(false);
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (!ws) return;
try {
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
ws.send(JSON.stringify({ type: 'connection_terminate' }));
} catch {
// ignore
}
try {
ws.close();
} catch {
// ignore
}
ws = null;
},
};
}

View File

@@ -98,6 +98,8 @@ const uiProxyTarget =
inferUiProxyTarget(apiProxyTarget) || inferUiProxyTarget(apiProxyTarget) ||
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined); (apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget;
function applyProxyBasicAuth(proxyReq: any) { function applyProxyBasicAuth(proxyReq: any) {
if (!proxyBasicAuth) return false; if (!proxyBasicAuth) return false;
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64'); const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
@@ -131,6 +133,24 @@ const proxy: Record<string, any> = {
}, },
}; };
if (graphqlProxyTarget) {
for (const prefix of ['/graphql', '/graphql-ws']) {
proxy[prefix] = {
target: graphqlProxyTarget,
changeOrigin: true,
ws: true,
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
});
p.on('proxyReqWs', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
});
},
};
}
}
if (uiProxyTarget) { if (uiProxyTarget) {
for (const prefix of ['/whoami', '/auth', '/logout']) { for (const prefix of ['/whoami', '/auth', '/logout']) {
proxy[prefix] = { proxy[prefix] = {

View File

@@ -77,6 +77,7 @@ docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build
Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`. Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`.
- Vite trzyma `VITE_API_URL=/api` (default) i proxyuje `/api/*` do VPS. - Vite trzyma `VITE_API_URL=/api` (default) i proxyuje `/api/*` do VPS.
- UI ma też GraphQL (Hasura) pod `/graphql` (HTTP + WS subscriptions) w dev proxyujemy `/graphql` i `/graphql-ws` do VPS, żeby subscriptions działały na `http://localhost:5173`.
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxyujemy też `/whoami`, `/auth/*`, `/logout`. - Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxyujemy też `/whoami`, `/auth/*`, `/logout`.
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`. - Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
- Na VPS `trade-frontend` proxyuje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki). - Na VPS `trade-frontend` proxyuje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
@@ -85,7 +86,7 @@ Przykład:
```bash ```bash
cd apps/visualizer cd apps/visualizer
API_PROXY_TARGET=https://trade.mpabi.pl \ API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl \
npm run dev npm run dev
``` ```

View File

@@ -3,7 +3,9 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import tls from 'node:tls';
const PORT = Number.parseInt(process.env.PORT || '8081', 10); const PORT = Number.parseInt(process.env.PORT || '8081', 10);
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`); if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
@@ -16,6 +18,9 @@ const STATIC_DIR = process.env.STATIC_DIR || '/srv';
const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json'; const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json';
const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json'; const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json';
const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787'; const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787';
const HASURA_UPSTREAM = process.env.HASURA_UPSTREAM || 'http://hasura:8080';
const HASURA_GRAPHQL_PATH = process.env.HASURA_GRAPHQL_PATH || '/v1/graphql';
const GRAPHQL_CORS_ORIGIN = process.env.GRAPHQL_CORS_ORIGIN || process.env.CORS_ORIGIN || '*';
const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on') const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on')
.trim() .trim()
.toLowerCase(); .toLowerCase();
@@ -413,6 +418,122 @@ function proxyApi(req, res, apiReadToken) {
req.pipe(upstreamReq); req.pipe(upstreamReq);
} }
function withCors(res) {
res.setHeader('access-control-allow-origin', GRAPHQL_CORS_ORIGIN);
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
res.setHeader(
'access-control-allow-headers',
'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id'
);
}
function proxyGraphqlHttp(req, res) {
const upstreamBase = new URL(HASURA_UPSTREAM);
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const target = new URL(upstreamBase.toString());
target.pathname = HASURA_GRAPHQL_PATH;
target.search = inUrl.search;
const isHttps = target.protocol === 'https:';
const lib = isHttps ? https : http;
const headers = stripHopByHopHeaders(req.headers);
headers.host = target.host;
const upstreamReq = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: target.pathname + target.search,
headers,
},
(upstreamRes) => {
const outHeaders = stripHopByHopHeaders(upstreamRes.headers);
withCors(res);
res.writeHead(upstreamRes.statusCode || 502, outHeaders);
upstreamRes.pipe(res);
}
);
upstreamReq.on('error', (err) => {
if (!res.headersSent) {
withCors(res);
send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`);
} else {
res.destroy();
}
});
req.pipe(upstreamReq);
}
function isGraphqlPath(pathname) {
return pathname === '/graphql' || pathname === '/graphql-ws';
}
function proxyGraphqlWs(req, socket, head) {
const upstreamBase = new URL(HASURA_UPSTREAM);
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const target = new URL(upstreamBase.toString());
target.pathname = HASURA_GRAPHQL_PATH;
target.search = inUrl.search;
const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80));
const host = target.hostname;
const connect =
target.protocol === 'https:'
? () => tls.connect({ host, port, servername: host })
: () => net.connect({ host, port });
const upstream = connect();
upstream.setNoDelay(true);
socket.setNoDelay(true);
// For WebSocket upgrades we must forward `connection`/`upgrade` and related headers.
const headers = { ...req.headers };
delete headers['content-length'];
delete headers['content-type'];
headers.host = target.host;
const lines = [];
lines.push(`GET ${target.pathname + target.search} HTTP/1.1`);
for (const [k, v] of Object.entries(headers)) {
if (v == null) continue;
if (Array.isArray(v)) {
for (const vv of v) lines.push(`${k}: ${vv}`);
} else {
lines.push(`${k}: ${v}`);
}
}
lines.push('', '');
upstream.write(lines.join('\r\n'));
if (head?.length) upstream.write(head);
upstream.on('error', () => {
try {
socket.destroy();
} catch {
// ignore
}
});
socket.on('error', () => {
try {
upstream.destroy();
} catch {
// ignore
}
});
upstream.pipe(socket);
socket.pipe(upstream);
}
async function handler(req, res) { async function handler(req, res) {
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) { if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
send( send(
@@ -425,6 +546,25 @@ async function handler(req, res) {
} }
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (isGraphqlPath(url.pathname)) {
if (req.method === 'OPTIONS') {
withCors(res);
res.statusCode = 204;
res.end();
return;
}
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
const user = resolveAuthenticatedUser(req);
if (!user) {
withCors(res);
unauthorized(res);
return;
}
}
withCors(res);
proxyGraphqlHttp(req, res);
return;
}
if (req.method === 'GET' && url.pathname === '/whoami') { if (req.method === 'GET' && url.pathname === '/whoami') {
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE }); sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
return; return;
@@ -532,6 +672,30 @@ const server = http.createServer((req, res) => {
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
}); });
}); });
server.on('upgrade', (req, socket, head) => {
try {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (!isGraphqlPath(url.pathname)) {
socket.destroy();
return;
}
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
const user = resolveAuthenticatedUser(req);
if (!user) {
try {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
} catch {
// ignore
}
socket.destroy();
return;
}
}
proxyGraphqlWs(req, socket, head);
} catch {
socket.destroy();
}
});
server.listen(PORT, () => { server.listen(PORT, () => {
console.log( console.log(
JSON.stringify( JSON.stringify(
@@ -540,6 +704,7 @@ server.listen(PORT, () => {
port: PORT, port: PORT,
staticDir: STATIC_DIR, staticDir: STATIC_DIR,
apiUpstream: API_UPSTREAM, apiUpstream: API_UPSTREAM,
hasuraUpstream: HASURA_UPSTREAM,
basicAuthFile: BASIC_AUTH_FILE, basicAuthFile: BASIC_AUTH_FILE,
basicAuthMode: BASIC_AUTH_MODE, basicAuthMode: BASIC_AUTH_MODE,
apiReadTokenFile: API_READ_TOKEN_FILE, apiReadTokenFile: API_READ_TOKEN_FILE,