feat(chart): candle build indicator as direction line #1

Open
u1 wants to merge 31 commits from feat/candle-build-indicator into main
23 changed files with 2358 additions and 64 deletions

View File

@@ -12,6 +12,21 @@ npm ci
npm run dev npm run dev
``` ```
### Dev z backendem na VPS (staging)
Najprościej: trzymaj `VITE_API_URL=/api` i podepnij Vite proxy do VPS (żeby nie bawić się w CORS i nie wkładać tokena do przeglądarki):
```bash
cd apps/visualizer
API_PROXY_TARGET=https://trade.mpabi.pl \
npm run dev
```
Vite proxyuje wtedy: `/api/*`, `/whoami`, `/auth/*`, `/logout` do VPS. Dodatkowo w dev usuwa `Secure` z `Set-Cookie`, żeby sesja działała na `http://localhost:5173`.
Jeśli staging jest dodatkowo chroniony basic auth (np. Traefik `basicAuth`), ustaw:
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
## Docker ## Docker
```bash ```bash

View File

@@ -1,12 +1,15 @@
# 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`.
# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws).
# VITE_HASURA_WS_URL=/graphql-ws
# 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=SOL-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
VITE_POLL_MS=1000 VITE_POLL_MS=1000

24
apps/visualizer/__start Normal file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "${SCRIPT_DIR}"
export API_PROXY_TARGET="${API_PROXY_TARGET:-https://trade.mpabi.pl}"
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}"
export VITE_API_URL="${VITE_API_URL:-/api}"
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then
if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then
export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json"
else
echo "Missing basic auth config for VPS proxy."
echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2
fi
fi
npm run dev

View File

@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useLocalStorageState } from './app/hooks/useLocalStorageState'; import { useLocalStorageState } from './app/hooks/useLocalStorageState';
import AppShell from './layout/AppShell'; import AppShell from './layout/AppShell';
@@ -11,6 +12,11 @@ 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';
import { useDlobL2 } from './features/market/useDlobL2';
import { useDlobSlippage } from './features/market/useDlobSlippage';
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
import DlobDashboard from './features/market/DlobDashboard';
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];
@@ -36,6 +42,11 @@ function formatQty(v: number | null | undefined, decimals: number): string {
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); 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;
}
type WhoamiResponse = { type WhoamiResponse = {
ok?: boolean; ok?: boolean;
user?: string | null; user?: string | null;
@@ -99,17 +110,18 @@ 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', '1MBONK-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', 'SOL-PERP'));
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', '')); const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m')); const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000)); const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300)); const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true); const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false);
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
const [bottomTab, setBottomTab] = useLocalStorageState< const [bottomTab, setBottomTab] = useLocalStorageState<
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' 'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
>('trade.bottomTab', 'positions'); >('trade.bottomTab', 'positions');
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long'); const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>( const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
@@ -119,7 +131,17 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0); const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1); const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
const { candles, indicators, loading, error, refresh } = useChartData({ useEffect(() => {
if (symbol === 'BONK-PERP') {
setSymbol('1MBONK-PERP');
return;
}
if (!markets.includes(symbol)) {
setSymbol('SOL-PERP');
}
}, [markets, setSymbol, symbol]);
const { candles, indicators, meta, loading, error, refresh } = useChartData({
symbol, symbol,
source: source.trim() ? source : undefined, source: source.trim() ? source : undefined,
tf, tf,
@@ -127,12 +149,18 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
pollMs, pollMs,
}); });
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 14 });
const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol);
const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(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 =
first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null; first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null;
const orderbook = useMemo(() => { 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 (!latest) return { asks: [], bids: [], mid: null as number | null };
const mid = latest.close; const mid = latest.close;
const step = Math.max(mid * 0.00018, 0.0001); const step = Math.max(mid * 0.00018, 0.0001);
@@ -163,7 +191,19 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
}); });
return { asks, bids, mid }; return { asks, bids, mid };
}, [latest]); }, [dlobL2, latest]);
const maxAskTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.asks) max = Math.max(max, r.total || 0);
return max;
}, [orderbook.asks]);
const maxBidTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.bids) max = Math.max(max, r.total || 0);
return max;
}, [orderbook.bids]);
const trades = useMemo(() => { const trades = useMemo(() => {
const slice = candles.slice(-24).reverse(); const slice = candles.slice(-24).reverse();
@@ -183,6 +223,24 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
return latest?.close ?? tradePrice; return latest?.close ?? tradePrice;
}, [latest?.close, tradeOrderType, tradePrice]); }, [latest?.close, tradeOrderType, tradePrice]);
const orderValueUsd = useMemo(() => {
if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null;
if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null;
const v = effectiveTradePrice * tradeSize;
return Number.isFinite(v) && v > 0 ? v : null;
}, [effectiveTradePrice, tradeSize]);
const dynamicSlippage = useMemo(() => {
if (orderValueUsd == null) return null;
const side = tradeSide === 'short' ? 'sell' : 'buy';
const rows = slippageRows.filter((r) => r.side === side).slice();
rows.sort((a, b) => a.sizeUsd - b.sizeUsd);
if (!rows.length) return null;
const biggest = rows[rows.length - 1];
const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest;
return match;
}, [orderValueUsd, slippageRows, tradeSide]);
const topItems = useMemo( const topItems = useMemo(
() => [ () => [
{ key: 'BTC', label: 'BTC', changePct: 1.28, active: false }, { key: 'BTC', label: 'BTC', changePct: 1.28, active: false },
@@ -208,14 +266,32 @@ 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 || '—',
},
{
key: 'l2',
label: 'L2',
value: dlobL2Connected ? 'live' : '—',
sub: dlobL2Error ? <span className="neg">{dlobL2Error}</span> : dlobL2?.updatedAt || '—',
},
]; ];
}, [latest?.close, latest?.oracle, changePct]); }, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error]);
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []); const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
const bucketSeconds = meta?.bucketSeconds ?? 60;
return ( return (
<AppShell <AppShell
@@ -281,15 +357,38 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
candles={candles} candles={candles}
indicators={indicators} indicators={indicators}
timeframe={tf} timeframe={tf}
bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
onTimeframeChange={setTf} onTimeframeChange={setTf}
showIndicators={showIndicators} showIndicators={showIndicators}
onToggleIndicators={() => setShowIndicators((v) => !v)} onToggleIndicators={() => setShowIndicators((v) => !v)}
showBuild={showBuild}
onToggleBuild={() => setShowBuild((v) => !v)}
seriesLabel={seriesLabel} seriesLabel={seriesLabel}
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
/> />
<Card className="bottomCard"> <Card className="bottomCard">
<Tabs <Tabs
items={[ items={[
{
id: 'dlob',
label: 'DLOB',
content: (
<DlobDashboard
market={symbol}
stats={dlob}
statsConnected={dlobConnected}
statsError={dlobError}
depthBands={depthBands}
depthBandsConnected={depthBandsConnected}
depthBandsError={depthBandsError}
slippageRows={slippageRows}
slippageConnected={slippageConnected}
slippageError={slippageError}
/>
),
},
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> }, { id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> }, { id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> }, { id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
@@ -316,7 +415,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
title={ title={
<div className="sideHead"> <div className="sideHead">
<div className="sideHead__title">Orderbook</div> <div className="sideHead__title">Orderbook</div>
<div className="sideHead__subtitle">{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}</div> <div className="sideHead__subtitle">{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}</div>
</div> </div>
} }
> >
@@ -334,18 +433,26 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</div> </div>
<div className="orderbook__rows"> <div className="orderbook__rows">
{orderbook.asks.map((r) => ( {orderbook.asks.map((r) => (
<div key={`a-${r.price}`} className="orderbookRow orderbookRow--ask"> <div
key={`a-${r.price}`}
className="orderbookRow orderbookRow--ask"
style={orderbookBarStyle(maxAskTotal > 0 ? r.total / maxAskTotal : 0)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span> <span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span> <span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span> <span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
</div> </div>
))} ))}
<div className="orderbookMid"> <div className="orderbookMid">
<span className="orderbookMid__price">{latest ? formatQty(latest.close, 3) : '—'}</span> <span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
<span className="orderbookMid__label">mid</span> <span className="orderbookMid__label">mid</span>
</div> </div>
{orderbook.bids.map((r) => ( {orderbook.bids.map((r) => (
<div key={`b-${r.price}`} className="orderbookRow orderbookRow--bid"> <div
key={`b-${r.price}`}
className="orderbookRow orderbookRow--bid"
style={orderbookBarStyle(maxBidTotal > 0 ? r.total / maxBidTotal : 0)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span> <span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span> <span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span> <span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
@@ -480,7 +587,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</div> </div>
<div className="tradeMeta__row"> <div className="tradeMeta__row">
<span className="tradeMeta__label">Slippage (Dynamic)</span> <span className="tradeMeta__label">Slippage (Dynamic)</span>
<span className="tradeMeta__value"></span> <span className="tradeMeta__value">
{slippageError ? (
<span className="neg">{slippageError}</span>
) : dynamicSlippage?.impactBps == null ? (
slippageConnected ? (
'—'
) : (
'offline'
)
) : (
<>
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
<span className="muted">
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
: ''}
</span>
</>
)}
</span>
</div> </div>
<div className="tradeMeta__row"> <div className="tradeMeta__row">
<span className="tradeMeta__label">Margin Required</span> <span className="tradeMeta__label">Margin Required</span>

View File

@@ -142,38 +142,30 @@ export default function ChartLayersPanel({
<div className="chartLayersCell chartLayersCell--actions">Actions</div> <div className="chartLayersCell chartLayersCell--actions">Actions</div>
</div> </div>
{drawingsLayer ? ( {layers.map((layer) => (
<div className="chartLayersRow chartLayersRow--layer"> <div key={layer.id} className="chartLayersRow chartLayersRow--layer">
<div className="chartLayersCell chartLayersCell--icon"> <div className="chartLayersCell chartLayersCell--icon">
<IconButton <IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
title="Toggle visible"
active={drawingsLayer.visible}
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
>
<IconEye /> <IconEye />
</IconButton> </IconButton>
</div> </div>
<div className="chartLayersCell chartLayersCell--icon"> <div className="chartLayersCell chartLayersCell--icon">
<IconButton <IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
title="Toggle lock"
active={drawingsLayer.locked}
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
>
<IconLock /> <IconLock />
</IconButton> </IconButton>
</div> </div>
<div className="chartLayersCell chartLayersCell--name"> <div className="chartLayersCell chartLayersCell--name">
<div className="layersName layersName--layer"> <div className="layersName layersName--layer">
{drawingsLayer.name} {layer.name}
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> {layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
</div> </div>
</div> </div>
<div className="chartLayersCell chartLayersCell--opacity"> <div className="chartLayersCell chartLayersCell--opacity">
<OpacitySlider value={drawingsLayer.opacity} onChange={(next) => onSetLayerOpacity(drawingsLayer.id, next)} /> <OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
</div> </div>
<div className="chartLayersCell chartLayersCell--actions" /> <div className="chartLayersCell chartLayersCell--actions" />
</div> </div>
) : null} ))}
{drawingsLayer && fibPresent ? ( {drawingsLayer && fibPresent ? (
<div <div
@@ -212,4 +204,3 @@ export default function ChartLayersPanel({
</> </>
); );
} }

View File

@@ -6,16 +6,21 @@ import ChartSideToolbar from './ChartSideToolbar';
import ChartToolbar from './ChartToolbar'; import ChartToolbar from './ChartToolbar';
import TradingChart from './TradingChart'; import TradingChart from './TradingChart';
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive'; import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
import type { IChartApi } from 'lightweight-charts'; import { LineStyle, type IChartApi } from 'lightweight-charts';
import type { OverlayLayer } from './ChartPanel.types'; import type { OverlayLayer } from './ChartPanel.types';
type Props = { type Props = {
candles: Candle[]; candles: Candle[];
indicators: ChartIndicators; indicators: ChartIndicators;
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
timeframe: string; timeframe: string;
bucketSeconds: number;
seriesKey: string;
onTimeframeChange: (tf: string) => void; onTimeframeChange: (tf: string) => void;
showIndicators: boolean; showIndicators: boolean;
onToggleIndicators: () => void; onToggleIndicators: () => void;
showBuild: boolean;
onToggleBuild: () => void;
seriesLabel: string; seriesLabel: string;
}; };
@@ -41,10 +46,15 @@ function isEditableTarget(t: EventTarget | null): boolean {
export default function ChartPanel({ export default function ChartPanel({
candles, candles,
indicators, indicators,
dlobQuotes,
timeframe, timeframe,
bucketSeconds,
seriesKey,
onTimeframeChange, onTimeframeChange,
showIndicators, showIndicators,
onToggleIndicators, onToggleIndicators,
showBuild,
onToggleBuild,
seriesLabel, seriesLabel,
}: Props) { }: Props) {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
@@ -53,6 +63,7 @@ export default function ChartPanel({
const [fib, setFib] = useState<FibRetracement | null>(null); const [fib, setFib] = useState<FibRetracement | null>(null);
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null); const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
const [layers, setLayers] = useState<OverlayLayer[]>([ const [layers, setLayers] = useState<OverlayLayer[]>([
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 }, { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
]); ]);
const [layersOpen, setLayersOpen] = useState(false); const [layersOpen, setLayersOpen] = useState(false);
@@ -188,6 +199,37 @@ export default function ChartPanel({
return Math.max(0, Math.min(1, v)); return Math.max(0, Math.min(1, v));
} }
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
const quotesVisible = Boolean(quotesLayer?.visible);
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
const priceLines = useMemo(() => {
if (!quotesVisible) return [];
return [
{
id: 'dlob-bid',
title: 'DLOB Bid',
price: dlobQuotes?.bid ?? null,
color: `rgba(34,197,94,${quotesOpacity})`,
lineStyle: LineStyle.Dotted,
},
{
id: 'dlob-mid',
title: 'DLOB Mid',
price: dlobQuotes?.mid ?? null,
color: `rgba(230,233,239,${quotesOpacity})`,
lineStyle: LineStyle.Dashed,
},
{
id: 'dlob-ask',
title: 'DLOB Ask',
price: dlobQuotes?.ask ?? null,
color: `rgba(239,68,68,${quotesOpacity})`,
lineStyle: LineStyle.Dotted,
},
];
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]);
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) { function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l))); setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
} }
@@ -234,6 +276,7 @@ export default function ChartPanel({
const pointer = pendingMoveRef.current; const pointer = pendingMoveRef.current;
if (!pointer) return; if (!pointer) return;
if (activeToolRef.current !== 'fib-retracement') return; if (activeToolRef.current !== 'fib-retracement') return;
const start2 = fibStartRef.current; const start2 = fibStartRef.current;
if (!start2) return; if (!start2) return;
setFibDraft({ a: start2, b: pointer }); setFibDraft({ a: start2, b: pointer });
@@ -267,6 +310,8 @@ export default function ChartPanel({
onTimeframeChange={onTimeframeChange} onTimeframeChange={onTimeframeChange}
showIndicators={showIndicators} showIndicators={showIndicators}
onToggleIndicators={onToggleIndicators} onToggleIndicators={onToggleIndicators}
showBuild={showBuild}
onToggleBuild={onToggleBuild}
priceAutoScale={priceAutoScale} priceAutoScale={priceAutoScale}
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)} onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
seriesLabel={seriesLabel} seriesLabel={seriesLabel}
@@ -295,6 +340,10 @@ export default function ChartPanel({
ema20={indicators.ema20} ema20={indicators.ema20}
bb20={indicators.bb20} bb20={indicators.bb20}
showIndicators={showIndicators} showIndicators={showIndicators}
showBuild={showBuild}
bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
priceLines={priceLines}
fib={fibRenderable} fib={fibRenderable}
fibOpacity={fibEffectiveOpacity} fibOpacity={fibEffectiveOpacity}
fibSelected={fibSelected} fibSelected={fibSelected}

View File

@@ -5,6 +5,8 @@ type Props = {
onTimeframeChange: (tf: string) => void; onTimeframeChange: (tf: string) => void;
showIndicators: boolean; showIndicators: boolean;
onToggleIndicators: () => void; onToggleIndicators: () => void;
showBuild: boolean;
onToggleBuild: () => void;
priceAutoScale: boolean; priceAutoScale: boolean;
onTogglePriceAutoScale: () => void; onTogglePriceAutoScale: () => void;
seriesLabel: string; seriesLabel: string;
@@ -12,13 +14,15 @@ type Props = {
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
}; };
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const; const timeframes = ['5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
export default function ChartToolbar({ export default function ChartToolbar({
timeframe, timeframe,
onTimeframeChange, onTimeframeChange,
showIndicators, showIndicators,
onToggleIndicators, onToggleIndicators,
showBuild,
onToggleBuild,
priceAutoScale, priceAutoScale,
onTogglePriceAutoScale, onTogglePriceAutoScale,
seriesLabel, seriesLabel,
@@ -45,6 +49,9 @@ export default function ChartToolbar({
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button"> <Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
Indicators Indicators
</Button> </Button>
<Button size="sm" variant={showBuild ? 'primary' : 'ghost'} onClick={onToggleBuild} type="button">
Build
</Button>
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button"> <Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
Auto Scale Auto Scale
</Button> </Button>

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,
@@ -25,6 +30,18 @@ type Props = {
ema20?: SeriesPoint[]; ema20?: SeriesPoint[];
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
showIndicators: boolean; showIndicators: boolean;
showBuild: boolean;
bucketSeconds: number;
seriesKey: string;
priceLines?: Array<{
id: string;
title: string;
price: number | null;
color: string;
lineWidth?: number;
lineStyle?: LineStyle;
axisLabelVisible?: boolean;
}>;
fib?: FibRetracement | null; fib?: FibRetracement | null;
fibOpacity?: number; fibOpacity?: number;
fibSelected?: boolean; fibSelected?: boolean;
@@ -44,11 +61,30 @@ type Props = {
}; };
type LinePoint = LineData | WhitespaceData; 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 { function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp; return t as UTCTimestamp;
} }
function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number {
if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds;
if (candles.length >= 2) {
const last = candles[candles.length - 1]?.time;
const prev = candles[candles.length - 2]?.time;
const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0;
if (Number.isFinite(delta) && delta > 0) return delta;
}
return 60;
}
function samplePriceFromCandles(candles: Candle[]): number | null { function samplePriceFromCandles(candles: Candle[]): number | null {
for (let i = candles.length - 1; i >= 0; i -= 1) { for (let i = candles.length - 1; i >= 0; i -= 1) {
const close = candles[i]?.close; const close = candles[i]?.close;
@@ -82,18 +118,371 @@ function toCandleData(candles: Candle[]): CandlestickData[] {
function toVolumeData(candles: Candle[]): HistogramData[] { function toVolumeData(candles: Candle[]): HistogramData[] {
return candles.map((c) => { return candles.map((c) => {
const up = c.close >= c.open;
return { return {
time: toTime(c.time), time: toTime(c.time),
value: c.volume ?? 0, value: c.volume ?? 0,
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)', color: 'rgba(148,163,184,0.22)',
}; };
}); });
} }
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] { function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
if (!points?.length) return []; if (!points?.length) return [];
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 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 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 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;
// Prefer server-provided `flowRows`: brick-by-brick direction per time slice inside the candle.
// Fallback to `flow` (3 shares) or net candle direction.
const rowsFromApi = Array.isArray((c as any).flowRows) ? ((c as any).flowRows as any[]) : null;
const rowDirs = rowsFromApi?.length ? rowsFromApi : null;
if (rowDirs) {
const rows = Math.max(1, rowDirs.length);
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
const movesFromApi = Array.isArray((c as any).flowMoves) ? ((c as any).flowMoves as any[]) : null;
const rowMoves = movesFromApi && movesFromApi.length === rows ? movesFromApi : null;
let maxMove = 0;
if (rowMoves) {
for (let r = 0; r < progressRows; r += 1) {
const v = Number(rowMoves[r]);
if (Number.isFinite(v) && v > maxMove) maxMove = v;
}
}
const sepPx = 1; // black separator line between steps
const sepColor = 'rgba(0,0,0,0.75)';
// Blue (flat) bricks have a constant pixel height (unit).
// Up/down bricks are scaled by |Δ| in their dt.
const minNonFlatPx = 1;
let unitFlatPx = 2;
let flatCount = 0;
let nonFlatCount = 0;
for (let r = 0; r < progressRows; r += 1) {
const dirRaw = rowDirs[r];
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
if (dir === 0) flatCount += 1;
else nonFlatCount += 1;
}
const sepTotal = Math.max(0, progressRows - 1) * sepPx;
if (flatCount > 0) {
const maxUnit = Math.floor((barHeightPx - sepTotal - nonFlatCount * minNonFlatPx) / flatCount);
unitFlatPx = Math.max(1, Math.min(unitFlatPx, maxUnit));
} else {
unitFlatPx = 0;
}
let nonFlatAvailable = barHeightPx - sepTotal - flatCount * unitFlatPx;
if (!Number.isFinite(nonFlatAvailable) || nonFlatAvailable < 0) nonFlatAvailable = 0;
const baseNonFlat = nonFlatCount * minNonFlatPx;
let extra = nonFlatAvailable - baseNonFlat;
if (!Number.isFinite(extra) || extra < 0) extra = 0;
let sumMove = 0;
if (nonFlatCount > 0) {
for (let r = 0; r < progressRows; r += 1) {
const dirRaw = rowDirs[r];
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
if (dir === 0) continue;
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
sumMove += mv;
}
}
if (!(flatCount + nonFlatCount > 0) || barHeightPx <= sepTotal) {
continue;
}
// Stack bricks bottom-up (earliest slices at the bottom).
let usedExtra = 0;
let nonFlatSeen = 0;
let y = yBottomPx;
for (let r = 0; r < progressRows; r += 1) {
const dirRaw = rowDirs[r];
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
const isLast = r === progressRows - 1;
let h = 0;
if (dir === 0) {
h = unitFlatPx;
} else {
nonFlatSeen += 1;
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
const share = sumMove > 0 ? mv / sumMove : 1 / nonFlatCount;
const wantExtra = Math.floor(extra * share);
const isLastNonFlat = nonFlatSeen === nonFlatCount;
const add = isLastNonFlat ? Math.max(0, extra - usedExtra) : Math.max(0, wantExtra);
usedExtra += add;
h = minNonFlatPx + add;
}
if (h <= 0) continue;
y -= h;
context.fillStyle = sliceColorForDelta(dir);
context.fillRect(x0, y, w, h);
if (!isLast) {
y -= sepPx;
context.fillStyle = sepColor;
context.fillRect(x0, y, w, sepPx);
}
}
continue;
}
const f: any = (c as any).flow;
let upShare = typeof f?.up === 'number' ? f.up : Number(f?.up);
let downShare = typeof f?.down === 'number' ? f.down : Number(f?.down);
let flatShare = typeof f?.flat === 'number' ? f.flat : Number(f?.flat);
if (!Number.isFinite(upShare)) upShare = 0;
if (!Number.isFinite(downShare)) downShare = 0;
if (!Number.isFinite(flatShare)) flatShare = 0;
upShare = Math.max(0, upShare);
downShare = Math.max(0, downShare);
flatShare = Math.max(0, flatShare);
let sum = upShare + downShare + flatShare;
if (!(sum > 0)) {
const overallDir = dirForDelta(c.close - c.open);
upShare = overallDir > 0 ? 1 : 0;
downShare = overallDir < 0 ? 1 : 0;
flatShare = overallDir === 0 ? 1 : 0;
sum = 1;
}
upShare /= sum;
downShare /= sum;
flatShare /= sum;
const downH = Math.floor(barHeightPx * downShare);
const flatH = Math.floor(barHeightPx * flatShare);
const upH = Math.max(0, barHeightPx - downH - flatH);
let y = yBottomPx;
if (downH > 0) {
context.fillStyle = sliceColorForDelta(-1);
context.fillRect(x0, y - downH, w, downH);
y -= downH;
}
if (flatH > 0) {
context.fillStyle = sliceColorForDelta(0);
context.fillRect(x0, y - flatH, w, flatH);
y -= flatH;
}
if (upH > 0) {
context.fillStyle = sliceColorForDelta(1);
context.fillRect(x0, y - upH, w, upH);
}
}
});
}
}
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({
@@ -103,6 +492,10 @@ export default function TradingChart({
ema20, ema20,
bb20, bb20,
showIndicators, showIndicators,
showBuild,
bucketSeconds,
seriesKey,
priceLines,
fib, fib,
fibOpacity = 1, fibOpacity = 1,
fibSelected = false, fibSelected = false,
@@ -119,14 +512,23 @@ 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 buildKeyRef = useRef<string | null>(null);
const lastBuildCandleStartRef = useRef<number | null>(null);
const hoverCandleTimeRef = useRef<number | null>(null);
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
const priceLinesRef = useRef<Map<string, any>>(new Map());
const seriesRef = useRef<{ const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>; candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>; volume?: ISeriesApi<'Histogram'>;
buildHover?: ISeriesApi<'Line'>;
oracle?: ISeriesApi<'Line'>; oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>; sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>; ema20?: ISeriesApi<'Line'>;
@@ -177,6 +579,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;
@@ -225,7 +635,27 @@ export default function TradingChart({
color: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.15)',
}); });
volumeSeries.priceScale().applyOptions({ volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 }, scaleMargins: { top: 0.88, bottom: 0 },
});
const buildSlicesPrimitive = new BuildSlicesPrimitive();
volumeSeries.attachPrimitive(buildSlicesPrimitive);
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
buildSlicesPrimitive.setEnabled(!showBuildRef.current);
const buildHoverSeries = chart.addSeries(LineSeries, {
color: BUILD_FLAT_COLOR,
lineWidth: 2,
priceFormat,
priceScaleId: 'build',
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
buildHoverSeries.priceScale().applyOptions({
scaleMargins: { top: 0.72, bottom: 0.12 },
visible: false,
borderVisible: false,
}); });
const oracleSeries = chart.addSeries(LineSeries, { const oracleSeries = chart.addSeries(LineSeries, {
@@ -249,6 +679,7 @@ export default function TradingChart({
seriesRef.current = { seriesRef.current = {
candles: candleSeries, candles: candleSeries,
volume: volumeSeries, volume: volumeSeries,
buildHover: buildHoverSeries,
oracle: oracleSeries, oracle: oracleSeries,
sma20: smaSeries, sma20: smaSeries,
ema20: emaSeries, ema20: emaSeries,
@@ -298,7 +729,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);
@@ -543,15 +990,75 @@ 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;
}
const lines = priceLinesRef.current;
for (const line of Array.from(lines.values())) {
try {
candleSeries.removePriceLine(line);
} catch {
// ignore
}
}
lines.clear();
chart.remove(); chart.remove();
chartRef.current = null; chartRef.current = null;
seriesRef.current = {}; seriesRef.current = {};
}; };
}, []); }, []);
useEffect(() => {
const candlesSeries = seriesRef.current.candles;
if (!candlesSeries) return;
const desired = (priceLines || []).filter((l) => l.price != null && Number.isFinite(l.price));
const desiredIds = new Set(desired.map((l) => l.id));
const map = priceLinesRef.current;
for (const [id, line] of Array.from(map.entries())) {
if (desiredIds.has(id)) continue;
try {
candlesSeries.removePriceLine(line);
} catch {
// ignore
}
map.delete(id);
}
for (const spec of desired) {
const opts: any = {
price: spec.price,
color: spec.color,
title: spec.title,
lineWidth: spec.lineWidth ?? 1,
lineStyle: spec.lineStyle ?? LineStyle.Dotted,
axisLabelVisible: spec.axisLabelVisible ?? true,
};
const existing = map.get(spec.id);
if (!existing) {
try {
const created = candlesSeries.createPriceLine(opts);
map.set(spec.id, created);
} catch {
// ignore
}
continue;
}
try {
existing.applyOptions(opts);
} catch {
// ignore
}
}
}, [priceLines]);
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles || !s.volume) 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);
@@ -561,17 +1068,111 @@ export default function TradingChart({
s.bbLower?.setData(bbLower); s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid); s.bbMid?.setData(bbMid);
const bs = resolveBucketSeconds(bucketSeconds, candles);
const eps = 1e-3;
const maxPointsPerCandle = 600;
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
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));
for (const start of Array.from(map.keys())) {
if (!visibleStarts.has(start)) map.delete(start);
}
const last = candles[candles.length - 1];
if (last) {
const prevStart = lastBuildCandleStartRef.current;
if (prevStart != null && prevStart !== last.time) {
const prevCandle = candles.find((c) => c.time === prevStart);
if (prevCandle) {
const endT = prevStart + bs - eps;
const finalDelta = prevCandle.close - prevCandle.open;
const list = map.get(prevStart) ?? [];
const lastT = list.length ? list[list.length - 1]!.t : -Infinity;
if (endT > lastT + eps) {
list.push({ t: endT, v: finalDelta });
} else if (list.length) {
list[list.length - 1] = { t: lastT, v: finalDelta };
}
map.set(prevStart, list);
}
}
const start = last.time;
const endT = start + bs - eps;
const delta = last.close - last.open;
const nowT = Date.now() / 1000;
const tClamped = Math.min(endT, Math.max(start + eps, nowT));
const list = map.get(start) ?? [];
if (list.length) {
const lastPt = list[list.length - 1]!;
if (tClamped - lastPt.t < minStep) {
list[list.length - 1] = { t: tClamped, v: delta };
} else {
const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped));
if (t > lastPt.t) list.push({ t, v: delta });
}
} else {
list.push({ t: tClamped, v: delta });
}
map.set(start, list);
lastBuildCandleStartRef.current = start;
}
const buildPrimitive = buildSlicesPrimitiveRef.current;
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
buildPrimitive?.setEnabled(!showBuild);
if (showBuild) {
const hoverTime = hoverCandleTime;
const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime);
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
if (hoverData.length) {
s.buildHover.applyOptions({ visible: true });
s.buildHover.setData(hoverData);
} else {
s.buildHover.applyOptions({ visible: false });
s.buildHover.setData([]);
}
} else {
s.buildHover.applyOptions({ visible: false });
s.buildHover.setData([]);
}
s.sma20?.applyOptions({ visible: showIndicators }); s.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators }); s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators }); s.bbUpper?.applyOptions({ visible: showIndicators });
s.bbLower?.applyOptions({ visible: showIndicators }); s.bbLower?.applyOptions({ visible: showIndicators });
s.bbMid?.applyOptions({ visible: showIndicators }); s.bbMid?.applyOptions({ visible: showIndicators });
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]); }, [
candleData,
volumeData,
oracleData,
smaData,
emaData,
bbUpper,
bbLower,
bbMid,
showIndicators,
showBuild,
candles,
bucketSeconds,
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.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

@@ -14,6 +14,7 @@ type Params = {
type Result = { type Result = {
candles: Candle[]; candles: Candle[];
indicators: ChartIndicators; indicators: ChartIndicators;
meta: { tf: string; bucketSeconds: number } | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => Promise<void>; refresh: () => Promise<void>;
@@ -22,6 +23,7 @@ type Result = {
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result { export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
const [candles, setCandles] = useState<Candle[]>([]); const [candles, setCandles] = useState<Candle[]>([]);
const [indicators, setIndicators] = useState<ChartIndicators>({}); const [indicators, setIndicators] = useState<ChartIndicators>({});
const [meta, setMeta] = useState<{ tf: string; bucketSeconds: number } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false); const inFlight = useRef(false);
@@ -34,6 +36,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
const res = await fetchChart({ symbol, source, tf, limit }); const res = await fetchChart({ symbol, source, tf, limit });
setCandles(res.candles); setCandles(res.candles);
setIndicators(res.indicators); setIndicators(res.indicators);
setMeta(res.meta);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
setError(String(e?.message || e)); setError(String(e?.message || e));
@@ -50,8 +53,7 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
useInterval(() => void fetchOnce(), pollMs); useInterval(() => void fetchOnce(), pollMs);
return useMemo( return useMemo(
() => ({ candles, indicators, loading, error, refresh: fetchOnce }), () => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }),
[candles, indicators, loading, error, fetchOnce] [candles, indicators, meta, loading, error, fetchOnce]
); );
} }

View File

@@ -0,0 +1,136 @@
import type { ReactNode } from 'react';
import type { DlobStats } from './useDlobStats';
import type { DlobDepthBandRow } from './useDlobDepthBands';
import type { DlobSlippageRow } from './useDlobSlippage';
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
import DlobSlippageChart from './DlobSlippageChart';
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
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)}`;
}
function formatBps(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${v.toFixed(1)} bps`;
}
function formatPct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${(v * 100).toFixed(0)}%`;
}
function statusLabel(connected: boolean, error: string | null): ReactNode {
if (error) return <span className="neg">{error}</span>;
return connected ? <span className="pos">live</span> : <span className="muted">offline</span>;
}
export default function DlobDashboard({
market,
stats,
statsConnected,
statsError,
depthBands,
depthBandsConnected,
depthBandsError,
slippageRows,
slippageConnected,
slippageError,
}: {
market: string;
stats: DlobStats | null;
statsConnected: boolean;
statsError: string | null;
depthBands: DlobDepthBandRow[];
depthBandsConnected: boolean;
depthBandsError: string | null;
slippageRows: DlobSlippageRow[];
slippageConnected: boolean;
slippageError: string | null;
}) {
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
return (
<div className="dlobDash">
<div className="dlobDash__head">
<div className="dlobDash__title">DLOB</div>
<div className="dlobDash__meta">
<span className="dlobDash__market">{market}</span>
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
</div>
</div>
<div className="dlobDash__statuses">
<div className="dlobStatus">
<span className="dlobStatus__label">stats</span>
<span className="dlobStatus__value">{statusLabel(statsConnected, statsError)}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">depth bands</span>
<span className="dlobStatus__value">{statusLabel(depthBandsConnected, depthBandsError)}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">slippage</span>
<span className="dlobStatus__value">{statusLabel(slippageConnected, slippageError)}</span>
</div>
</div>
<div className="dlobDash__grid">
<div className="dlobKpi">
<div className="dlobKpi__label">Bid</div>
<div className="dlobKpi__value pos">{formatUsd(stats?.bestBid ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Ask</div>
<div className="dlobKpi__value neg">{formatUsd(stats?.bestAsk ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Mid</div>
<div className="dlobKpi__value">{formatUsd(stats?.mid ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Spread</div>
<div className="dlobKpi__value">{formatBps(stats?.spreadBps ?? null)}</div>
<div className="dlobKpi__sub muted">{formatUsd(stats?.spreadAbs ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Depth (bid/ask)</div>
<div className="dlobKpi__value">
<span className="pos">{formatUsd(stats?.depthBidUsd ?? null)}</span>{' '}
<span className="muted">/</span> <span className="neg">{formatUsd(stats?.depthAskUsd ?? null)}</span>
</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Imbalance</div>
<div className="dlobKpi__value">{formatPct(stats?.imbalance ?? null)}</div>
<div className="dlobKpi__sub muted">[-1..1]</div>
</div>
</div>
<div className="dlobDash__panes">
<div className="dlobDash__pane">
<DlobDepthBandsPanel rows={depthBands} />
</div>
<div className="dlobDash__pane">
<div className="dlobSlippage">
<div className="dlobSlippage__head">
<div className="dlobSlippage__title">Slippage (impact bps)</div>
<div className="dlobSlippage__meta muted">by size (USD)</div>
</div>
{slippageRows.length ? (
<div className="dlobSlippage__chartWrap">
<DlobSlippageChart rows={slippageRows} />
</div>
) : (
<div className="dlobSlippage__empty muted">No slippage rows yet.</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import type { DlobDepthBandRow } from './useDlobDepthBands';
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
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)}`;
}
function formatPct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${v.toFixed(0)}%`;
}
function bandRowStyle(askScale: number, bidScale: number): CSSProperties {
const a = Number.isFinite(askScale) && askScale > 0 ? Math.min(1, askScale) : 0;
const b = Number.isFinite(bidScale) && bidScale > 0 ? Math.min(1, bidScale) : 0;
return { ['--ask-scale' as any]: a, ['--bid-scale' as any]: b } as CSSProperties;
}
export default function DlobDepthBandsPanel({ rows }: { rows: DlobDepthBandRow[] }) {
const sorted = useMemo(() => rows.slice().sort((a, b) => a.bandBps - b.bandBps), [rows]);
const maxUsd = useMemo(() => {
let max = 0;
for (const r of sorted) {
if (r.askUsd != null && Number.isFinite(r.askUsd)) max = Math.max(max, r.askUsd);
if (r.bidUsd != null && Number.isFinite(r.bidUsd)) max = Math.max(max, r.bidUsd);
}
return max;
}, [sorted]);
return (
<div className="dlobDepth">
<div className="dlobDepth__head">
<div className="dlobDepth__title">Depth (bands)</div>
<div className="dlobDepth__meta">±bps around mid</div>
</div>
<div className="dlobDepth__table">
<div className="dlobDepthRow dlobDepthRow--head">
<span>Band</span>
<span className="dlobDepthRow__num">Ask USD</span>
<span className="dlobDepthRow__num">Bid USD</span>
<span className="dlobDepthRow__num">Bid %</span>
</div>
{sorted.length ? (
sorted.map((r) => (
<div
key={r.bandBps}
className="dlobDepthRow"
style={bandRowStyle(maxUsd > 0 ? (r.askUsd || 0) / maxUsd : 0, maxUsd > 0 ? (r.bidUsd || 0) / maxUsd : 0)}
title={`band=${r.bandBps}bps bid=${r.bidUsd ?? '—'} ask=${r.askUsd ?? '—'} imbalance=${r.imbalance ?? '—'}`}
>
<span className="dlobDepthRow__band">{r.bandBps} bps</span>
<span className="dlobDepthRow__num neg">{formatUsd(r.askUsd)}</span>
<span className="dlobDepthRow__num pos">{formatUsd(r.bidUsd)}</span>
<span className="dlobDepthRow__num muted">
{r.imbalance == null || !Number.isFinite(r.imbalance)
? '—'
: formatPct(((r.imbalance + 1) / 2) * 100)}
</span>
</div>
))
) : (
<div className="dlobDepth__empty muted">No depth band rows yet.</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef } from 'react';
import Chart from 'chart.js/auto';
import type { DlobSlippageRow } from './useDlobSlippage';
type Point = { x: number; y: number | null };
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(1, v));
}
export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart | null>(null);
const { buy, sell, maxImpact } = useMemo(() => {
const buy: Point[] = [];
const sell: Point[] = [];
let maxImpact = 0;
for (const r of rows) {
if (!Number.isFinite(r.sizeUsd) || r.sizeUsd <= 0) continue;
const y = r.impactBps == null || !Number.isFinite(r.impactBps) ? null : r.impactBps;
if (y != null) maxImpact = Math.max(maxImpact, y);
if (r.side === 'buy') buy.push({ x: r.sizeUsd, y });
if (r.side === 'sell') sell.push({ x: r.sizeUsd, y });
}
buy.sort((a, b) => a.x - b.x);
sell.sort((a, b) => a.x - b.x);
return { buy, sell, maxImpact };
}, [rows]);
useEffect(() => {
if (!canvasRef.current) return;
if (chartRef.current) return;
chartRef.current = new Chart(canvasRef.current, {
type: 'line',
data: {
datasets: [
{
label: 'Buy',
data: [],
borderColor: 'rgba(34,197,94,0.9)',
backgroundColor: 'rgba(34,197,94,0.15)',
pointRadius: 2,
pointHoverRadius: 4,
borderWidth: 2,
tension: 0.2,
fill: false,
},
{
label: 'Sell',
data: [],
borderColor: 'rgba(239,68,68,0.9)',
backgroundColor: 'rgba(239,68,68,0.15)',
pointRadius: 2,
pointHoverRadius: 4,
borderWidth: 2,
tension: 0.2,
fill: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
parsing: false,
interaction: { mode: 'nearest', intersect: false },
plugins: {
legend: { labels: { color: '#e6e9ef' } },
tooltip: { enabled: true },
},
scales: {
x: {
type: 'linear',
title: { display: true, text: 'Size (USD)', color: '#c7cbd4' },
ticks: { color: '#c7cbd4' },
grid: { color: 'rgba(255,255,255,0.06)' },
},
y: {
type: 'linear',
beginAtZero: true,
suggestedMax: Math.max(10, maxImpact * (1 + clamp01(0.15))),
title: { display: true, text: 'Impact (bps)', color: '#c7cbd4' },
ticks: { color: '#c7cbd4' },
grid: { color: 'rgba(255,255,255,0.06)' },
},
},
},
});
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [maxImpact]);
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
chart.data.datasets[0]!.data = buy as any;
chart.data.datasets[1]!.data = sell as any;
chart.update('none');
}, [buy, sell]);
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
export type DlobDepthBandRow = {
marketName: string;
bandBps: number;
midPrice: number | null;
bestBid: number | null;
bestAsk: number | null;
bidUsd: number | null;
askUsd: number | null;
bidBase: number | null;
askBase: number | null;
imbalance: 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;
}
function toInt(v: unknown): number | null {
if (v == null) return null;
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
if (typeof v === 'string') {
const s = v.trim();
if (!s) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
return null;
}
type HasuraRow = {
market_name: string;
band_bps: unknown;
mid_price?: unknown;
best_bid_price?: unknown;
best_ask_price?: unknown;
bid_usd?: unknown;
ask_usd?: unknown;
bid_base?: unknown;
ask_base?: unknown;
imbalance?: unknown;
updated_at?: string | null;
};
type SubscriptionData = {
dlob_depth_bps_latest: HasuraRow[];
};
export function useDlobDepthBands(
marketName: string
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
const [rows, setRows] = useState<DlobDepthBandRow[]>([]);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
useEffect(() => {
if (!normalizedMarket) {
setRows([]);
setError(null);
setConnected(false);
return;
}
setError(null);
const query = `
subscription DlobDepthBands($market: String!) {
dlob_depth_bps_latest(
where: { market_name: { _eq: $market } }
order_by: [{ band_bps: asc }]
) {
market_name
band_bps
mid_price
best_bid_price
best_ask_price
bid_usd
ask_usd
bid_base
ask_base
imbalance
updated_at
}
}
`;
const sub = subscribeGraphqlWs<SubscriptionData>({
query,
variables: { market: normalizedMarket },
onStatus: ({ connected }) => setConnected(connected),
onError: (e) => setError(e),
onData: (data) => {
const out: DlobDepthBandRow[] = [];
for (const r of data?.dlob_depth_bps_latest || []) {
if (!r?.market_name) continue;
const bandBps = toInt(r.band_bps);
if (bandBps == null || bandBps <= 0) continue;
out.push({
marketName: r.market_name,
bandBps,
midPrice: toNum(r.mid_price),
bestBid: toNum(r.best_bid_price),
bestAsk: toNum(r.best_ask_price),
bidUsd: toNum(r.bid_usd),
askUsd: toNum(r.ask_usd),
bidBase: toNum(r.bid_base),
askBase: toNum(r.ask_base),
imbalance: toNum(r.imbalance),
updatedAt: r.updated_at ?? null,
});
}
setRows(out);
},
});
return () => sub.unsubscribe();
}, [normalizedMarket]);
return { rows, connected, error };
}

View File

@@ -0,0 +1,158 @@
import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
export type OrderbookRow = {
price: number;
size: number;
total: number;
};
export type DlobL2 = {
marketName: string;
bids: OrderbookRow[];
asks: OrderbookRow[];
bestBid: number | null;
bestAsk: number | null;
mid: number | null;
updatedAt: string | null;
};
function envNumber(name: string): number | undefined {
const raw = (import.meta as any).env?.[name];
if (raw == null) return undefined;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
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;
}
function normalizeJson(v: unknown): unknown {
if (typeof v !== 'string') return v;
const s = v.trim();
if (!s) return null;
try {
return JSON.parse(s);
} catch {
return v;
}
}
function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number): Array<{ price: number; size: number }> {
const v = normalizeJson(raw);
if (!Array.isArray(v)) return [];
const out: Array<{ price: number; size: number }> = [];
for (const item of v) {
const priceInt = toNum((item as any)?.price);
const sizeInt = toNum((item as any)?.size);
if (priceInt == null || sizeInt == null) continue;
const price = priceInt / pricePrecision;
const size = sizeInt / basePrecision;
if (!Number.isFinite(price) || !Number.isFinite(size)) continue;
out.push({ price, size });
}
return out;
}
function withTotals(levels: Array<{ price: number; size: number }>): OrderbookRow[] {
let total = 0;
return levels.map((l) => {
total += l.size;
return { ...l, total };
});
}
type HasuraDlobL2Row = {
market_name: string;
bids?: unknown;
asks?: unknown;
updated_at?: string | null;
};
type SubscriptionData = {
dlob_l2_latest: HasuraDlobL2Row[];
};
export function useDlobL2(
marketName: string,
opts?: { levels?: number }
): { l2: DlobL2 | null; connected: boolean; error: string | null } {
const [l2, setL2] = useState<DlobL2 | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]);
const pricePrecision = useMemo(() => envNumber('VITE_DLOB_PRICE_PRECISION') ?? 1_000_000, []);
const basePrecision = useMemo(() => envNumber('VITE_DLOB_BASE_PRECISION') ?? 1_000_000_000, []);
useEffect(() => {
if (!normalizedMarket) {
setL2(null);
setError(null);
setConnected(false);
return;
}
setError(null);
const query = `
subscription DlobL2($market: String!) {
dlob_l2_latest(where: {market_name: {_eq: $market}}, limit: 1) {
market_name
bids
asks
updated_at
}
}
`;
const sub = subscribeGraphqlWs<SubscriptionData>({
query,
variables: { market: normalizedMarket },
onStatus: ({ connected }) => setConnected(connected),
onError: (e) => setError(e),
onData: (data) => {
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 bids = withTotals(bidsRaw);
const asks = withTotals(asksRaw).slice().reverse();
const bestBid = bidsRaw.length ? bidsRaw[0].price : null;
const bestAsk = asksRaw.length ? asksRaw[0].price : null;
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
setL2({
marketName: row.market_name,
bids,
asks,
bestBid,
bestAsk,
mid,
updatedAt: row.updated_at ?? null,
});
},
});
return () => sub.unsubscribe();
}, [normalizedMarket, levels, pricePrecision, basePrecision]);
return { l2, connected, error };
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
export type DlobSlippageRow = {
marketName: string;
side: 'buy' | 'sell';
sizeUsd: number;
midPrice: number | null;
vwapPrice: number | null;
worstPrice: number | null;
filledUsd: number | null;
filledBase: number | null;
impactBps: number | null;
levelsConsumed: number | null;
fillPct: 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;
}
function toInt(v: unknown): number | null {
if (v == null) return null;
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
if (typeof v === 'string') {
const s = v.trim();
if (!s) return null;
const n = Number.parseInt(s, 10);
return Number.isFinite(n) ? n : null;
}
return null;
}
type HasuraRow = {
market_name: string;
side: string;
size_usd: unknown;
mid_price?: unknown;
vwap_price?: unknown;
worst_price?: unknown;
filled_usd?: unknown;
filled_base?: unknown;
impact_bps?: unknown;
levels_consumed?: unknown;
fill_pct?: unknown;
updated_at?: string | null;
};
type SubscriptionData = {
dlob_slippage_latest: HasuraRow[];
};
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
const [rows, setRows] = useState<DlobSlippageRow[]>([]);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
useEffect(() => {
if (!normalizedMarket) {
setRows([]);
setError(null);
setConnected(false);
return;
}
setError(null);
const query = `
subscription DlobSlippage($market: String!) {
dlob_slippage_latest(
where: { market_name: { _eq: $market } }
order_by: [{ side: asc }, { size_usd: asc }]
) {
market_name
side
size_usd
mid_price
vwap_price
worst_price
filled_usd
filled_base
impact_bps
levels_consumed
fill_pct
updated_at
}
}
`;
const sub = subscribeGraphqlWs<SubscriptionData>({
query,
variables: { market: normalizedMarket },
onStatus: ({ connected }) => setConnected(connected),
onError: (e) => setError(e),
onData: (data) => {
const out: DlobSlippageRow[] = [];
for (const r of data?.dlob_slippage_latest || []) {
if (!r?.market_name) continue;
const side = String(r.side || '').trim();
if (side !== 'buy' && side !== 'sell') continue;
const sizeUsd = toInt(r.size_usd);
if (sizeUsd == null || sizeUsd <= 0) continue;
out.push({
marketName: r.market_name,
side,
sizeUsd,
midPrice: toNum(r.mid_price),
vwapPrice: toNum(r.vwap_price),
worstPrice: toNum(r.worst_price),
filledUsd: toNum(r.filled_usd),
filledBase: toNum(r.filled_base),
impactBps: toNum(r.impact_bps),
levelsConsumed: toInt(r.levels_consumed),
fillPct: toNum(r.fill_pct),
updatedAt: r.updated_at ?? null,
});
}
setRows(out);
},
});
return () => sub.unsubscribe();
}, [normalizedMarket]);
return { rows, connected, error };
}

View File

@@ -0,0 +1,123 @@
import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
export type DlobStats = {
marketName: string;
markPrice: number | null;
oraclePrice: number | null;
bestBid: number | null;
bestAsk: number | null;
mid: number | null;
spreadAbs: number | null;
spreadBps: number | null;
depthBidBase: number | null;
depthAskBase: number | null;
depthBidUsd: number | null;
depthAskUsd: number | null;
imbalance: 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;
mark_price?: string | null;
oracle_price?: string | null;
best_bid_price?: string | null;
best_ask_price?: string | null;
mid_price?: string | null;
spread_abs?: string | null;
spread_bps?: string | null;
depth_bid_base?: string | null;
depth_ask_base?: string | null;
depth_bid_usd?: string | null;
depth_ask_usd?: string | null;
imbalance?: 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
mark_price
oracle_price
best_bid_price
best_ask_price
mid_price
spread_abs
spread_bps
depth_bid_base
depth_ask_base
depth_bid_usd
depth_ask_usd
imbalance
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,
markPrice: toNum(row.mark_price),
oraclePrice: toNum(row.oracle_price),
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),
depthBidBase: toNum(row.depth_bid_base),
depthAskBase: toNum(row.depth_ask_base),
depthBidUsd: toNum(row.depth_bid_usd),
depthAskUsd: toNum(row.depth_ask_usd),
imbalance: toNum(row.imbalance),
updatedAt: row.updated_at ?? null,
});
},
});
return () => sub.unsubscribe();
}, [normalizedMarket]);
return { stats, connected, error };
}

View File

@@ -6,6 +6,9 @@ export type Candle = {
close: number; close: number;
volume?: number; volume?: number;
oracle?: number | null; oracle?: number | null;
flow?: { up: number; down: number; flat: number };
flowRows?: number[];
flowMoves?: number[];
}; };
export type SeriesPoint = { export type SeriesPoint = {
@@ -68,9 +71,18 @@ export async function fetchChart(params: {
close: Number(c.close), close: Number(c.close),
volume: c.volume == null ? undefined : Number(c.volume), volume: c.volume == null ? undefined : Number(c.volume),
oracle: c.oracle == null ? null : Number(c.oracle), oracle: c.oracle == null ? null : Number(c.oracle),
flow:
(c as any)?.flow && typeof (c as any).flow === 'object'
? {
up: Number((c as any).flow.up),
down: Number((c as any).flow.down),
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,
})), })),
indicators: json.indicators || {}, indicators: json.indicators || {},
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) }, meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
}; };
} }

View File

@@ -0,0 +1,181 @@
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) {
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
return `${proto}//${host}${path}`;
}
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

@@ -18,7 +18,7 @@ function getApiUrl(): string | undefined {
} }
function getHasuraUrl(): string { function getHasuraUrl(): string {
return (import.meta as any).env?.VITE_HASURA_URL || 'http://localhost:8080/v1/graphql'; return (import.meta as any).env?.VITE_HASURA_URL || '/graphql';
} }
function getAuthToken(): string | undefined { function getAuthToken(): string | undefined {

View File

@@ -7,6 +7,13 @@ import react from '@vitejs/plugin-react';
const DIR = path.dirname(fileURLToPath(import.meta.url)); const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(DIR, '../..'); const ROOT = path.resolve(DIR, '../..');
type BasicAuth = { username: string; password: string };
function stripTrailingSlashes(p: string): string {
const out = p.replace(/\/+$/, '');
return out || '/';
}
function readApiReadToken(): string | undefined { function readApiReadToken(): string | undefined {
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN; if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
const p = path.join(ROOT, 'tokens', 'read.json'); const p = path.join(ROOT, 'tokens', 'read.json');
@@ -20,24 +27,132 @@ function readApiReadToken(): string | undefined {
} }
} }
function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
const raw = String(value || '').trim();
if (!raw) return undefined;
const idx = raw.indexOf(':');
if (idx <= 0) return undefined;
const username = raw.slice(0, idx).trim();
const password = raw.slice(idx + 1);
if (!username || !password) return undefined;
return { username, password };
}
function readProxyBasicAuth(): BasicAuth | undefined {
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH);
if (fromEnv) return fromEnv;
const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim();
if (!fileRaw) return undefined;
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
if (!fs.existsSync(p)) return undefined;
try {
const raw = fs.readFileSync(p, 'utf8');
const json = JSON.parse(raw) as { username?: string; password?: string };
const username = typeof json?.username === 'string' ? json.username.trim() : '';
const password = typeof json?.password === 'string' ? json.password : '';
if (!username || !password) return undefined;
return { username, password };
} catch {
return undefined;
}
}
const apiReadToken = readApiReadToken(); const apiReadToken = readApiReadToken();
const proxyBasicAuth = readProxyBasicAuth();
const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:8787';
function parseUrl(v: string): URL | undefined {
try {
return new URL(v);
} catch {
return undefined;
}
}
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
function inferUiProxyTarget(apiTarget: string): string | undefined {
try {
const u = new URL(apiTarget);
const p = stripTrailingSlashes(u.pathname || '/');
if (!p.endsWith('/api')) return undefined;
const basePath = p.slice(0, -'/api'.length) || '/';
u.pathname = basePath;
u.search = '';
u.hash = '';
const out = u.toString();
return out.endsWith('/') ? out.slice(0, -1) : out;
} catch {
return undefined;
}
}
const uiProxyTarget =
process.env.FRONTEND_PROXY_TARGET ||
process.env.UI_PROXY_TARGET ||
process.env.AUTH_PROXY_TARGET ||
inferUiProxyTarget(apiProxyTarget) ||
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
function applyProxyBasicAuth(proxyReq: any) {
if (!proxyBasicAuth) return false;
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
proxyReq.setHeader('Authorization', `Basic ${b64}`);
return true;
}
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
const v = proxyRes?.headers?.['set-cookie'];
if (!v) return;
const rewrite = (cookie: string) => {
let out = cookie.replace(/;\s*secure\b/gi, '');
out = out.replace(/;\s*domain=[^;]+/gi, '');
out = out.replace(/;\s*samesite=none\b/gi, '; SameSite=Lax');
return out;
};
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
}
const proxy: Record<string, any> = {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
if (applyProxyBasicAuth(proxyReq)) return;
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
});
},
},
};
if (uiProxyTarget) {
for (const prefix of ['/whoami', '/auth', '/logout']) {
proxy[prefix] = {
target: uiProxyTarget,
changeOrigin: true,
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
});
p.on('proxyRes', (proxyRes: any) => {
rewriteSetCookieForLocalDevHttp(proxyRes);
});
},
};
}
}
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
strictPort: true, strictPort: true,
proxy: { proxy,
'/api': {
target: process.env.API_PROXY_TARGET || 'http://localhost:8787',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
});
},
},
},
}, },
}); });

153
doc/dlob-services.md Normal file
View File

@@ -0,0 +1,153 @@
# Serwisy DLOB na VPS (k3s / `trade-staging`)
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`
- **Rola:** kolektor L2 + wyliczenie “basic stats”.
- **Wejście:** HTTP L2 z `DLOB_HTTP_URL` (u nas obecnie `https://dlob.drift.trade`, ale można przełączyć na `http://dlob-server:6969`).
- **Wyjście:** upsert do Hasury (Postgres) tabel:
- `dlob_l2_latest` (raw snapshot L2, JSON leveli)
- `dlob_stats_latest` (pochodne: best bid/ask, mid, spread, depth, imbalance, itp.)
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 500 ms).
### `dlob-slippage-worker`
- **Rola:** symulacja slippage vs rozmiar zlecenia na podstawie L2.
- **Wejście:** czyta z Hasury `dlob_l2_latest` (dla listy rynków).
- **Wyjście:** upsert do Hasury tabeli `dlob_slippage_latest` (m.in. `impact_bps`, `vwap_price`, `worst_price`, `fill_pct`).
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 1000 ms); rozmiary w `DLOB_SLIPPAGE_SIZES_USD`.
### `dlob-depth-worker`
- **Rola:** metryki “głębokości” w pasmach ±bps wokół mid.
- **Wejście:** czyta z Hasury `dlob_l2_latest`.
- **Wyjście:** upsert do Hasury tabeli `dlob_depth_bps_latest` (per `(market_name, band_bps)`).
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 1000 ms); pasma w `DLOB_DEPTH_BPS_BANDS`.
### `dlob-publisher`
- **Rola:** utrzymuje “żywy” DLOB na podstawie subskrypcji on-chain i publikuje snapshoty do Redis.
- **Wejście:** Solana RPC/WS (`ENDPOINT`, `WS_ENDPOINT` z secreta `trade-dlob-rpc`), Drift SDK; konfiguracja rynków np. `PERP_MARKETS_TO_LOAD`.
- **Wyjście:** zapis/publish do `dlob-redis` (cache / pubsub / streamy), z którego korzysta serwer HTTP (i ewentualnie WS manager).
### `dlob-server`
- **Rola:** HTTP API do danych DLOB (np. `/l2`, `/l3`) serwowane z cache Redis.
- **Wejście:** `dlob-redis` + slot subscriber (do oceny “świeżości” danych).
- **Wyjście:** endpoint HTTP w klastrze (Service `dlob-server:6969`), który może być źródłem dla `dlob-worker` (gdy `DLOB_HTTP_URL=http://dlob-server:6969`).
### `dlob-redis`
- **Rola:** Redis (u nas single-node “cluster mode”) jako **cache i kanał komunikacji** między `dlob-publisher` a `dlob-server`.
- **Uwagi:** to “klej” między komponentami publish/serve; bez niego publisher i server nie współpracują.
## Jak to się spina (przepływ danych)
1) `dlob-publisher` (on-chain) → publikuje snapshoty do `dlob-redis`.
2) `dlob-server` → serwuje `/l2` i `/l3` z `dlob-redis` (HTTP w klastrze).
3) `dlob-worker` → pobiera L2 (obecnie z `https://dlob.drift.trade`; opcjonalnie z `dlob-server`) i zapisuje “latest” do Hasury/DB.
4) `dlob-slippage-worker` + `dlob-depth-worker` → liczą agregaty z `dlob_l2_latest` i zapisują do Hasury/DB (pod UI).
## Co to jest L1 / L2 / L3 (orderbook)
- `L1` (top-of-book): tylko najlepszy bid i najlepszy ask (czasem też spread).
- `L2` (Level 2): **zagregowane poziomy cenowe** po stronie bid/ask — lista leveli `{ price, size }`, gdzie `size` to suma wolumenu na danej cenie (to jest typowy “orderbook UI” i baza pod spread/depth/imbalance).
- `L3` (Level 3): **niezagregowane, pojedyncze zlecenia** (każde osobno, zwykle z dodatkowymi polami/identyfikatorami). Większy wolumen danych; przydatne do “pro” analiz i debugowania mikrostruktury.
W tym stacku:
- `dlob-server` udostępnia REST endpointy `/l2` i `/l3`.
- Hasura/DB trzyma “latest” snapshot L2 w `dlob_l2_latest` oraz metryki w `dlob_stats_latest` / `dlob_depth_bps_latest` / `dlob_slippage_latest`.
## Słownik pojęć (bid/ask/spread i metryki)
### Podstawy orderbooka
- **Bid**: zlecenia kupna (chęć kupna). W orderbooku “bid side”.
- **Ask**: zlecenia sprzedaży (chęć sprzedaży). W orderbooku “ask side”.
- **Best bid / best ask**: najlepsza (najwyższa) cena kupna i najlepsza (najniższa) cena sprzedaży na topie księgi (L1).
- **Spread**: różnica pomiędzy `best_ask` a `best_bid`. Im mniejszy spread, tym “taniej” wejść/wyjść (mniej kosztów natychmiastowej realizacji).
- **Mid price**: cena “po środku”: `(best_bid + best_ask) / 2`. Używana jako punkt odniesienia do bps i slippage.
- **Level**: pojedynczy poziom cenowy w L2 (np. `price=100.00`, `size=12.3`).
- **Size**: ilość/płynność na poziomie (zwykle w jednostkach “base asset”).
- **Base / Quote**:
- `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%.
- **USD**: u nas wiele wartości jest przeliczanych do USD (np. `size_base * price`).
### Metryki “stats” (np. `dlob_stats_latest`)
- `spread_abs` (USD): `best_ask - best_bid`.
- `spread_bps` (bps): `(spread_abs / mid_price) * 10_000`.
- `depth_levels`: ile leveli (topN) z każdej strony braliśmy do liczenia “depth”.
- `depth_bid_base` / `depth_ask_base`: suma `size` po topN levelach bid/ask (w base).
- `depth_bid_usd` / `depth_ask_usd`: suma `size_base * price` po topN levelach (w USD).
- `imbalance` ([-1..1]): miara asymetrii płynności:
- `(depth_bid_usd - depth_ask_usd) / (depth_bid_usd + depth_ask_usd)`
- >0 = relatywnie więcej płynności po bid, <0 = po ask.
- `oracle_price`: cena z oracla (np. Pyth) jako punkt odniesienia.
- `mark_price`: mark z rynku/perp (cena referencyjna dla rozliczeń); różni się od oracle/top-of-book.
### Metryki “depth bands” (np. `dlob_depth_bps_latest`)
- `band_bps`: szerokość pasma wokół `mid_price` (np. 5/10/20/50/100/200 bps).
- `bid_usd` / `ask_usd`: płynność po danej stronie, ale **tylko z poziomów mieszczących się w oknie ±`band_bps`** wokół mid.
- `imbalance`: jak wyżej, ale liczony per band.
### Metryki “slippage” (np. `dlob_slippage_latest`)
To jest symulacja gdybym teraz zrobił market order o rozmiarze X na podstawie L2.
- `size_usd`: docelowy rozmiar zlecenia w USD.
- `vwap_price`: średnia cena realizacji (Volume Weighted Average Price) dla symulowanego fill.
- `impact_bps`: koszt/odchylenie względem `mid_price` wyrażone w bps (zwykle na bazie `vwap` vs `mid`).
- `worst_price`: najgorsza cena dotknięta podczas zjadania kolejnych leveli.
- `filled_usd` / `filled_base`: ile realnie udało się wypełnić (może być < docelowego, jeśli brakuje płynności).
- `fill_pct`: procent wypełnienia (100% = pełny fill).
- `levels_consumed`: ile leveli zostało zjedzonych podczas fill.
### Metadane czasu (“świeżość”)
- `ts`: timestamp źródła (czas snapshotu).
- `slot`: slot Solany, z którego pochodzi snapshot (monotoniczny numer czasu chaina).
- `updated_at`: kiedy nasz worker zapisał/odświeżył rekord w DB (do oceny, czy dane świeże).
## Szybka diagnostyka na VPS
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy | grep -E 'dlob-(worker|slippage-worker|depth-worker|publisher|server|redis)'
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-worker --tail=80
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-publisher --tail=80
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-server --tail=80
```
## Ważna uwaga (źródło L2 w `dlob-worker`)
Jeśli chcesz, żeby `dlob-worker` polegał na **naszym** stacku (własny RPC + `dlob-publisher` + `dlob-server`), ustaw:
- `DLOB_HTTP_URL=http://dlob-server:6969`
Aktualnie w `trade-staging` jest ustawione:
- `DLOB_HTTP_URL=https://dlob.drift.trade`

