feat(visualizer): brick-stack flow bars + DLOB dashboard

This commit is contained in:
u1
2026-01-10 23:15:04 +01:00
parent fa79340cf5
commit f76045a9a5
26 changed files with 2854 additions and 86 deletions

View File

@@ -5,10 +5,11 @@ VITE_API_URL=/api
# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
VITE_HASURA_URL=/graphql
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
# VITE_HASURA_WS_URL=wss://YOUR_HOST/graphql
# 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_SYMBOL=PUMP-PERP
VITE_SYMBOL=SOL-PERP
# Optional: filter by source (leave empty for all)
# VITE_SOURCE=drift_oracle
VITE_POLL_MS=1000

View File

@@ -1 +1,24 @@
API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl npm run dev
#!/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 { useLocalStorageState } from './app/hooks/useLocalStorageState';
import AppShell from './layout/AppShell';
@@ -12,6 +13,10 @@ import TopNav from './layout/TopNav';
import AuthStatus from './layout/AuthStatus';
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 {
const v = (import.meta as any).env?.[name];
@@ -37,6 +42,11 @@ function formatQty(v: number | null | undefined, decimals: number): string {
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
function orderbookBarStyle(scale: number): CSSProperties {
const s = Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
return { ['--ob-bar-scale' as any]: s } as CSSProperties;
}
type WhoamiResponse = {
ok?: boolean;
user?: string | null;
@@ -100,9 +110,9 @@ export default function App() {
}
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BONK-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 [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
@@ -111,7 +121,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false);
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
const [bottomTab, setBottomTab] = useLocalStorageState<
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
>('trade.bottomTab', 'positions');
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
@@ -121,6 +131,16 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
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,
source: source.trim() ? source : undefined,
@@ -130,6 +150,9 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
});
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 14 });
const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol);
const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol);
const latest = candles.length ? candles[candles.length - 1] : null;
const first = candles.length ? candles[0] : null;
@@ -137,6 +160,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null;
const orderbook = useMemo(() => {
if (dlobL2) return { asks: dlobL2.asks, bids: dlobL2.bids, mid: dlobL2.mid as number | null };
if (!latest) return { asks: [], bids: [], mid: null as number | null };
const mid = latest.close;
const step = Math.max(mid * 0.00018, 0.0001);
@@ -167,7 +191,19 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
});
return { asks, bids, mid };
}, [latest]);
}, [dlobL2, latest]);
const maxAskTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.asks) max = Math.max(max, r.total || 0);
return max;
}, [orderbook.asks]);
const maxBidTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.bids) max = Math.max(max, r.total || 0);
return max;
}, [orderbook.bids]);
const trades = useMemo(() => {
const slice = candles.slice(-24).reverse();
@@ -187,6 +223,24 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
return latest?.close ?? 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(
() => [
{ key: 'BTC', label: 'BTC', changePct: 1.28, active: false },
@@ -226,8 +280,14 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
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, dlob, dlobConnected, dlobError]);
}, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error]);
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
@@ -305,11 +365,30 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
showBuild={showBuild}
onToggleBuild={() => setShowBuild((v) => !v)}
seriesLabel={seriesLabel}
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
/>
<Card className="bottomCard">
<Tabs
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: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
@@ -336,7 +415,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
title={
<div className="sideHead">
<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>
}
>
@@ -354,18 +433,26 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</div>
<div className="orderbook__rows">
{orderbook.asks.map((r) => (
<div key={`a-${r.price}`} className="orderbookRow orderbookRow--ask">
<div
key={`a-${r.price}`}
className="orderbookRow orderbookRow--ask"
style={orderbookBarStyle(maxAskTotal > 0 ? r.total / maxAskTotal : 0)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
</div>
))}
<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>
</div>
{orderbook.bids.map((r) => (
<div key={`b-${r.price}`} className="orderbookRow orderbookRow--bid">
<div
key={`b-${r.price}`}
className="orderbookRow orderbookRow--bid"
style={orderbookBarStyle(maxBidTotal > 0 ? r.total / maxBidTotal : 0)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
@@ -500,7 +587,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</div>
<div className="tradeMeta__row">
<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 className="tradeMeta__row">
<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>
{drawingsLayer ? (
<div className="chartLayersRow chartLayersRow--layer">
{layers.map((layer) => (
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
<div className="chartLayersCell chartLayersCell--icon">
<IconButton
title="Toggle visible"
active={drawingsLayer.visible}
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
>
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
<IconEye />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--icon">
<IconButton
title="Toggle lock"
active={drawingsLayer.locked}
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
>
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
<IconLock />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--name">
<div className="layersName layersName--layer">
{drawingsLayer.name}
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span>
{layer.name}
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
</div>
</div>
<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 className="chartLayersCell chartLayersCell--actions" />
</div>
) : null}
))}
{drawingsLayer && fibPresent ? (
<div
@@ -212,4 +204,3 @@ export default function ChartLayersPanel({
</>
);
}

View File

@@ -6,12 +6,13 @@ import ChartSideToolbar from './ChartSideToolbar';
import ChartToolbar from './ChartToolbar';
import TradingChart from './TradingChart';
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
import type { IChartApi } from 'lightweight-charts';
import { LineStyle, type IChartApi } from 'lightweight-charts';
import type { OverlayLayer } from './ChartPanel.types';
type Props = {
candles: Candle[];
indicators: ChartIndicators;
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
timeframe: string;
bucketSeconds: number;
seriesKey: string;
@@ -45,6 +46,7 @@ function isEditableTarget(t: EventTarget | null): boolean {
export default function ChartPanel({
candles,
indicators,
dlobQuotes,
timeframe,
bucketSeconds,
seriesKey,
@@ -61,6 +63,7 @@ export default function ChartPanel({
const [fib, setFib] = useState<FibRetracement | null>(null);
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
const [layers, setLayers] = useState<OverlayLayer[]>([
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
]);
const [layersOpen, setLayersOpen] = useState(false);
@@ -196,6 +199,37 @@ export default function ChartPanel({
return Math.max(0, Math.min(1, v));
}
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
const quotesVisible = Boolean(quotesLayer?.visible);
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
const priceLines = useMemo(() => {
if (!quotesVisible) return [];
return [
{
id: 'dlob-bid',
title: 'DLOB Bid',
price: dlobQuotes?.bid ?? null,
color: `rgba(34,197,94,${quotesOpacity})`,
lineStyle: LineStyle.Dotted,
},
{
id: 'dlob-mid',
title: 'DLOB Mid',
price: dlobQuotes?.mid ?? null,
color: `rgba(230,233,239,${quotesOpacity})`,
lineStyle: LineStyle.Dashed,
},
{
id: 'dlob-ask',
title: 'DLOB Ask',
price: dlobQuotes?.ask ?? null,
color: `rgba(239,68,68,${quotesOpacity})`,
lineStyle: LineStyle.Dotted,
},
];
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]);
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
}
@@ -309,6 +343,7 @@ export default function ChartPanel({
showBuild={showBuild}
bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
priceLines={priceLines}
fib={fibRenderable}
fibOpacity={fibEffectiveOpacity}
fibSelected={fibSelected}

View File

@@ -14,7 +14,7 @@ type Props = {
onToggleFullscreen: () => void;
};
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
const timeframes = ['5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
export default function ChartToolbar({
timeframe,

View File

@@ -33,6 +33,15 @@ type Props = {
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;
fibOpacity?: number;
fibSelected?: boolean;
@@ -203,7 +212,6 @@ class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
if (!candles.length || !series || !chart) return;
const bs = resolveBucketSeconds(bucketSeconds, candles);
const rows = Math.min(60, Math.max(12, Math.floor(bs)));
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
const yBase = series.priceToCoordinate(0);
@@ -221,7 +229,7 @@ class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
if (!Number.isFinite(x)) continue;
const c = candles[i]!;
const list = samples.get(c.time);
const start = c.time;
const end = start + bs;
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
@@ -251,58 +259,159 @@ class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
if (!(yBottomPx > yTopPx)) continue;
const barHeightPx = yBottomPx - yTopPx;
const rowDirs: Array<SliceDir | null> = Array.from({ length: rows }, () => null);
const overallDir = dirForDelta(c.close - c.open);
const points: BuildSample[] = [{ t: start, v: 0 }];
if (list?.length) {
for (const p of list) {
if (!Number.isFinite(p.t) || !Number.isFinite(p.v)) continue;
const t = Math.min(end, Math.max(start, p.t));
points.push({ t, v: p.v });
}
}
const endV = c.close - c.open;
const lastPt = points[points.length - 1]!;
if (progressT > lastPt.t + 1e-3) {
points.push({ t: progressT, v: endV });
} else {
lastPt.v = endV;
}
for (let j = 1; j < points.length; j += 1) {
const a = points[j - 1]!;
const b = points[j]!;
const dir = dirForDelta(b.v - a.v);
const from = Math.max(0, Math.min(rows - 1, Math.floor(((a.t - start) / bs) * rows)));
const to = Math.max(0, Math.min(rows - 1, Math.floor(((b.t - start) / bs) * rows)));
for (let r = from; r <= to; r += 1) rowDirs[r] = dir;
}
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
for (let r = 0; r < progressRows; r += 1) {
if (rowDirs[r] == null) rowDirs[r] = overallDir;
}
const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx));
const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx));
const w = x1 - x0;
if (!(w > 0)) continue;
// 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 dir = rowDirs[r];
if (dir == null) continue;
const v = Number(rowMoves[r]);
if (Number.isFinite(v) && v > maxMove) maxMove = v;
}
}
const rowTop = yBottomPx - Math.round(((r + 1) * barHeightPx) / rows);
const rowBottom = yBottomPx - Math.round((r * barHeightPx) / rows);
const h = rowBottom - rowTop;
if (!(h > 0)) continue;
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, rowTop, w, h);
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);
}
}
});
@@ -384,6 +493,7 @@ export default function TradingChart({
showBuild,
bucketSeconds,
seriesKey,
priceLines,
fib,
fibOpacity = 1,
fibSelected = false,
@@ -412,6 +522,7 @@ export default function TradingChart({
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<{
candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>;
@@ -881,12 +992,68 @@ export default function TradingChart({
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();
chartRef.current = null;
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(() => {
const s = seriesRef.current;
if (!s.candles || !s.volume || !s.buildHover) return;

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

View File

@@ -0,0 +1,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

@@ -3,13 +3,18 @@ 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;
};
@@ -27,13 +32,18 @@ function toNum(v: unknown): number | 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;
};
@@ -62,13 +72,18 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
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
}
}
@@ -84,13 +99,18 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
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,
});
},
@@ -101,4 +121,3 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
return { stats, connected, error };
}

View File

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

View File

@@ -20,7 +20,16 @@ function resolveGraphqlHttpUrl(): string {
function resolveGraphqlWsUrl(): string {
const explicit = envString('VITE_HASURA_WS_URL');
if (explicit) return explicit;
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;
@@ -170,4 +179,3 @@ export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onSta
},
};
}

View File

@@ -18,7 +18,7 @@ function getApiUrl(): string | undefined {
}
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 {

View File

@@ -46,6 +46,10 @@ a:hover {
color: var(--neg);
}
.muted {
color: var(--muted);
}
.shell {
height: 100vh;
display: flex;
@@ -981,6 +985,226 @@ body.chartFullscreen {
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 {
padding: 10px 12px;
display: flex;
@@ -1046,6 +1270,45 @@ body.chartFullscreen {
padding: 2px 2px;
border-radius: 8px;
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;
transform: scaleX(var(--ob-bar-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__num {

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

@@ -0,0 +1,132 @@
# Serwisy DLOB na VPS (k3s / `trade-staging`)
Ten dokument opisuje rolę serwisów “DLOB” uruchomionych w namespace `trade-staging` oraz ich przepływ danych.
## 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).
### 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

View File

@@ -86,8 +86,7 @@ Przykład:
```bash
cd apps/visualizer
API_PROXY_TARGET=https://trade.mpabi.pl GRAPHQL_PROXY_TARGET=https://trade.mpabi.pl \
npm run dev
bash __start
```
Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj: