Compare commits
33 Commits
e20a1f5198
...
feat/candl
| Author | SHA1 | Date | |
|---|---|---|---|
| fc92392705 | |||
| b70257fc5f | |||
| ca9e44a41a | |||
| c1bc6f9e2f | |||
| 2a158334bf | |||
| bff6560f43 | |||
| 9d1ebba39d | |||
| 965774dfbd | |||
| fb307f0279 | |||
| 6904be4a51 | |||
| 912a78588d | |||
| 62baa9700e | |||
| fa0ff11b5a | |||
| 879f45aa5c | |||
| 5a9c2b0a85 | |||
| 9592d6ac16 | |||
| dff4d347ad | |||
| ae41f1a9de | |||
| 42e0a4d86d | |||
| a9ccc0b00e | |||
| 9420c89f52 | |||
| 545e1abfaa | |||
| 759173b5be | |||
| 194d596284 | |||
| 444f427420 | |||
| af267ad6c9 | |||
| f3c4a999c3 | |||
| 1c8a6900e8 | |||
| abaee44835 | |||
| f57366fad2 | |||
| b0c7806cb6 | |||
| a12c86f1f8 | |||
| 6107c4e0ef |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,7 +1,14 @@
|
|||||||
|
# Secrets (never commit)
|
||||||
|
tokens/*
|
||||||
|
!tokens/*.example.json
|
||||||
|
!tokens/*.example.yml
|
||||||
|
!tokens/*.example.yaml
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
tokens/*.json
|
|
||||||
tokens/*.yml
|
# Local scratch / build output
|
||||||
tokens/*.yaml
|
_tmp/
|
||||||
|
apps/visualizer/dist/
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -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 proxy’uje 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
|
||||||
|
|||||||
@@ -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
24
apps/visualizer/__start
Normal 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -156,6 +156,21 @@ export function IconEye(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconLayers(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Layers'} {...props}>
|
||||||
|
<path
|
||||||
|
d="M3.0 6.2L9.0 3.2L15.0 6.2L9.0 9.2L3.0 6.2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M3.0 9.2L9.0 12.2L15.0 9.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.85" />
|
||||||
|
<path d="M3.0 12.2L9.0 15.2L15.0 12.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.65" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IconTrash(props: IconProps) {
|
export function IconTrash(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Svg title={props.title ?? 'Delete'} {...props}>
|
<Svg title={props.title ?? 'Delete'} {...props}>
|
||||||
|
|||||||
206
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
206
apps/visualizer/src/features/chart/ChartLayersPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { OverlayLayer } from './ChartPanel.types';
|
||||||
|
import { IconEye, IconLock, IconTrash } from './ChartIcons';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
layers: OverlayLayer[];
|
||||||
|
onRequestClose: () => void;
|
||||||
|
|
||||||
|
onToggleLayerVisible: (layerId: string) => void;
|
||||||
|
onToggleLayerLocked: (layerId: string) => void;
|
||||||
|
onSetLayerOpacity: (layerId: string, opacity: number) => void;
|
||||||
|
|
||||||
|
fibPresent: boolean;
|
||||||
|
fibSelected: boolean;
|
||||||
|
fibVisible: boolean;
|
||||||
|
fibLocked: boolean;
|
||||||
|
fibOpacity: number;
|
||||||
|
onSelectFib: () => void;
|
||||||
|
onToggleFibVisible: () => void;
|
||||||
|
onToggleFibLocked: () => void;
|
||||||
|
onSetFibOpacity: (opacity: number) => void;
|
||||||
|
onDeleteFib: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 1;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function opacityToPct(opacity: number): number {
|
||||||
|
return Math.round(clamp01(opacity) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctToOpacity(pct: number): number {
|
||||||
|
if (!Number.isFinite(pct)) return 1;
|
||||||
|
return clamp01(pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
title,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['layersBtn', active ? 'layersBtn--active' : null].filter(Boolean).join(' ')}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpacitySlider({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
}) {
|
||||||
|
const pct = opacityToPct(value);
|
||||||
|
return (
|
||||||
|
<div className="layersOpacity">
|
||||||
|
<input
|
||||||
|
className="layersOpacity__range"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={pct}
|
||||||
|
onChange={(e) => onChange(pctToOpacity(Number(e.target.value)))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="layersOpacity__pct">{pct}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChartLayersPanel({
|
||||||
|
open,
|
||||||
|
layers,
|
||||||
|
onRequestClose,
|
||||||
|
onToggleLayerVisible,
|
||||||
|
onToggleLayerLocked,
|
||||||
|
onSetLayerOpacity,
|
||||||
|
fibPresent,
|
||||||
|
fibSelected,
|
||||||
|
fibVisible,
|
||||||
|
fibLocked,
|
||||||
|
fibOpacity,
|
||||||
|
onSelectFib,
|
||||||
|
onToggleFibVisible,
|
||||||
|
onToggleFibLocked,
|
||||||
|
onSetFibOpacity,
|
||||||
|
onDeleteFib,
|
||||||
|
}: Props) {
|
||||||
|
const drawingsLayer = useMemo(() => layers.find((l) => l.id === 'drawings'), [layers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={['chartLayersBackdrop', open ? 'chartLayersBackdrop--open' : null].filter(Boolean).join(' ')}
|
||||||
|
onClick={open ? onRequestClose : undefined}
|
||||||
|
/>
|
||||||
|
<div className={['chartLayersPanel', open ? 'chartLayersPanel--open' : null].filter(Boolean).join(' ')}>
|
||||||
|
<div className="chartLayersPanel__head">
|
||||||
|
<div className="chartLayersPanel__title">Layers</div>
|
||||||
|
<button type="button" className="chartLayersPanel__close" onClick={onRequestClose} aria-label="Close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chartLayersTable">
|
||||||
|
<div className="chartLayersRow chartLayersRow--head">
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon" title="Visible">
|
||||||
|
<IconEye />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon" title="Lock">
|
||||||
|
<IconLock />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">Name</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">Opacity</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
|
||||||
|
<IconEye />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
|
||||||
|
<IconLock />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">
|
||||||
|
<div className="layersName layersName--layer">
|
||||||
|
{layer.name}
|
||||||
|
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">
|
||||||
|
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{drawingsLayer && fibPresent ? (
|
||||||
|
<div
|
||||||
|
className={['chartLayersRow', 'chartLayersRow--object', fibSelected ? 'chartLayersRow--selected' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={onSelectFib}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle visible" active={fibVisible} onClick={onToggleFibVisible}>
|
||||||
|
<IconEye />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle lock" active={fibLocked} onClick={onToggleFibLocked}>
|
||||||
|
<IconLock />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">
|
||||||
|
<div className="layersName layersName--object">Fib Retracement</div>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">
|
||||||
|
<OpacitySlider value={fibOpacity} onChange={onSetFibOpacity} />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions">
|
||||||
|
<IconButton title="Delete fib" onClick={onDeleteFib}>
|
||||||
|
<IconTrash />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,29 +1,60 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||||
import Card from '../../ui/Card';
|
import Card from '../../ui/Card';
|
||||||
|
import ChartLayersPanel from './ChartLayersPanel';
|
||||||
import ChartSideToolbar from './ChartSideToolbar';
|
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';
|
||||||
|
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FibDragMode = 'move' | 'edit-b';
|
||||||
|
|
||||||
|
type FibDrag = {
|
||||||
|
pointerId: number;
|
||||||
|
mode: FibDragMode;
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
start: FibAnchor;
|
||||||
|
origin: FibRetracement;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isEditableTarget(t: EventTarget | null): boolean {
|
||||||
|
if (!(t instanceof HTMLElement)) return false;
|
||||||
|
const tag = t.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
return t.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -31,11 +62,28 @@ export default function ChartPanel({
|
|||||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||||
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[]>([
|
||||||
|
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
|
||||||
|
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
||||||
|
]);
|
||||||
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
|
const [fibVisible, setFibVisible] = useState(true);
|
||||||
|
const [fibLocked, setFibLocked] = useState(false);
|
||||||
|
const [fibOpacity, setFibOpacity] = useState(1);
|
||||||
|
const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
|
||||||
|
const [priceAutoScale, setPriceAutoScale] = useState(true);
|
||||||
|
|
||||||
const chartApiRef = useRef<IChartApi | null>(null);
|
const chartApiRef = useRef<IChartApi | null>(null);
|
||||||
const activeToolRef = useRef(activeTool);
|
const activeToolRef = useRef(activeTool);
|
||||||
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
||||||
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
||||||
|
const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
const spaceDownRef = useRef<boolean>(false);
|
||||||
|
const dragRef = useRef<FibDrag | null>(null);
|
||||||
|
const selectPointerRef = useRef<number | null>(null);
|
||||||
|
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
|
||||||
|
const fibRef = useRef<FibRetracement | null>(fib);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
@@ -67,16 +115,64 @@ export default function ChartPanel({
|
|||||||
fibStartRef.current = fibStart;
|
fibStartRef.current = fibStart;
|
||||||
}, [fibStart]);
|
}, [fibStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedOverlayIdRef.current = selectedOverlayId;
|
||||||
|
}, [selectedOverlayId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fibRef.current = fib;
|
||||||
|
}, [fib]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key !== 'Escape') return;
|
if (isEditableTarget(e.target)) return;
|
||||||
if (activeToolRef.current !== 'fib-retracement') return;
|
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
spaceDownRef.current = true;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (dragRef.current) {
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
setFibDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeToolRef.current === 'fib-retracement') {
|
||||||
setFibStart(null);
|
setFibStart(null);
|
||||||
setFibDraft(null);
|
setFibDraft(null);
|
||||||
setActiveTool('cursor');
|
setActiveTool('cursor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOverlayIdRef.current) setSelectedOverlayId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (selectedOverlayIdRef.current === 'fib') {
|
||||||
|
clearFib();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
spaceDownRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
window.addEventListener('keyup', onKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('keyup', onKeyUp);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,6 +194,112 @@ export default function ChartPanel({
|
|||||||
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
|
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 1;
|
||||||
|
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>) {
|
||||||
|
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFib() {
|
||||||
|
setFib(null);
|
||||||
|
setFibStart(null);
|
||||||
|
setFibDraft(null);
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
setSelectedOverlayId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFibFromDrag(drag: FibDrag, pointer: FibAnchor): FibRetracement {
|
||||||
|
if (drag.mode === 'edit-b') return { a: drag.origin.a, b: pointer };
|
||||||
|
const deltaLogical = pointer.logical - drag.start.logical;
|
||||||
|
const deltaPrice = pointer.price - drag.start.price;
|
||||||
|
return {
|
||||||
|
a: { logical: drag.origin.a.logical + deltaLogical, price: drag.origin.a.price + deltaPrice },
|
||||||
|
b: { logical: drag.origin.b.logical + deltaLogical, price: drag.origin.b.price + deltaPrice },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFrame() {
|
||||||
|
if (rafRef.current != null) return;
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null;
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
const pendingDrag = pendingDragRef.current;
|
||||||
|
if (drag && pendingDrag) {
|
||||||
|
if (!drag.moved) {
|
||||||
|
const dx = pendingDrag.clientX - drag.startClientX;
|
||||||
|
const dy = pendingDrag.clientY - drag.startClientY;
|
||||||
|
if (dx * dx + dy * dy >= 16) drag.moved = true; // ~4px threshold
|
||||||
|
}
|
||||||
|
if (drag.moved) {
|
||||||
|
setFibDraft(computeFibFromDrag(drag, pendingDrag.anchor));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = pendingMoveRef.current;
|
||||||
|
if (!pointer) return;
|
||||||
|
if (activeToolRef.current !== 'fib-retracement') return;
|
||||||
|
|
||||||
|
const start2 = fibStartRef.current;
|
||||||
|
if (!start2) return;
|
||||||
|
setFibDraft({ a: start2, b: pointer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawingsLayer =
|
||||||
|
layers.find((l) => l.id === 'drawings') ?? { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 };
|
||||||
|
const fibEffectiveVisible = fibVisible && drawingsLayer.visible;
|
||||||
|
const fibEffectiveOpacity = fibOpacity * drawingsLayer.opacity;
|
||||||
|
const fibEffectiveLocked = fibLocked || drawingsLayer.locked;
|
||||||
|
const fibSelected = selectedOverlayId === 'fib';
|
||||||
|
const fibRenderable = fibEffectiveVisible ? (fibDraft ?? fib) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOverlayId !== 'fib') return;
|
||||||
|
if (!fib) {
|
||||||
|
setSelectedOverlayId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fibEffectiveVisible) setSelectedOverlayId(null);
|
||||||
|
}, [fib, fibEffectiveVisible, selectedOverlayId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||||
@@ -108,6 +310,10 @@ export default function ChartPanel({
|
|||||||
onTimeframeChange={onTimeframeChange}
|
onTimeframeChange={onTimeframeChange}
|
||||||
showIndicators={showIndicators}
|
showIndicators={showIndicators}
|
||||||
onToggleIndicators={onToggleIndicators}
|
onToggleIndicators={onToggleIndicators}
|
||||||
|
showBuild={showBuild}
|
||||||
|
onToggleBuild={onToggleBuild}
|
||||||
|
priceAutoScale={priceAutoScale}
|
||||||
|
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
||||||
@@ -118,15 +324,13 @@ export default function ChartPanel({
|
|||||||
timeframe={timeframe}
|
timeframe={timeframe}
|
||||||
activeTool={activeTool}
|
activeTool={activeTool}
|
||||||
hasFib={fib != null || fibDraft != null}
|
hasFib={fib != null || fibDraft != null}
|
||||||
|
isLayersOpen={layersOpen}
|
||||||
onToolChange={setActiveTool}
|
onToolChange={setActiveTool}
|
||||||
|
onToggleLayers={() => setLayersOpen((v) => !v)}
|
||||||
onZoomIn={() => zoomTime(0.8)}
|
onZoomIn={() => zoomTime(0.8)}
|
||||||
onZoomOut={() => zoomTime(1.25)}
|
onZoomOut={() => zoomTime(1.25)}
|
||||||
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
|
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
|
||||||
onClearFib={() => {
|
onClearFib={clearFib}
|
||||||
setFib(null);
|
|
||||||
setFibStart(null);
|
|
||||||
setFibDraft(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="chartCard__chart">
|
<div className="chartCard__chart">
|
||||||
<TradingChart
|
<TradingChart
|
||||||
@@ -136,12 +340,19 @@ export default function ChartPanel({
|
|||||||
ema20={indicators.ema20}
|
ema20={indicators.ema20}
|
||||||
bb20={indicators.bb20}
|
bb20={indicators.bb20}
|
||||||
showIndicators={showIndicators}
|
showIndicators={showIndicators}
|
||||||
fib={fibDraft ?? fib}
|
showBuild={showBuild}
|
||||||
|
bucketSeconds={bucketSeconds}
|
||||||
|
seriesKey={seriesKey}
|
||||||
|
priceLines={priceLines}
|
||||||
|
fib={fibRenderable}
|
||||||
|
fibOpacity={fibEffectiveOpacity}
|
||||||
|
fibSelected={fibSelected}
|
||||||
|
priceAutoScale={priceAutoScale}
|
||||||
onReady={({ chart }) => {
|
onReady={({ chart }) => {
|
||||||
chartApiRef.current = chart;
|
chartApiRef.current = chart;
|
||||||
}}
|
}}
|
||||||
onChartClick={(p) => {
|
onChartClick={(p) => {
|
||||||
if (activeTool !== 'fib-retracement') return;
|
if (activeTool === 'fib-retracement') {
|
||||||
if (!fibStartRef.current) {
|
if (!fibStartRef.current) {
|
||||||
fibStartRef.current = p;
|
fibStartRef.current = p;
|
||||||
setFibStart(p);
|
setFibStart(p);
|
||||||
@@ -153,21 +364,103 @@ export default function ChartPanel({
|
|||||||
fibStartRef.current = null;
|
fibStartRef.current = null;
|
||||||
setFibDraft(null);
|
setFibDraft(null);
|
||||||
setActiveTool('cursor');
|
setActiveTool('cursor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.target === 'chart') setSelectedOverlayId(null);
|
||||||
}}
|
}}
|
||||||
onChartCrosshairMove={(p) => {
|
onChartCrosshairMove={(p) => {
|
||||||
if (activeToolRef.current !== 'fib-retracement') return;
|
|
||||||
const start = fibStartRef.current;
|
|
||||||
if (!start) return;
|
|
||||||
pendingMoveRef.current = p;
|
pendingMoveRef.current = p;
|
||||||
if (rafRef.current != null) return;
|
scheduleFrame();
|
||||||
rafRef.current = window.requestAnimationFrame(() => {
|
|
||||||
rafRef.current = null;
|
|
||||||
const move = pendingMoveRef.current;
|
|
||||||
const start2 = fibStartRef.current;
|
|
||||||
if (!move || !start2) return;
|
|
||||||
setFibDraft({ a: start2, b: move });
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
|
onPointerEvent={({ type, logical, price, target, event }) => {
|
||||||
|
const pointer: FibAnchor = { logical, price };
|
||||||
|
|
||||||
|
if (type === 'pointerdown') {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (spaceDownRef.current) return;
|
||||||
|
if (activeToolRef.current !== 'cursor') return;
|
||||||
|
if (target !== 'fib') return;
|
||||||
|
if (!fibRef.current) return;
|
||||||
|
if (!fibEffectiveVisible) return;
|
||||||
|
|
||||||
|
if (selectedOverlayIdRef.current !== 'fib') {
|
||||||
|
setSelectedOverlayId('fib');
|
||||||
|
selectPointerRef.current = event.pointerId;
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fibEffectiveLocked) {
|
||||||
|
selectPointerRef.current = event.pointerId;
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
mode: event.ctrlKey ? 'edit-b' : 'move',
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startClientY: event.clientY,
|
||||||
|
start: pointer,
|
||||||
|
origin: fibRef.current,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||||
|
setFibDraft(fibRef.current);
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (drag && drag.pointerId === event.pointerId) {
|
||||||
|
if (type === 'pointermove') {
|
||||||
|
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||||
|
scheduleFrame();
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
if (type === 'pointerup' || type === 'pointercancel') {
|
||||||
|
if (drag.moved) setFib(computeFibFromDrag(drag, pointer));
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
setFibDraft(null);
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectPointerRef.current != null && selectPointerRef.current === event.pointerId) {
|
||||||
|
if (type === 'pointermove') return { consume: true };
|
||||||
|
if (type === 'pointerup' || type === 'pointercancel') {
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChartLayersPanel
|
||||||
|
open={layersOpen}
|
||||||
|
layers={layers}
|
||||||
|
onRequestClose={() => setLayersOpen(false)}
|
||||||
|
onToggleLayerVisible={(layerId) => {
|
||||||
|
const layer = layers.find((l) => l.id === layerId);
|
||||||
|
if (!layer) return;
|
||||||
|
updateLayer(layerId, { visible: !layer.visible });
|
||||||
|
}}
|
||||||
|
onToggleLayerLocked={(layerId) => {
|
||||||
|
const layer = layers.find((l) => l.id === layerId);
|
||||||
|
if (!layer) return;
|
||||||
|
updateLayer(layerId, { locked: !layer.locked });
|
||||||
|
}}
|
||||||
|
onSetLayerOpacity={(layerId, opacity) => updateLayer(layerId, { opacity: clamp01(opacity) })}
|
||||||
|
fibPresent={fib != null}
|
||||||
|
fibSelected={fibSelected}
|
||||||
|
fibVisible={fibVisible}
|
||||||
|
fibLocked={fibLocked}
|
||||||
|
fibOpacity={fibOpacity}
|
||||||
|
onSelectFib={() => setSelectedOverlayId('fib')}
|
||||||
|
onToggleFibVisible={() => setFibVisible((v) => !v)}
|
||||||
|
onToggleFibLocked={() => setFibLocked((v) => !v)}
|
||||||
|
onSetFibOpacity={(opacity) => setFibOpacity(clamp01(opacity))}
|
||||||
|
onDeleteFib={clearFib}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
8
apps/visualizer/src/features/chart/ChartPanel.types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type OverlayLayer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
opacity: number; // 0..1
|
||||||
|
};
|
||||||
|
|
||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
IconBrush,
|
IconBrush,
|
||||||
IconCrosshair,
|
IconCrosshair,
|
||||||
IconCursor,
|
IconCursor,
|
||||||
IconEye,
|
|
||||||
IconFib,
|
IconFib,
|
||||||
|
IconLayers,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
IconRuler,
|
IconRuler,
|
||||||
@@ -24,7 +24,9 @@ type Props = {
|
|||||||
timeframe: string;
|
timeframe: string;
|
||||||
activeTool: ActiveTool;
|
activeTool: ActiveTool;
|
||||||
hasFib: boolean;
|
hasFib: boolean;
|
||||||
|
isLayersOpen: boolean;
|
||||||
onToolChange: (tool: ActiveTool) => void;
|
onToolChange: (tool: ActiveTool) => void;
|
||||||
|
onToggleLayers: () => void;
|
||||||
onZoomIn: () => void;
|
onZoomIn: () => void;
|
||||||
onZoomOut: () => void;
|
onZoomOut: () => void;
|
||||||
onResetView: () => void;
|
onResetView: () => void;
|
||||||
@@ -35,7 +37,9 @@ export default function ChartSideToolbar({
|
|||||||
timeframe,
|
timeframe,
|
||||||
activeTool,
|
activeTool,
|
||||||
hasFib,
|
hasFib,
|
||||||
|
isLayersOpen,
|
||||||
onToolChange,
|
onToolChange,
|
||||||
|
onToggleLayers,
|
||||||
onZoomIn,
|
onZoomIn,
|
||||||
onZoomOut,
|
onZoomOut,
|
||||||
onResetView,
|
onResetView,
|
||||||
@@ -195,9 +199,15 @@ export default function ChartSideToolbar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="chartToolBtn" title="Visibility" aria-label="Visibility" disabled>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['chartToolBtn', isLayersOpen ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||||
|
title="Layers"
|
||||||
|
aria-label="Layers"
|
||||||
|
onClick={onToggleLayers}
|
||||||
|
>
|
||||||
<span className="chartToolBtn__icon" aria-hidden="true">
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
<IconEye />
|
<IconLayers />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,18 +5,26 @@ type Props = {
|
|||||||
onTimeframeChange: (tf: string) => void;
|
onTimeframeChange: (tf: string) => void;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
onToggleIndicators: () => void;
|
onToggleIndicators: () => void;
|
||||||
|
showBuild: boolean;
|
||||||
|
onToggleBuild: () => void;
|
||||||
|
priceAutoScale: boolean;
|
||||||
|
onTogglePriceAutoScale: () => void;
|
||||||
seriesLabel: string;
|
seriesLabel: string;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
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,
|
||||||
|
onTogglePriceAutoScale,
|
||||||
seriesLabel,
|
seriesLabel,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
@@ -41,6 +49,12 @@ 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">
|
||||||
|
Auto Scale
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
||||||
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ type State = {
|
|||||||
fib: FibRetracement | null;
|
fib: FibRetracement | null;
|
||||||
series: ISeriesApi<'Candlestick', Time> | null;
|
series: ISeriesApi<'Candlestick', Time> | null;
|
||||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||||
|
selected: boolean;
|
||||||
|
opacity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||||
@@ -64,8 +66,10 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
draw(target: any) {
|
draw(target: any) {
|
||||||
const { fib, series, chart } = this._getState();
|
const { fib, series, chart, selected, opacity } = this._getState();
|
||||||
if (!fib || !series || !chart) return;
|
if (!fib || !series || !chart) return;
|
||||||
|
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
if (clampedOpacity <= 0) return;
|
||||||
|
|
||||||
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||||
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
||||||
@@ -78,6 +82,9 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
const delta = p1 - p0;
|
const delta = p1 - p0;
|
||||||
|
|
||||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha *= clampedOpacity;
|
||||||
|
try {
|
||||||
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
||||||
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
|
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
|
||||||
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
||||||
@@ -132,7 +139,7 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
const bx = Math.round(x2 * horizontalPixelRatio);
|
const bx = Math.round(x2 * horizontalPixelRatio);
|
||||||
const by = Math.round(y1 * verticalPixelRatio);
|
const by = Math.round(y1 * verticalPixelRatio);
|
||||||
|
|
||||||
context.strokeStyle = 'rgba(226,232,240,0.55)';
|
context.strokeStyle = selected ? 'rgba(250,204,21,0.65)' : 'rgba(226,232,240,0.55)';
|
||||||
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||||
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
@@ -141,14 +148,23 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
|
|||||||
context.stroke();
|
context.stroke();
|
||||||
context.setLineDash([]);
|
context.setLineDash([]);
|
||||||
|
|
||||||
const r = Math.max(2, Math.round(3 * horizontalPixelRatio));
|
const r = Math.max(2, Math.round((selected ? 4 : 3) * horizontalPixelRatio));
|
||||||
context.fillStyle = 'rgba(147,197,253,0.95)';
|
context.fillStyle = selected ? 'rgba(250,204,21,0.95)' : 'rgba(147,197,253,0.95)';
|
||||||
|
if (selected) {
|
||||||
|
context.strokeStyle = 'rgba(15,23,42,0.85)';
|
||||||
|
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||||
|
}
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(ax, ay, r, 0, Math.PI * 2);
|
context.arc(ax, ay, r, 0, Math.PI * 2);
|
||||||
context.fill();
|
context.fill();
|
||||||
|
if (selected) context.stroke();
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.arc(bx, by, r, 0, Math.PI * 2);
|
context.arc(bx, by, r, 0, Math.PI * 2);
|
||||||
context.fill();
|
context.fill();
|
||||||
|
if (selected) context.stroke();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
context.restore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -170,11 +186,19 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
|||||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||||
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||||
private _fib: FibRetracement | null = null;
|
private _fib: FibRetracement | null = null;
|
||||||
|
private _selected = false;
|
||||||
|
private _opacity = 1;
|
||||||
private readonly _paneView: FibPaneView;
|
private readonly _paneView: FibPaneView;
|
||||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._paneView = new FibPaneView(() => ({ fib: this._fib, series: this._series, chart: this._param?.chart ?? null }));
|
this._paneView = new FibPaneView(() => ({
|
||||||
|
fib: this._fib,
|
||||||
|
series: this._series,
|
||||||
|
chart: this._param?.chart ?? null,
|
||||||
|
selected: this._selected,
|
||||||
|
opacity: this._opacity,
|
||||||
|
}));
|
||||||
this._paneViews = [this._paneView];
|
this._paneViews = [this._paneView];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,4 +220,14 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
|||||||
this._fib = next;
|
this._fib = next;
|
||||||
this._param?.requestUpdate();
|
this._param?.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelected(next: boolean) {
|
||||||
|
this._selected = next;
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpacity(next: number) {
|
||||||
|
this._opacity = Number.isFinite(next) ? next : 1;
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
136
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
111
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
133
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
133
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal 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 };
|
||||||
|
}
|
||||||
158
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
158
apps/visualizer/src/features/market/useDlobL2.ts
Normal 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 };
|
||||||
|
}
|
||||||
137
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
137
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal 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 };
|
||||||
|
}
|
||||||
123
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
123
apps/visualizer/src/features/market/useDlobStats.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -16,16 +16,15 @@ type Props = {
|
|||||||
active?: NavId;
|
active?: NavId;
|
||||||
onSelect?: (id: NavId) => void;
|
onSelect?: (id: NavId) => void;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
rightEndSlot?: ReactNode;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEndSlot }: Props) {
|
export default function TopNav({ active = 'trade', onSelect, rightSlot }: Props) {
|
||||||
return (
|
return (
|
||||||
<header className="topNav">
|
<header className="topNav">
|
||||||
<div className="topNav__left">
|
<div className="topNav__left">
|
||||||
<div className="topNav__brand" aria-label="Drift">
|
<div className="topNav__brand" aria-label="Trade">
|
||||||
<div className="topNav__brandMark" aria-hidden="true" />
|
<div className="topNav__brandMark" aria-hidden="true" />
|
||||||
<div className="topNav__brandName">Drift</div>
|
<div className="topNav__brandName">Trade</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="topNav__menu" aria-label="Primary">
|
<nav className="topNav__menu" aria-label="Primary">
|
||||||
{navItems.map((it) => (
|
{navItems.map((it) => (
|
||||||
@@ -56,7 +55,6 @@ export default function TopNav({ active = 'trade', onSelect, rightSlot, rightEnd
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{rightEndSlot}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
181
apps/visualizer/src/lib/graphqlWs.ts
Normal file
181
apps/visualizer/src/lib/graphqlWs.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -689,6 +689,216 @@ a:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chartLayersBackdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 60;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersBackdrop--open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
background: rgba(17, 19, 28, 0.92);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 70;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 140ms ease, transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel--open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__head {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__title {
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__close {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(230, 233, 239, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersPanel__close:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(230, 233, 239, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersTable {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px 34px minmax(0, 1fr) 150px 40px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(17, 19, 28, 0.92);
|
||||||
|
color: rgba(230, 233, 239, 0.60);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--layer {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--object {
|
||||||
|
cursor: pointer;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--object:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersRow--selected {
|
||||||
|
background: rgba(168, 85, 247, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--icon {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--opacity {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartLayersCell--actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
background: rgba(0, 0, 0, 0.10);
|
||||||
|
color: rgba(230, 233, 239, 0.88);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn--active {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersBtn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName--layer {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName--object {
|
||||||
|
font-weight: 800;
|
||||||
|
color: rgba(230, 233, 239, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersName__meta {
|
||||||
|
opacity: 0.65;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersOpacity {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 44px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersOpacity__range {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layersOpacity__pct {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(230, 233, 239, 0.70);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.chartCard__chart {
|
.chartCard__chart {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -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
153
doc/dlob-services.md
Normal 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 (top‑N) z każdej strony braliśmy do liczenia “depth”.
|
||||||
|
- `depth_bid_base` / `depth_ask_base`: suma `size` po top‑N levelach bid/ask (w base).
|
||||||
|
- `depth_bid_usd` / `depth_ask_usd`: suma `size_base * price` po top‑N 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 są ś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
42
doc/visualizer-candles.md
Normal 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` — mikro‑ruchy (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
99
doc/workflow.md
Normal 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 **snapshoot’em**, 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 proxy’uje `/api/*` do VPS.
|
||||||
|
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxy’ujemy też `/whoami`, `/auth/*`, `/logout`.
|
||||||
|
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
|
||||||
|
- Na VPS `trade-frontend` proxy’uje 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.
|
||||||
Reference in New Issue
Block a user