42
doc/visualizer-candles.md Normal file
View File

@@ -0,0 +1,42 @@
# Visualizer: świeczki + “brick stack” pod świecą
## Timeframe (tf)
W visualizerze `tf` to długość świecy (bucket) przekazywana do API:
- `3s`, `5s`, `15s`, `30s` — mikroruchy (dużo szumu, ale świetne do obserwacji mikrostruktury)
- `1m`, `5m`, `15m`, `1h`, `4h`, `1d` — klasyczne interwały
Kiedy ma to sens:
- `3s/5s`: gdy chcesz widzieć “jak cena się buduje” w krótkich falach (np. po newsie / w dużej zmienności).
- `15s/30s`: często najlepszy kompromis między szumem a czytelnością, jeżeli patrzysz na very-short-term.
## Co pokazuje “brick stack” na dole
Pod każdą świecą rysujemy słupek złożony z “bricków” (małych segmentów) odpowiadających kolejnym krokom czasu wewnątrz świecy.
Kolory bricków:
- zielony = w tym kroku cena poszła w górę
- czerwony = w tym kroku cena poszła w dół
- niebieski = w tym kroku cena była stała (flat)
Wysokość bricków:
- zielony/czerwony: proporcjonalna do `|Δprice|` w danym kroku
- niebieski: stała (unit height)
Bricki są rozdzielone cienką czarną linią (1px), żeby było widać strukturę “krok po kroku”.
## Jakie pola musi zwracać API
Endpoint `GET /v1/chart` zwraca w każdej świecy:
- `flow`: udziały czasu `up/down/flat` w całym buckecie (0..1)
- `flowRows`: tablica kierunków per krok czasu: `-1` (down), `0` (flat), `1` (up)
- `flowMoves`: tablica “move magnitude” per krok czasu (wartości dodatnie; 0 jeśli flat)
To właśnie `flowRows` + `flowMoves` są używane do narysowania brick stacka.
## Domyślny rynek
W visualizerze domyślnie ustawiony jest `SOL-PERP`.

