diff --git a/apps/visualizer/.env.example b/apps/visualizer/.env.example index 75c30ad..aae6f87 100644 --- a/apps/visualizer/.env.example +++ b/apps/visualizer/.env.example @@ -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 diff --git a/apps/visualizer/__start b/apps/visualizer/__start index bd85039..8b9ad5f 100644 --- a/apps/visualizer/__start +++ b/apps/visualizer/__start @@ -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 diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index bb997ca..38f842f 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -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('trade.form.price', 0); const [tradeSize, setTradeSize] = useLocalStorageState('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 ? {dlobError} : dlob?.updatedAt || '—', }, + { + key: 'l2', + label: 'L2', + value: dlobL2Connected ? 'live' : '—', + sub: dlobL2Error ? {dlobL2Error} : 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 }} /> + ), + }, { id: 'positions', label: 'Positions', content:
Positions (next)
}, { id: 'orders', label: 'Orders', content:
Orders (next)
}, { id: 'balances', label: 'Balances', content:
Balances (next)
}, @@ -336,7 +415,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { title={
Orderbook
-
{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}
+
{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}
} > @@ -354,18 +433,26 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
{orderbook.asks.map((r) => ( -
+
0 ? r.total / maxAskTotal : 0)} + > {formatQty(r.price, 3)} {formatQty(r.size, 2)} {formatQty(r.total, 2)}
))}
- {latest ? formatQty(latest.close, 3) : '—'} + {formatQty(orderbook.mid, 3)} mid
{orderbook.bids.map((r) => ( -
+
0 ? r.total / maxBidTotal : 0)} + > {formatQty(r.price, 3)} {formatQty(r.size, 2)} {formatQty(r.total, 2)} @@ -500,7 +587,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
Slippage (Dynamic) - + + {slippageError ? ( + {slippageError} + ) : dynamicSlippage?.impactBps == null ? ( + slippageConnected ? ( + '—' + ) : ( + 'offline' + ) + ) : ( + <> + {dynamicSlippage.impactBps.toFixed(1)} bps{' '} + + ({dynamicSlippage.sizeUsd.toLocaleString()} USD) + {dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9 + ? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill` + : ''} + + + )} +
Margin Required diff --git a/apps/visualizer/src/features/chart/ChartLayersPanel.tsx b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx index e4569f4..3942e45 100644 --- a/apps/visualizer/src/features/chart/ChartLayersPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx @@ -142,38 +142,30 @@ export default function ChartLayersPanel({
Actions
- {drawingsLayer ? ( -
+ {layers.map((layer) => ( +
- onToggleLayerVisible(drawingsLayer.id)} - > + onToggleLayerVisible(layer.id)}>
- onToggleLayerLocked(drawingsLayer.id)} - > + onToggleLayerLocked(layer.id)}>
- {drawingsLayer.name} - {fibPresent ? ' (1)' : ' (0)'} + {layer.name} + {layer.id === 'drawings' ? {fibPresent ? ' (1)' : ' (0)'} : null}
- onSetLayerOpacity(drawingsLayer.id, next)} /> + onSetLayerOpacity(layer.id, next)} />
- ) : null} + ))} {drawingsLayer && fibPresent ? (
); } - diff --git a/apps/visualizer/src/features/chart/ChartPanel.tsx b/apps/visualizer/src/features/chart/ChartPanel.tsx index 02b5557..f06a677 100644 --- a/apps/visualizer/src/features/chart/ChartPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartPanel.tsx @@ -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(null); const [fibDraft, setFibDraft] = useState(null); const [layers, setLayers] = useState([ + { 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) { 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} diff --git a/apps/visualizer/src/features/chart/ChartToolbar.tsx b/apps/visualizer/src/features/chart/ChartToolbar.tsx index 638dc76..6e9328a 100644 --- a/apps/visualizer/src/features/chart/ChartToolbar.tsx +++ b/apps/visualizer/src/features/chart/ChartToolbar.tsx @@ -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, diff --git a/apps/visualizer/src/features/chart/TradingChart.tsx b/apps/visualizer/src/features/chart/TradingChart.tsx index d05e355..466cbe2 100644 --- a/apps/visualizer/src/features/chart/TradingChart.tsx +++ b/apps/visualizer/src/features/chart/TradingChart.tsx @@ -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 = Array.from({ length: rows }, () => null); - const overallDir = dirForDelta(c.close - c.open); - - const points: BuildSample[] = [{ t: start, v: 0 }]; - if (list?.length) { - for (const p of list) { - if (!Number.isFinite(p.t) || !Number.isFinite(p.v)) continue; - const t = Math.min(end, Math.max(start, p.t)); - points.push({ t, v: p.v }); - } - } - - const endV = c.close - c.open; - const lastPt = points[points.length - 1]!; - if (progressT > lastPt.t + 1e-3) { - points.push({ t: progressT, v: endV }); - } else { - lastPt.v = endV; - } - - for (let j = 1; j < points.length; j += 1) { - const a = points[j - 1]!; - const b = points[j]!; - const dir = dirForDelta(b.v - a.v); - - const from = Math.max(0, Math.min(rows - 1, Math.floor(((a.t - start) / bs) * rows))); - const to = Math.max(0, Math.min(rows - 1, Math.floor(((b.t - start) / bs) * rows))); - - for (let r = from; r <= to; r += 1) rowDirs[r] = dir; - } - - const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows))); - for (let r = 0; r < progressRows; r += 1) { - if (rowDirs[r] == null) rowDirs[r] = overallDir; - } - const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx)); const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx)); const w = x1 - x0; if (!(w > 0)) continue; - for (let r = 0; r < progressRows; r += 1) { - const dir = rowDirs[r]; - if (dir == null) continue; + // 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; - 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; + if (rowDirs) { + const rows = Math.max(1, rowDirs.length); + const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows))); + const movesFromApi = Array.isArray((c as any).flowMoves) ? ((c as any).flowMoves as any[]) : null; + const rowMoves = movesFromApi && movesFromApi.length === rows ? movesFromApi : null; + let maxMove = 0; + if (rowMoves) { + for (let r = 0; r < progressRows; r += 1) { + const v = Number(rowMoves[r]); + if (Number.isFinite(v) && v > maxMove) maxMove = v; + } + } - context.fillStyle = sliceColorForDelta(dir); - context.fillRect(x0, rowTop, w, h); + const sepPx = 1; // black separator line between steps + const sepColor = 'rgba(0,0,0,0.75)'; + + // Blue (flat) bricks have a constant pixel height (unit). + // Up/down bricks are scaled by |Δ| in their dt. + const minNonFlatPx = 1; + let unitFlatPx = 2; + + let flatCount = 0; + let nonFlatCount = 0; + for (let r = 0; r < progressRows; r += 1) { + const dirRaw = rowDirs[r]; + const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0; + if (dir === 0) flatCount += 1; + else nonFlatCount += 1; + } + + const sepTotal = Math.max(0, progressRows - 1) * sepPx; + if (flatCount > 0) { + const maxUnit = Math.floor((barHeightPx - sepTotal - nonFlatCount * minNonFlatPx) / flatCount); + unitFlatPx = Math.max(1, Math.min(unitFlatPx, maxUnit)); + } else { + unitFlatPx = 0; + } + + let nonFlatAvailable = barHeightPx - sepTotal - flatCount * unitFlatPx; + if (!Number.isFinite(nonFlatAvailable) || nonFlatAvailable < 0) nonFlatAvailable = 0; + + const baseNonFlat = nonFlatCount * minNonFlatPx; + let extra = nonFlatAvailable - baseNonFlat; + if (!Number.isFinite(extra) || extra < 0) extra = 0; + + let sumMove = 0; + if (nonFlatCount > 0) { + for (let r = 0; r < progressRows; r += 1) { + const dirRaw = rowDirs[r]; + const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0; + if (dir === 0) continue; + const mvRaw = rowMoves ? Number(rowMoves[r]) : 0; + const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0; + sumMove += mv; + } + } + + if (!(flatCount + nonFlatCount > 0) || barHeightPx <= sepTotal) { + continue; + } + + // Stack bricks bottom-up (earliest slices at the bottom). + let usedExtra = 0; + let nonFlatSeen = 0; + let y = yBottomPx; + for (let r = 0; r < progressRows; r += 1) { + const dirRaw = rowDirs[r]; + const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0; + const isLast = r === progressRows - 1; + + let h = 0; + if (dir === 0) { + h = unitFlatPx; + } else { + nonFlatSeen += 1; + const mvRaw = rowMoves ? Number(rowMoves[r]) : 0; + const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0; + const share = sumMove > 0 ? mv / sumMove : 1 / nonFlatCount; + const wantExtra = Math.floor(extra * share); + const isLastNonFlat = nonFlatSeen === nonFlatCount; + const add = isLastNonFlat ? Math.max(0, extra - usedExtra) : Math.max(0, wantExtra); + usedExtra += add; + h = minNonFlatPx + add; + } + + if (h <= 0) continue; + y -= h; + context.fillStyle = sliceColorForDelta(dir); + context.fillRect(x0, y, w, h); + + if (!isLast) { + y -= sepPx; + context.fillStyle = sepColor; + context.fillRect(x0, y, w, sepPx); + } + } + continue; + } + + const f: any = (c as any).flow; + let upShare = typeof f?.up === 'number' ? f.up : Number(f?.up); + let downShare = typeof f?.down === 'number' ? f.down : Number(f?.down); + let flatShare = typeof f?.flat === 'number' ? f.flat : Number(f?.flat); + + if (!Number.isFinite(upShare)) upShare = 0; + if (!Number.isFinite(downShare)) downShare = 0; + if (!Number.isFinite(flatShare)) flatShare = 0; + upShare = Math.max(0, upShare); + downShare = Math.max(0, downShare); + flatShare = Math.max(0, flatShare); + + let sum = upShare + downShare + flatShare; + if (!(sum > 0)) { + const overallDir = dirForDelta(c.close - c.open); + upShare = overallDir > 0 ? 1 : 0; + downShare = overallDir < 0 ? 1 : 0; + flatShare = overallDir === 0 ? 1 : 0; + sum = 1; + } + + upShare /= sum; + downShare /= sum; + flatShare /= sum; + + const downH = Math.floor(barHeightPx * downShare); + const flatH = Math.floor(barHeightPx * flatShare); + const upH = Math.max(0, barHeightPx - downH - flatH); + + let y = yBottomPx; + + if (downH > 0) { + context.fillStyle = sliceColorForDelta(-1); + context.fillRect(x0, y - downH, w, downH); + y -= downH; + } + if (flatH > 0) { + context.fillStyle = sliceColorForDelta(0); + context.fillRect(x0, y - flatH, w, flatH); + y -= flatH; + } + if (upH > 0) { + context.fillStyle = sliceColorForDelta(1); + context.fillRect(x0, y - upH, w, upH); } } }); @@ -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(null); const hoverCandleTimeRef = useRef(null); const [hoverCandleTime, setHoverCandleTime] = useState(null); + const priceLinesRef = useRef>(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; diff --git a/apps/visualizer/src/features/market/DlobDashboard.tsx b/apps/visualizer/src/features/market/DlobDashboard.tsx new file mode 100644 index 0000000..6d85cfd --- /dev/null +++ b/apps/visualizer/src/features/market/DlobDashboard.tsx @@ -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 {error}; + return connected ? live : offline; +} + +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 ( +
+
+
DLOB
+
+ {market} + {updatedAt ? `updated ${updatedAt}` : '—'} +
+
+ +
+
+ stats + {statusLabel(statsConnected, statsError)} +
+
+ depth bands + {statusLabel(depthBandsConnected, depthBandsError)} +
+
+ slippage + {statusLabel(slippageConnected, slippageError)} +
+
+ +
+
+
Bid
+
{formatUsd(stats?.bestBid ?? null)}
+
+
+
Ask
+
{formatUsd(stats?.bestAsk ?? null)}
+
+
+
Mid
+
{formatUsd(stats?.mid ?? null)}
+
+
+
Spread
+
{formatBps(stats?.spreadBps ?? null)}
+
{formatUsd(stats?.spreadAbs ?? null)}
+
+
+
Depth (bid/ask)
+
+ {formatUsd(stats?.depthBidUsd ?? null)}{' '} + / {formatUsd(stats?.depthAskUsd ?? null)} +
+
+
+
Imbalance
+
{formatPct(stats?.imbalance ?? null)}
+
[-1..1]
+
+
+ +
+
+ +
+ +
+
+
+
Slippage (impact bps)
+
by size (USD)
+
+ {slippageRows.length ? ( +
+ +
+ ) : ( +
No slippage rows yet.
+ )} +
+
+
+
+ ); +} + diff --git a/apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx b/apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx new file mode 100644 index 0000000..6b0d27b --- /dev/null +++ b/apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx @@ -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 ( +
+
+
Depth (bands)
+
±bps around mid
+
+ +
+
+ Band + Ask USD + Bid USD + Bid % +
+ + {sorted.length ? ( + sorted.map((r) => ( +
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 ?? '—'}`} + > + {r.bandBps} bps + {formatUsd(r.askUsd)} + {formatUsd(r.bidUsd)} + + {r.imbalance == null || !Number.isFinite(r.imbalance) + ? '—' + : formatPct(((r.imbalance + 1) / 2) * 100)} + +
+ )) + ) : ( +
No depth band rows yet.
+ )} +
+
+ ); +} diff --git a/apps/visualizer/src/features/market/DlobSlippageChart.tsx b/apps/visualizer/src/features/market/DlobSlippageChart.tsx new file mode 100644 index 0000000..64c9bd0 --- /dev/null +++ b/apps/visualizer/src/features/market/DlobSlippageChart.tsx @@ -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(null); + const chartRef = useRef(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 ; +} + diff --git a/apps/visualizer/src/features/market/useDlobDepthBands.ts b/apps/visualizer/src/features/market/useDlobDepthBands.ts new file mode 100644 index 0000000..665f1fb --- /dev/null +++ b/apps/visualizer/src/features/market/useDlobDepthBands.ts @@ -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([]); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(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({ + 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 }; +} + diff --git a/apps/visualizer/src/features/market/useDlobL2.ts b/apps/visualizer/src/features/market/useDlobL2.ts new file mode 100644 index 0000000..e981faa --- /dev/null +++ b/apps/visualizer/src/features/market/useDlobL2.ts @@ -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(null); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(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({ + 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 }; +} + diff --git a/apps/visualizer/src/features/market/useDlobSlippage.ts b/apps/visualizer/src/features/market/useDlobSlippage.ts new file mode 100644 index 0000000..3bc17fd --- /dev/null +++ b/apps/visualizer/src/features/market/useDlobSlippage.ts @@ -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([]); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(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({ + 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 }; +} + diff --git a/apps/visualizer/src/features/market/useDlobStats.ts b/apps/visualizer/src/features/market/useDlobStats.ts index 0611e53..dbce7ff 100644 --- a/apps/visualizer/src/features/market/useDlobStats.ts +++ b/apps/visualizer/src/features/market/useDlobStats.ts @@ -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 }; } - diff --git a/apps/visualizer/src/lib/api.ts b/apps/visualizer/src/lib/api.ts index ff220f2..87d16a4 100644 --- a/apps/visualizer/src/lib/api.ts +++ b/apps/visualizer/src/lib/api.ts @@ -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) }, }; } - diff --git a/apps/visualizer/src/lib/graphqlWs.ts b/apps/visualizer/src/lib/graphqlWs.ts index 36be894..fa5a126 100644 --- a/apps/visualizer/src/lib/graphqlWs.ts +++ b/apps/visualizer/src/lib/graphqlWs.ts @@ -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({ query, variables, onData, onError, onSta }, }; } - diff --git a/apps/visualizer/src/lib/hasura.ts b/apps/visualizer/src/lib/hasura.ts index e947f3a..bf6431a 100644 --- a/apps/visualizer/src/lib/hasura.ts +++ b/apps/visualizer/src/lib/hasura.ts @@ -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 { diff --git a/apps/visualizer/src/styles.css b/apps/visualizer/src/styles.css index 844963c..f8f1ecc 100644 --- a/apps/visualizer/src/styles.css +++ b/apps/visualizer/src/styles.css @@ -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 { diff --git a/doc/dlob-services.md b/doc/dlob-services.md new file mode 100644 index 0000000..1eb4af8 --- /dev/null +++ b/doc/dlob-services.md @@ -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 (top‑N) z każdej strony braliśmy do liczenia “depth”. +- `depth_bid_base` / `depth_ask_base`: suma `size` po top‑N levelach bid/ask (w base). +- `depth_bid_usd` / `depth_ask_usd`: suma `size_base * price` po top‑N levelach (w USD). +- `imbalance` ([-1..1]): miara asymetrii płynności: + - `(depth_bid_usd - depth_ask_usd) / (depth_bid_usd + depth_ask_usd)` + - >0 = relatywnie więcej płynności po bid, <0 = po ask. +- `oracle_price`: cena z oracla (np. Pyth) jako punkt odniesienia. +- `mark_price`: “mark” z rynku/perp (cena referencyjna dla rozliczeń); różni się od oracle/top-of-book. + +### Metryki “depth bands” (np. `dlob_depth_bps_latest`) + +- `band_bps`: szerokość pasma wokół `mid_price` (np. 5/10/20/50/100/200 bps). +- `bid_usd` / `ask_usd`: płynność po danej stronie, ale **tylko z poziomów mieszczących się w oknie ±`band_bps`** wokół mid. +- `imbalance`: jak wyżej, ale liczony per band. + +### Metryki “slippage” (np. `dlob_slippage_latest`) + +To jest symulacja “gdybym teraz zrobił market order o rozmiarze X” na podstawie L2. + +- `size_usd`: docelowy rozmiar zlecenia w USD. +- `vwap_price`: średnia cena realizacji (Volume Weighted Average Price) dla symulowanego fill. +- `impact_bps`: koszt/odchylenie względem `mid_price` wyrażone w bps (zwykle na bazie `vwap` vs `mid`). +- `worst_price`: najgorsza cena dotknięta podczas “zjadania” kolejnych leveli. +- `filled_usd` / `filled_base`: ile realnie udało się wypełnić (może być < docelowego, jeśli brakuje płynności). +- `fill_pct`: procent wypełnienia (100% = pełny fill). +- `levels_consumed`: ile leveli zostało “zjedzonych” podczas fill. + +### Metadane czasu (“świeżość”) + +- `ts`: timestamp źródła (czas snapshotu). +- `slot`: slot Solany, z którego pochodzi snapshot (monotoniczny “numer czasu” chaina). +- `updated_at`: kiedy nasz worker zapisał/odświeżył rekord w DB (do oceny, czy dane są świeże). + +## Szybka diagnostyka na VPS + +```bash +KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy | grep -E 'dlob-(worker|slippage-worker|depth-worker|publisher|server|redis)' +KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-worker --tail=80 +KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-publisher --tail=80 +KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-server --tail=80 +``` + +## Ważna uwaga (źródło L2 w `dlob-worker`) + +Jeśli chcesz, żeby `dlob-worker` polegał na **naszym** stacku (własny RPC + `dlob-publisher` + `dlob-server`), ustaw: + +- `DLOB_HTTP_URL=http://dlob-server:6969` + +Aktualnie w `trade-staging` jest ustawione: + +- `DLOB_HTTP_URL=https://dlob.drift.trade` diff --git a/doc/gitea-k3s-rv32i.md b/doc/gitea-k3s-rv32i.md new file mode 100644 index 0000000..d6cc41d --- /dev/null +++ b/doc/gitea-k3s-rv32i.md @@ -0,0 +1,150 @@ +# Gitea na k3s (Traefik + cert-manager + Let’s Encrypt) dla `rv32i.pl` + +Ten dokument instaluje Gitea w k3s: +- Ingress: Traefik +- TLS: cert-manager + Let’s 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 (Let’s 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). + diff --git a/doc/k8s-migracja.md b/doc/k8s-migracja.md new file mode 100644 index 0000000..f7573a6 --- /dev/null +++ b/doc/k8s-migracja.md @@ -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ęść release’u) + +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-` 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? + diff --git a/doc/migration.md b/doc/migration.md new file mode 100644 index 0000000..640bba2 --- /dev/null +++ b/doc/migration.md @@ -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` (Let’s 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 + Let’s 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-` (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) Namespace’y (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ć snowflake’a). Każdy deploy ma być **snapshoot’em**, do którego można wrócić: *git commit w `trade-deploy` + pin do obrazu* (`sha-` 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 snapshoot’a. + +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 release’y Helm (np. `trade-v1`, `trade-v2`) albo osobne namespace’y +- 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:` + - `rv32i.pl/trade/trade-ingestor:` + - `rv32i.pl/trade/trade-frontend:` +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). diff --git a/doc/stats.md b/doc/stats.md new file mode 100644 index 0000000..46796ff --- /dev/null +++ b/doc/stats.md @@ -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 top‑N 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 top‑of‑book (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), mikro‑ceny, 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 top‑N levelach. +- `depth_bid_usd`, `depth_ask_usd` (USD): suma `size_base * price` po top‑N 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 “top‑N 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 slippage‑vs‑size. + +## 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` (top‑of‑book). +- 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ść top‑N + 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 oś, zakres [-1..1]) albo jako wskaźnik liczbowy. + +Efekt: ile jest płynności “najbliżej” top‑of‑book (w definicji top‑N 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 / multi‑line: + - 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. 0–10, 10–20, 20–50 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`. + +- Oś X: `size_usd`. +- Oś 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` są ś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 time‑series (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 2–5 mogą być prawdziwymi wykresami “w czasie”, a nie tylko bieżącym odczytem. + diff --git a/doc/steps.md b/doc/steps.md new file mode 100644 index 0000000..5f7f011 --- /dev/null +++ b/doc/steps.md @@ -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 proxy’uje 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). diff --git a/doc/workflow-api-ingest.md b/doc/workflow-api-ingest.md new file mode 100644 index 0000000..2841109 --- /dev/null +++ b/doc/workflow-api-ingest.md @@ -0,0 +1,194 @@ +# Workflow: init bazy + app stack + ingest przez API (tokeny + basic auth) + +Docelowy podział na 3 compose’y: +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` są `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 diff --git a/doc/workflow.md b/doc/workflow.md index f180354..2e3a7bf 100644 --- a/doc/workflow.md +++ b/doc/workflow.md @@ -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: