15 Commits

Author SHA1 Message Date
u1
5a9d61d674 feat(visualizer): update chart flow and DLOB docs 2026-03-12 23:24:49 +01:00
u1
f76045a9a5 feat(visualizer): brick-stack flow bars + DLOB dashboard 2026-01-10 23:15:04 +01:00
u1
fa79340cf5 feat(v2): add Hasura /graphql WS proxy and live DLOB stats 2026-01-10 01:17:53 +01:00
u1
a9ccc0b00e feat(visualizer): add build overlay toggle
Default is off so candles match production; enable via the new Build button when needed.
2026-01-09 03:22:31 +01:00
u1
9420c89f52 docs: update Vite staging proxy instructions 2026-01-08 06:00:23 +00:00
u1
545e1abfaa docs(workflow): update staging Vite proxy notes 2026-01-08 06:00:11 +00:00
u1
759173b5be fix(dev): enable Vite session auth proxy 2026-01-08 05:59:40 +00:00
u1
194d596284 docs(workflow): clarify local FE vs VPS backend defaults 2026-01-07 19:59:16 +00:00
u1
444f427420 docs: describe Vite dev against VPS backend 2026-01-07 19:50:44 +00:00
u1
af267ad6c9 feat(dev): support Vite proxy to VPS backend 2026-01-07 19:50:28 +00:00
u1
f3c4a999c3 docs: add workflow playbook 2026-01-07 19:49:59 +00:00
u1
1c8a6900e8 feat(chart): add candle build indicator line 2026-01-07 08:09:09 +00:00
u1
abaee44835 feat(chart): wire build indicator meta 2026-01-07 08:04:49 +00:00
u1
f57366fad2 feat(chart): pass build indicator props 2026-01-07 08:01:57 +00:00
u1
b0c7806cb6 feat(chart): expose bucketSeconds meta 2026-01-07 07:56:45 +00:00
31 changed files with 4268 additions and 81 deletions

9
.gitignore vendored
View File

@@ -4,11 +4,20 @@ tokens/*
!tokens/*.example.yml !tokens/*.example.yml
!tokens/*.example.yaml !tokens/*.example.yaml
# Local secrets (never commit)
pass/
argo/pass
node_modules/ node_modules/
dist/ dist/
.env .env
*.log *.log
# Baremetal IaC local overlays (do not commit)
infra/baremetal-solana-rpc/ansible/inventory/hosts.ini
infra/baremetal-solana-rpc/ansible/group_vars/solana_rpc.yml
infra/baremetal-solana-rpc/ansible/group_vars/vault.yml
# Local scratch / build output # Local scratch / build output
_tmp/ _tmp/
apps/visualizer/dist/ apps/visualizer/dist/

View File

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

View File

@@ -1,12 +1,15 @@
# Default: UI reads ticks from the same-origin API proxy at `/api`. # Default: UI reads ticks from the same-origin API proxy at `/api`.
VITE_API_URL=/api VITE_API_URL=/api
# Fallback (optional): query Hasura directly (not recommended in browser). # Hasura GraphQL endpoint (supports subscriptions via WS).
VITE_HASURA_URL=http://localhost:8080/v1/graphql # On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
# Optional (only if you intentionally query Hasura directly from the browser): VITE_HASURA_URL=/graphql
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws).
# VITE_HASURA_WS_URL=/graphql-ws
# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT # VITE_HASURA_AUTH_TOKEN=YOUR_JWT
# VITE_HASURA_ADMIN_SECRET=devsecret VITE_SYMBOL=SOL-PERP
VITE_SYMBOL=PUMP-PERP
# Optional: filter by source (leave empty for all) # Optional: filter by source (leave empty for all)
# VITE_SOURCE=drift_oracle # VITE_SOURCE=drift_oracle
VITE_POLL_MS=1000 VITE_POLL_MS=1000

24
apps/visualizer/__start Normal file
View File

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

View File

@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useLocalStorageState } from './app/hooks/useLocalStorageState'; import { useLocalStorageState } from './app/hooks/useLocalStorageState';
import AppShell from './layout/AppShell'; import AppShell from './layout/AppShell';
@@ -11,6 +12,11 @@ import Button from './ui/Button';
import TopNav from './layout/TopNav'; import TopNav from './layout/TopNav';
import AuthStatus from './layout/AuthStatus'; import AuthStatus from './layout/AuthStatus';
import LoginScreen from './layout/LoginScreen'; import LoginScreen from './layout/LoginScreen';
import { useDlobStats } from './features/market/useDlobStats';
import { useDlobL2 } from './features/market/useDlobL2';
import { useDlobSlippage } from './features/market/useDlobSlippage';
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
import DlobDashboard from './features/market/DlobDashboard';
function envNumber(name: string, fallback: number): number { function envNumber(name: string, fallback: number): number {
const v = (import.meta as any).env?.[name]; const v = (import.meta as any).env?.[name];
@@ -26,7 +32,8 @@ function envString(name: string, fallback: string): string {
function formatUsd(v: number | null | undefined): string { function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'; if (v == null || !Number.isFinite(v)) return '—';
if (v >= 1000) return `$${v.toFixed(0)}`; 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)}`; if (v >= 1) return `$${v.toFixed(2)}`;
return `$${v.toPrecision(4)}`; return `$${v.toPrecision(4)}`;
} }
@@ -36,6 +43,38 @@ 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 formatCompact(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`;
if (abs >= 1) return v.toFixed(2);
return v.toPrecision(4);
}
function clamp01(scale: number): number {
return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
}
function barCurve(scale01: number): number {
// Makes small rows visible without letting a single wall dominate.
return Math.sqrt(clamp01(scale01));
}
function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties {
return {
['--ob-total-scale' as any]: barCurve(totalScale),
['--ob-level-scale' as any]: barCurve(levelScale),
} as CSSProperties;
}
function liquidityStyle(bid: number, ask: number): CSSProperties {
const max = Math.max(1e-9, bid, ask);
const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0;
const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0;
return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties;
}
type WhoamiResponse = { type WhoamiResponse = {
ok?: boolean; ok?: boolean;
user?: string | null; user?: string | null;
@@ -99,17 +138,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', true);
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 +159,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,43 +177,98 @@ 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: 10 });
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 (!latest) return { asks: [], bids: [], mid: null as number | null }; if (dlobL2) {
return {
asks: dlobL2.asks,
bids: dlobL2.bids,
mid: dlobL2.mid as number | null,
bestBid: dlobL2.bestBid,
bestAsk: dlobL2.bestAsk,
};
}
if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: 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);
const levels = 14; const levels = 10;
const asksRaw = Array.from({ length: levels }, (_, i) => ({ const asksRaw = Array.from({ length: levels }, (_, i) => ({
price: mid + (i + 1) * step, price: mid + (i + 1) * step,
size: 0.1 + ((i * 7) % 15) * 0.1, sizeBase: 0.1 + ((i * 7) % 15) * 0.1,
})); }));
const bidsRaw = Array.from({ length: levels }, (_, i) => ({ const bidsRaw = Array.from({ length: levels }, (_, i) => ({
price: mid - (i + 1) * step, price: mid - (i + 1) * step,
size: 0.1 + ((i * 5) % 15) * 0.1, sizeBase: 0.1 + ((i * 5) % 15) * 0.1,
})); }));
let askTotal = 0; let askTotalBase = 0;
let askTotalUsd = 0;
const asks = asksRaw const asks = asksRaw
.slice() .slice()
.reverse() .reverse()
.map((r) => { .map((r) => {
askTotal += r.size; const sizeUsd = r.sizeBase * r.price;
return { ...r, total: askTotal }; askTotalBase += r.sizeBase;
askTotalUsd += sizeUsd;
return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd };
}); });
let bidTotal = 0; let bidTotalBase = 0;
let bidTotalUsd = 0;
const bids = bidsRaw.map((r) => { const bids = bidsRaw.map((r) => {
bidTotal += r.size; const sizeUsd = r.sizeBase * r.price;
return { ...r, total: bidTotal }; bidTotalBase += r.sizeBase;
bidTotalUsd += sizeUsd;
return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd };
}); });
return { asks, bids, mid }; return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null };
}, [latest]); }, [dlobL2, latest]);
const maxAskTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.asks) max = Math.max(max, (r as any).totalUsd || 0);
return max;
}, [orderbook.asks]);
const maxBidTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.bids) max = Math.max(max, (r as any).totalUsd || 0);
return max;
}, [orderbook.bids]);
const maxAskSize = useMemo(() => {
let max = 0;
for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0);
return max;
}, [orderbook.asks]);
const maxBidSize = useMemo(() => {
let max = 0;
for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0);
return max;
}, [orderbook.bids]);
const liquidity = useMemo(() => {
const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0;
const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0;
const bestBid = orderbook.bestBid;
const bestAsk = orderbook.bestAsk;
const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null;
const spreadPct =
spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null;
return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct };
}, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]);
const trades = useMemo(() => { const trades = useMemo(() => {
const slice = candles.slice(-24).reverse(); const slice = candles.slice(-24).reverse();
@@ -183,6 +288,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 +331,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 +422,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 +480,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>
} }
> >
@@ -329,28 +493,63 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
<div className="orderbook"> <div className="orderbook">
<div className="orderbook__header"> <div className="orderbook__header">
<span>Price</span> <span>Price</span>
<span className="orderbook__num">Size</span> <span className="orderbook__num">Size (USD)</span>
<span className="orderbook__num">Total</span> <span className="orderbook__num">Total (USD)</span>
</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={orderbookRowBarStyle(
maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0,
maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 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">{formatCompact((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span> <span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</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={orderbookRowBarStyle(
maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0,
maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 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">{formatCompact((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span> <span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
</div> </div>
))} ))}
<div className="orderbookMeta">
<div className="orderbookMeta__row">
<span className="muted">Spread</span>
<span className="orderbookMeta__val">
{liquidity.spreadAbs == null || liquidity.spreadPct == null
? '—'
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
</span>
</div>
<div className="orderbookMeta__row">
<span className="muted">Liquidity (L1L10)</span>
<span className="orderbookMeta__val">
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
</span>
</div>
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
<div className="liquidityBar__bid" />
<div className="liquidityBar__ask" />
</div>
</div>
</div> </div>
</div> </div>
), ),
@@ -480,7 +679,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</div> </div>
<div className="tradeMeta__row"> <div className="tradeMeta__row">
<span className="tradeMeta__label">Slippage (Dynamic)</span> <span className="tradeMeta__label">Slippage (Dynamic)</span>
<span className="tradeMeta__value"></span> <span className="tradeMeta__value">
{slippageError ? (
<span className="neg">{slippageError}</span>
) : dynamicSlippage?.impactBps == null ? (
slippageConnected ? (
'—'
) : (
'offline'
)
) : (
<>
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
<span className="muted">
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
: ''}
</span>
</>
)}
</span>
</div> </div>
<div className="tradeMeta__row"> <div className="tradeMeta__row">
<span className="tradeMeta__label">Margin Required</span> <span className="tradeMeta__label">Margin Required</span>

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,18 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
CandlestickSeries, CandlestickSeries,
ColorType, ColorType,
CrosshairMode, CrosshairMode,
HistogramSeries, HistogramSeries,
type IPrimitivePaneRenderer,
type IPrimitivePaneView,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type ISeriesPrimitive,
LineStyle, LineStyle,
LineSeries, LineSeries,
type SeriesAttachedParameter,
type Time,
createChart, createChart,
type UTCTimestamp, type UTCTimestamp,
type CandlestickData, type CandlestickData,
@@ -25,6 +30,18 @@ type Props = {
ema20?: SeriesPoint[]; ema20?: SeriesPoint[];
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] }; bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
showIndicators: boolean; showIndicators: boolean;
showBuild: boolean;
bucketSeconds: number;
seriesKey: string;
priceLines?: Array<{
id: string;
title: string;
price: number | null;
color: string;
lineWidth?: number;
lineStyle?: LineStyle;
axisLabelVisible?: boolean;
}>;
fib?: FibRetracement | null; fib?: FibRetracement | null;
fibOpacity?: number; fibOpacity?: number;
fibSelected?: boolean; fibSelected?: boolean;
@@ -44,11 +61,30 @@ type Props = {
}; };
type LinePoint = LineData | WhitespaceData; type LinePoint = LineData | WhitespaceData;
type BuildSample = { t: number; v: number };
const BUILD_UP_COLOR = '#22c55e';
const BUILD_DOWN_COLOR = '#ef4444';
const BUILD_FLAT_COLOR = '#60a5fa';
const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)';
const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)';
const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)';
function toTime(t: number): UTCTimestamp { function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp; return t as UTCTimestamp;
} }
function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number {
if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds;
if (candles.length >= 2) {
const last = candles[candles.length - 1]?.time;
const prev = candles[candles.length - 2]?.time;
const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0;
if (Number.isFinite(delta) && delta > 0) return delta;
}
return 60;
}
function samplePriceFromCandles(candles: Candle[]): number | null { function samplePriceFromCandles(candles: Candle[]): number | null {
for (let i = candles.length - 1; i >= 0; i -= 1) { for (let i = candles.length - 1; i >= 0; i -= 1) {
const close = candles[i]?.close; const close = candles[i]?.close;
@@ -82,11 +118,10 @@ function toCandleData(candles: Candle[]): CandlestickData[] {
function toVolumeData(candles: Candle[]): HistogramData[] { function toVolumeData(candles: Candle[]): HistogramData[] {
return candles.map((c) => { return candles.map((c) => {
const up = c.close >= c.open;
return { return {
time: toTime(c.time), time: toTime(c.time),
value: c.volume ?? 0, value: c.volume ?? 0,
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)', color: 'rgba(148,163,184,0.22)',
}; };
}); });
} }
@@ -96,6 +131,358 @@ function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value })); return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
} }
function colorForDelta(delta: number): string {
if (delta > 0) return BUILD_UP_COLOR;
if (delta < 0) return BUILD_DOWN_COLOR;
return BUILD_FLAT_COLOR;
}
function sliceColorForDelta(delta: number): string {
if (delta > 0) return BUILD_UP_SLICE;
if (delta < 0) return BUILD_DOWN_SLICE;
return BUILD_FLAT_SLICE;
}
type SliceDir = -1 | 0 | 1;
function dirForDelta(delta: number): SliceDir {
if (delta > 0) return 1;
if (delta < 0) return -1;
return 0;
}
function buildDeltaSeriesForCandle(candle: Candle, bs: number, samples: BuildSample[] | undefined): LinePoint[] {
const eps = 1e-3;
const startT = candle.time + eps;
const endT = candle.time + bs - eps;
if (!(endT > startT)) return [];
const points: BuildSample[] = [{ t: startT, v: 0 }];
let lastT = startT;
for (const p of samples || []) {
let t = p.t;
if (t <= lastT + eps) t = lastT + eps;
if (t >= endT) break;
points.push({ t, v: p.v });
lastT = t;
}
const finalDelta = candle.close - candle.open;
if (endT > lastT + eps) {
points.push({ t: endT, v: finalDelta });
} else if (points.length) {
points[points.length - 1] = { ...points[points.length - 1]!, v: finalDelta };
}
const out: LinePoint[] = [{ time: toTime(candle.time) } as WhitespaceData];
out.push({ time: toTime(points[0]!.t), value: points[0]!.v } as LineData);
let lastLineIdx = out.length - 1;
let lastVal = points[0]!.v;
for (let i = 1; i < points.length; i += 1) {
const v = points[i]!.v;
const prev = out[lastLineIdx] as LineData;
out[lastLineIdx] = { ...prev, color: colorForDelta(v - lastVal) } as LineData;
out.push({ time: toTime(points[i]!.t), value: v } as LineData);
lastLineIdx = out.length - 1;
lastVal = v;
}
return out;
}
type BuildSlicesState = {
enabled: boolean;
candles: Candle[];
bucketSeconds: number;
samples: Map<number, BuildSample[]>;
series: ISeriesApi<'Histogram', Time> | null;
chart: SeriesAttachedParameter<Time>['chart'] | null;
};
class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
private readonly _getState: () => BuildSlicesState;
constructor(getState: () => BuildSlicesState) {
this._getState = getState;
}
draw(target: any) {
const { enabled, candles, bucketSeconds, samples, series, chart } = this._getState();
if (!enabled) return;
if (!candles.length || !series || !chart) return;
const bs = resolveBucketSeconds(bucketSeconds, candles);
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
const yBase = series.priceToCoordinate(0);
if (yBase == null) return;
const xs = candles.map((c) => chart.timeScale().timeToCoordinate(toTime(c.time)));
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
const yBottomPx = Math.round(yBase * verticalPixelRatio);
const lastIdx = xs.length - 1;
for (let i = 0; i < candles.length; i += 1) {
const x = xs[i];
if (x == null) continue;
if (!Number.isFinite(x)) continue;
const c = candles[i]!;
const start = c.time;
const end = start + bs;
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
const now = Date.now() / 1000;
const progressT = isCurrent ? Math.min(end, Math.max(start, now)) : end;
let spacing = 0;
const prevX = i > 0 ? xs[i - 1] : null;
const nextX = i < lastIdx ? xs[i + 1] : null;
if (prevX != null && Number.isFinite(prevX)) spacing = x - prevX;
if (nextX != null && Number.isFinite(nextX)) {
const s2 = nextX - x;
spacing = spacing > 0 ? Math.min(spacing, s2) : s2;
}
if (!(spacing > 0)) spacing = 6;
const barWidthCss = Math.max(1, spacing * 0.9);
const barWidthPx = Math.max(1, Math.round(barWidthCss * horizontalPixelRatio));
const xCenterPx = Math.round(x * horizontalPixelRatio);
const xLeftPx = Math.round(xCenterPx - barWidthPx / 2);
const volumeValue = typeof c.volume === 'number' && Number.isFinite(c.volume) ? c.volume : 0;
if (!(volumeValue > 0)) continue;
const yTop = series.priceToCoordinate(volumeValue);
if (yTop == null) continue;
const yTopPx = Math.round(yTop * verticalPixelRatio);
if (!(yBottomPx > yTopPx)) continue;
const barHeightPx = yBottomPx - yTopPx;
const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx));
const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx));
const w = x1 - x0;
if (!(w > 0)) continue;
// Prefer server-provided `flowRows`: brick-by-brick direction per time slice inside the candle.
// Fallback to `flow` (3 shares) or net candle direction.
const rowsFromApi = Array.isArray((c as any).flowRows) ? ((c as any).flowRows as any[]) : null;
const rowDirs = rowsFromApi?.length ? rowsFromApi : null;
if (rowDirs) {
const rows = Math.max(1, rowDirs.length);
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
const movesFromApi = Array.isArray((c as any).flowMoves) ? ((c as any).flowMoves as any[]) : null;
const rowMoves = movesFromApi && movesFromApi.length === rows ? movesFromApi : null;
let maxMove = 0;
if (rowMoves) {
for (let r = 0; r < progressRows; r += 1) {
const v = Number(rowMoves[r]);
if (Number.isFinite(v) && v > maxMove) maxMove = v;
}
}
const sepPx = 1; // black separator line between steps
const sepColor = 'rgba(0,0,0,0.75)';
// Blue (flat) bricks have a constant pixel height (unit).
// Up/down bricks are scaled by |Δ| in their dt.
const minNonFlatPx = 1;
let unitFlatPx = 2;
let flatCount = 0;
let nonFlatCount = 0;
for (let r = 0; r < progressRows; r += 1) {
const dirRaw = rowDirs[r];
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
if (dir === 0) flatCount += 1;
else nonFlatCount += 1;
}
const sepTotal = Math.max(0, progressRows - 1) * sepPx;
if (flatCount > 0) {
const maxUnit = Math.floor((barHeightPx - sepTotal - nonFlatCount * minNonFlatPx) / flatCount);
unitFlatPx = Math.max(1, Math.min(unitFlatPx, maxUnit));
} else {
unitFlatPx = 0;
}
let nonFlatAvailable = barHeightPx - sepTotal - flatCount * unitFlatPx;
if (!Number.isFinite(nonFlatAvailable) || nonFlatAvailable < 0) nonFlatAvailable = 0;
const baseNonFlat = nonFlatCount * minNonFlatPx;
let extra = nonFlatAvailable - baseNonFlat;
if (!Number.isFinite(extra) || extra < 0) extra = 0;
let sumMove = 0;
if (nonFlatCount > 0) {
for (let r = 0; r < progressRows; r += 1) {
const dirRaw = rowDirs[r];
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
if (dir === 0) continue;
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
sumMove += mv;
}
}
if (!(flatCount + nonFlatCount > 0) || barHeightPx <= sepTotal) {
continue;
}
// Stack bricks bottom-up (earliest slices at the bottom).
let usedExtra = 0;
let nonFlatSeen = 0;
let y = yBottomPx;
for (let r = 0; r < progressRows; r += 1) {
const dirRaw = rowDirs[r];
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
const isLast = r === progressRows - 1;
let h = 0;
if (dir === 0) {
h = unitFlatPx;
} else {
nonFlatSeen += 1;
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
const share = sumMove > 0 ? mv / sumMove : 1 / nonFlatCount;
const wantExtra = Math.floor(extra * share);
const isLastNonFlat = nonFlatSeen === nonFlatCount;
const add = isLastNonFlat ? Math.max(0, extra - usedExtra) : Math.max(0, wantExtra);
usedExtra += add;
h = minNonFlatPx + add;
}
if (h <= 0) continue;
y -= h;
context.fillStyle = sliceColorForDelta(dir);
context.fillRect(x0, y, w, h);
if (!isLast) {
y -= sepPx;
context.fillStyle = sepColor;
context.fillRect(x0, y, w, sepPx);
}
}
continue;
}
const f: any = (c as any).flow;
let upShare = typeof f?.up === 'number' ? f.up : Number(f?.up);
let downShare = typeof f?.down === 'number' ? f.down : Number(f?.down);
let flatShare = typeof f?.flat === 'number' ? f.flat : Number(f?.flat);
if (!Number.isFinite(upShare)) upShare = 0;
if (!Number.isFinite(downShare)) downShare = 0;
if (!Number.isFinite(flatShare)) flatShare = 0;
upShare = Math.max(0, upShare);
downShare = Math.max(0, downShare);
flatShare = Math.max(0, flatShare);
let sum = upShare + downShare + flatShare;
if (!(sum > 0)) {
const overallDir = dirForDelta(c.close - c.open);
upShare = overallDir > 0 ? 1 : 0;
downShare = overallDir < 0 ? 1 : 0;
flatShare = overallDir === 0 ? 1 : 0;
sum = 1;
}
upShare /= sum;
downShare /= sum;
flatShare /= sum;
const downH = Math.floor(barHeightPx * downShare);
const flatH = Math.floor(barHeightPx * flatShare);
const upH = Math.max(0, barHeightPx - downH - flatH);
let y = yBottomPx;
if (downH > 0) {
context.fillStyle = sliceColorForDelta(-1);
context.fillRect(x0, y - downH, w, downH);
y -= downH;
}
if (flatH > 0) {
context.fillStyle = sliceColorForDelta(0);
context.fillRect(x0, y - flatH, w, flatH);
y -= flatH;
}
if (upH > 0) {
context.fillStyle = sliceColorForDelta(1);
context.fillRect(x0, y - upH, w, upH);
}
}
});
}
}
class BuildSlicesPaneView implements IPrimitivePaneView {
private readonly _renderer: BuildSlicesPaneRenderer;
constructor(getState: () => BuildSlicesState) {
this._renderer = new BuildSlicesPaneRenderer(getState);
}
zOrder() {
return 'top';
}
renderer() {
return this._renderer;
}
}
class BuildSlicesPrimitive implements ISeriesPrimitive<Time> {
private _param: SeriesAttachedParameter<Time> | null = null;
private _series: ISeriesApi<'Histogram', Time> | null = null;
private _enabled = true;
private _candles: Candle[] = [];
private _bucketSeconds = 0;
private _samples: Map<number, BuildSample[]> = new Map();
private readonly _paneView: BuildSlicesPaneView;
private readonly _paneViews: readonly IPrimitivePaneView[];
constructor() {
this._paneView = new BuildSlicesPaneView(() => ({
enabled: this._enabled,
candles: this._candles,
bucketSeconds: this._bucketSeconds,
samples: this._samples,
series: this._series,
chart: this._param?.chart ?? null,
}));
this._paneViews = [this._paneView];
}
attached(param: SeriesAttachedParameter<Time>) {
this._param = param;
this._series = param.series as ISeriesApi<'Histogram', Time>;
}
detached() {
this._param = null;
this._series = null;
}
paneViews() {
return this._paneViews;
}
setEnabled(next: boolean) {
this._enabled = Boolean(next);
this._param?.requestUpdate();
}
setData(next: { candles: Candle[]; bucketSeconds: number; samples: Map<number, BuildSample[]> }) {
this._candles = Array.isArray(next.candles) ? next.candles : [];
this._bucketSeconds = Number.isFinite(next.bucketSeconds) ? next.bucketSeconds : 0;
this._samples = next.samples;
this._param?.requestUpdate();
}
}
export default function TradingChart({ export default function TradingChart({
candles, candles,
oracle, oracle,
@@ -103,6 +490,10 @@ export default function TradingChart({
ema20, ema20,
bb20, bb20,
showIndicators, showIndicators,
showBuild,
bucketSeconds,
seriesKey,
priceLines,
fib, fib,
fibOpacity = 1, fibOpacity = 1,
fibSelected = false, fibSelected = false,
@@ -119,14 +510,23 @@ export default function TradingChart({
const fibOpacityRef = useRef<number>(fibOpacity); const fibOpacityRef = useRef<number>(fibOpacity);
const priceAutoScaleRef = useRef<boolean>(priceAutoScale); const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale); const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
const showBuildRef = useRef<boolean>(showBuild);
const onReadyRef = useRef<Props['onReady']>(onReady); const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick); const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove); const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent); const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
const capturedOverlayPointerRef = useRef<number | null>(null); const capturedOverlayPointerRef = useRef<number | null>(null);
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
const buildKeyRef = useRef<string | null>(null);
const lastBuildCandleStartRef = useRef<number | null>(null);
const hoverCandleTimeRef = useRef<number | null>(null);
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
const priceLinesRef = useRef<Map<string, any>>(new Map());
const seriesRef = useRef<{ const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>; candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>; volume?: ISeriesApi<'Histogram'>;
buildHover?: ISeriesApi<'Line'>;
oracle?: ISeriesApi<'Line'>; oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>; sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>; ema20?: ISeriesApi<'Line'>;
@@ -177,6 +577,14 @@ export default function TradingChart({
priceAutoScaleRef.current = priceAutoScale; priceAutoScaleRef.current = priceAutoScale;
}, [priceAutoScale]); }, [priceAutoScale]);
useEffect(() => {
showBuildRef.current = showBuild;
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
hoverCandleTimeRef.current = null;
setHoverCandleTime(null);
}
}, [showBuild, hoverCandleTime]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
if (chartRef.current) return; if (chartRef.current) return;
@@ -225,7 +633,27 @@ export default function TradingChart({
color: 'rgba(255,255,255,0.15)', color: 'rgba(255,255,255,0.15)',
}); });
volumeSeries.priceScale().applyOptions({ volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 }, scaleMargins: { top: 0.88, bottom: 0 },
});
const buildSlicesPrimitive = new BuildSlicesPrimitive();
volumeSeries.attachPrimitive(buildSlicesPrimitive);
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
buildSlicesPrimitive.setEnabled(showBuildRef.current);
const buildHoverSeries = chart.addSeries(LineSeries, {
color: BUILD_FLAT_COLOR,
lineWidth: 2,
priceFormat,
priceScaleId: 'build',
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
buildHoverSeries.priceScale().applyOptions({
scaleMargins: { top: 0.72, bottom: 0.12 },
visible: false,
borderVisible: false,
}); });
const oracleSeries = chart.addSeries(LineSeries, { const oracleSeries = chart.addSeries(LineSeries, {
@@ -249,6 +677,7 @@ export default function TradingChart({
seriesRef.current = { seriesRef.current = {
candles: candleSeries, candles: candleSeries,
volume: volumeSeries, volume: volumeSeries,
buildHover: buildHoverSeries,
oracle: oracleSeries, oracle: oracleSeries,
sma20: smaSeries, sma20: smaSeries,
ema20: emaSeries, ema20: emaSeries,
@@ -298,7 +727,23 @@ export default function TradingChart({
chart.subscribeClick(onClick); chart.subscribeClick(onClick);
const onCrosshairMove = (param: any) => { const onCrosshairMove = (param: any) => {
if (!param?.point) return; if (!param?.point) {
if (showBuildRef.current && hoverCandleTimeRef.current != null) {
hoverCandleTimeRef.current = null;
setHoverCandleTime(null);
}
return;
}
if (showBuildRef.current) {
const t = typeof param?.time === 'number' ? Number(param.time) : null;
const next = t != null && Number.isFinite(t) ? t : null;
if (hoverCandleTimeRef.current !== next) {
hoverCandleTimeRef.current = next;
setHoverCandleTime(next);
}
}
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x); const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return; if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y); const price = candleSeries.coordinateToPrice(param.point.y);
@@ -543,15 +988,75 @@ export default function TradingChart({
candleSeries.detachPrimitive(fibPrimitiveRef.current); candleSeries.detachPrimitive(fibPrimitiveRef.current);
fibPrimitiveRef.current = null; fibPrimitiveRef.current = null;
} }
if (buildSlicesPrimitiveRef.current) {
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
buildSlicesPrimitiveRef.current = null;
}
const lines = priceLinesRef.current;
for (const line of Array.from(lines.values())) {
try {
candleSeries.removePriceLine(line);
} catch {
// ignore
}
}
lines.clear();
chart.remove(); chart.remove();
chartRef.current = null; chartRef.current = null;
seriesRef.current = {}; seriesRef.current = {};
}; };
}, []); }, []);
useEffect(() => {
const candlesSeries = seriesRef.current.candles;
if (!candlesSeries) return;
const desired = (priceLines || []).filter((l) => l.price != null && Number.isFinite(l.price));
const desiredIds = new Set(desired.map((l) => l.id));
const map = priceLinesRef.current;
for (const [id, line] of Array.from(map.entries())) {
if (desiredIds.has(id)) continue;
try {
candlesSeries.removePriceLine(line);
} catch {
// ignore
}
map.delete(id);
}
for (const spec of desired) {
const opts: any = {
price: spec.price,
color: spec.color,
title: spec.title,
lineWidth: spec.lineWidth ?? 1,
lineStyle: spec.lineStyle ?? LineStyle.Dotted,
axisLabelVisible: spec.axisLabelVisible ?? true,
};
const existing = map.get(spec.id);
if (!existing) {
try {
const created = candlesSeries.createPriceLine(opts);
map.set(spec.id, created);
} catch {
// ignore
}
continue;
}
try {
existing.applyOptions(opts);
} catch {
// ignore
}
}
}, [priceLines]);
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles || !s.volume) return; if (!s.candles || !s.volume || !s.buildHover) return;
s.candles.setData(candleData); s.candles.setData(candleData);
s.volume.setData(volumeData); s.volume.setData(volumeData);
s.oracle?.setData(oracleData); s.oracle?.setData(oracleData);
@@ -561,17 +1066,111 @@ export default function TradingChart({
s.bbLower?.setData(bbLower); s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid); s.bbMid?.setData(bbMid);
const bs = resolveBucketSeconds(bucketSeconds, candles);
const eps = 1e-3;
const maxPointsPerCandle = 600;
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
const map = buildSamplesRef.current;
if (buildKeyRef.current !== seriesKey) {
map.clear();
buildKeyRef.current = seriesKey;
lastBuildCandleStartRef.current = null;
}
const visibleStarts = new Set(candles.map((c) => c.time));
for (const start of Array.from(map.keys())) {
if (!visibleStarts.has(start)) map.delete(start);
}
const last = candles[candles.length - 1];
if (last) {
const prevStart = lastBuildCandleStartRef.current;
if (prevStart != null && prevStart !== last.time) {
const prevCandle = candles.find((c) => c.time === prevStart);
if (prevCandle) {
const endT = prevStart + bs - eps;
const finalDelta = prevCandle.close - prevCandle.open;
const list = map.get(prevStart) ?? [];
const lastT = list.length ? list[list.length - 1]!.t : -Infinity;
if (endT > lastT + eps) {
list.push({ t: endT, v: finalDelta });
} else if (list.length) {
list[list.length - 1] = { t: lastT, v: finalDelta };
}
map.set(prevStart, list);
}
}
const start = last.time;
const endT = start + bs - eps;
const delta = last.close - last.open;
const nowT = Date.now() / 1000;
const tClamped = Math.min(endT, Math.max(start + eps, nowT));
const list = map.get(start) ?? [];
if (list.length) {
const lastPt = list[list.length - 1]!;
if (tClamped - lastPt.t < minStep) {
list[list.length - 1] = { t: tClamped, v: delta };
} else {
const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped));
if (t > lastPt.t) list.push({ t, v: delta });
}
} else {
list.push({ t: tClamped, v: delta });
}
map.set(start, list);
lastBuildCandleStartRef.current = start;
}
const buildPrimitive = buildSlicesPrimitiveRef.current;
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
buildPrimitive?.setEnabled(showBuild);
if (showBuild) {
const hoverTime = hoverCandleTime;
const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime);
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
if (hoverData.length) {
s.buildHover.applyOptions({ visible: true });
s.buildHover.setData(hoverData);
} else {
s.buildHover.applyOptions({ visible: false });
s.buildHover.setData([]);
}
} else {
s.buildHover.applyOptions({ visible: false });
s.buildHover.setData([]);
}
s.sma20?.applyOptions({ visible: showIndicators }); s.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators }); s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators }); s.bbUpper?.applyOptions({ visible: showIndicators });
s.bbLower?.applyOptions({ visible: showIndicators }); s.bbLower?.applyOptions({ visible: showIndicators });
s.bbMid?.applyOptions({ visible: showIndicators }); s.bbMid?.applyOptions({ visible: showIndicators });
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]); }, [
candleData,
volumeData,
oracleData,
smaData,
emaData,
bbUpper,
bbLower,
bbMid,
showIndicators,
showBuild,
candles,
bucketSeconds,
seriesKey,
hoverCandleTime,
]);
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles) return; if (!s.candles) return;
s.candles.applyOptions({ priceFormat }); s.candles.applyOptions({ priceFormat });
s.buildHover?.applyOptions({ priceFormat });
s.oracle?.applyOptions({ priceFormat }); s.oracle?.applyOptions({ priceFormat });
s.sma20?.applyOptions({ priceFormat }); s.sma20?.applyOptions({ priceFormat });
s.ema20?.applyOptions({ priceFormat }); s.ema20?.applyOptions({ priceFormat });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,182 @@
import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
export type OrderbookRow = {
price: number;
sizeBase: number;
sizeUsd: number;
totalBase: number;
totalUsd: 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; sizeBase: number }>): OrderbookRow[] {
let totalBase = 0;
let totalUsd = 0;
return levels.map((l) => {
const sizeUsd = l.sizeBase * l.price;
totalBase += l.sizeBase;
totalUsd += sizeUsd;
return {
price: l.price,
sizeBase: l.sizeBase,
sizeUsd,
totalBase,
totalUsd,
};
});
}
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 bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision)
.slice()
.sort((a, b) => b.price - a.price)
.slice(0, levels)
.map((l) => ({ price: l.price, sizeBase: l.size }));
const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision)
.slice()
.sort((a, b) => a.price - b.price)
.slice(0, levels)
.map((l) => ({ price: l.price, sizeBase: l.size }));
// We compute totals from best -> worse.
// For UI we display asks with best ask closest to mid (at the bottom), so we reverse.
const bids = withTotals(bidsSorted);
const asks = withTotals(asksSorted).slice().reverse();
const bestBid = bidsSorted.length ? bidsSorted[0].price : null;
const bestAsk = asksSorted.length ? asksSorted[0].price : null;
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
setL2({
marketName: row.market_name,
bids,
asks,
bestBid,
bestAsk,
mid,
updatedAt: row.updated_at ?? null,
});
},
});
return () => sub.unsubscribe();
}, [normalizedMarket, levels, pricePrecision, basePrecision]);
return { l2, connected, error };
}

View File

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

View File

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

View File

@@ -6,6 +6,9 @@ export type Candle = {
close: number; close: number;
volume?: number; volume?: number;
oracle?: number | null; oracle?: number | null;
flow?: { up: number; down: number; flat: number };
flowRows?: number[];
flowMoves?: number[];
}; };
export type SeriesPoint = { export type SeriesPoint = {
@@ -68,9 +71,26 @@ 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))
: Array.isArray((c as any)?.flow_rows)
? (c as any).flow_rows.map((x: any) => Number(x))
: undefined,
flowMoves: Array.isArray((c as any)?.flowMoves)
? (c as any).flowMoves.map((x: any) => Number(x))
: Array.isArray((c as any)?.flow_moves)
? (c as any).flow_moves.map((x: any) => Number(x))
: undefined,
})), })),
indicators: json.indicators || {}, indicators: json.indicators || {},
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) }, meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
}; };
} }

View File

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

View File

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

View File

@@ -46,6 +46,10 @@ a:hover {
color: var(--neg); color: var(--neg);
} }
.muted {
color: var(--muted);
}
.shell { .shell {
height: 100vh; height: 100vh;
display: flex; display: flex;
@@ -981,6 +985,226 @@ body.chartFullscreen {
padding: 10px 2px; padding: 10px 2px;
} }
.dlobDash {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.dlobDash__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.dlobDash__title {
font-weight: 950;
letter-spacing: 0.2px;
}
.dlobDash__meta {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12px;
}
.dlobDash__market {
font-weight: 800;
}
.dlobDash__statuses {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 12px;
}
.dlobStatus {
display: inline-flex;
gap: 8px;
align-items: baseline;
}
.dlobStatus__label {
color: var(--muted);
}
.dlobDash__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.dlobKpi {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 10px 12px;
}
.dlobKpi__label {
color: var(--muted);
font-size: 11px;
margin-bottom: 4px;
}
.dlobKpi__value {
font-variant-numeric: tabular-nums;
font-weight: 850;
}
.dlobKpi__sub {
margin-top: 2px;
font-size: 11px;
}
.dlobDash__panes {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 12px;
min-height: 0;
}
.dlobDash__pane {
min-height: 0;
}
.dlobDepth {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.dlobDepth__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
}
.dlobDepth__title {
font-weight: 900;
font-size: 12px;
}
.dlobDepth__meta {
color: var(--muted);
font-size: 11px;
}
.dlobDepth__table {
display: flex;
flex-direction: column;
gap: 6px;
}
.dlobDepthRow {
display: grid;
grid-template-columns: 0.9fr 1fr 1fr 0.7fr;
gap: 10px;
padding: 6px 8px;
border-radius: 10px;
position: relative;
overflow: hidden;
font-variant-numeric: tabular-nums;
}
.dlobDepthRow > * {
position: relative;
z-index: 1;
}
.dlobDepthRow::before,
.dlobDepthRow::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
opacity: 0.9;
}
.dlobDepthRow::before {
transform: scaleX(var(--ask-scale, 0));
transform-origin: left center;
background: linear-gradient(
90deg,
rgba(239, 68, 68, 0.18) 0%,
rgba(239, 68, 68, 0.06) 60%,
rgba(239, 68, 68, 0) 100%
);
}
.dlobDepthRow::after {
transform: scaleX(var(--bid-scale, 0));
transform-origin: right center;
background: linear-gradient(
90deg,
rgba(34, 197, 94, 0) 0%,
rgba(34, 197, 94, 0.06) 40%,
rgba(34, 197, 94, 0.18) 100%
);
}
.dlobDepthRow--head {
padding: 0 8px;
color: var(--muted);
font-size: 11px;
}
.dlobDepthRow--head::before,
.dlobDepthRow--head::after {
display: none;
}
.dlobDepthRow__num {
text-align: right;
}
.dlobDepth__empty {
padding: 8px 2px;
font-size: 12px;
}
.dlobSlippage {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.dlobSlippage__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
}
.dlobSlippage__title {
font-weight: 900;
font-size: 12px;
}
.dlobSlippage__chartWrap {
height: 220px;
min-height: 180px;
}
.dlobSlippageChart {
width: 100% !important;
height: 100% !important;
}
.dlobSlippage__empty {
padding: 8px 2px;
font-size: 12px;
}
.bottomCard .uiCard__body { .bottomCard .uiCard__body {
padding: 10px 12px; padding: 10px 12px;
display: flex; display: flex;
@@ -1046,6 +1270,78 @@ body.chartFullscreen {
padding: 2px 2px; padding: 2px 2px;
border-radius: 8px; border-radius: 8px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
position: relative;
overflow: hidden;
}
.orderbookRow > * {
position: relative;
z-index: 1;
}
.orderbookRow::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
opacity: 0.35;
transform: scaleX(var(--ob-total-scale, 0));
transition: transform 220ms ease-out;
will-change: transform;
}
.orderbookRow::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
transform: scaleX(var(--ob-level-scale, 0));
transition: transform 220ms ease-out;
will-change: transform;
}
.orderbookRow--ask::before {
transform-origin: left center;
background: linear-gradient(
90deg,
rgba(239, 68, 68, 0.24) 0%,
rgba(239, 68, 68, 0.09) 60%,
rgba(239, 68, 68, 0) 100%
);
}
.orderbookRow--bid::before {
transform-origin: right center;
background: linear-gradient(
90deg,
rgba(34, 197, 94, 0) 0%,
rgba(34, 197, 94, 0.09) 40%,
rgba(34, 197, 94, 0.24) 100%
);
}
.orderbookRow--ask::after {
transform-origin: left center;
background: linear-gradient(
90deg,
rgba(239, 68, 68, 0.36) 0%,
rgba(239, 68, 68, 0.12) 55%,
rgba(239, 68, 68, 0) 100%
);
}
.orderbookRow--bid::after {
transform-origin: right center;
background: linear-gradient(
90deg,
rgba(34, 197, 94, 0) 0%,
rgba(34, 197, 94, 0.12) 45%,
rgba(34, 197, 94, 0.36) 100%
);
} }
.orderbookRow__num { .orderbookRow__num {
@@ -1081,6 +1377,69 @@ body.chartFullscreen {
color: var(--muted); color: var(--muted);
} }
.orderbookMeta {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 2px 2px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.orderbookMeta__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 11px;
}
.orderbookMeta__val {
font-variant-numeric: tabular-nums;
}
.liquidityBar {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
}
.liquidityBar::after {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: rgba(0, 0, 0, 0.55);
transform: translateX(-0.5px);
pointer-events: none;
}
.liquidityBar__bid,
.liquidityBar__ask {
height: 100%;
transform: scaleX(0);
transform-origin: center;
transition: transform 180ms ease-out;
}
.liquidityBar__bid {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.0) 0%, rgba(34, 197, 94, 0.35) 55%, rgba(34, 197, 94, 0.85) 100%);
transform-origin: right center;
transform: scaleX(var(--liq-bid, 0));
}
.liquidityBar__ask {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.85) 0%, rgba(239, 68, 68, 0.35) 45%, rgba(239, 68, 68, 0.0) 100%);
transform-origin: left center;
transform: scaleX(var(--liq-ask, 0));
}
.trades { .trades {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -7,6 +7,13 @@ import react from '@vitejs/plugin-react';
const DIR = path.dirname(fileURLToPath(import.meta.url)); const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(DIR, '../..'); const ROOT = path.resolve(DIR, '../..');
type BasicAuth = { username: string; password: string };
function stripTrailingSlashes(p: string): string {
const out = p.replace(/\/+$/, '');
return out || '/';
}
function readApiReadToken(): string | undefined { function readApiReadToken(): string | undefined {
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN; if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
const p = path.join(ROOT, 'tokens', 'read.json'); const p = path.join(ROOT, 'tokens', 'read.json');
@@ -20,24 +27,152 @@ 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);
const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget;
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 (graphqlProxyTarget) {
for (const prefix of ['/graphql', '/graphql-ws']) {
proxy[prefix] = {
target: graphqlProxyTarget,
changeOrigin: true,
ws: true,
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
});
p.on('proxyReqWs', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
});
},
};
}
}
if (uiProxyTarget) {
for (const prefix of ['/whoami', '/auth', '/logout']) {
proxy[prefix] = {
target: uiProxyTarget,
changeOrigin: true,
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
});
p.on('proxyRes', (proxyRes: any) => {
rewriteSetCookieForLocalDevHttp(proxyRes);
});
},
};
}
}
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
strictPort: true, strictPort: true,
proxy: { proxy,
'/api': {
target: process.env.API_PROXY_TARGET || 'http://localhost:8787',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
});
},
},
},
}, },
}); });

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

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

150
doc/gitea-k3s-rv32i.md Normal file
View File

@@ -0,0 +1,150 @@
# Gitea na k3s (Traefik + cert-manager + Lets Encrypt) dla `rv32i.pl`
Ten dokument instaluje Gitea w k3s:
- Ingress: Traefik
- TLS: cert-manager + Lets Encrypt (`ClusterIssuer` = `letsencrypt-prod`)
- Host: `rv32i.pl`
- Secret TLS: `rv32i-pl-tls`
## Ważne (hasła/sekrety)
Nie wstawiam i nie zalecam trzymania haseł w plikach w repo (`doc/`), bo to zwykle kończy się wyciekiem (git history, backupy, screeny).
Zamiast tego:
- trzymamy hasła w **Kubernetes Secret** (w klastrze),
- a w dokumentacji zostawiamy **placeholderey** i komendy, które proszą o hasło interaktywnie.
Jeśli mimo wszystko chcesz „na sztywno” wpisać hasła do pliku, podaj je jawnie w wiadomości — ale to jest zła praktyka.
## 0) Wymagania
1) `cert-manager` działa, a `ClusterIssuer` jest gotowy:
```bash
sudo k3s kubectl get clusterissuer letsencrypt-prod
```
2) DNS wskazuje na VPS:
```bash
dig +short rv32i.pl A
```
Oczekiwane: `77.90.8.171`
3) Porty 80/443 są otwarte z internetu (firewall/ACL u providera).
## 1) Instalacja Helm (jeśli nie masz)
```bash
helm version || true
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sudo bash
helm version
```
## 2) Namespace
```bash
sudo k3s kubectl create namespace gitea
```
## 3) Secret z kontem admina (bez wpisywania hasła do historii)
Ten krok tworzy w klastrze sekret `gitea-admin` z loginem/hasłem/email admina.
```bash
read -rp "Gitea admin username: " GITEA_ADMIN_USER
read -rsp "Gitea admin password: " GITEA_ADMIN_PASS; echo
read -rp "Gitea admin email: " GITEA_ADMIN_EMAIL
sudo k3s kubectl -n gitea create secret generic gitea-admin \
--from-literal=username="$GITEA_ADMIN_USER" \
--from-literal=password="$GITEA_ADMIN_PASS" \
--from-literal=email="$GITEA_ADMIN_EMAIL"
```
## 4) Helm values (Ingress + TLS + Postgres w klastrze)
Utwórz plik `gitea-values.yaml` na VPS (nie musi być w repo):
```bash
cat <<'YAML' > gitea-values.yaml
ingress:
enabled: true
className: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: rv32i.pl
paths:
- path: /
pathType: Prefix
tls:
- secretName: rv32i-pl-tls
hosts:
- rv32i.pl
gitea:
admin:
existingSecret: gitea-admin
config:
server:
DOMAIN: rv32i.pl
ROOT_URL: https://rv32i.pl/
PROTOCOL: http
persistence:
enabled: true
storageClass: local-path
size: 10Gi
postgresql:
enabled: true
primary:
persistence:
enabled: true
storageClass: local-path
size: 10Gi
YAML
```
## 5) Instalacja Gitea
```bash
helm repo add gitea-charts https://dl.gitea.com/charts/
helm repo update
helm upgrade --install gitea gitea-charts/gitea \
-n gitea \
-f gitea-values.yaml
```
## 6) Sprawdzenie
```bash
sudo k3s kubectl -n gitea get pods -o wide
sudo k3s kubectl -n gitea get ingress -o wide
sudo k3s kubectl -n gitea get certificate -o wide
sudo k3s kubectl -n gitea get order,challenge
```
Wejdź na:
- `https://rv32i.pl`
Jeśli `Certificate` nie robi się `Ready=True`, to najczęściej:
- port 80 nie dochodzi z internetu (Lets Encrypt HTTP-01),
- Ingress nie ma `className: traefik`,
- DNS nie wskazuje na VPS.
## 7) (Opcjonalnie) dostęp do haseł po czasie
Jeśli chcesz podejrzeć login/email z sekretu (hasło też da się odczytać, ale rób to ostrożnie):
```bash
sudo k3s kubectl -n gitea get secret gitea-admin -o jsonpath='{.data.username}' | base64 -d; echo
sudo k3s kubectl -n gitea get secret gitea-admin -o jsonpath='{.data.email}' | base64 -d; echo
```
## 8) Uwaga o Git przez SSH
Na VPS port 22 zajmuje systemowy OpenSSH. Na start korzystaj z klonowania przez HTTPS.
Jeśli chcesz klonować przez SSH z Gitei:
- najprościej wystawić Gitea SSH na innym porcie (np. 2222) i zrobić NodePort/LoadBalancer,
- albo zmapować port przez Traefik TCP (wymaga dodatkowej konfiguracji).

167
doc/k8s-migracja.md Normal file
View File

@@ -0,0 +1,167 @@
# Plan migracji na Kubernetes (mikroserwisy + CI/CD)
## 1) Co jest dziś w repo (stan wejściowy)
Ten projekt już ma podział “mikroserwisowy” w Docker Compose:
- **DB stack** (`devops/db/docker-compose.yml`)
- `postgres` (TimescaleDB, port 5432)
- `hasura` (GraphQL Engine, port 8080)
- `pgadmin` (narzędzie dev, port 5050)
- **App stack** (`devops/app/docker-compose.yml`)
- `api` = **trade-api** (Node, port 8787, `devops/api/Dockerfile`)
- `frontend` = **trade-frontend** (Node + statyczny build visualizera, port 8081, `devops/app/frontend/Dockerfile`)
- `ingestor` = worker (Node, `devops/ingestor/Dockerfile`) opcjonalnie, profil `ingest`
- **One-shot bootstrap** (`devops/tools/bootstrap/docker-compose.yml`)
- `db-init` (aplikuje SQL `devops/db/initdb/001_init.sql`)
- `db-version` / `db-backfill` (migracje “wersji” ticków)
- `hasura-bootstrap` (Node script `devops/db/hasura-bootstrap.mjs`: track tabel + permissions + funkcje)
Konfiguracja i sekrety dziś są w `tokens/*.json` (gitignored) i są montowane jako pliki do kontenerów.
## 2) Docelowa architektura na K8s
Minimalny sensowny podział na K8s (1 namespace lub 2):
- **timescaledb** (stanowe) → `StatefulSet` + `PVC` + `Service`
- **hasura** → `Deployment` + `Service`
- **trade-api** → `Deployment` + `Service`
- **trade-frontend** → `Deployment` + `Service` + `Ingress`
- **trade-ingestor** → `Deployment` (1 replika) albo `CronJob` (jeśli chcesz uruchamiać okresowo)
- **bootstrap / migracje** → `Job` (odpalane ręcznie albo jako część releaseu)
Opcjonalnie:
- `pgadmin` tylko na dev/staging (nieprodukcyjnie).
## 3) Proponowane zasoby Kubernetes (mapowanie 1:1 z Compose)
### 3.1 timescaledb (Postgres)
- `StatefulSet` z `volumeClaimTemplates` (dane w PVC)
- `Service` typu `ClusterIP` (np. `timescaledb:5432`)
- Init schematu:
- *na pierwszy start* można nadal użyć mechanizmu `/docker-entrypoint-initdb.d` (ConfigMap z SQL),
- *dla istniejących wolumenów* potrzebny jest osobny `Job` (odpowiednik `db-init` z compose).
### 3.2 Hasura
- `Deployment` + `Service` (`hasura:8080`)
- `Secret` na:
- `HASURA_GRAPHQL_ADMIN_SECRET`
- klucz JWT (`HASURA_GRAPHQL_JWT_SECRET` / `HASURA_JWT_KEY`)
- connection string do Postgresa (albo osobno hasło)
- Bootstrap metadanych:
- `Job` uruchamiający `devops/db/hasura-bootstrap.mjs` (odpowiednik `hasura-bootstrap`).
### 3.3 trade-api
- `Deployment` + `Service` (`trade-api:8787`)
- `readinessProbe`/`livenessProbe`: `GET /healthz` (już istnieje)
- Konfiguracja:
- env: `HASURA_GRAPHQL_URL`, `TICKS_TABLE`, `CANDLES_FUNCTION`, `APP_VERSION`, `BUILD_TIMESTAMP`
- sekret plikowy (jak dziś): `tokens/hasura.json` + `tokens/api.json``Secret` montowany do `/app/tokens/*`
### 3.4 trade-frontend
- `Deployment` + `Service` (`trade-frontend:8081`)
- `Ingress` wystawiający UI na zewnątrz (TLS opcjonalnie)
- `readinessProbe`/`livenessProbe`: `GET /healthz` (już istnieje)
- Sekrety plikowe:
- `tokens/frontend.json` (basic auth do UI)
- `tokens/read.json` (read token do proxy `/api/*`)
- oba jako `Secret` montowany do `/tokens/*`
- `API_UPSTREAM`: `http://trade-api:8787` (Service DNS)
### 3.5 trade-ingestor
- `Deployment` (zwykle `replicas: 1`)
- Sekrety:
- RPC do Drift (np. `tokens/heliusN.json` / `rpcUrl` / `heliusApiKey`)
- write token do API (`tokens/alg.json`)
- Konfiguracja:
- `MARKET_NAME`, `INTERVAL_MS`, `SOURCE`, `INGEST_API_URL=http://trade-api:8787`
- Uwaga: worker wymaga egress do internetu (Helius RPC + Drift).
### 3.6 Jobs: db-init / db-version / db-backfill / hasura-bootstrap
Bezpieczny pattern:
- uruchamiane manualnie (kubectl) albo jako “hook” w Helm (pre/post-install)
- odpalane w tym samym namespace co DB/Hasura (żeby DNS działał prosto)
## 4) Sekrety i konfiguracja (z `tokens/` → K8s)
Rekomendacja: rozdziel na 2 typy:
- **Secret** (nie commitować w git):
- `tokens/hasura.json` (adminSecret, jwtKey)
- `tokens/api.json` (api adminSecret)
- `tokens/frontend.json` (basic auth)
- `tokens/read.json` (read token)
- `tokens/alg.json` (write token)
- `tokens/heliusN.json` (RPC token / url)
- **ConfigMap**:
- wersja i parametry nie-wrażliwe: `APP_VERSION`, `BUILD_TIMESTAMP`, `TICKS_TABLE`, `CANDLES_FUNCTION`, `MARKET_NAME`, `INTERVAL_MS`
Jeśli chcesz GitOps bez trzymania sekretów “na piechotę”, wybierz jedno:
- **Sealed Secrets** (zaszyfrowane sekrety w repo),
- **External Secrets Operator** (Vault / AWS / GCP / Azure),
- “na start” manualne `kubectl create secret ...` per środowisko.
## 5) Wersjonowanie (v1, v2…) i cutover bez wyłączania DB
W `scripts/ops/` jest workflow wersjonowania Compose:
- nowa wersja `api+frontend(+ingestor)` działa równolegle
- pisze do **nowej tabeli** (`drift_ticks_vN`) i używa **nowej funkcji** (`get_drift_candles_vN`)
Na K8s najprościej odwzorować to tak:
- Helm release name albo suffix w nazwach zasobów: `trade-v1`, `trade-v2`, …
- wspólne DB/Hasura pozostają bez zmian
- `Job` “version-init”:
- odpala SQL migracji (odpowiednik `db-version`)
- odpala `hasura-bootstrap` z `TICKS_TABLE` i `CANDLES_FUNCTION` ustawionymi na vN
- “cutover”:
- startujesz `trade-ingestor` vN
- stopujesz `trade-ingestor` v1
- po czasie robisz `db-backfill` (opcjonalnie) i sprzątasz stare zasoby
## 6) CI/CD “na gita” (build → deploy po pushu)
### Opcja A (polecana): GitOps (Argo CD / Flux)
1) CI (GitHub Actions / GitLab CI) buduje obrazy:
- `trade-api`
- `trade-frontend`
- `trade-ingestor`
2) CI publikuje je do registry (np. GHCR)
3) CD (ArgoCD/Flux) automatycznie synchronizuje manifesty/Helm z repo i robi rollout
Tagowanie obrazów:
- `:sha-<shortsha>` dla każdego commita
- opcjonalnie `:vN` dla releaseów
Aktualizacja tagów:
- ArgoCD Image Updater / Flux Image Automation **albo**
- CI robi commit do `k8s/` (np. podmienia tag w `values.yaml`)
### Opcja B (prostsza na start): “kubectl apply” z CI
1) CI buduje i pushuje obrazy
2) CI wykonuje `helm upgrade --install` albo `kubectl apply -k ...`
3) Dostęp do klastra przez sekret w repo (kubeconfig / token)
## 7) Proponowana sekwencja migracji (checklista)
1) **Decyzje**: gdzie stoi klaster (EKS/GKE/AKS/k3s), jakie registry, jaki Ingress, jak trzymamy sekrety.
2) **K8s “base”**: namespace, storage class, ingress controller, certy (jeśli TLS).
3) **DB**: wdroż Timescale (StatefulSet + PVC), odpal `db-init` job.
4) **Hasura**: wdroż Hasurę, odpal `hasura-bootstrap` job.
5) **API**: wdroż `trade-api`, sprawdź `/healthz`.
6) **Tokeny**: wygeneruj read/write tokeny (obecnym mechanizmem API) i wgraj je jako Secrets.
7) **Frontend**: wdroż `trade-frontend` + Ingress, sprawdź `/healthz` i UI.
8) **Ingestor**: wdroż `trade-ingestor` (1 replika), potwierdź że ticki wpadają.
9) **CI/CD**: dodaj workflow build+push i deploy (GitOps albo kubectl).
10) **Staging → Prod**: rollout na staging, potem prod.
## 8) Pytania, które domykają plan
1) Jaki “git”: **GitHub czy GitLab**?
2) Gdzie ma stać K8s: cloud (EKS/GKE/AKS) czy on-prem/k3s?
3) DB w klastrze (StatefulSet) czy zewnętrzny managed Postgres/Timescale?
4) Czy “po pushu” ma:
- tylko robić rollout na `main`,
- czy tworzyć **preview env per branch/PR**,
- czy startować **nową wersję vN równolegle** (jak `scripts/ops/`)?
5) Jaki dostęp z zewnątrz: domena + TLS, czy wystarczy port-forward / internal?

356
doc/migration.md Normal file
View File

@@ -0,0 +1,356 @@
# Migracja `trade` do k3s + GitOps (pull) na Gitea + CI/CD
Ten dokument opisuje plan migracji obecnego stacka (Docker Compose) do k3s z CD w modelu **pull-based** (GitOps): klaster sam synchronizuje „desired state” z repo na Gitei, a CI jedynie buduje/publikuje obrazy i aktualizuje repo deploymentu.
## Status (VPS / k3s)
Na VPS jest już uruchomione (k3s single-node):
- Ingress: Traefik (80/443)
- TLS: cert-manager + `ClusterIssuer/letsencrypt-prod`
- Gitea (Ingress `https://rv32i.pl`) + registry (`https://rv32i.pl/v2/`)
- Argo CD w namespace `argocd`
- Gitea Actions runner w namespace `gitea-actions` (act_runner + DinD)
- GitOps repo `trade/trade-deploy` podpięte do Argo:
- `Application/argocd/trade-staging` (auto-sync)
- `namespace/trade-staging`: Postgres/Timescale + Hasura + pgAdmin + Job `hasura-bootstrap`
- `namespace/trade-staging`: `trade-api` + `trade-ingestor` (obrazy z registry `rv32i.pl`)
- `namespace/trade-staging`: `trade-frontend` + Ingress `trade.rv32i.pl` (basic auth, TLS OK)
Szybka weryfikacja (VPS):
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy trade-api trade-ingestor -o wide
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/trade-ingestor --tail=40
```
### Tokeny `trade-api` (read/write)
Endpointy w `trade-api` są chronione tokenami z tabeli `api_tokens`:
- `GET /v1/ticks`, `GET /v1/chart` → scope `read`
- `POST /v1/ingest/tick` → scope `write`
W `staging` tokeny są przechowywane jako K8s Secrets (bez commitowania do gita):
- `trade-staging/Secret/trade-ingestor-tokens`: `alg.json` (write) + `heliusN.json` (RPC)
- `trade-staging/Secret/trade-read-token`: `read.json` (read)
- `trade-staging/Secret/trade-frontend-tokens`: `frontend.json` (basic auth) + `read.json` (proxy do API)
### Dostęp: Argo CD UI (bez konfliktu z lokalnym Hasurą na 8080)
Port-forward uruchamiasz „na żądanie” (to nie jest stały serwis). Jeśli lokalnie masz Hasurę na `8080`, użyj `8090`.
Na VPS:
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n argocd port-forward --address 127.0.0.1 svc/argocd-server 8090:443
```
Na swoim komputerze:
```bash
ssh -L 8090:127.0.0.1:8090 user@rv32i.pl
```
UI: `https://localhost:8090`
### Argo CD: username / hasło
- Username: `admin`
- Hasło startowe (pobierz na VPS; nie commituj / nie wklejaj do gita):
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo
```
Po pierwszym logowaniu ustaw własne hasło i (opcjonalnie) usuń `argocd-initial-admin-secret`.
### Runner: log (czy CI działa)
Na VPS:
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n gitea-actions logs deploy/gitea-act-runner -c runner --tail=80
```
### Portainer (Kubernetes UI): `portainer.rv32i.pl`
Portainer jest wdrażany przez Argo CD jako osobna aplikacja `portainer` (namespace `portainer`) z Ingressem i cert-managerem.
Wymagania:
- DNS: rekord A `portainer.rv32i.pl``77.90.8.171`
Weryfikacja na VPS:
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n argocd get application portainer -o wide
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n portainer get pods,svc,ingress,certificate -o wide
```
Jeśli cert-manager „utknie” na HTTP-01 z powodu cache NXDOMAIN w klastrze, pomocne jest zrestartowanie CoreDNS:
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n kube-system rollout restart deploy/coredns
```
Jeśli Portainer pokaże ekran `timeout.html` (setup/login timed out), zrestartuj deployment i od razu dokończ inicjalizację:
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n portainer rollout restart deploy/portainer
```
### Frontend UI: `trade.rv32i.pl`
Frontend jest wdrożony w `trade-staging` i ma Ingress na `trade.rv32i.pl` (Traefik + cert-manager).
Wymagania:
- DNS: rekord A `trade.rv32i.pl``77.90.8.171`
Weryfikacja na VPS:
```bash
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy trade-frontend -o wide
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get ingress trade-frontend -o wide
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get certificate trade-rv32i-pl-tls -o wide
```
Status na dziś:
- `Certificate/trade-rv32i-pl-tls` = `Ready=True` (Lets Encrypt)
## 0) Stan wejściowy (co dziś jest w repo)
Projekt ma już logiczny podział „mikroserwisowy”:
- **DB stack**: `devops/db/docker-compose.yml`
- TimescaleDB/Postgres (`postgres`, port 5432)
- Hasura (`hasura`, port 8080)
- pgAdmin (`pgadmin`, port 5050) raczej tylko dev/staging
- **App stack**: `devops/app/docker-compose.yml`
- `trade-api` (`api`, port 8787; health: `GET /healthz`)
- `trade-frontend` (`frontend`, port 8081; health: `GET /healthz`)
- `trade-ingestor` (`ingestor`, profil `ingest`) worker ingestujący ticki
- **One-shot bootstrap**: `devops/tools/bootstrap/docker-compose.yml`
- `db-init`, `db-version`, `db-backfill` (SQL)
- `hasura-bootstrap` (track tabel + permissions)
Sekrety/konfiguracja lokalnie są dziś w `tokens/*.json` (gitignored) i są montowane jako pliki do kontenerów.
## 1) Cel migracji
- Przeniesienie usług do k3s (Kubernetes) bez zmiany logiki aplikacji.
- Wdrożenia przez GitOps (**pull**, bez „kubectl apply” z CI).
- Powtarzalność: jedna ścieżka build → deploy, jednoznaczne tagi/digests.
- Minimalny downtime: możliwość uruchamiania wersji równolegle (v1/v2…), jak w `scripts/ops/*`.
## 2) Założenia i decyzje do podjęcia (checklista)
Ustal na start (zaznacz jedną opcję, resztę możesz doprecyzować później):
1) **CD (pull)**: Argo CD czy Flux
- Argo CD + (opcjonalnie) Image Updater popularny i „klikany”
- Flux + Image Automation bardzo „git-first”
2) **Registry obrazów**: Gitea Registry czy osobne (Harbor / registry:2)
3) **Sekrety w GitOps**: manualne seedy (na start) czy docelowo SOPS/SealedSecrets/ExternalSecrets
4) **DB**: w klastrze (StatefulSet + PVC) czy zewnętrzny Postgres/Timescale
5) **Środowiska**: `staging` + `prod`? (opcjonalnie preview per branch/PR)
6) **Ingress/TLS**: Traefik + cert-manager + Lets Encrypt (lub inny wariant)
## 3) Docelowa architektura na k3s (mapowanie 1:1)
Minimalny, praktyczny podział zasobów:
- `timescaledb` (Postgres/Timescale): `StatefulSet` + `PVC` + `Service`
- `hasura`: `Deployment` + `Service`
- `trade-api`: `Deployment` + `Service`
- `trade-frontend`: `Deployment` + `Service` + `Ingress`
- `trade-ingestor`: `Deployment` (zwykle `replicas: 1`) albo `CronJob` (jeśli ingest ma być okresowy)
- migracje/bootstrap:
- `Job` dla `db-init`/`db-version`/`db-backfill`
- `Job` dla `hasura-bootstrap`
Ważne:
- `trade-api` i `trade-frontend` mają gotowe endpointy `/healthz` do `readinessProbe`/`livenessProbe`.
- `trade-ingestor` potrzebuje egress do internetu (RPC do Helius/Drift).
## 4) Podział repozytoriów (rekomendowane)
Żeby GitOps było czyste i proste:
1) Repo aplikacji: **`trade`** (to repo)
- kod i Dockerfile: `devops/api/Dockerfile`, `devops/ingestor/Dockerfile`, `devops/app/frontend/Dockerfile`
2) Repo deploymentu: **`trade-deploy`** (nowe repo na Gitei)
- manifesty K8s (Helm chart albo Kustomize)
- overlays per środowisko: `staging/`, `prod/`
Zasada: CI nie wykonuje deploya do klastra; CI tylko aktualizuje `trade-deploy`, a CD w klastrze to „pulluje”.
## 5) CI na Gitei (build → push)
Do wyboru:
- **Gitea Actions** + `act_runner` (najbliżej GitHub Actions)
- alternatywnie Woodpecker/Drone, jeśli już masz w infra
Zakres CI:
- zbuduj i wypchnij obrazy:
- `trade-api`
- `trade-frontend`
- `trade-ingestor`
- taguj deterministycznie, np.:
- `sha-<shortsha>` (zawsze)
- opcjonalnie `vN` na release
- po push obrazu: zaktualizuj `trade-deploy` (tag/digest) lub zostaw to automatyzacji obrazów (patrz niżej).
## 6) CD (pull) z klastra: GitOps
Wariant A (najprostszy operacyjnie): **CI robi commit do `trade-deploy`**
- CI po zbudowaniu obrazu aktualizuje `values.yaml`/`kustomization.yaml` w `trade-deploy` (tag lub digest) i robi commit/push.
- Argo CD / Flux wykrywa zmianę i robi rollout.
Wariant B (bardziej „autopilot”): **automatyczna aktualizacja obrazów**
- Argo CD Image Updater albo Flux Image Automation obserwuje registry i sam aktualizuje repo `trade-deploy`.
- CI tylko buduje/pushuje obrazy.
## 7) Sekrety: `tokens/*.json` → Kubernetes Secret (plikowy mount)
Docelowo w K8s montujesz te same pliki co w Compose:
- `tokens/hasura.json` → dla `trade-api` (admin do Hasury)
- `tokens/api.json` → dla `trade-api` (admin secret do tokenów API)
- `tokens/read.json` → dla `trade-frontend` (proxy do API)
- `tokens/frontend.json` → dla `trade-frontend` (basic auth)
- `tokens/alg.json` → dla `trade-ingestor` (write token)
- `tokens/heliusN.json` (lub `rpcUrl`) → dla `trade-ingestor`
Nie commituj sekretów do gita. W GitOps wybierz jedną drogę:
- start: ręczne `kubectl create secret ...` na środowisko
- docelowo: SOPS (age) / SealedSecrets / ExternalSecrets (Vault itp.)
## 8) Plan wdrożenia (kolejność kroków)
### Etap 1: Klaster i narzędzia bazowe
1) Namespacey (np. `trade`, `gitea`, `argocd`/`flux-system`).
2) StorageClass (na start `local-path`, docelowo rozważ Longhorn).
3) Ingress controller (Traefik w k3s) + cert-manager + ClusterIssuer.
4) Registry obrazów (Gitea Registry / Harbor / registry:2).
5) GitOps controller (Argo CD albo Flux) podpięty do `trade-deploy`.
### Etap 2: DB + Hasura
6) Deploy TimescaleDB (StatefulSet + PVC + Service).
7) Uruchom `Job` `db-init` (idempotent) odpowiednik `devops/tools/bootstrap:db-init`.
8) Deploy Hasura (Deployment + Service + Secret na admin/jwt/db-url).
9) Uruchom `Job` `hasura-bootstrap` odpowiednik `devops/tools/bootstrap:hasura-bootstrap`.
### Etap 3: App stack
10) Deploy `trade-api` (Deployment + Service, env `HASURA_GRAPHQL_URL`, `TICKS_TABLE`, `CANDLES_FUNCTION`).
11) Sprawdź `GET /healthz` po Service DNS i (opcjonalnie) z zewnątrz.
12) Wygeneruj i wgraj tokeny:
- read token dla frontendu (`tokens/read.json`)
- write token dla ingestora (`tokens/alg.json`)
13) Deploy `trade-frontend` + Ingress (TLS opcjonalnie); ustaw `API_UPSTREAM=http://trade-api:8787`.
14) Deploy `trade-ingestor` (`replicas: 1`); potwierdź, że ticki wpadają.
### Etap 4: CI/CD end-to-end
15) Skonfiguruj runnera CI (Gitea Actions / Woodpecker).
16) Workflow CI: build+push obrazów (i ewentualny commit do `trade-deploy`).
17) Zasady promocji: staging → prod (np. tylko tag/release).
### Etap 4b: Workflow zmian (dev → staging) + snapshoty/rollback
Rekomendacja: nie robimy “ręcznych” zmian na VPS (żeby nie tworzyć snowflakea). Każdy deploy ma być **snapshootem**, do którego można wrócić: *git commit w `trade-deploy` + pin do obrazu* (`sha-<shortsha>` albo digest; bez `latest`).
Standardowy flow:
1) Zmiany robisz lokalnie (nie musisz odpalać lokalnego Dockera; na start wystarczy szybki build/typecheck).
2) Push do gita (PR/merge).
3) CI buduje i pushuje obrazy, a następnie aktualizuje `trade-deploy` (tag/digest + ewentualnie `BUILD_TIMESTAMP`).
4) Argo CD (auto-sync) wdraża do `trade-staging`.
5) Testujesz na VPS (UI/API/ingestor).
Rollback (szybki, preferowany):
- cofasz zmianę w `trade-deploy` (`git revert` / powrót do poprzedniej rewizji w Argo) → Argo wraca do poprzedniego snapshoota.
Rollback (bezpieczny dla “dużych” zmian, np. ingest/schema):
- użyj wersjonowania vN (osobna tabela/funkcja/porty) + cutover ingestora; jeśli zmiana nie siądzie, robisz cut back vN → v1 (dane w starej tabeli zostają).
## 9) Wersjonowanie v1/v2… (równoległe wdrożenia + cutover)
Repo ma już pattern wersjonowania w Compose (`scripts/ops/*` + `devops/versions/vN.env`).
Odwzorowanie na K8s:
- osobne releasey Helm (np. `trade-v1`, `trade-v2`) albo osobne namespacey
- wspólny DB/Hasura bez zmian
- `Job` „version-init”:
- uruchamia SQL `db-version` (tworzy `drift_ticks_vN` i funkcje)
- uruchamia `hasura-bootstrap` z `TICKS_TABLE`/`CANDLES_FUNCTION` ustawionymi na vN
- cutover:
- start `trade-ingestor` vN
- stop `trade-ingestor` v1
- (opcjonalnie) `db-backfill` jako osobny job
## 10) Definition of Done (DoD)
- `trade-api` i `trade-frontend` działają na k3s (proby `Ready=True`, brak crashloopów).
- `trade-ingestor` zapisuje ticki do właściwej tabeli, a UI pokazuje wykres.
- Deploy jest sterowany przez GitOps (zmiana w `trade-deploy` → rollout bez ręcznego `kubectl apply`).
- Sekrety są poza gitem (manualnie lub przez wybraną metodę GitOps).
## 11) Referencje w repo
- Compose: `devops/db/docker-compose.yml`, `devops/app/docker-compose.yml`, `devops/tools/bootstrap/docker-compose.yml`
- Workflow end-to-end: `doc/workflow-api-ingest.md`
- Szkic migracji K8s: `doc/k8s-migracja.md`
- Gitea na k3s (Traefik/TLS): `doc/gitea-k3s-rv32i.md`
## 12) Metoda „superproject” (subrepo) plan refaktoru repozytoriów
Cel: utrzymać osobne repo dla usług (API/ingestor/frontend) i osobne repo GitOps (`trade-deploy`), a do tego mieć jedno repo „główne” (superproject) spinające wszystko (np. jako **git submodules**).
### Wybór repo głównego
Rekomendacja: **`trade/trade-infra` jako repo główne (superproject)**:
- jest neutralne (infra/ops), nie miesza kodu aplikacji z manifestami GitOps,
- pozwala trzymać jedną „zafiksowaną” wersję całego systemu (SHAs submodułów),
- nie wymaga zmiany Argo CD (który nadal może śledzić `trade/trade-deploy`).
### Docelowy podział ról repo
- `trade/trade-api` kod + Dockerfile dla `trade-api`
- `trade/trade-ingestor` kod + Dockerfile dla `trade-ingestor`
- `trade/trade-frontend` kod + Dockerfile dla `trade-frontend` (apps/visualizer + serwer)
- `trade/trade-deploy` GitOps: Kustomize/Helm dla k3s (Argo CD pull)
- `trade/trade-doc` dokumentacja całego projektu (`doc/`), w tym log działań
- `trade/trade-infra` superproject + narzędzia/ops (np. makefile, skrypty, checklisty)
### Proponowany układ w superprojekcie
Przykład (ścieżki w `trade/trade-infra`):
```text
trade-infra/
api/ -> submodule: trade-api
ingestor/ -> submodule: trade-ingestor
frontend/ -> submodule: trade-frontend
deploy/ -> submodule: trade-deploy
doc/ -> submodule: trade-doc
```
### Plan wdrożenia refaktoru (po akceptacji)
1) Zainicjalizować `trade/trade-infra` jako superproject i dodać submodule do wszystkich subrepo.
2) Wyciąć obecny kod do subrepo:
- API: `services/api/*` + `devops/api/Dockerfile``trade-api/`
- Frontend: `apps/visualizer/*` + `services/frontend/*` + `devops/app/frontend/*``trade-frontend/`
- Ingestor: `scripts/ingest-drift-oracle.ts` (+ minimalny zestaw zależności i Dockerfile) → `trade-ingestor/`
3) Przenieść dokumentację do `trade-doc` (w tym log: `doc/steps.md`) i zostawić w pozostałych repo linki do docs.
4) Ujednolicić nazwy obrazów i tagowanie:
- `rv32i.pl/trade/trade-api:<tag>`
- `rv32i.pl/trade/trade-ingestor:<tag>`
- `rv32i.pl/trade/trade-frontend:<tag>`
5) CI/CD (Gitea Actions) per repo usługi:
- build → push do registry,
- aktualizacja `trade-deploy` (commit/PR z bumpem tagu/digesta),
- staging auto-sync w Argo (prod manual).
6) Walidacja end-to-end na k3s:
- rollout `trade-staging`,
- UI `https://trade.rv32i.pl`,
- ingest ticków (`trade-ingestor``trade-api`).
Uwaga: lokalnie repo nie ma historii commitów, więc refaktor będzie startem „od zera” w nowych repo (initial commit per subrepo).

158
doc/stats.md Normal file
View File

@@ -0,0 +1,158 @@
# DLOB stats — definicje i wizualizacja
Ten dokument opisuje metryki liczone z orderbooka DLOB (Drift Limit Order Book) oraz propozycje, jak je prezentować w naszej wizualizacji (warstwami/panelami).
## Źródła danych (tabele)
### `dlob_l2_latest` (snapshot L2, “surowy”)
Snapshot topN leveli orderbooka per market.
- `bids` / `asks`: tablice poziomów `{ price, size }` (wartości zwykle w “skalowanych intach” wg `PRICE_PRECISION` i `BASE_PRECISION`).
- `best_bid_price` / `best_ask_price`, `mark_price`, `oracle_price`: w praktyce do szybkiego odczytu topofbook (ale najdokładniej liczyć z `bids/asks`).
- `ts`, `slot`: czas/slot źródłowego snapshota z DLOB.
- `updated_at`: kiedy worker zapisał snapshot do DB (do oceny “świeżości”).
Z tego źródła robimy: orderbook UI (paski/heat), mikroceny, symulacje fill.
### `dlob_stats_latest` (agregat z L2 pod UI)
Pochodne metryki liczone na podstawie `dlob_l2_latest` (lub równoważnego L2), trzymane jako “latest” per market.
Metryki:
- `best_bid_price`, `best_ask_price` (USD): najlepszy bid/ask.
- `mid_price` (USD): `(best_bid_price + best_ask_price) / 2`.
- `spread_abs` (USD): `best_ask_price - best_bid_price`.
- `spread_bps` (bps): `(spread_abs / mid_price) * 10_000` (1 bps = 0.01%).
- `depth_levels` (liczba): ile leveli z każdej strony weszło do “depth”.
- `depth_bid_base`, `depth_ask_base` (base asset): suma size (w jednostkach bazowych) po topN levelach.
- `depth_bid_usd`, `depth_ask_usd` (USD): suma `size_base * price` po topN levelach.
- `imbalance` ([-1..1]): `(depth_bid_usd - depth_ask_usd) / (depth_bid_usd + depth_ask_usd)`; >0 = relatywnie większa płynność po bid.
- `mark_price`, `oracle_price` (USD): ceny referencyjne (mark i oracle).
- `ts`, `slot`, `updated_at`: metadane czasu/świeżości.
To jest najszybsze źródło do overlay na wykresie i do KPI w headerze.
### `dlob_depth_bps_latest` (płynność w pasmach wokół mid)
Metryki głębokości, ale nie “topN leveli” tylko “okno odległości od ceny” w bps.
Klucz:
- `(market_name, band_bps)` np. 5/10/20/50/100/200 bps.
Interpretacja:
- Dla danego `band_bps` sumujemy płynność tylko z poziomów, które mieszczą się w oknie ±`band_bps` wokół `mid_price`.
Metryki:
- `bid_base`, `ask_base` (base asset): suma size w oknie.
- `bid_usd`, `ask_usd` (USD): suma `size_base * price` w oknie.
- `imbalance` ([-1..1]): jak wyżej, ale per band.
- `mid_price`, `best_bid_price`, `best_ask_price` (USD): do kontekstu wyliczeń.
- `ts`, `slot`, `updated_at`, `raw`: metadane/diagnostyka.
To jest najlepsze źródło do wykresów “jak gruby jest orderbook blisko ceny”.
### `dlob_slippage_latest` (symulacja slippage vs rozmiar)
Symulacja wykonania zlecenia rynkowego po L2.
Klucz:
- `(market_name, side, size_usd)` gdzie `side ∈ {buy,sell}` a `size_usd` to predefiniowane progi (np. 100/500/1000/…).
Metryki:
- `impact_bps` (bps): wpływ wykonania vs `mid_price` (zwykle `vwap` względem mid).
- `vwap_price` (USD): średnia cena wykonania.
- `worst_price` (USD): najgorszy poziom dotknięty podczas fill.
- `filled_usd`, `filled_base`: ile realnie weszło w fill (gdy brak płynności, może być < docelowego).
- `fill_pct` (%): 100% = pełny fill.
- `levels_consumed`: ile leveli zostało zjedzone”.
- `mid_price`, `ts`, `slot`, `updated_at`, `raw`: metadane/diagnostyka.
To jest idealne do Dynamic Slippage w formularzu i do wykresu slippagevssize.
## Jak nanieść na wizualizację (warstwy/panele)
Poniżej propozycja warstw, pogrupowanych tak, żeby serie w jednej warstwie były w tej samej domenie (jednostki i semantyka).
### Warstwa 1: Cena / Quotes (oś Y = USD, overlay na głównym wykresie)
Źródło: `dlob_stats_latest`.
- Linie: `mark_price` i `oracle_price` (referencje).
- Linie: `best_bid_price` i `best_ask_price` (topofbook).
- Opcjonalnie: `mid_price` jako linia przerywana.
- Opcjonalnie: spread band (wypełnienie między bid i ask).
Efekt: w jednym miejscu widać gdzie jest rynek + jak szeroki jest spread.
### Warstwa 2: Spread (panel pod wykresem, oś Y = bps)
Źródło: `dlob_stats_latest`.
- Linia: `spread_bps`.
- Tooltip/secondary: `spread_abs` (USD) jako dodatkowa informacja (zwykle bez drugiej osi, żeby nie mieszać skali).
Efekt: szybkie koszt wejścia/wyjścia i jego zmienność.
### Warstwa 3: Płynność topN + imbalance (panel, oś Y = USD + opcjonalnie linia imbalance)
Źródło: `dlob_stats_latest`.
- Area: `depth_bid_usd` i `depth_ask_usd` (dwie serie, zielona/czerwona).
- Opcjonalnie: linia `imbalance` (druga , zakres [-1..1]) albo jako wskaźnik liczbowy.
Efekt: ile jest płynności najbliżej topofbook (w definicji topN leveli).
### Warstwa 4: Płynność jako funkcja odległości (bps bands) (panel)
Źródło: `dlob_depth_bps_latest`.
Dwa czytelne warianty prezentacji:
1) Fan chart / multiline:
- Linie `bid_usd(band_bps)` i `ask_usd(band_bps)` dla kilku bandów (np. 10/50/200).
2) Stacked:
- Słupki/area pokazujące ile dodaje kolejne pasmo (np. 010, 1020, 2050 bps), osobno dla bid i ask.
Efekt: jak szybko rośnie płynność, gdy odchodzę od mid”.
### Warstwa 5: Slippage vs size (osobny panel XY, nie timeline)
Źródło: `dlob_slippage_latest`.
- X: `size_usd`.
- Y: `impact_bps`.
- Dwie krzywe: `buy` i `sell`.
- Marker: aktualny Order Value z formularza (punkt na krzywej).
Efekt: bardzo czytelna krzywa kosztu wykonania względem rozmiaru.
### Warstwa 6: Heat / “paski” orderbooka (widok orderbook albo overlay z ograniczeniem)
Źródło: `dlob_l2_latest`.
- Paski (zielone/czerwone) per poziom ceny, intensywność `size` (jak na Drift UI).
- To najlepiej działa jako:
- osobny panel Orderbook (snapshot), albo
- edge overlay przy prawej krawędzi wykresu (bez historii).
Efekt: gdzie stoją ściany i jak się zmieniają.
## Uwaga o historii (“latest” vs wykres w czasie)
Tabele `*_latest` świetne do live UI i subscriptions, ale **nie przechowują historii** do rysowania timeline (np. spread przez ostatnie 24h).
Jeśli chcemy historię:
- opcja A: dodać osobne tabele timeseries (np. `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`) i zasilać je workerem,
- opcja B: rozszerzyć ingest ticków (`drift_ticks`) o dodatkowe pola/nową tabelę eventów dla metryk orderbooka.
Wtedy warstwy 25 mogą być prawdziwymi wykresami w czasie”, a nie tylko bieżącym odczytem.

231
doc/steps.md Normal file
View File

@@ -0,0 +1,231 @@
# steps.md
Log działań wykonywanych w ramach migracji `trade` → k3s + Gitea + GitOps (pull).
Uwaga: **nie zapisuję sekretów** (hasła, tokeny, prywatne klucze) jeśli pojawiają się w outputach narzędzi, są pomijane/redagowane.
## 2026-01-05
### Repo: inwentaryzacja i plan
- Przejrzano strukturę repo (`apps/`, `devops/`, `services/`, `doc/`) i istniejące Compose stacki:
- DB: `devops/db/docker-compose.yml`
- App: `devops/app/docker-compose.yml`
- Bootstrap (one-shot): `devops/tools/bootstrap/docker-compose.yml`
- Sprawdzono Dockerfile:
- API: `devops/api/Dockerfile`
- Frontend: `devops/app/frontend/Dockerfile`
- Ingestor: `devops/ingestor/Dockerfile`
- Sprawdzono skrypty wersjonowania Compose (v1/v2…): `scripts/ops/*` + env: `devops/versions/v1.env`.
- Dodano dokument planu migracji: `doc/migration.md`.
### Gitea: repo GitOps (MCP Gitea)
- Utworzono repo `trade/trade-deploy` (public, `main`, auto-init).
- Dodano szkielet Kustomize:
- `kustomize/base/` (placeholder ConfigMap)
- `kustomize/overlays/staging/` (`namespace: trade-staging`)
- `kustomize/overlays/prod/` (`namespace: trade-prod`)
- Dodano bootstrap manifesty Argo CD Application:
- `bootstrap/argocd/application-trade-staging.yaml` (auto-sync + CreateNamespace)
- `bootstrap/argocd/application-trade-prod.yaml` (CreateNamespace, bez auto-sync)
- Dodano DB stack do `trade-deploy` (manifests + bootstrap):
- Postgres/Timescale: `kustomize/base/postgres/*` + init SQL: `kustomize/base/initdb/001_init.sql`
- Hasura: `kustomize/base/hasura/*` + job `hasura-bootstrap` (track tables/functions)
- Staging: `kustomize/overlays/staging/pgadmin.yaml` + patch Hasury (console/dev mode)
- Zaktualizowano `README.md` w `trade-deploy` o wymagane sekrety i port-forward.
### VPS: audyt stanu (MCP SSH)
- Host: `qstack` (Debian), k3s działa; dostęp do klastra uzyskany przez `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` (bez sudo).
- Ingress: Traefik działa (LB na `77.90.8.171`, porty 80/443).
- TLS: cert-manager działa; `ClusterIssuer/letsencrypt-prod` jest `Ready=True`.
- Gitea jest zainstalowana w k3s (Helm release `gitea` w namespace `gitea`), Ingress na `rv32i.pl`, cert `rv32i-pl-tls` jest `Ready=True`.
- GitOps controller (Argo CD / Flux): **nie znaleziono** (brak namespace `argocd` i `flux-system`).
- Wdrożenia aplikacji `trade-*` w k3s: **nie znaleziono** (brak deploy/podów z nazwą `trade`).
- Registry: endpoint `https://rv32i.pl/v2/` odpowiada (401 bez autoryzacji).
### Gitea: audyt stanu (MCP Gitea)
- Zalogowany użytkownik: `u1` (admin).
- Organizacja `trade` istnieje; są utworzone repozytoria (puste, bez commitów/branchy):
- `trade/trade-api`
- `trade/trade-frontend`
- `trade/trade-ingestor`
- `trade/trade-infra`
- Repo `trade-deploy` (manifesty GitOps): **nie znaleziono**.
### VPS: Argo CD (MCP SSH)
- Zainstalowano Argo CD przez Helm: release `argocd` w namespace `argocd` (`argo/argo-cd`).
- Utworzono `Application` dla staging z `trade-deploy`:
- zastosowano `bootstrap/argocd/application-trade-staging.yaml`
- Argo utworzyło namespace `trade-staging` i zsynchronizowało placeholder ConfigMap `trade-deploy-info`.
- Uwaga: jeśli port `8080` jest zajęty lokalnie (np. przez Hasurę), do port-forward użyj innego portu (np. `8090:443`).
### VPS: Gitea Actions runner (MCP SSH)
- Wykryto wymaganie: `sudo` wymaga hasła, więc runner postawiony w k3s (bez instalacji binarki na hoście).
- Utworzono namespace `gitea-actions` oraz zasoby dla `act_runner` (DinD sidecar + runner):
- manifest w `trade-deploy`: `bootstrap/gitea-actions/act-runner.yaml`
- sekret `act-runner-registration-token` utworzony w klastrze z tokena rejestracyjnego pobranego z Gitei (API admin; token nie trafia do gita)
- Naprawiono start Dockera w sidecar:
- początkowo próba po unix-sockecie powodowała konflikt (`docker.sock` jako katalog)
- finalnie ustawiono `DOCKER_HOST=tcp://127.0.0.1:2375` (runner ↔ dind po localhost), co uruchomiło runnera poprawnie.
### Uwaga: seed sekretów (blokada narzędzia)
- Przy próbie seedowania sekretów do `trade-staging` przez MCP SSH pojawił się trwały timeout na każde polecenie (`ssh-mcp/exec`), więc ten krok został opisany w `trade-deploy/README.md` do wykonania ręcznie na VPS.
### VPS: dostęp SSH kluczem (wymagane hasło)
- Użytkownik poprosił o używanie naszego klucza prywatnego dla VPS: `k/qstack/qstack`.
- Klucz jest zaszyfrowany hasłem (passphrase). Bez podania passphrase nie da się go użyć w trybie nieinteraktywnym (`ssh`/`ssh-keygen` zwraca błąd o niepoprawnym passphrase / brak `ssh-askpass`).
- Żeby kontynuować automatyzację z tej sesji są 2 opcje:
1) dostarczyć passphrase poza czatem (np. lokalnie w pliku w `tokens/`, gitignored) i używać `SSH_ASKPASS`, albo
2) wygenerować osobny klucz bez passphrase jako “deploy key” tylko do automatyzacji i dodać jego publiczną część do `~user/.ssh/authorized_keys` na VPS.
### Następne kroki (do zrobienia)
- Wybrać GitOps controller (Argo CD vs Flux) i zainstalować w k3s.
- Utworzyć repo `trade-deploy` i dodać strukturę Helm/Kustomize (staging/prod).
- Postawić runner CI (Gitea Actions `act_runner` lub alternatywa) i dodać pipeline build+push obrazów.
- Dopiero potem: manifesty K8s dla `trade-api`/`trade-frontend`/`trade-ingestor` + DB/Hasura + joby migracji.
## 2026-01-06
### VPS: SSH kluczem projektu (passphrase)
- Użyto klucza prywatnego `k/qstack/qstack` do połączenia z VPS przez `ssh` (z `SSH_ASKPASS` czytającym passphrase z `tokens/vps-ssh.json`, plik gitignored).
- Skrypt `tokens/ssh-askpass.sh` był tworzony tymczasowo i został usunięty po użyciu (żeby nie ryzykować przypadkowego commita).
### Doprecyzowanie dostępu i bezpieczeństwo repo
- Dopisano w `doc/migration.md` bieżący status VPS/k3s oraz komendy do Argo CD (port-forward na `8090`) i logów runnera.
- Zaktualizowano `.gitignore`, żeby ignorować sekrety/klucze lokalne: `tokens/*`, `k/`, `argo/pass`, `gitea/token`.
### k3s: seed sekretów (staging)
- Utworzono sekrety w namespace `trade-staging` (wartości wygenerowane losowo, nie logowane):
- `trade-postgres` (`POSTGRES_*`)
- `trade-hasura` (`HASURA_GRAPHQL_ADMIN_SECRET`, `HASURA_JWT_KEY`)
- `trade-pgadmin` (`PGADMIN_DEFAULT_*`)
### Argo CD: deploy DB stack (staging)
- Wymuszono refresh aplikacji Argo `trade-staging` (annotation `argocd.argoproj.io/refresh=hard`).
- Status: `trade-staging` = `Synced` / `Healthy`.
- Workloady w `trade-staging` działają:
- `StatefulSet/postgres` = `postgres-0 Running`
- `Deployment/hasura` = `Running`
- `Deployment/pgadmin` = `Running`
- `Job/hasura-bootstrap` = `Completed` (log: `[hasura-bootstrap] ok`)
### Portainer: dlaczego nie widać k3s (wyjaśnienie)
- Portainer uruchomiony jako kontener Docker domyślnie widzi tylko środowisko Docker; k3s używa `containerd`, więc nie zobaczysz „podów” w `docker ps` ani w Portainerze jako kontenerów Dockera.
- Żeby Portainer widział „wnętrze” k3s trzeba dodać środowisko typu `Kubernetes` w Portainer UI albo postawić Portainera bezpośrednio w klastrze (rekomendowane).
### Portainer (opcja A): wdrożenie w k3s + Ingress `portainer.rv32i.pl`
- Dodano do GitOps repo `trade/trade-deploy` paczkę Kustomize: `kustomize/infra/portainer` (Namespace+RBAC+PVC+Deployment+Service+Ingress).
- Dodano Argo CD `Application` dla Portainera: `bootstrap/argocd/application-portainer.yaml`.
- Zastosowano `Application` na VPS i potwierdzono status: `portainer` = `Synced` / `Healthy`.
- Ingress utworzony na host `portainer.rv32i.pl` (Traefik).
- cert-manager utworzył `Certificate/portainer-rv32i-pl-tls`, ale ACME jest `pending` dopóki DNS `portainer.rv32i.pl` nie wskazuje na `77.90.8.171` (brak rekordu A w momencie sprawdzania).
### Portainer: DNS + TLS + odblokowanie „New Portainer installation timed out”
- Potwierdzono, że rekord A `portainer.rv32i.pl` istnieje na autorytatywnych DNS (`dns*.home.pl`) i rozwiązuje się na publicznych resolverach.
- Zrestartowano `Deployment/portainer` w namespace `portainer`, żeby odblokować ekran inicjalizacji (komunikat “timed out for security purposes”).
- cert-manager miał „zawieszone” HTTP-01 self-check przez cache NXDOMAIN w klastrze; zrestartowano `Deployment/coredns` w `kube-system`, po czym certyfikat stał się `Ready=True` (`Certificate/portainer-rv32i-pl-tls`).
### Registry: token do Gitea Packages + docker login
- Na VPS (z poziomu poda Gitei) wygenerowano token `read:package,write:package` dla użytkownika `u1`:
- polecenie: `gitea admin user generate-access-token --username u1 --scopes "read:package,write:package" --raw`
- token zapisany lokalnie w `tokens/gitea-registry.token` (gitignored; wartość nie jest logowana).
- Wykonano `docker login rv32i.pl` (bez logowania tokena).
- Zaktualizowano `.dockerignore`, żeby nie wysyłać do build context katalogów z sekretami/kluczami (`tokens/*`, `k/`, `gitea/`, `argo/`).
### Obrazy: build + push (trade-api, trade-ingestor)
- Zbudowano i wypchnięto obrazy do Gitea registry:
- `rv32i.pl/trade/trade-api:k3s-20260106013603`
- `rv32i.pl/trade/trade-ingestor:k3s-20260106013603`
- Tag zapisany lokalnie w `tokens/last-image-tag.txt` (gitignored).
### GitOps: manifesty k3s dla API + ingestor (trade-deploy)
- Dodano do repo `trade/trade-deploy`:
- `kustomize/base/api/deployment.yaml` + `kustomize/base/api/service.yaml`
- `kustomize/base/ingestor/deployment.yaml`
- aktualizacja `kustomize/base/kustomization.yaml` (dopięcie nowych zasobów).
- Konfiguracja:
- `trade-api` używa `HASURA_GRAPHQL_URL=http://hasura:8080/v1/graphql` i sekretów `trade-hasura` + `trade-api` (admin).
- `trade-ingestor` startuje w trybie `INGEST_VIA=hasura` (bez tokenów API) i używa `trade-hasura` + `trade-ingestor-tokens` (RPC).
### k3s: seedy sekretów + weryfikacja (staging)
- Utworzono w `trade-staging`:
- `Secret/gitea-registry` (imagePullSecret do `rv32i.pl`)
- `Secret/trade-api` (`API_ADMIN_SECRET`, wygenerowany losowo; nie logowany)
- `Secret/trade-ingestor-tokens` (plik `heliusN.json`; wartość nie logowana)
- Argo CD `Application/trade-staging` po refresh: `Synced` / `Healthy`.
- Pody w `trade-staging` uruchomione: `trade-api` i `trade-ingestor` (`Running`).
- Logi:
- `trade-api` startuje i maskuje sekrety (`***`).
- `trade-ingestor` pobiera ceny i ingestuje ticki; URL RPC jest redagowany (`api-key=***`).
### Ingest przez API + tokeny read/write
- Zmieniono `trade-ingestor` na `INGEST_VIA=api` (pisze do `trade-api` zamiast bezpośrednio do Hasury); manifest w `trade/trade-deploy`: `kustomize/base/ingestor/deployment.yaml`.
- Utworzono tokeny w `trade-api`:
- `write` (dla ingestora) oraz `read` (dla klientów UI/odczytu)
- tokeny zapisane jako K8s Secrets (wartości nie logowane):
- `trade-staging/Secret/trade-ingestor-tokens`: `alg.json` (write) + `heliusN.json` (RPC)
- `trade-staging/Secret/trade-read-token`: `read.json` (read)
- Wymuszono rollout `Deployment/trade-ingestor` i potwierdzono w logach:
- `ingestVia: "api"`, `writeUrl: "http://trade-api:8787/v1/ingest/tick"`, `auth: "bearer"`.
- Potwierdzono, że `trade-api /v1/ticks` działa z tokenem `read` (bez ujawniania tokena).
### Frontend: deploy na k3s (staging) + Ingress `trade.rv32i.pl`
- Zbudowano i wypchnięto obraz do Gitea registry:
- `rv32i.pl/trade/trade-frontend:k3s-20260106013603`
- Dodano manifesty do `trade/trade-deploy`:
- `kustomize/base/frontend/deployment.yaml` + `kustomize/base/frontend/service.yaml`
- staging: `kustomize/overlays/staging/frontend-ingress.yaml` (host `trade.rv32i.pl`)
- aktualizacje `kustomize/base/kustomization.yaml` i `kustomize/overlays/staging/kustomization.yaml`.
- Utworzono sekret `trade-staging/Secret/trade-frontend-tokens` (pliki `frontend.json` + `read.json`; wartości nie logowane).
- Argo CD `Application/trade-staging`: `Synced` / `Healthy`; `Deployment/trade-frontend` = `Running`, `/healthz` działa.
- TLS dla `trade.rv32i.pl` jest `pending` dopóki DNS `trade.rv32i.pl` nie wskazuje na `77.90.8.171` (w razie cache NXDOMAIN: restart `kube-system/coredns` jak wcześniej).
### DNS: `trade.rv32i.pl` (weryfikacja)
- Sprawdzono rekord A `trade.rv32i.pl` na autorytatywnych DNS (`dns*.home.pl`) oraz na publicznych resolverach: **brak odpowiedzi** (rekord nie był jeszcze widoczny na NS), więc cert-manager trzyma `trade-rv32i-pl-tls` w stanie `pending` (NXDOMAIN).
- Uwaga: jeśli w panelu DNS dodasz rekord i nadal masz `NXDOMAIN`, sprawdź czy host nie ma literówki (np. `rade` zamiast `trade`) i czy rekord jest zapisany jako A/CNAME dla właściwej nazwy `trade.rv32i.pl`.
### DNS/TLS: `trade.rv32i.pl` (finalizacja)
- Użytkownik potwierdził rekord A `trade.rv32i.pl``77.90.8.171` na autorytatywnych DNS (`dns.home.pl`).
- Na VPS potwierdzono:
- `Ingress/trade-frontend` ma host `trade.rv32i.pl`.
- `Certificate/trade-rv32i-pl-tls` = `Ready=True`.
- `https://trade.rv32i.pl` odpowiada `HTTP 401` (basic auth) czyli Ingress + TLS działają.
### Weryfikacja wdrożenia (MCP SSH)
- `argocd/Application`: `trade-staging` i `portainer` = `Synced` / `Healthy`.
- `trade-staging`: wszystkie workloady `Running` (Postgres/Hasura/pgAdmin/trade-api/trade-ingestor/trade-frontend).
- `trade-ingestor` ma `INGEST_VIA=api` oraz `INGEST_API_URL=http://trade-api:8787` (ingest przez API z tokenem write).
### Portainer: odblokowanie ekranu `timeout.html`
- Zrestartowano `Deployment/portainer` w namespace `portainer`, żeby ponownie pojawił się kreator inicjalizacji (admin user/password).
### Gitea: lista repo w organizacji `trade`
- `trade/trade-api`
- `trade/trade-deploy`
- `trade/trade-doc`
- `trade/trade-frontend`
- `trade/trade-infra`
- `trade/trade-ingestor`
## 2026-01-06
### Zmiana: log działań w `doc/`
- Przeniesiono log działań z `steps.md``doc/steps.md` (zgodnie z nową zasadą: wszystko projektowe ląduje w `doc/`).
### Superproject: decyzja + plan (do akceptacji)
- Zaproponowano `trade/trade-infra` jako repo główne (superproject) spinające subrepo (API/ingestor/frontend/deploy/doc).
- Plan refaktoru opisany w `doc/migration.md` (sekcja „Metoda superproject”).
- Użytkownik zaakceptował `trade/trade-infra` jako superproject; do decyzji pozostało: `submodules` vs `subtree` (rekomendacja: submodules, jeśli chcemy zachować niezależne repo + pinowanie wersji).
## 2026-01-10
### V2: GraphQL + WS (Hasura) + DLOB stats (staging)
- `trade/trade-deploy`:
- Podbito obraz frontendu do `gitea.mpabi.pl/trade/trade-frontend:sha-f85e6da` (UI proxyuje Hasurę pod `/graphql` + WS pod `/graphql-ws`).
- W Hasurze włączono `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public` (UI bez tokena; bootstrap nadaje ograniczone `select`).
- W schemacie Postgresa dodano tabele pod statystyki DLOB: `public.dlob_l2_latest` i `public.dlob_stats_latest` (w `kustomize/base/initdb/001_init.sql`).
- Dodano job migracji DB dla istniejących wolumenów: `kustomize/base/postgres/job-migrate.yaml` (Argo hook; uruchamia `psql -f 001_init.sql`).
- `kustomize/base/hasura/job-bootstrap.yaml` działa jako Argo hook (re-run na sync) i trackuje tabele/permissions DLOB.
- Dodano `dlob-worker` w k3s: `kustomize/base/dlob-worker/*` (Deployment + script jako ConfigMap); worker polluje `https://dlob.drift.trade/l2` i upsertuje do Hasury dla `PUMP/SOL/BONK/BTC/ETH` perps.
- `apps/visualizer` (frontend na laptopie, dev mode):
- `apps/visualizer/__start` odpala Vite z proxy do `https://trade.mpabi.pl` + ustawia `VITE_HASURA_WS_URL=/graphql-ws`.
- `apps/visualizer/src/lib/graphqlWs.ts` wspiera względny `VITE_HASURA_WS_URL` (np. `/graphql-ws`) i normalizuje go do pełnego `ws(s)://...`.
- `apps/visualizer/src/lib/hasura.ts` domyślnie używa `/graphql` (zgodnie z flow: dev UI → staging przez proxy).

194
doc/workflow-api-ingest.md Normal file
View File

@@ -0,0 +1,194 @@
# Workflow: init bazy + app stack + ingest przez API (tokeny + basic auth)
Docelowy podział na 3 composey:
1) `devops/db/docker-compose.yml`**Postgres/TimescaleDB + Hasura + pgAdmin** (DB stack)
2) `devops/tools/bootstrap/docker-compose.yml`**one-shot tools** (init SQL + Hasura metadata)
3) `devops/app/docker-compose.yml`**API + frontend (+ opcjonalny ingestor)** (app stack)
## TL;DR (local, end-to-end)
1) `npm install`
2) DB: `docker compose -f devops/db/docker-compose.yml up -d`
3) Schema/metadata: `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`
4) Tokeny: skopiuj `tokens/*.example.json``tokens/*.json` + ustaw RPC w `tokens/heliusN.json` (albo po prostu `cp tokens/helius.json tokens/heliusN.json`)
5) API: `docker compose -f devops/app/docker-compose.yml up -d api` + `curl -sS http://localhost:8787/healthz`
6) Tokeny (revocable): `npm run token:api -- --scopes write --out tokens/alg.json` + `npm run token:api -- --scopes read --out tokens/read.json`
7) Frontend: `docker compose -f devops/app/docker-compose.yml up -d frontend`
8) Ingestor: `docker compose -f devops/app/docker-compose.yml --profile ingest up -d`
9) DLOB worker (Hasura stats/subscriptions): `docker compose -f devops/app/docker-compose.yml --profile dlob up -d dlob-worker`
Uwaga: ceny w `drift_ticks``NUMERIC` (Hasura zwykle zwraca/oczekuje stringów dla `numeric`); `trade-api` normalizuje je do `number` w `/v1/chart`.
## DLOB worker (Drift DLOB → Hasura) + GraphQL subscriptions
Cel: UI dostaje “global stats” (best bid/ask/spread/depth) przez **GraphQL + WS** (Hasura subscriptions), bez wystawiania sekretów do przeglądarki.
- Worker: `dlob-worker` pobiera snapshoty orderbooka z `https://dlob.drift.trade/l2` i upsertuje do Hasury.
- Tabele:
- `dlob_l2_latest` (raw L2 snapshot)
- `dlob_stats_latest` (wyliczone statystyki)
- UI: subskrybuje `dlob_stats_latest` po `/graphql` (proxy w `trade-frontend` obsługuje też WS upgrade).
Start lokalnie:
```bash
docker compose -f devops/app/docker-compose.yml --profile dlob up -d --build dlob-worker
```
Opcjonalne env:
- `DLOB_MARKETS=PUMP-PERP,SOL-PERP,BONK-PERP,BTC-PERP,ETH-PERP`
- `DLOB_POLL_MS=500`
- `DLOB_DEPTH=10`
Uwaga: UI nie potrzebuje sekretów Hasury, bo w `devops/db/docker-compose.yml` jest `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`, a `hasura-bootstrap` nadaje roli `public` tylko `select` na tabelach DLOB.
## Migracja (bez kasowania danych)
Szybka sekwencja do migracji schematu + odpalenia stacka (bez `down -v`) jest w `r1.txt`.
## Wersjonowanie API/UI (v2, v3...) bez ruszania DB/Hasury
Cel: uruchamiasz nową wersję `api+frontend(+ingestor)` równolegle, na **nowych tabelach**, na **nowych portach**, a potem robisz cutover ingestora.
Konwencja:
- v1: tabela `drift_ticks`, funkcja `get_drift_candles`, porty `8787/8081`
- vN: tabela `drift_ticks_vN`, funkcja `get_drift_candles_vN`, porty `8787+(N-1)` i `8081+(N-1)`
- wspólne: Postgres/Hasura/pgAdmin + `api_tokens` (tokeny) zostają bez wersjonowania
Skrypty: `scripts/ops/` (tworzą też env w `devops/versions/vN.env`)
- `bash scripts/ops/version-init.sh 2` migracja DB + create `drift_ticks_v2` + track w Hasurze
- `bash scripts/ops/version-up.sh 2` start `api+frontend` v2 (host: `8788/8082`)
- `bash scripts/ops/version-ingestor-up.sh 2` start ingestor v2 (pisze do v2 API → `drift_ticks_v2`)
- `bash scripts/ops/version-cutover.sh 1 2` start v2 ingestor, potem stop v1 ingestor
- `bash scripts/ops/version-backfill.sh 1 2` backfill ALL: `drift_ticks``drift_ticks_v2`
- `bash scripts/ops/version-down.sh 1` usuń stare kontenery v1 (DB bez zmian)
- `bash scripts/ops/version-check.sh 2` healthz/ticks/chart dla v2
## Reset (UWAGA: kasuje dane z bazy)
Jeśli uruchomisz `docker compose -f devops/db/docker-compose.yml down -v`, Postgres/Timescale traci cały wolumen (czyli rekordy z `drift_ticks`).
## Dane logowania (dev)
- Postgres: `postgres://admin:pass@localhost:5432/crypto`
- Hasura: `http://localhost:8080` (admin secret: `tokens/hasura.json``adminSecret`)
- pgAdmin: `http://localhost:5050` (login: `admin@example.com`, hasło: `admin`)
- Frontend UI: `http://localhost:8081` (basic auth: `tokens/frontend.json``username`/`password`)
## 0) Start DB stack
Z root repo:
```bash
docker compose -f devops/db/docker-compose.yml up -d
```
Ten krok tworzy wspólną sieć Dockera `trade-net`, przez którą app stack łączy się do Hasury.
## 1) Init / upgrade schematu bazy (idempotent)
Użyj zwłaszcza, jeśli masz istniejący wolumen Postgresa:
```bash
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init
```
To jest też krok, który trzeba odpalić po zmianach w `devops/db/initdb/001_init.sql` (np. migracja `DOUBLE PRECISION``NUMERIC`).
## 2) Hasura metadata bootstrap (track tabel + permissions)
```bash
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap
```
## 3) Skonfiguruj API + frontend
Utwórz lokalne pliki (gitignored) w `tokens/`:
- `tokens/hasura.json` z `tokens/hasura.example.json` (URL + admin secret dla Hasury, używane przez `trade-api`)
- `tokens/api.json` z `tokens/api.example.json` (sekret admina do tworzenia/revoke tokenów API)
- `tokens/frontend.json` z `tokens/frontend.example.json` (basic auth do UI)
- `tokens/heliusN.json` (RPC dla Drift; albo `heliusApiKey`, albo pełny `rpcUrl`)
Jeśli odpalasz lokalne skrypty (`npm run token:*`, `npm run ingest:*`) wykonaj też:
```bash
npm install
```
## 4) Start API (trade-api)
```bash
docker compose -f devops/app/docker-compose.yml up -d api
```
- API: `http://localhost:8787`
Healthcheck:
```bash
curl -sS http://localhost:8787/healthz
```
## 5) Wygeneruj tokeny (revocable)
**Write token** dla ingestora (domyślnie zapisuje do `tokens/alg.json`):
```bash
npm run token:api -- --name algo1 --scopes write --out tokens/alg.json
```
Jeśli wcześniej uruchomiłeś `frontend` bez pliku `tokens/read.json`, Docker mógł utworzyć katalog `tokens/read.json`.
Usuń go przed generacją tokena:
```bash
rm -rf tokens/read.json
```
**Read token** dla frontendu (proxy wstrzykuje go serwerowo; nie trafia do JS):
```bash
npm run token:api -- --name reader-ui --scopes read --out tokens/read.json
```
## 6) Start frontend (UI)
```bash
docker compose -f devops/app/docker-compose.yml up -d frontend
```
- Frontend: `http://localhost:8081` (basic auth)
Dev mode (Vite + proxy `/api` z tokenem z `tokens/read.json`):
```bash
npm run visualizer:dev
```
- Dev UI: `http://localhost:5173`
## 7) Uruchom kontener ingestora, który wysyła ticki do API
W ramach app stack (profil `ingest`):
```bash
docker compose -f devops/app/docker-compose.yml --profile ingest up -d
```
Alternatywnie (bez Dockera) możesz odpalić ingest lokalnie:
```bash
npm run ingest:oracle -- --ingest-via api --market-name PUMP-PERP --interval-ms 1000 --source drift_oracle
```
Wymaga: `tokens/heliusN.json` (RPC) + `tokens/alg.json` (write token do API) + działającego `trade-api`.
Override (przykład):
```bash
MARKET_NAME=PUMP-PERP INTERVAL_MS=250 SOURCE=drift_oracle \
docker compose -f devops/app/docker-compose.yml --profile ingest up -d
```
## 8) Sprawdź, czy rekordy wpadają
Przez API (wymaga read tokena):
```bash
curl -sS "http://localhost:8787/v1/ticks?symbol=PUMP-PERP&limit=5" \
-H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"
```
W UI: `http://localhost:8081` → wykres powinien się aktualizować.
Sprawdź agregacje + wskaźniki (candles + indicators z backendu):
```bash
curl -sS "http://localhost:8787/v1/chart?symbol=PUMP-PERP&tf=1m&limit=120" \
-H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"
```
## Checklist (copy/paste)
- `npm install` zainstaluj zależności do `npm run token:*` / `npm run ingest:*`
- `cp tokens/hasura.example.json tokens/hasura.json` skopiuj konfigurację Hasury
- `cp tokens/api.example.json tokens/api.json` skopiuj konfigurację API (adminSecret)
- `cp tokens/frontend.example.json tokens/frontend.json` skopiuj basic auth do UI
- `cp tokens/helius.json tokens/heliusN.json` ustaw RPC dla Drift (fallback jest też `tokens/helius.json`)
- `docker compose -f devops/db/docker-compose.yml up -d` uruchom Postgres/Timescale + Hasura + pgAdmin
- `docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init` zastosuj schemat PG (tabele/indeksy/hypertable)
- `docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap` track tabel + permissions w Hasurze
- `docker compose -f devops/app/docker-compose.yml up -d api` uruchom API
- `curl -sS http://localhost:8787/healthz` sprawdź czy API działa
- `npm run token:api -- --name algo1 --scopes write --out tokens/alg.json` wygeneruj write token dla ingestora
- `npm run token:api -- --name reader-ui --scopes read --out tokens/read.json` wygeneruj read token dla UI/curl
- `docker compose -f devops/app/docker-compose.yml up -d frontend` uruchom frontend
- `docker compose -f devops/app/docker-compose.yml --profile ingest up -d` uruchom ingestora (Drift → API → DB)
- `curl -sS "http://localhost:8787/v1/ticks?symbol=PUMP-PERP&limit=5" -H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"` sprawdź czy ticki wpadają
- `curl -sS "http://localhost:8787/v1/chart?symbol=PUMP-PERP&tf=1m&limit=120" -H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"` sprawdź candles + wskaźniki z backendu

99
doc/workflow.md Normal file
View File

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

View File

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