99
doc/workflow.md Normal file
View File

@@ -0,0 +1,99 @@
# Workflow pracy w projekcie `trade` (dev → staging na VPS) + snapshot/rollback
Ten plik jest “source of truth” dla sposobu pracy nad zmianami w `trade`.
Cel: **zero ręcznych zmian na VPS**, każdy deploy jest **snapshootem**, do którego można wrócić.
## Dla agenta / po restarcie sesji
1) Przeczytaj ten plik: `doc/workflow.md`.
2) Kontekst funkcjonalny: `README.md`, `info.md`.
3) Kontekst stacka: `doc/workflow-api-ingest.md` oraz `devops/*/README.md`.
4) Stan VPS/k3s + GitOps: `doc/migration.md` i log zmian: `doc/steps.md`.
## Zasady (must-have)
- **Nie edytujemy “na żywo” VPS** (żadnych ręcznych poprawek w kontenerach/plikach na serwerze) → tylko Git + CI + Argo.
- **Sekrety nie trafiają do gita**: `tokens/*.json` są gitignored; w dokumentacji/logach redagujemy hasła/tokeny.
- **Brak `latest`**: obrazy w deployu są przypięte do `sha-<shortsha>` albo digesta.
- **Każda zmiana = snapshot**: “wersja” to commit w repo deploy + przypięte obrazy.
## Domyślne środowisko pracy (ważne)
- **Frontend**: domyślnie pracujemy lokalnie (Vite) i łączymy się z backendem na VPS (staging) przez proxy. Deploy frontendu na VPS robimy tylko jeśli jest to wyraźnie powiedziane (“wdrażam na VPS”).
- **Backend (trade-api + ingestor)**: zmiany backendu weryfikujemy/wdrażamy na VPS (staging), bo tam działa ingestor i tam są dane. Nie traktujemy lokalnego uruchomienia backendu jako domyślnego (tylko na wyraźną prośbę do debugowania).
## Standardowy flow zmian (polecany)
1) Zmiana w kodzie lokalnie.
- Nie musisz odpalać lokalnego Dockera; na start rób szybkie walidacje (build/typecheck).
2) Commit + push (najlepiej przez PR).
3) CI:
- build → push obrazów (tag `sha-*` lub digest),
- aktualizacja `trade-deploy` (bump obrazu/rewizji).
4) Argo CD (auto-sync na staging) wdraża nowy snapshot w `trade-staging`.
5) Test na VPS:
- API: `/healthz`, `/v1/ticks`, `/v1/chart`
- UI: `trade.mpabi.pl`
- Ingest: logi `trade-ingestor` + napływ ticków do tabeli.
## Snapshoty i rollback (playbook)
### Rollback szybki (preferowany)
- Cofnij snapshot w repo deploy:
- `git revert` commita, który podbił obrazy, **albo**
- w Argo cofnij do poprzedniej rewizji (ten sam efekt).
Efekt: Argo wraca do poprzedniego “dobrego” zestawu obrazów i konfiguracji.
### Rollback bezpieczny dla “dużych” zmian (schema/ingest)
Jeśli zmiana dotyka danych/ingestu, rób ją jako nową wersję vN:
- nowa tabela: `drift_ticks_vN`
- nowa funkcja: `get_drift_candles_vN`
- osobny deploy API/UI (inne porty/sufiksy), a ingest przełączany “cutover”.
W razie problemów: robisz “cut back” vN → v1 (stare dane zostają nietknięte).
## Lokalne uruchomienie (opcjonalne, do debugowania)
Dokładna instrukcja: `doc/workflow-api-ingest.md`.
Skrót:
```bash
npm install
docker compose -f devops/db/docker-compose.yml up -d
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap
docker compose -f devops/app/docker-compose.yml up -d --build api
npm run token:api -- --scopes write --out tokens/alg.json
npm run token:api -- --scopes read --out tokens/read.json
docker compose -f devops/app/docker-compose.yml up -d --build frontend
docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build
```
### Frontend dev (Vite) z backendem na VPS (staging)
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.
- 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`.
- Na VPS `trade-frontend` proxyuje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
Przykład:
```bash
cd apps/visualizer
API_PROXY_TARGET=https://trade.mpabi.pl \
npm run dev
```
Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj:
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
## Definicja “done” dla zmiany
- Jest snapshot (commit w deploy) i można wrócić jednym ruchem.
- Staging działa (API/ingest/UI) i ma podstawowe smoke-checki.
- Sekrety nie zostały dodane do repo ani do logów/komentarzy.