3 Commits

Author SHA1 Message Date
u1
fc26e8eac9 docs(todo): add staging + visualizer next steps 2026-02-01 22:20:41 +01:00
u1
b06fe7f9a4 docs: add rpc/dlob/candles notes 2026-02-01 21:44:45 +01:00
u1
89415f6793 feat(visualizer): add layers + fast timeframe switching 2026-02-01 21:44:29 +01:00
32 changed files with 4852 additions and 127 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ tokens/*
!tokens/*.example.json
!tokens/*.example.yml
!tokens/*.example.yaml
gitea/token
node_modules/
dist/

View File

@@ -6,19 +6,17 @@ 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}"
DEFAULT_PROXY_TARGET="${VISUALIZER_PROXY_TARGET:-${TRADE_UI_URL:-${TRADE_VPS_URL:-https://trade.mpabi.pl}}}"
export API_PROXY_TARGET="${API_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
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
# Safety: avoid passing stale auth env vars into Hasura WS unless explicitly enabled.
if [[ "${VISUALIZER_USE_HASURA_AUTH:-}" != "1" ]]; then
unset VITE_HASURA_AUTH_TOKEN
unset VITE_HASURA_ADMIN_SECRET
fi
npm run dev

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
import AppShell from './layout/AppShell';
import ChartPanel from './features/chart/ChartPanel';
@@ -17,6 +17,78 @@ import { useDlobL2 } from './features/market/useDlobL2';
import { useDlobSlippage } from './features/market/useDlobSlippage';
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
import DlobDashboard from './features/market/DlobDashboard';
import ContractCostsPanel from './features/contracts/ContractCostsPanel';
type PaneId = 'chart' | 'dlob' | 'costsActive' | 'costsNew';
// Order matters: missing panes are appended in this order; last is default "top".
const ALL_PANES: PaneId[] = ['chart', 'dlob', 'costsActive', 'costsNew'];
function makePaneRecord<T>(factory: (id: PaneId) => T): Record<PaneId, T> {
const out: any = {};
for (const id of ALL_PANES) out[id] = factory(id);
return out as Record<PaneId, T>;
}
function normalizePaneOrder(raw: unknown): PaneId[] {
const out: PaneId[] = [];
const arr = Array.isArray(raw) ? raw : [];
for (const v of arr) {
if (v === 'chart' || v === 'dlob' || v === 'costsActive' || v === 'costsNew') {
if (!out.includes(v)) out.push(v);
}
}
for (const id of ALL_PANES) {
if (!out.includes(id)) out.push(id);
}
return out;
}
function normalizeLayerOpacity(raw: unknown): Record<PaneId, number> {
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
const clamp = (v: unknown, fallback: number) => {
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(1, Math.max(0, n));
};
return makePaneRecord((id) => clamp((input as any)[id], 1));
}
function normalizeLayerToggle(raw: unknown, fallback: boolean): Record<PaneId, boolean> {
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
const toBool = (v: unknown) => (typeof v === 'boolean' ? v : fallback);
return makePaneRecord((id) => toBool((input as any)[id]));
}
function normalizeLayerFactor(raw: unknown, fallback: number, min: number, max: number): Record<PaneId, number> {
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
const clamp = (v: unknown) => {
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
};
return makePaneRecord((id) => clamp((input as any)[id]));
}
function reorderList<T>(items: T[], from: T, to: T): T[] {
if (from === to) return items.slice();
const next = items.filter((x) => x !== from);
const idx = next.indexOf(to);
if (idx < 0) return next.concat(from);
next.splice(idx, 0, from);
return next;
}
function clampNumber(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return min;
return Math.min(max, Math.max(min, v));
}
function stepByWheel(e: React.WheelEvent, step: number): number {
// Wheel up => increase, wheel down => decrease
if (e.deltaY === 0) return 0;
return e.deltaY < 0 ? step : -step;
}
function envNumber(name: string, fallback: number): number {
const v = (import.meta as any).env?.[name];
@@ -32,7 +104,8 @@ function envString(name: string, fallback: string): string {
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
if (v >= 1000) return `$${v.toFixed(0)}`;
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
if (v >= 1) return `$${v.toFixed(2)}`;
return `$${v.toPrecision(4)}`;
}
@@ -42,9 +115,36 @@ 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;
function formatCompact(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
const abs = Math.abs(v);
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`;
if (abs >= 1) return v.toFixed(2);
return v.toPrecision(4);
}
function clamp01(scale: number): number {
return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
}
function barCurve(scale01: number): number {
// Makes small rows visible without letting a single wall dominate.
return Math.sqrt(clamp01(scale01));
}
function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties {
return {
['--ob-total-scale' as any]: barCurve(totalScale),
['--ob-level-scale' as any]: barCurve(levelScale),
} as CSSProperties;
}
function liquidityStyle(bid: number, ask: number): CSSProperties {
const max = Math.max(1e-9, bid, ask);
const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0;
const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0;
return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties;
}
type WhoamiResponse = {
@@ -112,17 +212,28 @@ export default function App() {
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
const normalizeTf = (raw: string): string => {
const v = String(raw || '').trim();
if (!v) return '1m';
const lower = v.toLowerCase();
// keep backwards compatibility with older saved values (e.g. "1D")
if (lower === '1d') return '1d';
return lower;
};
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 [tfRaw, setTfRaw] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
const tf = useMemo(() => normalizeTf(tfRaw), [tfRaw]);
const setTf = (next: string) => setTfRaw(normalizeTf(next));
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false);
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true);
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
const [bottomTab, setBottomTab] = useLocalStorageState<
'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
>('trade.bottomTab', 'positions');
'dlob' | 'costs' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
>('trade.bottomTab', envString('VITE_BOTTOM_TAB', 'dlob') as any);
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
'trade.form.type',
@@ -131,6 +242,213 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
const [layoutMode, setLayoutMode] = useLocalStorageState<'grid' | 'stack'>('trade.layoutMode', 'grid');
const [stackOrderRaw, setStackOrder] = useLocalStorageState<PaneId[]>('trade.stackOrder', ALL_PANES);
const stackOrder = useMemo(() => normalizePaneOrder(stackOrderRaw), [stackOrderRaw]);
const activePane = stackOrder[stackOrder.length - 1] ?? 'chart';
const [stackOrderManual, setStackOrderManual] = useLocalStorageState<boolean>('trade.stackOrderManual', false);
const escRef = useRef<number>(0);
const [stackPanelLocked, setStackPanelLocked] = useLocalStorageState<boolean>('trade.stackPanelLocked', false);
const [stackPanelOpen, setStackPanelOpen] = useState(true);
const stackPanelHideTimerRef = useRef<number | null>(null);
const [stackDrawerOpacity, setStackDrawerOpacity] = useLocalStorageState<number>('trade.stackDrawerOpacity', 0.92);
const [stackBackdropOpacity, setStackBackdropOpacity] = useLocalStorageState<number>('trade.stackBackdropOpacity', 0.55);
const [layerOpacityRaw, setLayerOpacity] = useLocalStorageState<Record<PaneId, number>>('trade.layerOpacity', {
chart: 1,
dlob: 1,
costsActive: 1,
costsNew: 1,
});
const layerOpacity = useMemo(() => normalizeLayerOpacity(layerOpacityRaw), [layerOpacityRaw]);
const [layerVisibleRaw, setLayerVisible] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerVisible', {
chart: true,
dlob: true,
costsActive: false,
costsNew: true,
});
const layerVisible = useMemo(() => normalizeLayerToggle(layerVisibleRaw, true), [layerVisibleRaw]);
const [layerLockedRaw, setLayerLocked] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerLocked', {
chart: false,
dlob: false,
costsActive: false,
costsNew: false,
});
const layerLocked = useMemo(() => normalizeLayerToggle(layerLockedRaw, false), [layerLockedRaw]);
const [layerBrightnessRaw, setLayerBrightness] = useLocalStorageState<Record<PaneId, number>>('trade.layerBrightness', {
chart: 1,
dlob: 1,
costsActive: 1,
costsNew: 1,
});
const layerBrightness = useMemo(() => normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8), [layerBrightnessRaw]);
const [activeContractIdSeen, setActiveContractIdSeen] = useLocalStorageState<string>('trade.activeContractIdSeen', '');
const [activeContractId] = useLocalStorageState<string>('trade.contractId', '');
const hasActiveContract = Boolean(activeContractId.trim());
useEffect(() => {
const normalized = normalizePaneOrder(stackOrderRaw);
if (normalized.join('|') !== (Array.isArray(stackOrderRaw) ? stackOrderRaw.join('|') : '')) {
setStackOrder(normalized);
}
}, [setStackOrder, stackOrderRaw]);
useEffect(() => {
const normalized = normalizeLayerOpacity(layerOpacityRaw);
const raw = layerOpacityRaw as any;
if (!raw || normalized.chart !== raw.chart || normalized.dlob !== raw.dlob) setLayerOpacity(normalized);
}, [layerOpacityRaw, setLayerOpacity]);
useEffect(() => {
const normalized = normalizeLayerToggle(layerVisibleRaw, true);
const raw = layerVisibleRaw as any;
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
if (needsFix) setLayerVisible(normalized);
}, [layerVisibleRaw, setLayerVisible]);
useEffect(() => {
const normalized = normalizeLayerToggle(layerLockedRaw, false);
const raw = layerLockedRaw as any;
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
if (needsFix) setLayerLocked(normalized);
}, [layerLockedRaw, setLayerLocked]);
useEffect(() => {
const normalized = normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8);
const raw = layerBrightnessRaw as any;
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
if (needsFix) setLayerBrightness(normalized);
}, [layerBrightnessRaw, setLayerBrightness]);
// When a contract is "pushed" (contractId becomes non-empty), ensure Active layer is visible and placed
// directly below Costs(New) (unless user reorders later). We do this once per contract id.
useEffect(() => {
const id = activeContractId.trim();
if (!id) return;
if (id === activeContractIdSeen) return;
setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), costsActive: true }));
if (!stackOrderManual) {
setStackOrder((prev) => {
const normalized = normalizePaneOrder(prev);
const without = normalized.filter((p) => p !== 'costsActive');
const idxNew = without.indexOf('costsNew');
const insertAt = idxNew >= 0 ? idxNew : Math.max(0, without.length - 1);
without.splice(insertAt, 0, 'costsActive');
return without;
});
}
setActiveContractIdSeen(id);
}, [activeContractId, activeContractIdSeen, setActiveContractIdSeen, setLayerVisible, setStackOrder, stackOrderManual]);
useEffect(() => {
document.body.classList.toggle('stackMode', layoutMode === 'stack');
return () => document.body.classList.remove('stackMode');
}, [layoutMode]);
useEffect(() => {
const drawer = clampNumber(stackDrawerOpacity, 0.05, 1);
const backdrop = clampNumber(stackBackdropOpacity, 0, 0.95);
document.body.style.setProperty('--stack-drawer-opacity', String(drawer));
document.body.style.setProperty('--stack-backdrop-opacity', String(backdrop));
return () => {
document.body.style.removeProperty('--stack-drawer-opacity');
document.body.style.removeProperty('--stack-backdrop-opacity');
};
}, [stackBackdropOpacity, stackDrawerOpacity]);
useEffect(() => {
if (layoutMode !== 'stack') return;
// Default focus order in stack mode unless the user manually reordered (DnD).
if (!stackOrderManual) {
setStackOrder(() => normalizePaneOrder(ALL_PANES));
}
setStackPanelOpen(true);
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
stackPanelHideTimerRef.current = null;
}, [layoutMode, setStackOrder, stackOrderManual]);
useEffect(() => {
return () => {
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
stackPanelHideTimerRef.current = null;
};
}, []);
useEffect(() => {
if (layoutMode !== 'stack') return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
const now = Date.now();
if (now - escRef.current < 800) {
escRef.current = 0;
setLayoutMode('grid');
return;
}
escRef.current = now;
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [layoutMode, setLayoutMode]);
const openStackPanel = () => {
if (layoutMode !== 'stack') return;
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
stackPanelHideTimerRef.current = null;
setStackPanelOpen(true);
};
const scheduleHideStackPanel = () => {
if (layoutMode !== 'stack') return;
if (stackPanelLocked) return;
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
stackPanelHideTimerRef.current = window.setTimeout(() => {
setStackPanelOpen(false);
stackPanelHideTimerRef.current = null;
}, 1000);
};
const enterStack = (pane: PaneId) => {
if (pane === 'dlob') setBottomTab('dlob');
setLayoutMode('stack');
setStackOrder((prev) => {
const normalized = normalizePaneOrder(prev);
return normalized.filter((p) => p !== pane).concat(pane);
});
openStackPanel();
};
const exitStack = () => setLayoutMode('grid');
const togglePaneFullscreen = (pane: PaneId) => {
if (layoutMode === 'stack' && activePane === pane) {
exitStack();
return;
}
enterStack(pane);
};
const stackTopFirst = useMemo(() => stackOrder.slice().reverse(), [stackOrder]);
const stackZ = useMemo(() => {
const z: Record<PaneId, number> = makePaneRecord(() => 2600);
const base = 2600;
const step = 20;
for (let i = 0; i < stackOrder.length; i++) {
const id = stackOrder[i];
z[id] = base + i * step;
}
return z;
}, [stackOrder]);
const effectiveLayerOpacity = useMemo(() => {
const out: Record<PaneId, number> = makePaneRecord(() => 0);
for (const id of ALL_PANES) {
out[id] = layerVisible[id] ? clampNumber(layerOpacity[id] ?? 1, 0, 1) : 0;
}
return out;
}, [layerOpacity, layerVisible]);
useEffect(() => {
if (symbol === 'BONK-PERP') {
setSymbol('1MBONK-PERP');
@@ -141,6 +459,13 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
}
}, [markets, setSymbol, symbol]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const wanted = (params.get('bottomTab') || params.get('tab') || '').trim();
const allowed = new Set(['dlob', 'costs', 'positions', 'orders', 'balances', 'orderHistory', 'positionHistory']);
if (wanted && allowed.has(wanted)) setBottomTab(wanted as any);
}, [setBottomTab]);
const { candles, indicators, meta, loading, error, refresh } = useChartData({
symbol,
source: source.trim() ? source : undefined,
@@ -150,7 +475,7 @@ 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 { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 10 });
const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol);
const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol);
@@ -160,51 +485,88 @@ 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 };
if (dlobL2) {
return {
asks: dlobL2.asks,
bids: dlobL2.bids,
mid: dlobL2.mid as number | null,
bestBid: dlobL2.bestBid,
bestAsk: dlobL2.bestAsk,
};
}
if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: null as number | null };
const mid = latest.close;
const step = Math.max(mid * 0.00018, 0.0001);
const levels = 14;
const levels = 10;
const asksRaw = Array.from({ length: levels }, (_, i) => ({
price: mid + (i + 1) * step,
size: 0.1 + ((i * 7) % 15) * 0.1,
sizeBase: 0.1 + ((i * 7) % 15) * 0.1,
}));
const bidsRaw = Array.from({ length: levels }, (_, i) => ({
price: mid - (i + 1) * step,
size: 0.1 + ((i * 5) % 15) * 0.1,
sizeBase: 0.1 + ((i * 5) % 15) * 0.1,
}));
let askTotal = 0;
let askTotalBase = 0;
let askTotalUsd = 0;
const asks = asksRaw
.slice()
.reverse()
.map((r) => {
askTotal += r.size;
return { ...r, total: askTotal };
const sizeUsd = r.sizeBase * r.price;
askTotalBase += r.sizeBase;
askTotalUsd += sizeUsd;
return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd };
});
let bidTotal = 0;
let bidTotalBase = 0;
let bidTotalUsd = 0;
const bids = bidsRaw.map((r) => {
bidTotal += r.size;
return { ...r, total: bidTotal };
const sizeUsd = r.sizeBase * r.price;
bidTotalBase += r.sizeBase;
bidTotalUsd += sizeUsd;
return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd };
});
return { asks, bids, mid };
return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null };
}, [dlobL2, latest]);
const maxAskTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.asks) max = Math.max(max, r.total || 0);
for (const r of orderbook.asks) max = Math.max(max, (r as any).totalUsd || 0);
return max;
}, [orderbook.asks]);
const maxBidTotal = useMemo(() => {
let max = 0;
for (const r of orderbook.bids) max = Math.max(max, r.total || 0);
for (const r of orderbook.bids) max = Math.max(max, (r as any).totalUsd || 0);
return max;
}, [orderbook.bids]);
const maxAskSize = useMemo(() => {
let max = 0;
for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0);
return max;
}, [orderbook.asks]);
const maxBidSize = useMemo(() => {
let max = 0;
for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0);
return max;
}, [orderbook.bids]);
const liquidity = useMemo(() => {
const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0;
const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0;
const bestBid = orderbook.bestBid;
const bestAsk = orderbook.bestAsk;
const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null;
const spreadPct =
spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null;
return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct };
}, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]);
const trades = useMemo(() => {
const slice = candles.slice(-24).reverse();
return slice.map((c) => {
@@ -294,6 +656,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
const bucketSeconds = meta?.bucketSeconds ?? 60;
return (
<>
<AppShell
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
top={<TickerBar items={topItems} />}
@@ -366,6 +729,18 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
onToggleBuild={() => setShowBuild((v) => !v)}
seriesLabel={seriesLabel}
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
fullscreenOverride={layoutMode === 'stack'}
onToggleFullscreenOverride={() => togglePaneFullscreen('chart')}
fullscreenStyle={
layoutMode === 'stack'
? {
zIndex: stackZ.chart,
opacity: effectiveLayerOpacity.chart,
filter: `brightness(${layerBrightness.chart})`,
pointerEvents: activePane === 'chart' ? 'auto' : 'none',
}
: undefined
}
/>
<Card className="bottomCard">
@@ -386,9 +761,16 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
slippageRows={slippageRows}
slippageConnected={slippageConnected}
slippageError={slippageError}
isFullscreen={layoutMode === 'stack' && activePane === 'dlob'}
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
/>
),
},
{
id: 'costs',
label: 'Costs',
content: <ContractCostsPanel market={symbol} />,
},
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
@@ -428,19 +810,22 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
<div className="orderbook">
<div className="orderbook__header">
<span>Price</span>
<span className="orderbook__num">Size</span>
<span className="orderbook__num">Total</span>
<span className="orderbook__num">Size (USD)</span>
<span className="orderbook__num">Total (USD)</span>
</div>
<div className="orderbook__rows">
{orderbook.asks.map((r) => (
<div
key={`a-${r.price}`}
className="orderbookRow orderbookRow--ask"
style={orderbookBarStyle(maxAskTotal > 0 ? r.total / maxAskTotal : 0)}
style={orderbookRowBarStyle(
maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0,
maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0
)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
</div>
))}
<div className="orderbookMid">
@@ -451,13 +836,37 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
<div
key={`b-${r.price}`}
className="orderbookRow orderbookRow--bid"
style={orderbookBarStyle(maxBidTotal > 0 ? r.total / maxBidTotal : 0)}
style={orderbookRowBarStyle(
maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0,
maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0
)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
</div>
))}
<div className="orderbookMeta">
<div className="orderbookMeta__row">
<span className="muted">Spread</span>
<span className="orderbookMeta__val">
{liquidity.spreadAbs == null || liquidity.spreadPct == null
? '—'
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
</span>
</div>
<div className="orderbookMeta__row">
<span className="muted">Liquidity (L1L10)</span>
<span className="orderbookMeta__val">
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
</span>
</div>
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
<div className="liquidityBar__bid" />
<div className="liquidityBar__ask" />
</div>
</div>
</div>
</div>
),
@@ -622,5 +1031,308 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</Card>
}
/>
{layoutMode === 'stack' ? (
<>
<div className="stackDrawerHotspot" onMouseEnter={openStackPanel} aria-label="Layers hotspot" />
<Card
className={['stackDrawer', stackPanelOpen ? 'stackDrawer--open' : 'stackDrawer--closed'].join(' ')}
title="Layers"
right={
<div className="stackPanel__actions">
<span className="muted stackPanel__hint">Esc ×2</span>
<Button
size="sm"
variant={stackPanelLocked ? 'primary' : 'ghost'}
onClick={() => {
setStackPanelLocked((v) => !v);
openStackPanel();
}}
type="button"
title={stackPanelLocked ? 'Auto-hide disabled' : 'Auto-hide enabled'}
>
{stackPanelLocked ? 'Locked' : 'Auto'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setStackPanelOpen(false);
scheduleHideStackPanel();
}}
type="button"
>
Hide
</Button>
<Button size="sm" variant="primary" onClick={exitStack} type="button">
Back
</Button>
</div>
}
onMouseEnter={openStackPanel}
onMouseLeave={scheduleHideStackPanel}
>
<div className="stackPanel__sub muted">UI Opacity</div>
<div className="stackPanel__sliders" style={{ marginBottom: 14 }}>
<label className="stackPanel__sliderRow">
<span className="stackPanel__sliderLabel muted">Backdrop</span>
<input
className="stackPanel__slider"
type="range"
min={0}
max={95}
step={1}
value={Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}
onChange={(e) => setStackBackdropOpacity(Number(e.target.value) / 100)}
onWheel={(e) => {
e.preventDefault();
const cur = Math.round(clampNumber(stackBackdropOpacity, 0, 0.95) * 100);
const step = e.shiftKey ? 5 : 1;
const next = clampNumber(cur + stepByWheel(e, step), 0, 95);
setStackBackdropOpacity(next / 100);
}}
/>
<span className="stackPanel__sliderValue muted">
{Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}%
</span>
</label>
<label className="stackPanel__sliderRow">
<span className="stackPanel__sliderLabel muted">Panel</span>
<input
className="stackPanel__slider"
type="range"
min={5}
max={100}
step={1}
value={Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}
onChange={(e) => setStackDrawerOpacity(Number(e.target.value) / 100)}
onWheel={(e) => {
e.preventDefault();
const cur = Math.round(clampNumber(stackDrawerOpacity, 0.05, 1) * 100);
const step = e.shiftKey ? 5 : 1;
const next = clampNumber(cur + stepByWheel(e, step), 5, 100);
setStackDrawerOpacity(next / 100);
}}
/>
<span className="stackPanel__sliderValue muted">
{Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}%
</span>
</label>
</div>
<div className="stackPanel__sub muted">
Drag to reorder, click to focus (top = active).
</div>
<div className="stackPanel__list" role="list">
{stackTopFirst.map((pane) => {
const isActive = pane === activePane;
const label =
pane === 'chart'
? 'Chart'
: pane === 'dlob'
? 'DLOB'
: pane === 'costsNew'
? 'Costs (New)'
: 'Costs (Active)';
const pct = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100);
const isVisible = layerVisible[pane];
const isLocked = layerLocked[pane];
const brightPct = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100);
return (
<div
key={pane}
role="listitem"
className={[
'stackPanel__item',
isActive ? 'stackPanel__item--active' : null,
isLocked ? 'stackPanel__item--locked' : null,
!isVisible ? 'stackPanel__item--hidden' : null,
]
.filter(Boolean)
.join(' ')}
draggable={!isLocked}
onClick={() => {
if (isLocked) return;
enterStack(pane);
}}
onDragStart={(e) => {
if (isLocked) return;
e.dataTransfer.setData('text/plain', pane);
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
if (isLocked) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDrop={(e) => {
e.preventDefault();
const dragged = e.dataTransfer.getData('text/plain') as PaneId;
if (!ALL_PANES.includes(dragged)) return;
if (layerLocked[dragged]) return;
if (isLocked) return;
const nextTop = reorderList(stackTopFirst, dragged, pane);
setStackOrder(nextTop.slice().reverse() as any);
setStackOrderManual(true);
}}
>
<span className="stackPanel__drag"></span>
<span className="stackPanel__label">{label}</span>
<button
type="button"
className="stackPanel__iconBtn"
title={isVisible ? 'Hide layer' : 'Show layer'}
onClick={(e) => {
e.stopPropagation();
setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), [pane]: !isVisible }));
}}
>
{isVisible ? '👁' : '🚫'}
</button>
<button
type="button"
className="stackPanel__iconBtn"
title={isLocked ? 'Unlock layer' : 'Lock layer'}
onClick={(e) => {
e.stopPropagation();
setLayerLocked((prev) => ({ ...normalizeLayerToggle(prev, false), [pane]: !isLocked }));
}}
>
{isLocked ? '🔒' : '🔓'}
</button>
<input
className="stackPanel__layerBrightness"
type="range"
min={60}
max={180}
step={1}
value={brightPct}
title={`${label} brightness`}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => {
e.preventDefault();
const cur = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100);
const step = e.shiftKey ? 10 : 2;
const next = clampNumber(cur + stepByWheel(e, step), 60, 180);
setLayerBrightness((prev) => ({ ...normalizeLayerFactor(prev, 1, 0.6, 1.8), [pane]: next / 100 }));
}}
disabled={isLocked}
onChange={(e) =>
setLayerBrightness((prev) => ({
...normalizeLayerFactor(prev, 1, 0.6, 1.8),
[pane]: Number(e.target.value) / 100,
}))
}
/>
<span className="stackPanel__layerBrightnessValue muted">{brightPct}%</span>
<input
className="stackPanel__layerOpacity"
type="range"
min={0}
max={100}
step={1}
value={pct}
title={`${label} opacity`}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => {
e.preventDefault();
const cur = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100);
const step = e.shiftKey ? 5 : 1;
const next = clampNumber(cur + stepByWheel(e, step), 0, 100);
setLayerOpacity((prev) => ({ ...normalizeLayerOpacity(prev), [pane]: next / 100 }));
}}
disabled={isLocked}
onChange={(e) =>
setLayerOpacity((prev) => ({
...normalizeLayerOpacity(prev),
[pane]: Number(e.target.value) / 100,
}))
}
/>
<span className="stackPanel__layerOpacityValue muted">{pct}%</span>
{isActive ? <span className="stackPanel__badge">active</span> : null}
</div>
);
})}
</div>
</Card>
</>
) : null}
{layoutMode === 'stack' ? <div className="stackBackdrop" aria-hidden="true" /> : null}
{layoutMode === 'stack' && effectiveLayerOpacity.dlob > 0 ? (
<div
className="stackLayer stackLayer--dlob"
role="dialog"
aria-label="DLOB layer"
style={{
zIndex: stackZ.dlob,
opacity: effectiveLayerOpacity.dlob,
filter: `brightness(${layerBrightness.dlob})`,
pointerEvents: activePane === 'dlob' ? 'auto' : 'none',
}}
>
<div className="stackLayer__body">
<Card className="stackLayer__card">
<DlobDashboard
market={symbol}
stats={dlob}
statsConnected={dlobConnected}
statsError={dlobError}
depthBands={depthBands}
depthBandsConnected={depthBandsConnected}
depthBandsError={depthBandsError}
slippageRows={slippageRows}
slippageConnected={slippageConnected}
slippageError={slippageError}
isFullscreen
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
/>
</Card>
</div>
</div>
) : null}
{layoutMode === 'stack' && effectiveLayerOpacity.costsActive > 0 && hasActiveContract ? (
<div
className="stackLayer stackLayer--costsActive"
role="dialog"
aria-label="Costs active layer"
style={{
zIndex: stackZ.costsActive,
opacity: effectiveLayerOpacity.costsActive,
filter: `brightness(${layerBrightness.costsActive})`,
pointerEvents: activePane === 'costsActive' ? 'auto' : 'none',
}}
>
<div className="stackLayer__body">
<Card className="stackLayer__card">
<ContractCostsPanel market={symbol} view="active" />
</Card>
</div>
</div>
) : null}
{layoutMode === 'stack' && effectiveLayerOpacity.costsNew > 0 ? (
<div
className="stackLayer stackLayer--costsNew"
role="dialog"
aria-label="Costs new layer"
style={{
zIndex: stackZ.costsNew,
opacity: effectiveLayerOpacity.costsNew,
filter: `brightness(${layerBrightness.costsNew})`,
pointerEvents: activePane === 'costsNew' ? 'auto' : 'none',
}}
>
<div className="stackLayer__body">
<Card className="stackLayer__card">
<ContractCostsPanel market={symbol} view="new" />
</Card>
</div>
</div>
) : null}
</>
);
}

View File

@@ -1,3 +1,4 @@
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Candle, ChartIndicators } from '../../lib/api';
import Card from '../../ui/Card';
@@ -22,6 +23,9 @@ type Props = {
showBuild: boolean;
onToggleBuild: () => void;
seriesLabel: string;
fullscreenOverride?: boolean;
onToggleFullscreenOverride?: () => void;
fullscreenStyle?: CSSProperties;
};
type FibDragMode = 'move' | 'edit-b';
@@ -56,8 +60,16 @@ export default function ChartPanel({
showBuild,
onToggleBuild,
seriesLabel,
fullscreenOverride,
onToggleFullscreenOverride,
fullscreenStyle,
}: Props) {
const [isFullscreen, setIsFullscreen] = useState(false);
const isExternalFullscreen = typeof fullscreenOverride === 'boolean';
const effectiveFullscreen = isExternalFullscreen ? (fullscreenOverride as boolean) : isFullscreen;
const toggleFullscreen = isExternalFullscreen
? onToggleFullscreenOverride ?? (() => {})
: () => setIsFullscreen((v) => !v);
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
const [fib, setFib] = useState<FibRetracement | null>(null);
@@ -86,22 +98,29 @@ export default function ChartPanel({
const fibRef = useRef<FibRetracement | null>(fib);
useEffect(() => {
if (isExternalFullscreen) return;
if (!isFullscreen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsFullscreen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isFullscreen]);
}, [isExternalFullscreen, isFullscreen]);
useEffect(() => {
if (isExternalFullscreen) return;
document.body.classList.toggle('chartFullscreen', isFullscreen);
return () => document.body.classList.remove('chartFullscreen');
}, [isFullscreen]);
}, [isExternalFullscreen, isFullscreen]);
const cardClassName = useMemo(() => {
return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
}, [isFullscreen]);
return ['chartCard', effectiveFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
}, [effectiveFullscreen]);
const cardStyle = useMemo(() => {
if (!effectiveFullscreen) return undefined;
return fullscreenStyle;
}, [effectiveFullscreen, fullscreenStyle]);
useEffect(() => {
activeToolRef.current = activeTool;
@@ -302,8 +321,8 @@ export default function ChartPanel({
return (
<>
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
<Card className={cardClassName}>
{!isExternalFullscreen && isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
<Card className={cardClassName} style={cardStyle}>
<div className="chartCard__toolbar">
<ChartToolbar
timeframe={timeframe}
@@ -315,8 +334,8 @@ export default function ChartPanel({
priceAutoScale={priceAutoScale}
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
seriesLabel={seriesLabel}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
isFullscreen={effectiveFullscreen}
onToggleFullscreen={toggleFullscreen}
/>
</div>
<div className="chartCard__content">

View File

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

View File

@@ -641,7 +641,7 @@ export default function TradingChart({
const buildSlicesPrimitive = new BuildSlicesPrimitive();
volumeSeries.attachPrimitive(buildSlicesPrimitive);
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
buildSlicesPrimitive.setEnabled(!showBuildRef.current);
buildSlicesPrimitive.setEnabled(showBuildRef.current);
const buildHoverSeries = chart.addSeries(LineSeries, {
color: BUILD_FLAT_COLOR,
@@ -1127,7 +1127,7 @@ export default function TradingChart({
const buildPrimitive = buildSlicesPrimitiveRef.current;
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
buildPrimitive?.setEnabled(!showBuild);
buildPrimitive?.setEnabled(showBuild);
if (showBuild) {
const hoverTime = hoverCandleTime;

View File

@@ -27,33 +27,60 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inFlight = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
const fetchOnce = useCallback(
async ({ force }: { force: boolean }) => {
if (!force && inFlight.current) return;
// On timeframe/params change we want an immediate response — abort the older request.
if (force && abortRef.current) abortRef.current.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const reqId = requestIdRef.current + 1;
requestIdRef.current = reqId;
const fetchOnce = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true;
setLoading(true);
try {
const res = await fetchChart({ symbol, source, tf, limit });
const res = await fetchChart({ symbol, source, tf, limit, signal: ctrl.signal });
if (requestIdRef.current !== reqId) return; // stale response
setCandles(res.candles);
setIndicators(res.indicators);
setMeta(res.meta);
setError(null);
} catch (e: any) {
setError(String(e?.message || e));
// Aborts are expected during fast tf switching.
const name = String(e?.name || '');
const msg = String(e?.message || e);
if (name === 'AbortError' || msg.toLowerCase().includes('abort')) return;
if (requestIdRef.current !== reqId) return;
setError(msg);
} finally {
if (requestIdRef.current === reqId) {
setLoading(false);
inFlight.current = false;
}
}, [symbol, source, tf, limit]);
}
},
[symbol, source, tf, limit]
);
useEffect(() => {
void fetchOnce();
void fetchOnce({ force: true });
return () => {
abortRef.current?.abort();
};
}, [fetchOnce]);
useInterval(() => void fetchOnce(), pollMs);
useInterval(() => void fetchOnce({ force: false }), pollMs);
return useMemo(
() => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }),
() => ({ candles, indicators, meta, loading, error, refresh: () => fetchOnce({ force: true }) }),
[candles, indicators, meta, loading, error, fetchOnce]
);
}

View File

@@ -0,0 +1,858 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import Chart from 'chart.js/auto';
import 'chartjs-adapter-luxon';
import { useLocalStorageState } from '../../app/hooks/useLocalStorageState';
import Button from '../../ui/Button';
type ContractMonitorResponse = {
ok?: boolean;
error?: string;
contract?: any;
eventsCount?: number;
costs?: {
tradeFeeUsd: number;
txFeeUsd: number;
slippageUsd: number;
fundingUsd: number;
realizedPnlUsd: number;
totalCostsUsd: number;
netPnlUsd: number;
txCount: number;
fillCount: number;
cancelCount: number;
modifyCount: number;
errorCount: number;
};
series?: Array<{
ts: string;
tradeFeeUsd: number;
txFeeUsd: number;
slippageUsd: number;
fundingUsd: number;
totalCostsUsd: number;
realizedPnlUsd: number;
netPnlUsd: number;
}> | null;
closeEstimate?: any;
};
type CostEstimateResponse = {
ok?: boolean;
error?: string;
input?: any;
dlob?: any;
breakdown?: {
trade_fee_usd: number;
slippage_usd: number;
tx_fee_usd: number;
expected_modify_usd: number;
total_usd: number;
total_bps: number;
breakeven_bps: number;
};
};
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
const abs = Math.abs(v);
if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (abs >= 1000) return `$${(v / 1000).toFixed(1)}K`;
return `$${v.toFixed(4)}`;
}
function formatBps(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${v.toFixed(2)} bps`;
}
function clampNumber(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return min;
return Math.min(max, Math.max(min, v));
}
function MiniLineChart({
title,
points,
valueKey,
format,
}: {
title: string;
points: Array<Record<string, any>>;
valueKey: string;
format?: (v: number | null | undefined) => string;
}) {
const values = useMemo(() => {
const out: number[] = [];
for (const p of points) {
const v = Number(p?.[valueKey]);
if (Number.isFinite(v)) out.push(v);
}
return out;
}, [points, valueKey]);
const last = values.length ? values[values.length - 1] : null;
const min = values.length ? Math.min(...values) : 0;
const max = values.length ? Math.max(...values) : 1;
const span = max - min || 1;
const w = 360;
const h = 84;
const pad = 6;
const d = useMemo(() => {
if (!values.length) return '';
const step = values.length > 1 ? (w - pad * 2) / (values.length - 1) : 0;
let path = '';
for (let i = 0; i < values.length; i++) {
const x = pad + i * step;
const y = pad + ((max - values[i]) / span) * (h - pad * 2);
path += i === 0 ? `M ${x.toFixed(2)} ${y.toFixed(2)}` : ` L ${x.toFixed(2)} ${y.toFixed(2)}`;
}
return path;
}, [h, max, span, values, w]);
const cls = last != null && last >= 0 ? 'pos' : 'neg';
const fmt = format || formatUsd;
return (
<div className="costChart">
<div className="costChart__head">
<span className="costChart__title">{title}</span>
<span className={['costChart__value', cls].join(' ')}>{fmt(last)}</span>
</div>
{values.length ? (
<svg className="costChart__svg" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
<path d={d} fill="none" stroke="rgba(168, 85, 247, 0.9)" strokeWidth="2" />
<path d={`M ${pad} ${h - pad} H ${w - pad}`} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="1" />
</svg>
) : (
<div className="muted" style={{ fontSize: 12 }}>
No series yet.
</div>
)}
</div>
);
}
type EstimatePoint = {
ts: number;
tradeFeeUsd: number;
slippageUsd: number;
txFeeUsd: number;
modifyUsd: number;
totalUsd: number;
breakevenBps: number;
totalBps: number;
midPrice: number | null;
vwapPrice: number | null;
impactBps: number | null;
};
function useWindowedEstimateSeries(points: EstimatePoint[], windowSec: number) {
const nowMs = Date.now();
const windowMs = Math.max(10, Math.min(3600, Math.floor(windowSec))) * 1000;
const startMs = nowMs - windowMs;
return useMemo(() => points.filter((p) => p.ts >= startMs && p.ts <= nowMs + 2000), [nowMs, points, startMs]);
}
function EstimateChart({
title,
points,
windowSec,
kind,
}: {
title: string;
points: EstimatePoint[];
windowSec: number;
kind: 'price' | 'costUsd' | 'costBps';
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart | null>(null);
const windowed = useWindowedEstimateSeries(points, windowSec);
const datasets = useMemo(() => {
const toXY = (key: keyof EstimatePoint) =>
windowed.map((p) => ({
x: p.ts,
y: (p[key] as any) == null ? null : Number(p[key] as any),
}));
if (kind === 'price') {
return [
{
label: 'Mid',
data: toXY('midPrice'),
borderColor: 'rgba(96,165,250,0.95)',
backgroundColor: 'rgba(96,165,250,0.10)',
pointRadius: 0,
borderWidth: 2,
tension: 0.15,
yAxisID: 'price',
},
{
label: 'VWAP (quote)',
data: toXY('vwapPrice'),
borderColor: 'rgba(168,85,247,0.95)',
backgroundColor: 'rgba(168,85,247,0.10)',
pointRadius: 0,
borderDash: [6, 4],
borderWidth: 2,
tension: 0.15,
yAxisID: 'price',
},
];
}
if (kind === 'costBps') {
return [
{
label: 'Impact (bps)',
data: toXY('impactBps'),
borderColor: 'rgba(34,197,94,0.95)',
backgroundColor: 'rgba(34,197,94,0.10)',
pointRadius: 0,
borderWidth: 2,
tension: 0.15,
yAxisID: 'bps',
},
{
label: 'Total (bps)',
data: toXY('totalBps'),
borderColor: 'rgba(239,68,68,0.95)',
backgroundColor: 'rgba(239,68,68,0.10)',
pointRadius: 0,
borderWidth: 2,
tension: 0.15,
yAxisID: 'bps',
},
];
}
return [
{
label: 'Total',
data: toXY('totalUsd'),
borderColor: 'rgba(168,85,247,0.95)',
backgroundColor: 'rgba(168,85,247,0.10)',
pointRadius: 0,
borderWidth: 2,
tension: 0.15,
yAxisID: 'usd',
},
{
label: 'Slippage',
data: toXY('slippageUsd'),
borderColor: 'rgba(34,197,94,0.95)',
backgroundColor: 'rgba(34,197,94,0.10)',
pointRadius: 0,
borderWidth: 2,
tension: 0.15,
yAxisID: 'usd',
},
{
label: 'Tx',
data: toXY('txFeeUsd'),
borderColor: 'rgba(96,165,250,0.95)',
backgroundColor: 'rgba(96,165,250,0.10)',
pointRadius: 0,
borderDash: [6, 4],
borderWidth: 2,
tension: 0.15,
yAxisID: 'usd',
},
];
}, [kind, windowed]);
useEffect(() => {
if (!canvasRef.current) return;
if (chartRef.current) return;
chartRef.current = new Chart(canvasRef.current, {
type: 'line',
data: { datasets: datasets as any },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
parsing: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
labels: {
color: '#e6e9ef',
font: { size: 13, weight: '600' },
boxWidth: 10,
boxHeight: 10,
},
},
tooltip: {
enabled: true,
titleFont: { size: 13, weight: '700' },
bodyFont: { size: 13, weight: '600' },
},
},
scales: {
x: {
type: 'time',
time: { unit: 'second' },
// Time labels are already readable; keep them slightly smaller than the rest.
ticks: { color: '#c7cbd4', font: { size: 11, weight: '600' } },
grid: { color: 'rgba(255,255,255,0.06)' },
},
price: {
type: 'linear',
position: 'right',
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
grid: { color: 'rgba(255,255,255,0.06)' },
display: kind === 'price',
},
usd: {
type: 'linear',
position: 'right',
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
grid: { color: 'rgba(255,255,255,0.06)' },
display: kind === 'costUsd',
},
bps: {
type: 'linear',
position: 'right',
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
grid: { color: 'rgba(255,255,255,0.06)' },
display: kind === 'costBps',
},
},
},
});
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kind]);
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
chart.data.datasets = datasets as any;
chart.update('none');
}, [datasets]);
return (
<div className={['costChart', 'costChart--big', `costChart--${kind}`].join(' ')}>
<div className="costChart__head">
<span className="costChart__title">{title}</span>
<span className="muted" style={{ fontSize: 12 }}>
{windowSec}s
</span>
</div>
<div className="costChart__canvas">
<canvas ref={canvasRef} />
</div>
</div>
);
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
const text = await res.text();
let json: any = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
// ignore
}
if (!res.ok) {
const msg = json?.error || text || `HTTP ${res.status}`;
throw new Error(String(msg));
}
return (json ?? {}) as T;
}
export default function ContractCostsPanel({
market,
view,
}: {
market: string;
view?: 'both' | 'active' | 'new';
}) {
const [contractId, setContractId] = useLocalStorageState<string>('trade.contractId', '');
const [autoPoll, setAutoPoll] = useLocalStorageState<boolean>('trade.contractMonitor.autoPoll', true);
const [pollMs, setPollMs] = useLocalStorageState<number>('trade.contractMonitor.pollMs', 1500);
const [monitor, setMonitor] = useState<ContractMonitorResponse | null>(null);
const [monitorLoading, setMonitorLoading] = useState(false);
const [monitorError, setMonitorError] = useState<string | null>(null);
const lastMonitorAtRef = useRef<number>(0);
const normalizedContractId = useMemo(() => contractId.trim(), [contractId]);
const loadMonitor = async () => {
const id = normalizedContractId;
if (!id) return;
setMonitorLoading(true);
setMonitorError(null);
try {
const data = await fetchJson<ContractMonitorResponse>(
`/api/v1/contracts/${encodeURIComponent(id)}/monitor?eventsLimit=2000&series=1&seriesMax=600`,
{
method: 'GET',
headers: { 'cache-control': 'no-store' },
}
);
setMonitor(data);
lastMonitorAtRef.current = Date.now();
} catch (e: any) {
setMonitor(null);
setMonitorError(String(e?.message || e));
} finally {
setMonitorLoading(false);
}
};
useEffect(() => {
if (!autoPoll) return;
if (!normalizedContractId) return;
const ms = clampNumber(pollMs, 250, 30_000);
void loadMonitor();
const t = window.setInterval(() => void loadMonitor(), ms);
return () => window.clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPoll, normalizedContractId, pollMs]);
const [notionalUsd, setNotionalUsd] = useLocalStorageState<number>('trade.newContract.notionalUsd', 10);
const [side, setSide] = useLocalStorageState<'long' | 'short'>('trade.newContract.side', 'long');
const [orderType, setOrderType] = useLocalStorageState<'market' | 'limit'>('trade.newContract.orderType', 'market');
const [advancedOpen, setAdvancedOpen] = useLocalStorageState<boolean>('trade.newContract.advancedOpen', false);
const [feeTakerBps, setFeeTakerBps] = useLocalStorageState<number>('trade.newContract.feeTakerBps', 5);
const [feeMakerBps, setFeeMakerBps] = useLocalStorageState<number>('trade.newContract.feeMakerBps', 0);
const [txFeeUsdEst, setTxFeeUsdEst] = useLocalStorageState<number>('trade.newContract.txFeeUsdEst', 0);
const [expectedReprices, setExpectedReprices] = useLocalStorageState<number>('trade.newContract.expectedReprices', 0);
const [estimate, setEstimate] = useState<CostEstimateResponse | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false);
const [estimateError, setEstimateError] = useState<string | null>(null);
const [autoEstimate, setAutoEstimate] = useLocalStorageState<boolean>('trade.newContract.autoEstimate', true);
const [estimateWindowSec, setEstimateWindowSec] = useLocalStorageState<number>('trade.newContract.estimateWindowSec', 300);
const estimateInFlightRef = useRef(false);
const [estimateSeries, setEstimateSeries] = useState<EstimatePoint[]>([]);
const runEstimate = async () => {
setEstimateLoading(true);
setEstimateError(null);
try {
const body: any = {
market_name: market,
notional_usd: notionalUsd,
side,
order_type: orderType,
};
if (advancedOpen) {
body.fee_taker_bps = feeTakerBps;
body.fee_maker_bps = feeMakerBps;
body.tx_fee_usd_est = txFeeUsdEst;
body.expected_reprices_per_entry = expectedReprices;
}
const data = await fetchJson<CostEstimateResponse>('/api/v1/contracts/costs/estimate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
setEstimate(data);
if (data?.breakdown) {
const b = data.breakdown;
const midPriceRaw = data?.dlob?.mid_price;
const vwapPriceRaw = data?.dlob?.vwap_price;
const impactBpsRaw = data?.dlob?.impact_bps;
const midPrice = midPriceRaw == null ? null : Number(midPriceRaw);
const vwapPrice = vwapPriceRaw == null ? null : Number(vwapPriceRaw);
const impactBps = impactBpsRaw == null ? null : Number(impactBpsRaw);
setEstimateSeries((prev) => {
const next = [
...prev,
{
ts: Date.now(),
tradeFeeUsd: Number(b.trade_fee_usd ?? 0) || 0,
slippageUsd: Number(b.slippage_usd ?? 0) || 0,
txFeeUsd: Number(b.tx_fee_usd ?? 0) || 0,
modifyUsd: Number(b.expected_modify_usd ?? 0) || 0,
totalUsd: Number(b.total_usd ?? 0) || 0,
breakevenBps: Number(b.breakeven_bps ?? 0) || 0,
totalBps: Number(b.total_bps ?? b.breakeven_bps ?? 0) || 0,
midPrice: Number.isFinite(midPrice) ? midPrice : null,
vwapPrice: Number.isFinite(vwapPrice) ? vwapPrice : null,
impactBps: Number.isFinite(impactBps) ? impactBps : null,
},
];
return next.slice(-4000);
});
}
} catch (e: any) {
setEstimate(null);
setEstimateError(String(e?.message || e));
} finally {
setEstimateLoading(false);
}
};
const tickEstimate = async () => {
if (estimateInFlightRef.current) return;
estimateInFlightRef.current = true;
try {
await runEstimate();
} finally {
estimateInFlightRef.current = false;
}
};
useEffect(() => {
if (!autoEstimate) return;
void tickEstimate();
const t = window.setInterval(() => void tickEstimate(), 1000);
return () => window.clearInterval(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
autoEstimate,
market,
notionalUsd,
side,
orderType,
advancedOpen,
feeTakerBps,
feeMakerBps,
txFeeUsdEst,
expectedReprices,
]);
const [mode, setMode] = useLocalStorageState<'both' | 'active' | 'new'>('trade.costsPanel.mode', 'both');
const effectiveMode = view || mode;
const showActive = effectiveMode === 'both' || effectiveMode === 'active';
const showNew = effectiveMode === 'both' || effectiveMode === 'new';
return (
<div className={['costsPanel', view ? 'costsPanel--stack' : null].filter(Boolean).join(' ')}>
{!view ? (
<div className="costsPanel__toolbar">
<span className="muted">View</span>
<Button
size="sm"
variant={mode === 'both' ? 'primary' : 'ghost'}
onClick={() => setMode('both')}
type="button"
>
Both
</Button>
<Button
size="sm"
variant={mode === 'active' ? 'primary' : 'ghost'}
onClick={() => setMode('active')}
type="button"
>
Active
</Button>
<Button
size="sm"
variant={mode === 'new' ? 'primary' : 'ghost'}
onClick={() => setMode('new')}
type="button"
>
New
</Button>
</div>
) : null}
<div
className={['costsPanel__grid', !showActive || !showNew ? 'costsPanel__grid--single' : null]
.filter(Boolean)
.join(' ')}
>
{showActive ? (
<section className="costsCard">
<div className="costsCard__head">
<div className="costsCard__title">Active contract</div>
<div className="costsCard__actions">
<Button size="sm" variant={autoPoll ? 'primary' : 'ghost'} onClick={() => setAutoPoll((v) => !v)} type="button">
Auto
</Button>
<Button size="sm" variant="ghost" onClick={() => void loadMonitor()} type="button" disabled={!normalizedContractId || monitorLoading}>
Refresh
</Button>
</div>
</div>
<div className="costsForm">
<label className="inlineField">
<span className="inlineField__label">contract_id</span>
<input
className="inlineField__input"
value={contractId}
onChange={(e) => setContractId(e.target.value)}
placeholder="uuid…"
/>
</label>
<label className="inlineField">
<span className="inlineField__label">poll ms</span>
<input
className="inlineField__input"
type="number"
min={250}
step={250}
value={pollMs}
onChange={(e) => setPollMs(Number(e.target.value))}
disabled={!autoPoll}
/>
</label>
</div>
{monitorError ? <div className="uiError">{monitorError}</div> : null}
<div className="costsKpis">
<div className="costKpi">
<div className="costKpi__label">Fees</div>
<div className="costKpi__value">{formatUsd(monitor?.costs?.tradeFeeUsd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Tx</div>
<div className="costKpi__value">{formatUsd(monitor?.costs?.txFeeUsd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Slippage</div>
<div className="costKpi__value">{formatUsd(monitor?.costs?.slippageUsd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Funding</div>
<div className="costKpi__value">{formatUsd(monitor?.costs?.fundingUsd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Total costs</div>
<div className="costKpi__value">{formatUsd(monitor?.costs?.totalCostsUsd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Net PnL</div>
<div className={['costKpi__value', (monitor?.costs?.netPnlUsd ?? 0) >= 0 ? 'pos' : 'neg'].join(' ')}>
{formatUsd(monitor?.costs?.netPnlUsd ?? null)}
</div>
</div>
</div>
<div className="costsMeta muted">
<div>events: {monitor?.eventsCount ?? '—'}</div>
<div>tx: {monitor?.costs?.txCount ?? '—'}</div>
<div>fills: {monitor?.costs?.fillCount ?? '—'}</div>
<div>cancel: {monitor?.costs?.cancelCount ?? '—'}</div>
<div>modify: {monitor?.costs?.modifyCount ?? '—'}</div>
</div>
<div className="costsCard__subhead">PnL / costs over time</div>
<div className="costCharts">
<MiniLineChart title="Net PnL" points={monitor?.series || []} valueKey="netPnlUsd" />
<MiniLineChart title="Total costs" points={monitor?.series || []} valueKey="totalCostsUsd" />
</div>
<div className="costsCard__subhead">Close now (DLOB quote)</div>
<div className="costsClose">
<div className="costsClose__row">
<span className="muted">buy impact</span>
<span>{formatBps(monitor?.closeEstimate?.buy?.impact_bps ?? null)}</span>
<span className="muted">vwap</span>
<span>{monitor?.closeEstimate?.buy?.vwap_price ?? '—'}</span>
</div>
<div className="costsClose__row">
<span className="muted">sell impact</span>
<span>{formatBps(monitor?.closeEstimate?.sell?.impact_bps ?? null)}</span>
<span className="muted">vwap</span>
<span>{monitor?.closeEstimate?.sell?.vwap_price ?? '—'}</span>
</div>
</div>
</section>
) : null}
{showNew ? (
<section className="costsCard costsCard--new">
<div className="costsCard__head">
<div className="costsCard__title">New contract estimate</div>
<div className="costsCard__actions">
<Button
size="sm"
variant={autoEstimate ? 'primary' : 'ghost'}
onClick={() => setAutoEstimate((v) => !v)}
type="button"
title="Auto refresh (1s)"
>
Auto
</Button>
<Button size="sm" variant="primary" onClick={() => void runEstimate()} type="button" disabled={estimateLoading}>
Estimate
</Button>
<Button size="sm" variant={advancedOpen ? 'primary' : 'ghost'} onClick={() => setAdvancedOpen((v) => !v)} type="button">
Advanced
</Button>
</div>
</div>
<div className="costsNewLayout">
<div className="costsNewCharts">
<div className="costsMeta costsMeta--new">
<span className="muted">Live window</span>
<select
className="inlineField__input"
value={estimateWindowSec}
onChange={(e) => setEstimateWindowSec(Number(e.target.value))}
style={{ width: 120 }}
title="Chart window"
>
<option value={60}>60s</option>
<option value={300}>5m</option>
<option value={900}>15m</option>
<option value={3600}>1h</option>
</select>
</div>
<div className="costCharts costCharts--new">
<EstimateChart
title="Price (mid vs vwap)"
points={estimateSeries}
windowSec={estimateWindowSec}
kind="price"
/>
<EstimateChart title="Costs (bps)" points={estimateSeries} windowSec={estimateWindowSec} kind="costBps" />
<EstimateChart title="Costs (USD)" points={estimateSeries} windowSec={estimateWindowSec} kind="costUsd" />
</div>
</div>
<aside className="costsNewSide">
<div className="costsForm costsForm--newSide">
<label className="inlineField">
<span className="inlineField__label">market</span>
<input className="inlineField__input" value={market} disabled />
</label>
<label className="inlineField">
<span className="inlineField__label">notional</span>
<input
className="inlineField__input"
type="number"
min={0.01}
step={0.01}
value={notionalUsd}
onChange={(e) => setNotionalUsd(Number(e.target.value))}
/>
</label>
<label className="inlineField">
<span className="inlineField__label">side</span>
<select className="inlineField__input" value={side} onChange={(e) => setSide(e.target.value as any)}>
<option value="long">long</option>
<option value="short">short</option>
</select>
</label>
<label className="inlineField">
<span className="inlineField__label">order</span>
<select
className="inlineField__input"
value={orderType}
onChange={(e) => setOrderType(e.target.value as any)}
>
<option value="market">market (taker)</option>
<option value="limit">limit/post-only (maker)</option>
</select>
</label>
</div>
{advancedOpen ? (
<div className="costsForm costsForm--newSide">
<label className="inlineField">
<span className="inlineField__label">taker bps</span>
<input
className="inlineField__input"
type="number"
step={0.1}
value={feeTakerBps}
onChange={(e) => setFeeTakerBps(Number(e.target.value))}
/>
</label>
<label className="inlineField">
<span className="inlineField__label">maker bps</span>
<input
className="inlineField__input"
type="number"
step={0.1}
value={feeMakerBps}
onChange={(e) => setFeeMakerBps(Number(e.target.value))}
/>
</label>
<label className="inlineField">
<span className="inlineField__label">tx usd est</span>
<input
className="inlineField__input"
type="number"
step={0.001}
value={txFeeUsdEst}
onChange={(e) => setTxFeeUsdEst(Number(e.target.value))}
/>
</label>
<label className="inlineField">
<span className="inlineField__label">reprices</span>
<input
className="inlineField__input"
type="number"
min={0}
step={1}
value={expectedReprices}
onChange={(e) => setExpectedReprices(Number(e.target.value))}
/>
</label>
</div>
) : (
<div className="muted" style={{ fontSize: 12 }}>
Defaults: backend computes fee/tx/modify estimates (use Advanced to override).
</div>
)}
{estimateError ? <div className="uiError">{estimateError}</div> : null}
<div className="costsKpis costsKpis--newSide">
<div className="costKpi">
<div className="costKpi__label">Total</div>
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.total_usd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Breakeven</div>
<div className="costKpi__value">{formatBps(estimate?.breakdown?.breakeven_bps ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Fee</div>
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.trade_fee_usd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Slippage</div>
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.slippage_usd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Tx</div>
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.tx_fee_usd ?? null)}</div>
</div>
<div className="costKpi">
<div className="costKpi__label">Modify</div>
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.expected_modify_usd ?? null)}</div>
</div>
</div>
<div className="costsCard__subhead">DLOB quote used</div>
<div className="costsClose">
<div className="costsClose__row">
<span className="muted">size</span>
<span>{estimate?.dlob?.size_usd ?? '—'}</span>
<span className="muted">impact</span>
<span>{formatBps(estimate?.dlob?.impact_bps ?? null)}</span>
<span className="muted">fill</span>
<span>{estimate?.dlob?.fill_pct == null ? '—' : `${Number(estimate.dlob.fill_pct).toFixed(0)}%`}</span>
</div>
</div>
</aside>
</div>
</section>
) : null}
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import type { DlobDepthBandRow } from './useDlobDepthBands';
import type { DlobSlippageRow } from './useDlobSlippage';
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
import DlobSlippageChart from './DlobSlippageChart';
import Button from '../../ui/Button';
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
@@ -39,6 +40,8 @@ export default function DlobDashboard({
slippageRows,
slippageConnected,
slippageError,
isFullscreen,
onToggleFullscreen,
}: {
market: string;
stats: DlobStats | null;
@@ -50,6 +53,8 @@ export default function DlobDashboard({
slippageRows: DlobSlippageRow[];
slippageConnected: boolean;
slippageError: string | null;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
}) {
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
@@ -60,6 +65,16 @@ export default function DlobDashboard({
<div className="dlobDash__meta">
<span className="dlobDash__market">{market}</span>
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
{onToggleFullscreen ? (
<Button
size="sm"
variant={isFullscreen ? 'primary' : 'ghost'}
onClick={onToggleFullscreen}
type="button"
>
{isFullscreen ? 'Exit' : 'Fullscreen'}
</Button>
) : null}
</div>
</div>

View File

@@ -3,8 +3,10 @@ import { subscribeGraphqlWs } from '../../lib/graphqlWs';
export type OrderbookRow = {
price: number;
size: number;
total: number;
sizeBase: number;
sizeUsd: number;
totalBase: number;
totalUsd: number;
};
export type DlobL2 = {
@@ -66,11 +68,22 @@ function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number
return out;
}
function withTotals(levels: Array<{ price: number; size: number }>): OrderbookRow[] {
let total = 0;
function withTotals(levels: Array<{ price: number; sizeBase: number }>): OrderbookRow[] {
let totalBase = 0;
let totalUsd = 0;
return levels.map((l) => {
total += l.size;
return { ...l, total };
const sizeUsd = l.sizeBase * l.price;
totalBase += l.sizeBase;
totalUsd += sizeUsd;
return {
price: l.price,
sizeBase: l.sizeBase,
sizeUsd,
totalBase,
totalUsd,
};
});
}
@@ -129,14 +142,25 @@ export function useDlobL2(
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 bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision)
.slice()
.sort((a, b) => b.price - a.price)
.slice(0, levels)
.map((l) => ({ price: l.price, sizeBase: l.size }));
const bids = withTotals(bidsRaw);
const asks = withTotals(asksRaw).slice().reverse();
const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision)
.slice()
.sort((a, b) => a.price - b.price)
.slice(0, levels)
.map((l) => ({ price: l.price, sizeBase: l.size }));
const bestBid = bidsRaw.length ? bidsRaw[0].price : null;
const bestAsk = asksRaw.length ? asksRaw[0].price : null;
// We compute totals from best -> worse.
// For UI we display asks with best ask closest to mid (at the bottom), so we reverse.
const bids = withTotals(bidsSorted);
const asks = withTotals(asksSorted).slice().reverse();
const bestBid = bidsSorted.length ? bidsSorted[0].price : null;
const bestAsk = asksSorted.length ? asksSorted[0].price : null;
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
setL2({

View File

@@ -28,18 +28,6 @@ function toNum(v: unknown): number | 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;
@@ -57,6 +45,7 @@ type HasuraRow = {
type SubscriptionData = {
dlob_slippage_latest: HasuraRow[];
dlob_slippage_latest_v2: HasuraRow[];
};
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
@@ -78,6 +67,23 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
const query = `
subscription DlobSlippage($market: String!) {
dlob_slippage_latest_v2(
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
}
dlob_slippage_latest(
where: { market_name: { _eq: $market } }
order_by: [{ side: asc }, { size_usd: asc }]
@@ -105,11 +111,13 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
onError: (e) => setError(e),
onData: (data) => {
const out: DlobSlippageRow[] = [];
for (const r of data?.dlob_slippage_latest || []) {
const v2 = data?.dlob_slippage_latest_v2 || [];
const src = v2.length ? v2 : data?.dlob_slippage_latest || [];
for (const r of src) {
if (!r?.market_name) continue;
const side = String(r.side || '').trim();
if (side !== 'buy' && side !== 'sell') continue;
const sizeUsd = toInt(r.size_usd);
const sizeUsd = toNum(r.size_usd);
if (sizeUsd == null || sizeUsd <= 0) continue;
out.push({
marketName: r.market_name,
@@ -121,7 +129,10 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
filledUsd: toNum(r.filled_usd),
filledBase: toNum(r.filled_base),
impactBps: toNum(r.impact_bps),
levelsConsumed: toInt(r.levels_consumed),
levelsConsumed: (() => {
const v = toNum(r.levels_consumed);
return v == null ? null : Math.trunc(v);
})(),
fillPct: toNum(r.fill_pct),
updatedAt: r.updated_at ?? null,
});

View File

@@ -47,6 +47,7 @@ export async function fetchChart(params: {
source?: string;
tf: string;
limit: number;
signal?: AbortSignal;
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
const base = getApiBaseUrl();
const u = new URL(base, window.location.origin);
@@ -56,7 +57,7 @@ export async function fetchChart(params: {
u.searchParams.set('limit', String(params.limit));
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
const res = await fetch(u.toString());
const res = await fetch(u.toString(), { signal: params.signal });
const text = await res.text();
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
const json = JSON.parse(text) as ChartResponse;
@@ -79,8 +80,16 @@ export async function fetchChart(params: {
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,
flowRows: Array.isArray((c as any)?.flowRows)
? (c as any).flowRows.map((x: any) => Number(x))
: Array.isArray((c as any)?.flow_rows)
? (c as any).flow_rows.map((x: any) => Number(x))
: undefined,
flowMoves: Array.isArray((c as any)?.flowMoves)
? (c as any).flowMoves.map((x: any) => Number(x))
: Array.isArray((c as any)?.flow_moves)
? (c as any).flow_moves.map((x: any) => Number(x))
: undefined,
})),
indicators: json.indicators || {},
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },

View File

@@ -43,13 +43,30 @@ function resolveGraphqlWsUrl(): string {
}
function resolveAuthHeaders(): HeadersMap | undefined {
const token = envString('VITE_HASURA_AUTH_TOKEN');
if (token) return { authorization: `Bearer ${token}` };
const rawToken = envString('VITE_HASURA_AUTH_TOKEN');
if (rawToken) {
const bearer = normalizeBearerToken(rawToken);
if (bearer) return { authorization: `Bearer ${bearer}` };
}
const secret = envString('VITE_HASURA_ADMIN_SECRET');
if (secret) return { 'x-hasura-admin-secret': secret };
return undefined;
}
function normalizeBearerToken(raw: string): string | undefined {
const trimmed = String(raw || '').trim();
if (!trimmed) return undefined;
const m = trimmed.match(/^Bearer\s+(.+)$/i);
const token = (m ? m[1] : trimmed).trim();
if (!token) return undefined;
const parts = token.split(/\s+/).filter(Boolean);
if (!parts.length) return undefined;
if (parts.length > 1) {
console.warn('VITE_HASURA_AUTH_TOKEN contains whitespace; using the first segment only.');
}
return parts[0];
}
type WsMessage =
| { type: 'connection_ack' | 'ka' | 'complete' }
| { type: 'connection_error'; payload?: any }

View File

@@ -23,7 +23,18 @@ function getHasuraUrl(): string {
function getAuthToken(): string | undefined {
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
return v ? String(v) : undefined;
const raw = v ? String(v) : '';
const trimmed = raw.trim();
if (!trimmed) return undefined;
const m = trimmed.match(/^Bearer\s+(.+)$/i);
const token = (m ? m[1] : trimmed).trim();
if (!token) return undefined;
const parts = token.split(/\s+/).filter(Boolean);
if (!parts.length) return undefined;
if (parts.length > 1) {
console.warn('VITE_HASURA_AUTH_TOKEN contains whitespace; using the first segment only.');
}
return parts[0];
}
function getAdminSecret(): string | undefined {

View File

@@ -411,7 +411,7 @@ a:hover {
.marketHeader {
display: flex;
align-items: center;
align-items: flex-start;
gap: 14px;
}
@@ -480,12 +480,16 @@ a:hover {
.statsRow {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
/* 7 stat tiles by default (Last/Oracle/Bid/Ask/Spread/DLOB/L2), but keep it responsive. */
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
column-gap: 14px;
row-gap: 10px;
align-items: start;
}
.stat {
min-width: 0;
overflow: hidden;
}
.stat__label {
@@ -497,12 +501,18 @@ a:hover {
margin-top: 4px;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat__sub {
margin-top: 2px;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chartCard {
@@ -981,6 +991,526 @@ 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;
}
.costsPanel {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.costsPanel--stack {
height: 100%;
}
.costsPanel--stack .costsPanel__grid {
height: 100%;
}
.costsPanel__toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.costsPanel__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-height: 0;
}
.costsPanel__grid--single {
grid-template-columns: 1fr;
}
.costsCard {
background: rgba(0, 0, 0, 0.20);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 12px;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.costsCard--new {
gap: 8px;
}
.costsCard--new .costsCard__subhead {
margin-top: 0;
}
.costsCard__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.costsCard__title {
font-weight: 950;
letter-spacing: 0.2px;
}
.costsCard__actions {
display: flex;
align-items: center;
gap: 8px;
}
.costsCard__subhead {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.costsForm {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.costsForm--new {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.costsForm--newSide {
grid-template-columns: 1fr;
}
.costsKpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.costsKpis--new {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.costsKpis--newSide {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.costsNewLayout {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
gap: 12px;
min-height: 0;
}
.costsNewCharts {
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.costsNewSide {
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.costKpi {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 10px;
}
.costsCard--new .costKpi {
padding: 8px;
}
.costsCard--new .costKpi__label {
font-size: 11px;
}
.costsCard--new .costKpi__value {
margin-top: 3px;
font-size: 12px;
}
.costKpi__label {
font-size: 12px;
color: var(--muted);
}
.costKpi__value {
margin-top: 4px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.costsMeta {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 12px;
}
.costsMeta--new {
margin-top: 2px;
}
.costsClose {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
}
.costsClose__row {
display: grid;
grid-template-columns: auto auto auto auto;
gap: 10px;
align-items: baseline;
}
.costCharts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.costCharts--new {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"price price"
"bps usd";
}
.costChart {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.costChart--big {
padding: 12px;
}
.costChart--price {
grid-area: price;
}
.costChart--costBps {
grid-area: bps;
}
.costChart--costUsd {
grid-area: usd;
}
.costChart__canvas {
height: 280px;
min-height: 180px;
}
.costChart--price .costChart__canvas {
height: 240px;
}
.costChart--costBps .costChart__canvas,
.costChart--costUsd .costChart__canvas {
height: 180px;
min-height: 160px;
}
.costChart__canvas canvas {
width: 100% !important;
height: 100% !important;
}
.costChart__head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
}
.costChart__title {
font-size: 12px;
color: var(--muted);
font-weight: 800;
}
.costChart__value {
font-variant-numeric: tabular-nums;
font-weight: 900;
font-size: 12px;
}
.costChart__svg {
width: 100%;
height: 84px;
}
.costChart__scroll {
overflow-x: auto;
overflow-y: hidden;
}
.costChart__svg--time {
display: block;
}
@media (max-width: 1100px) {
.costsPanel__grid {
grid-template-columns: 1fr;
}
.costCharts {
grid-template-columns: 1fr;
}
.costsForm--new {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.costsKpis--new {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.costCharts--new {
grid-template-columns: 1fr;
grid-template-areas:
"price"
"bps"
"usd";
}
.costChart--price .costChart__canvas {
height: 220px;
}
.costsNewLayout {
grid-template-columns: 1fr;
}
}
.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 +1576,78 @@ 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;
opacity: 0.35;
transform: scaleX(var(--ob-total-scale, 0));
transition: transform 220ms ease-out;
will-change: transform;
}
.orderbookRow::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 0;
transform: scaleX(var(--ob-level-scale, 0));
transition: transform 220ms ease-out;
will-change: transform;
}
.orderbookRow--ask::before {
transform-origin: left center;
background: linear-gradient(
90deg,
rgba(239, 68, 68, 0.24) 0%,
rgba(239, 68, 68, 0.09) 60%,
rgba(239, 68, 68, 0) 100%
);
}
.orderbookRow--bid::before {
transform-origin: right center;
background: linear-gradient(
90deg,
rgba(34, 197, 94, 0) 0%,
rgba(34, 197, 94, 0.09) 40%,
rgba(34, 197, 94, 0.24) 100%
);
}
.orderbookRow--ask::after {
transform-origin: left center;
background: linear-gradient(
90deg,
rgba(239, 68, 68, 0.36) 0%,
rgba(239, 68, 68, 0.12) 55%,
rgba(239, 68, 68, 0) 100%
);
}
.orderbookRow--bid::after {
transform-origin: right center;
background: linear-gradient(
90deg,
rgba(34, 197, 94, 0) 0%,
rgba(34, 197, 94, 0.12) 45%,
rgba(34, 197, 94, 0.36) 100%
);
}
.orderbookRow__num {
@@ -1081,6 +1683,69 @@ body.chartFullscreen {
color: var(--muted);
}
.orderbookMeta {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 2px 2px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.orderbookMeta__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 11px;
}
.orderbookMeta__val {
font-variant-numeric: tabular-nums;
}
.liquidityBar {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
}
.liquidityBar::after {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background: rgba(0, 0, 0, 0.55);
transform: translateX(-0.5px);
pointer-events: none;
}
.liquidityBar__bid,
.liquidityBar__ask {
height: 100%;
transform: scaleX(0);
transform-origin: center;
transition: transform 180ms ease-out;
}
.liquidityBar__bid {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.0) 0%, rgba(34, 197, 94, 0.35) 55%, rgba(34, 197, 94, 0.85) 100%);
transform-origin: right center;
transform: scaleX(var(--liq-bid, 0));
}
.liquidityBar__ask {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.85) 0%, rgba(239, 68, 68, 0.35) 45%, rgba(239, 68, 68, 0.0) 100%);
transform-origin: left center;
transform: scaleX(var(--liq-ask, 0));
}
.trades {
display: flex;
flex-direction: column;
@@ -1309,6 +1974,215 @@ body.chartFullscreen {
display: none;
}
.statsRow {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
}
body.stackMode {
overflow: hidden;
}
.stackBackdrop {
position: fixed;
inset: 0;
z-index: 1990;
background: rgba(0, 0, 0, var(--stack-backdrop-opacity, 0.55));
}
.stackDrawerHotspot {
position: fixed;
top: 0;
left: 0;
width: 10px;
height: 96px;
z-index: 4050;
background: transparent;
}
.stackDrawer {
position: fixed;
top: 12px;
left: 12px;
bottom: 12px;
width: min(460px, calc(100vw - 24px));
z-index: 4000;
transform: translateX(0);
transition: transform 180ms ease;
display: flex;
align-items: stretch;
}
.stackDrawer--closed {
transform: translateX(calc(-100% - 20px));
opacity: 0;
box-shadow: none;
pointer-events: none;
border-color: transparent;
}
.stackDrawer.uiCard {
box-shadow: 0 30px 120px rgba(0, 0, 0, 0.55);
pointer-events: auto;
background: rgba(10, 11, 16, var(--stack-drawer-opacity, 0.92));
border-color: rgba(255, 255, 255, 0.10);
backdrop-filter: blur(10px);
}
.stackPanel__actions {
display: flex;
align-items: center;
gap: 10px;
}
.stackPanel__sliders {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.stackPanel__sliderRow {
display: grid;
grid-template-columns: 68px 1fr 46px;
gap: 10px;
align-items: center;
}
.stackPanel__slider {
width: 100%;
}
.stackPanel__sliderValue {
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.stackPanel__layerOpacity {
margin-left: 8px;
width: 160px;
}
.stackPanel__layerOpacityValue {
width: 42px;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.stackPanel__layerBrightness {
margin-left: 8px;
width: 110px;
}
.stackPanel__layerBrightnessValue {
width: 46px;
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.stackPanel__iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.18);
color: rgba(230, 233, 239, 0.9);
cursor: pointer;
user-select: none;
}
.stackPanel__iconBtn:hover {
border-color: rgba(255, 255, 255, 0.16);
background: rgba(0, 0, 0, 0.25);
}
.stackPanel__item--locked {
opacity: 0.92;
}
.stackPanel__item--hidden .stackPanel__label {
opacity: 0.55;
}
.stackPanel__hint {
font-size: 12px;
}
.stackPanel__sub {
font-size: 12px;
margin-bottom: 10px;
}
.stackPanel__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.stackPanel__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.22);
cursor: pointer;
user-select: none;
}
.stackPanel__item:hover {
border-color: rgba(255, 255, 255, 0.16);
background: rgba(0, 0, 0, 0.28);
}
.stackPanel__item--active {
border-color: rgba(168, 85, 247, 0.45);
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.12), 0 18px 50px rgba(0, 0, 0, 0.25);
}
.stackPanel__drag {
font-size: 12px;
color: var(--muted);
opacity: 0.9;
}
.stackPanel__label {
font-weight: 900;
letter-spacing: 0.2px;
}
.stackPanel__badge {
margin-left: auto;
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(168, 85, 247, 0.14);
border: 1px solid rgba(168, 85, 247, 0.25);
color: rgba(230, 233, 239, 0.92);
}
.stackLayer {
position: fixed;
inset: 0;
z-index: 2500;
background: transparent;
padding: 12px;
box-sizing: border-box;
}
.stackLayer__body {
height: 100%;
display: flex;
min-height: 0;
}
.stackLayer__card {
flex: 1;
min-height: 0;
}

View File

@@ -61,7 +61,18 @@ function readProxyBasicAuth(): BasicAuth | undefined {
const apiReadToken = readApiReadToken();
const proxyBasicAuth = readProxyBasicAuth();
const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:8787';
const apiProxyTarget =
process.env.API_PROXY_TARGET ||
process.env.VISUALIZER_PROXY_TARGET ||
process.env.TRADE_UI_URL ||
process.env.TRADE_VPS_URL ||
'https://trade.mpabi.pl';
function isLocalHost(hostname: string | undefined): boolean {
const h = String(hostname || '').trim().toLowerCase();
if (!h) return false;
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0';
}
function parseUrl(v: string): URL | undefined {
try {
@@ -71,9 +82,17 @@ function parseUrl(v: string): URL | undefined {
}
}
function toOrigin(u: URL | undefined): string | undefined {
if (!u) return undefined;
return `${u.protocol}//${u.host}`;
}
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
const apiProxyOrigin = toOrigin(apiProxyTargetUrl);
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
const apiProxyIsLocal = isLocalHost(apiProxyTargetUrl?.hostname);
const apiProxyForceBearer = process.env.API_PROXY_FORCE_BEARER === '1' || process.env.API_PROXY_USE_READ_TOKEN === '1';
function inferUiProxyTarget(apiTarget: string): string | undefined {
try {
@@ -97,7 +116,12 @@ const uiProxyTarget =
process.env.AUTH_PROXY_TARGET ||
inferUiProxyTarget(apiProxyTarget) ||
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
const uiProxyOrigin = toOrigin(parseUrl(uiProxyTarget || ''));
const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget;
const graphqlProxyOrigin = toOrigin(parseUrl(graphqlProxyTarget || ''));
const graphqlProxyBasicAuthEnabled =
process.env.GRAPHQL_PROXY_BASIC_AUTH === '1' || process.env.HASURA_PROXY_BASIC_AUTH === '1';
function applyProxyBasicAuth(proxyReq: any) {
if (!proxyBasicAuth) return false;
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
@@ -105,6 +129,12 @@ function applyProxyBasicAuth(proxyReq: any) {
return true;
}
function applyProxyOrigin(proxyReq: any, origin: string | undefined) {
if (!origin) return;
// Some upstreams (notably WS endpoints) validate Origin and may drop the connection when it doesn't match.
proxyReq.setHeader('Origin', origin);
}
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
const v = proxyRes?.headers?.['set-cookie'];
if (!v) return;
@@ -124,13 +154,37 @@ const proxy: Record<string, any> = {
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyOrigin(proxyReq, apiProxyOrigin);
if (applyProxyBasicAuth(proxyReq)) return;
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
if ((apiProxyIsLocal || apiProxyForceBearer) && apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
});
p.on('proxyReqWs', (proxyReq: any) => {
applyProxyOrigin(proxyReq, apiProxyOrigin);
applyProxyBasicAuth(proxyReq);
});
},
},
};
if (graphqlProxyTarget) {
for (const prefix of ['/graphql', '/graphql-ws']) {
proxy[prefix] = {
target: graphqlProxyTarget,
changeOrigin: true,
ws: true,
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyOrigin(proxyReq, graphqlProxyOrigin);
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq);
});
p.on('proxyReqWs', (proxyReq: any) => {
applyProxyOrigin(proxyReq, graphqlProxyOrigin);
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq);
});
},
};
}
}
if (uiProxyTarget) {
for (const prefix of ['/whoami', '/auth', '/logout']) {
proxy[prefix] = {
@@ -138,6 +192,7 @@ if (uiProxyTarget) {
changeOrigin: true,
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyOrigin(proxyReq, uiProxyOrigin);
applyProxyBasicAuth(proxyReq);
});
p.on('proxyRes', (proxyRes: any) => {
@@ -152,7 +207,7 @@ export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
strictPort: false,
proxy,
},
});

39
doc/candles-cache.md Normal file
View File

@@ -0,0 +1,39 @@
# Candles cache: precompute wszystkich timeframe (1s…1d)
Cel: przełączanie `tf` w UI ma być natychmiastowe. Backend ma **ciągle liczyć** i **przechowywać** świeczki dla wszystkich timeframe:
`1s 3s 5s 15s 30s 1m 3m 5m 15m 30m 1h 4h 12h 1d`
## Jak to działa
1) Ticki (append-only) lądują w `drift_ticks`.
2) Worker `candles-cache-worker`:
- liczy świeczki dla **każdego** `bucket_seconds` bezpośrednio z `drift_ticks`,
- trzyma w DB “ostatnie N” świec (domyślnie `N=1024`) per `(symbol, source, tf)`,
- jeśli danych historycznych jest mniej (np. brak wielu dni) — zapisuje tylko to, co istnieje,
- robi backfill/warmup przy starcie i potem dopisuje “na bieżąco” w pętli.
3) API `GET /v1/chart` czyta **cache-first** z `drift_candles_cache` (fallback do on-demand funkcji, jeśli cache pusty).
## Tabela
- `drift_candles_cache` (Timescale hypertable, partycjonowanie po `bucket`)
- `bucket_seconds` = długość świecy w sekundach
- `source=''` oznacza “(any)” (brak filtra po źródle ticków)
## Worker
Plik: `services/candles-worker/candles-cache-worker.mjs`
Env:
- `CANDLES_SYMBOLS` (np. `SOL-PERP,PUMP-PERP`)
- `CANDLES_SOURCES` (np. `any,drift_oracle`)
- `CANDLES_TFS` (np. `1s,3s,5s,15s,...,1d`)
- `CANDLES_TARGET_POINTS` (default `1024`)
- `CANDLES_BACKFILL_DAYS` (opcjonalnie: wymusza minimalny warmup “co najmniej X dni”)
- `CANDLES_POLL_MS` (default `5000`)
## Dlaczego to jest szybkie
- najcięższe agregacje są robione raz i utrzymywane “na bieżąco”,
- przełączenie `tf` to tylko query po gotowych wierszach (`order_by bucket desc limit N`),
- “flow/brick stack” w `/v1/chart` jest liczone z cache “point candles” (np. `1s/3s/5s/15s/…`) bez skanowania `drift_ticks`.

216
doc/dlob-basics.md Normal file
View File

@@ -0,0 +1,216 @@
# DLOB + L1…L10 — podstawy (co jest czym i gdzie to liczymy)
Ten dokument wyjaśnia pojęcia:
- **DLOB** (Drift Limit Order Book),
- **L1 / L2 / L3** oraz potoczne **L1…L10**,
- na jakich warstwach w naszym stacku powstają dane i metryki,
- gdzie “pracuje AI” (modele/strategie) vs gdzie jest execution (order placement).
## Co to jest DLOB
**DLOB** = *Decentralized Limit Order Book* w Drift.
W praktyce: to jest **księga zleceń** dla rynku (np. `SOL-PERP`):
- **bids** = zlecenia kupna (po stronie bid),
- **asks** = zlecenia sprzedaży (po stronie ask).
Księga ma wiele “poziomów” cenowych; przy każdej cenie stoi pewna ilość (size).
## L1 / L2 / L3 (format i sens)
### L1 (Top of Book)
L1 to skrót od “top of book”:
- **best bid** = najwyższa cena kupna (pierwszy poziom po stronie bid),
- **best ask** = najniższa cena sprzedaży (pierwszy poziom po stronie ask).
Z L1 najczęściej liczysz:
- **spread** = `best_ask - best_bid`,
- **mid** = `(best_bid + best_ask) / 2`.
### L2 (zagregowane poziomy)
L2 to lista poziomów (levels) po obu stronach:
- `bids: [{ price, size }, ...]` (zwykle posortowane malejąco po `price`)
- `asks: [{ price, size }, ...]` (zwykle posortowane rosnąco po `price`)
To jest najpopularniejszy “orderbook UI”: słupki/heat per poziom ceny.
### L3 (pojedyncze zlecenia)
L3 to “niezagregowane” dane: pojedyncze zlecenia (większy wolumen danych).
U nas pod UI i metryki zazwyczaj wystarcza L2.
## L1…L10 (co to znaczy w praktyce)
**L1…L10** to potoczne określenie:
> “pierwsze 10 poziomów z L2 najbliżej top of book”.
To nie jest osobny format; to po prostu wycinek L2.
W naszym stacku “ile leveli bierzemy” kontroluje:
- `DLOB_DEPTH` (np. 10 → “L1…L10”).
## Jak to działa w naszym stacku (warstwy)
Poniżej “łańcuch” od źródła do metryk:
### Warstwa A: On-chain → DLOB w pamięci (VPS/k3s)
Komponent: `dlob-publisher`.
- Łączy się do Solany przez `ENDPOINT` (HTTP RPC) i `WS_ENDPOINT` (WebSocket).
- Subskrybuje konta/zdarzenia i buduje DLOB (orderbook) w pamięci.
- Publikuje snapshoty do Redis (u nas: `dlob-redis`).
To jest najbliżej źródła i zwykle najbardziej “real-time”.
### Warstwa B: Cache + REST API (VPS/k3s)
Komponenty: `dlob-redis` + `dlob-server`.
- `dlob-redis` trzyma snapshoty/publish.
- `dlob-server` udostępnia HTTP:
- `GET /l2?marketName=SOL-PERP&depth=10` → L2 (bids/asks + best bid/ask itp.)
- `GET /l3?...` → L3 (jeśli potrzebujesz)
To jest warstwa dystrybucji danych “w klastrze”, żeby inne serwisy nie musiały gadać bezpośrednio z Solaną.
Uwaga o rynkach:
- `dlob-publisher` ładuje rynki wg `PERP_MARKETS_TO_LOAD` (indeksy) / `SPOT_MARKETS_TO_LOAD`.
- Jeśli rynek nie jest załadowany przez publisher, `dlob-server` nie rozpozna `marketName`.
### Warstwa C: Metryki w DB/Hasura (VPS/k3s)
Komponenty: `dlob-worker`, `dlob-depth-worker`, `dlob-slippage-worker`.
To są “workery pod UI/AI”, które liczą metryki i zapisują je do Postgresa (Hasura).
#### `dlob-worker` (collector + basic stats)
Wejście:
- odpytuje `dlob-server` po HTTP `/l2` (źródło L2),
- rynki: `DLOB_MARKETS`,
- głębokość (ile leveli): `DLOB_DEPTH`,
- częstotliwość: `DLOB_POLL_MS`.
Wyjście (upsert do DB):
- `dlob_l2_latest` = snapshot L2 “latest” per market,
- `dlob_stats_latest` = pochodne metryki liczone z topN leveli (N=`DLOB_DEPTH`), m.in.:
- `mid_price`, `spread_abs`, `spread_bps`,
- `depth_bid_*` / `depth_ask_*`,
- `imbalance`.
Czyli: jeśli pytasz “gdzie liczymy L1…L10 metryki” → tutaj (w `dlob-worker`), bo bierze topN leveli z L2.
#### `dlob-depth-worker` (depth w bandach bps)
Wejście:
- czyta z DB `dlob_l2_latest` (czyli już “przetworzone” L2).
Wyjście:
- `dlob_depth_bps_latest` = płynność w pasmach wokół mid (np. ±5/10/20/50/100/200 bps).
To nie jest “L1…L10”, tylko “ile płynności mieści się w oknie cenowym” wokół mid.
#### `dlob-slippage-worker` (slippage vs size)
Wejście:
- czyta z DB `dlob_l2_latest`.
Wyjście:
- `dlob_slippage_latest` = symulacja wykonania zlecenia (market) po L2 dla progów `DLOB_SLIPPAGE_SIZES_USD`.
To jest bardzo użyteczne jako feature do strategii (“ile kosztuje wejście/wyjście teraz dla X USD”).
## Gdzie “pracuje AI” (TFT itp.)
AI/strategia powinna pracować na warstwie “features”, a nie na surowych subskrypcjach Solany:
Najczęstszy zestaw wejść dla modelu:
- candles/ticki (np. `drift_ticks` + `get_drift_candles(...)`),
- bieżące statsy z DLOB:
- `dlob_stats_latest` (mid/spread/depth/imbalance),
- `dlob_depth_bps_latest` (depth w bandach),
- `dlob_slippage_latest` (slippage vs size),
- opcjonalnie pełny snapshot L2 (z `dlob_l2_latest`), jeśli model potrzebuje “kształtu” książki.
Kluczowa zasada bezpieczeństwa:
- **Model (np. na Vast)** może sugerować “desired state” (wejść/wyjść/parametry),
- **Executor na VPS** zawsze odpowiada za:
- risk checks,
- składanie/cancel/close,
- klucze prywatne i podpisywanie transakcji,
- kill switch.
## Szybki słownik (1-liner)
- **bid**: kupno, zielona strona książki
- **ask**: sprzedaż, czerwona strona książki
- **best bid / best ask (L1)**: top-of-book
- **spread**: koszt “wejścia/wyjścia natychmiast” (ask-bid)
- **mid**: punkt odniesienia między bid/ask
- **L2**: lista poziomów `{price,size}`
- **L1…L10**: top 10 poziomów z L2 (u nas kontrolowane przez `DLOB_DEPTH`)
## Jak liczymy “liquidity” i “kasa” (USD) w metrykach
W UI/DB słowo “liquidity” zwykle oznacza **depth**: “ile wolumenu stoi w orderbooku blisko ceny”.
U nas trzymamy to rozdzielnie dla bid/ask oraz w dwóch wariantach:
### A) TopN leveli (np. L1…L10) — `dlob_stats_latest`
Liczone w `dlob-worker` na podstawie L2 z `/l2`:
- Bierzemy pierwsze `N = DLOB_DEPTH` leveli z `bids` i `asks`.
- Każdy level ma:
- `price = price_int / PRICE_PRECISION`
- `size_base = size_int / BASE_PRECISION`
- “kasa” (notional) na tym levelu: `size_usd = size_base * price`
- Sumujemy po levelach:
- `depth_bid_base = Σ size_base` (po stronie bid),
- `depth_bid_usd = Σ (size_base * price)` (po stronie bid),
- analogicznie `depth_ask_base`, `depth_ask_usd` (po stronie ask).
To odpowiada intuicji “ile jest płynności na L1…LN”.
### B) Okno cenowe w bps od mid — `dlob_depth_bps_latest`
Liczone w `dlob-depth-worker` na podstawie `dlob_l2_latest`:
- Dla pasma `band_bps` wyznaczamy:
- `minBidPrice = mid * (1 - band_bps/10_000)`
- `maxAskPrice = mid * (1 + band_bps/10_000)`
- Sumujemy wszystkie levele, które mieszczą się w tym oknie:
- bids: `price >= minBidPrice`
- asks: `price <= maxAskPrice`
- Liczymy sumy:
- `bid_base`, `bid_usd`, `ask_base`, `ask_usd` tak jak wyżej (`usd = base * price`).
To odpowiada intuicji “ile płynności jest *blisko* ceny w ±X bps”.
### Ważne doprecyzowanie
Te liczby to **notional z orderbooka** (ile “stoi” na poziomach cenowych).
Nie są to “pieniądze w kontrakcie”, tylko przybliżenie kosztu/pojemności wykonania przy danej cenie i bez przesunięcia rynku.
## Spec: Orderbook UI (L1…L10 + “liquidity bars”)
Wizualizacja orderbooka (jak na screenach) jest oparta o L2 i pokazuje tylko topN leveli:
- `N` = liczba leveli wyświetlanych na stronę (np. 10 → “L1…L10”).
### Kolumny / wartości
Na każdym levelu liczymy:
- `size_usd = size_base * price`
W UI pokazujemy:
- `Size (USD)` = `size_usd` dla danego poziomu,
- `Total (USD)` = suma skumulowana od bestprice “w głąb” (cumulative):
- bids: kumulacja od best bid w dół,
- asks: kumulacja od best ask w górę (w UI zwykle best ask jest bliżej środka).
### “Liquidity bars” (znormalizowane słupki tła)
Żeby “na oko” widzieć gdzie stoi płynność:
1) **Level bar (perpoziom)** — normalizacja do największego `size_usd` w widocznych levelach danej strony:
- `level_scale = size_usd / max(size_usd w widoku)`
2) **Total bar (cumulative)** — normalizacja do największego `total_usd` w widocznych levelach danej strony:
- `total_scale = total_usd / max(total_usd w widoku)`
Żeby duże “ściany” nie zabijały kontrastu, warto użyć krzywej:
- `scale_curved = sqrt(clamp01(scale))`
Interpretacja:
- **level bar** = “ile stoi na tym poziomie”,
- **total bar** = “ile stoi łącznie do tego poziomu”.

120
doc/drift-costs.md Normal file
View File

@@ -0,0 +1,120 @@
# Drift Perp: koszty wejścia/edycji/wyjścia (stan na 2026-01-31)
Ten dokument zbiera **wszystkie realne składowe kosztu** przy handlu perps na Drift, żebyśmy mogli je liczyć na backendzie i wizualizować w UI.
## 1) Składowe kosztu (per trade / per pozycja)
### A. Opłata transakcyjna Drift (maker/taker)
- **Taker fee**: procent od **notional** (wartości pozycji w USD/USDC).
- **Maker fee**: zwykle **ujemny** (rebate) dla zleceń maker (np. post-only), zgodnie z aktualnym cennikiem.
- Stawki zależą od wolumenu 30D oraz stakingu DRIFT (dodatkowe zniżki / większe rebate).
- W **High Leverage Mode** taker fee może być podbite (np. 2× najniższy tier).
> TODO: potwierdzić aktualne stawki fee (z Drift SDK / on-chain) i zapisać je jako “source of truth” dla backendu.
**Wzór (pojedynczy fill):**
- `notional = |size_base| * fill_price`
- `trade_fee_usd = notional * fee_rate` (dla maker `fee_rate` może być < 0)
### B. Slippage / spread (koszt rynkowy)
To nie jest fee protokołu, ale realny koszt wejścia/wyjścia:
- `slippage_cost_usd ≈ (fill_price - mid_price) * size_base` (znak zależy od long/short)
- U nas to powinno być liczone z DLOB (L2 + symulacja fill).
### C. Funding (koszt/zarobek w czasie trzymania pozycji)
- Funding jest naliczany w czasie i realizowany przy akcjach użytkownika (trade/deposit/withdraw) w praktyce dla krótkich holdingów (minuty1h) zwykle jest małym składnikiem, ale nie zawsze zerowym.
**Wzór (upraszczając):**
- `funding_usd ≈ Σ (position_notional_usd * funding_rate_interval)`
### D. P&L settlement / “unsettled P&L” (wpływ na withdraw)
- Żeby **wypłacić zysk**, czasem trzeba wykonać `settlePNL` (rozlicza P&L do P&L Pool; nie zamyka pozycji, tylko zmienia cost basis).
- Jeśli brakuje środków w per-market P&L Pool, zysk może być częściowo **unsettled** i nie będzie w pełni wypłacalny od razu.
### E. Liquidation penalty (jeśli konto spadnie poniżej maintenance)
- Przy wejściu w liquidację protokół najpierw anuluje otwarte ordery/LP, a następnie liquidator może redukować pozycje.
- Penalty/fee jest ustawiana per-market i zwykle jest wyższa niż zwykły taker fee (żeby dać rebate liquidatorowi).
### F. Koszt sieci Solana (per instrukcja / per tx)
To koszt infrastrukturalny każdej akcji on-chain (order, cancel, modify, settlePNL, deposit/withdraw, close).
- **Base fee**: 5000 lamports per signature (minimum).
- **Priority fee**: opcjonalny, zależy od congestion.
- Jednorazowo może dojść **rent/account creation** (np. token account), jeśli czegoś brakuje.
## 2) “Ile kosztuje” konkretna akcja (checklista)
### Wejście w pozycję (open / increase)
1) **Solana tx fee** (base + ewentualnie priority)
2) **Drift trading fee** (maker/taker) od notional
3) **Slippage/spread** (z DLOB)
4) (w tle) funding zaczyna naliczać się w czasie
### Zmiana pozycji (increase/decrease/flip)
To po prostu kolejny trade:
- znowu `tx fee + trading fee + slippage`
- oraz często realizacja funding (zależy od tego czy funding został zaktualizowany)
### Wyjście z pozycji (close)
1) `tx fee`
2) `trading fee` (druga strona round-trip)
3) `slippage`
4) **realized PnL** = różnica cen ± funding fees
5) jeśli chcesz wypłacić: możliwe `settlePNL` oraz limit z P&L pool
### Edycja zlecenia (modify)
Zwykle koszt to:
- `tx fee` (czasem modify = cancel+place, zależnie od ścieżki w kliencie)
- brak trading fee, jeśli nie było fill
### Cancel zlecenia
- `tx fee`
- brak trading fee (jeśli 0 fill)
### Monitorowanie zysku / risk (PnL, margin, health)
On-chain: bez kosztu, jeśli tylko czytasz RPC/indexera.
Koszt pojawia się dopiero przy akcjach typu trade/cancel/settle/withdraw.
## 3) Przykład liczbowy (taker, round-trip)
Załóż:
- `notional = 10,000 USDC`
- `taker_fee_rate = 0.0350%` (PRZYKŁAD realna stawka zależy od tieru)
Wtedy:
- wejście: `10,000 * 0.00035 = 3.50 USDC`
- wyjście: `3.50 USDC`
- razem fee (bez slippage/funding): `7.00 USDC` + 2× Solana tx fee (+ priority jeśli ustawisz).
## 4) Co musimy znać, żeby liczyć to “dokładnie” w backendzie
Minimalny zestaw wejść:
- market (np. `SOL-PERP`)
- order type (market/limit/post-only), przewidywany fill path (taker vs maker)
- notional/size, przewidywany fill (DLOB simulation)
- fee tier użytkownika + staking/discounty + ew. fee adjusted markets
- funding history + horyzont (np. 1h/4h/24h/7d)
- czy chcemy uwzględniać `settlePNL` oraz status unsettled PnL przed withdraw
---
## 5) Słownik (kluczowe pojęcia w UI/API)
Poniżej jest skrót pojęć, których używamy w warstwach Costs (New)” i Costs (Active)”:
- `notional` wartość pozycji w USD (np. 10 USD); na tym liczymy bps i fee.
- `bps` (basis points) punkty bazowe: `1 bps = 0.01% = 0.0001`.
Przeliczenie na koszt: `koszt_usd ≈ notional_usd * bps / 10_000`.
- `fee` opłata protokołu Drift (maker/taker) od `notional`; zwykle stała dla danego trybu/tieru.
- `tx fee` koszt transakcji na Solanie (base fee + ewentualny priority fee).
- `slippage` koszt rynkowy wejścia/wyjścia, bo wykonujesz się gorzej niż `mid` (zależy od płynności).
- `impact (bps)` slippage wyrażony w bps (dla danego notionalu).
- `spread` różnica `best_ask - best_bid`; minimalny koszt natychmiastowego wejścia/wyjścia w płytkim booku.
- `mid` `(best_bid + best_ask) / 2`; punkt odniesienia ceny z orderbooka.
- `VWAP` średnia cena wykonania dla danego rozmiaru (symulacja fill po L2).
- `breakeven (bps)` minimalny ruch ceny (w bps), żeby koszty się zwróciły (wyjść na 0).
- `PnL` (profit and loss) zysk/strata:
- `unrealized PnL` na papierze”, gdy pozycja jest otwarta (zależy od ceny teraz),
- `realized PnL` zrealizowany po zamknięciu (lub częściowym zamknięciu) pozycji,
- `net PnL` PnL po odjęciu kosztów (`fee + tx + slippage + funding`).
- `funding` okresowa płatność longshort; koszt albo zysk zależny od rynku i czasu trzymania.
- `close now` estymata kosztu natychmiastowego zamknięcia pozycji (zwykle po przeciwnej stronie booka).
- `modify` / `reprice` koszt zarządzania zleceniem (cancel+place itp.), głównie `tx fee` (czasem wielokrotnie).

View File

@@ -0,0 +1,109 @@
# Drift / Solana: czy mamy dostęp do danych bez Solana RPC?
Pytanie ma dwa znaczenia — rozdzielmy je jasno:
1) **bez własnego (bare metal) RPC** — czyli nie utrzymujemy swojego `solana-validator --rpc`, ale korzystamy z dostawcy RPC albo zewnętrznych serwisów,
2) **bez żadnego RPC w ogóle** — czyli nikt w naszym systemie nie pyta Solany o stan onchain.
TL;DR:
- **Bez własnego RPC**: tak, da się na start (hosted RPC +/lub serwisy zewnętrzne).
- **Bez żadnego RPC**: tylko częściowo (dane “rynkowe” można brać z zewnętrznego DLOB), ale **stan konta/pozycji/fille/funding** i tak pochodzi z chaina, więc ktoś musi mieć RPC.
---
## Co z Twojego “speca” da się mieć bez własnego RPC?
Poniżej mapowanie kategorii danych (z Twojego opisu AF) na źródła:
### B) Prices / microstructure (oracle/mark/BBO, “close now”)
**Da się bez własnego RPC**:
- Tak. W naszym stacku te dane mogą pochodzić z pipeline DLOB (`dlob_*_latest`) i ticków (`drift_ticks`), które są już w DB i dostępne przez Hasurę / `trade-api`.
**Da się bez żadnego RPC w naszej infra** (czyli “my nie łączymy się do RPC”):
- Częściowo tak, jeśli polegamy na zewnętrznym źródle L2/BBO (np. `https://dlob.drift.trade`) — ale to źródło i tak jest zasilane przez czyjeś RPC.
### A) Position snapshot (pozycja: base, entry, side)
**Bez własnego RPC**:
- Tak, jeśli mamy **jakikolwiek** komponent (executor/collector) korzystający z hosted RPC (Helius/QuickNode/itp.) i zapisujący snapshot pozycji do DB.
**Bez żadnego RPC**:
- Praktycznie nie (pozycja jest stanem konta onchain). Wyjątek: jeśli Twój executor/bot sam utrzymuje lokalny stan i zapisuje go do DB — ale po restarcie i tak potrzebujesz reconcile z chaina (czyli RPC).
### C) Account risk (margin/liquidation/health)
**Bez własnego RPC**:
- Tak, jeśli collector liczy to na backendzie z danych Drift (przez hosted RPC) i zapisuje do TS (`contract_metrics_ts` / analogicznie).
**Bez żadnego RPC**:
- Nie, bo margin/liq zależy od stanu konta i parametrów rynku onchain.
### D) Fills / trades (realized PnL + fees + slippage)
**Bez własnego RPC**:
- Tak, jeśli:
- executor składa zlecenia i loguje fille do `bot_events` (to już mamy jako koncept), albo
- collector subskrybuje eventy transakcji / kont przez hosted RPC i zapisuje fille do DB.
**Bez żadnego RPC**:
- Tylko jeśli fille są już zapisane w DB (np. przez bota). Na bieżąco — ktoś musi je wyciągać z chaina.
### E) Funding / payments
**Bez własnego RPC**:
- Tak, ale ktoś musi pobierać funding rate / funding payment (hosted RPC lub inny feed) i zapisywać do DB.
**Bez żadnego RPC**:
- Jak wyżej: tylko z historii zapisanej w DB; na żywo potrzebujesz źródła z chaina.
### F) Order lifecycle costs (cancel/replace/tx)
**Bez własnego RPC**:
- Tak, jeśli executor:
- loguje akcje (create/cancel/replace) i ich koszty (`tx_fee_usd`, priority fee) do `bot_events`, albo
- collector wyciąga metryki tx z RPC i mapuje do orderów.
**Bez żadnego RPC**:
- Tylko retrospektywnie (jeśli już w DB).
---
## Co to znaczy praktycznie dla architektury “backend liczy, UI tylko wyświetla”?
UI/Visualizer **może działać bez bezpośredniego kontaktu z RPC** (łączy się do `trade-api` + Hasura).
Natomiast backend “compute” (k3s) ma dwie opcje zasilania:
1) **Hosted RPC** (na start)
- pro: szybciej, taniej, mniej ops,
- con: limit subskrypcji/WS, możliwe rwania, vendor lockin.
2) **Własny RPC + Geyser/Yellowstone** (docelowo)
- pro: kontrola, stabilność na większej skali, streaming “pro”,
- con: koszt i ops (dyski/IO, tuning, monitoring).
W obu przypadkach “backend liczy” działa tak samo — różni się tylko źródło surowych danych.
---
## Co już mamy w DB “bez RPC w UI”
Z obecnego pipeline (VPS/k3s) mamy “rynkowe” dane pod BBO/slippage:
- `dlob_l2_latest`, `dlob_stats_latest`, `dlob_slippage_latest`, `dlob_depth_bps_latest` (+ TS przez `*_ts`)
- `drift_ticks` (ticki/ceny)
To wystarcza do:
- estymat wejścia/wyjścia (“close now”),
- wykresów spread/slippage/depth,
- części SIM (model slippage/fee) **bez** znajomości pełnego stanu konta.
Do pełnego `contract_metrics_ts` (PnL + risk) brakuje nam jeszcze stałego feedu:
- pozycji konta + margin/liq,
- filli i funding (albo z chaina, albo z logów executora).
## Zobacz też
- “Kanoniczna” architektura w pełni self-hosted (RPC + DLOB): `doc/rpc-dlob-kanoniczna-architektura.md`
- Runbook: bare metal RPC + Geyser/Yellowstone gRPC: `doc/solana-rpc-geyser-setup.md`
- Mapa dokumentów o RPC/DLOB/metrykach: `doc/solana-rpc.md`

262
doc/drift-perp-contract.md Normal file
View File

@@ -0,0 +1,262 @@
# Drift PERP “kontrakt bota” (SOL-PERP) — spec intent → egzekucja → audyt
Ten dokument definiuje **przyszłościowy** kontrakt między:
- **Vast (model/transformer na GPU)**: generuje *trade intent* (bez sekretów),
- **k3s/VPS (executor)**: waliduje ryzyko, wystawia i prowadzi zlecenia na Drift, loguje zdarzenia,
- **UI (visualizer)**: tylko wizualizuje warstwy i stan kontraktów (live + historia).
Kluczowa zasada: **model nigdy nie ma kluczy** i nie “handluje”. Handluje tylko executor w k3s.
Powiązane:
- Strategia “eskalacja horyzontu” (1m→5m→15m→30m→1h z bramkami): `doc/strategy-eskalacja-horyzontu.md`
---
## 1) Co nazywamy “kontraktem” (u nas vs Drift)
Na Drift istnieją:
- **orders** (zlecenia): limit/market/trigger, post-only, reduce-only, IOC/GTC itd.
- **position** (pozycja): rozmiar, kierunek, średnia cena, PnL itd.
- **konto/margin**: collateral i health.
W naszym systemie **kontrakt bota** to byt aplikacyjny (DB + logika), który:
1) opisuje *intent* (wejście + prowadzenie + wyjście),
2) mapuje intent na 1..N orderów na Drift,
3) jest **idempotentny** (nie dubluje orderów po restarcie),
4) jest **modyfikowalny** (cancel+place / zmiana desired state),
5) jest **kończony** (exit policy lub kill-switch),
6) jest **audytowalny** (pełny log decyzji i akcji).
---
## 2) Wybór “lepszy i przyszłościowy”
### A) Cena jako offset, nie absolutna cena (recommended)
Model zwraca cenę wejścia/wyjścia jako **offset** (ticks/bps) względem top-of-book / mid, a nie jako `limit_price`.
Dlaczego:
- odporniejsze na latency (cena się przesuwa, offset pozostaje sensowny),
- łatwiejsze “reprice” (edit policy jest naturalna),
- mniejsze ryzyko “starej ceny” przy krótkim TTL.
Executor i tak zna:
- `best_bid/best_ask/mid`,
- tick size i step size,
- aktualne gates (spread/slippage/depth/freshness).
### B) Desired-state jako rdzeń (recommended)
Kontrakt jest prowadzony jako **desired-state loop**:
- model/kontrakt mówi “co chcę mieć” (np. `target_exposure_usd`),
- executor porównuje “observed vs desired” i wykonuje minimalne akcje.
To upraszcza:
- edycję (zmiana target, update policy),
- reconcile po restarcie,
- panic exit.
---
## 3) Role: Vast vs executor (k3s)
### Vast (model) zwraca
- kompletny **trade_intent**: parametry wejścia/prowadzenia/wyjścia,
- sugestie gates (np. spread/slippage/depth), **ale** nie może ich omijać,
- `confidence/urgency` (metadata).
### Executor (k3s) jest “single source of execution”
- waliduje gates i limity,
- normalizuje do tick/step,
- nadaje idempotentne `client_order_id`,
- składa/canceluje/zamyka (reduce-only),
- prowadzi state machine,
- loguje eventy i mierzy koszty.
---
## 4) Spec: `trade_intent` (Vast → k3s)
Format jest wersjonowany: `intent_schema_version`.
### 4.1 Minimalny szkielet
```jsonc
{
"intent_schema_version": 1,
"decision_id": "ulid-or-uuid",
"bot_id": "bot-sol-perp-01",
"ts": "2026-01-31T00:00:00.000Z",
"ttl_ms": 15000,
"market_name": "SOL-PERP",
"subaccount_id": 0,
"mode": "enter|manage|exit|panic",
"confidence": 0.0,
"urgency": 0.0,
"desired": {
"target_exposure_usd": 0,
"max_position_usd": 200,
"min_trade_usd": 5
},
"entry": {
"side": "long|short",
"order_type": "post_only_limit|limit|market",
"size_usd": 25,
"limit_offset": { "ref": "best_bid|best_ask|mid", "ticks": 1 },
"time_in_force": "GTC|IOC",
"cancel_if_not_filled_ms": 8000
},
"manage": {
"reprice_after_ms": 750,
"reprice_offset_ticks": 1,
"max_reprices_per_min": 30,
"cooldown_ms": 250
},
"exit": {
"max_hold_s": 180,
"stop_loss_bps": 25,
"take_profit_bps": 35,
"exit_order_type": "reduce_only_limit|reduce_only_market",
"exit_limit_offset": { "ref": "best_bid|best_ask|mid", "ticks": 1 }
},
"gates": {
"freshness_max_ms": 1500,
"max_spread_bps": 10,
"max_slippage_bps": 25,
"min_depth_topn_usd": 5000,
"min_depth_band": { "band_bps": 20, "min_usd": 8000 }
}
}
```
### 4.2 Zasady interpretacji
- `ttl_ms`: po tym czasie executor ma prawo *zignorować* intent (stary sygnał).
- `mode`:
- `enter`: wolno otwierać/rozszerzać pozycję,
- `manage`: tylko zarządzanie już istniejącą pozycją/ordreami (bez zwiększania ryzyka),
- `exit`: przejście do `target_exposure_usd=0` i zamykanie,
- `panic`: natychmiast cancel + close (reduce-only), potem `off`.
- `desired.target_exposure_usd` jest źródłem prawdy, ale executor ma **hard cap** `max_position_usd` (model może sugerować, executor egzekwuje).
- `limit_offset`:
- `ref=best_bid` dla wejścia long (maker),
- `ref=best_ask` dla wejścia short,
- ticks/bps są zaokrąglane do tick size rynku.
---
## 5) Mapowanie `trade_intent` → Drift order params (PERP)
Executor buduje “order template” (pseudopola):
- `marketIndex` (wynik mapowania `market_name`)
- `direction`: `long|short`
- `orderType`: `market|limit` (+ `trigger*` jeśli później dodamy SL/TP jako ordery trigger)
- `baseAssetAmount` (z `size_usd` przeliczone do base i zaokrąglone do `baseStepSize`)
- `price` (dla limit): z `limit_offset` + aktualne top-of-book, zaokrąglone do `priceTickSize`
- `postOnly`: true, jeśli `order_type=post_only_limit`
- `reduceOnly`: true na wyjściu (`exit_order_type=reduce_only_*`)
- `immediateOrCancel`: true, jeśli `time_in_force=IOC`
- `clientOrderId` / `userOrderId`: deterministycznie z `decision_id` (patrz niżej)
### 5.1 Idempotencja: `client_order_id`
Wymaganie: po restarcie executora nie może dojść do “double order”.
Zasada:
- każde wejście/wyjście ma stabilne `client_order_id` wywiedzione z `decision_id`,
- jeśli Drift nie wspiera pełnego “modify”, executor robi `cancel + place` ale zachowuje spójne ID (np. `decision_id` + suffix `-r1`, `-r2` dla reprices).
---
## 6) State machine kontraktu (minimal)
Rekomendowane stany:
- `off` (nie handluje)
- `pending_entry` (intent zaakceptowany, order wysłany)
- `entered` (pozycja ≠ 0 lub entry fill)
- `managing` (utrzymuje desired state / repricing)
- `exiting` (reduce-only close w toku)
- `closed` (pozycja 0, brak orderów)
- `rejected` (gates fail / TTL expired)
- `panic` (cancel+close; potem `off`)
Każda zmiana stanu = event do DB.
---
## 7) Co UI wizualizuje (warstwy) a co liczy backend
UI nie liczy. UI:
- subskrybuje `*_latest` (live),
- pobiera `*_ts` (historia),
- renderuje warstwy i kontrakty.
Backend liczy:
- DLOB: `dlob_*_latest` + (docelowo) `dlob_*_ts`
- ticks/candles: `drift_ticks` + `get_drift_candles(...)`
- kontrakty: `bot_intents` / `bot_contracts` / `bot_events` (+ TS wersje)
---
## 8) Metryki do strojenia (co mierzyć i jakie okna czasowe)
Cel: stroić gates, politykę repricing i parametry exit.
### 8.1 Mikrostruktura (gates) — z czego stroimy progi
Źródła: `dlob_stats_*`, `dlob_depth_*`, `dlob_slippage_*`.
Mierz (per market, live + TS):
- `spread_bps`
- `impact_bps` dla docelowych `size_usd` (buy/sell)
- `depth_bid_usd`, `depth_ask_usd` (topN)
- depth w bandach (`band_bps`): `bid_usd/ask_usd`
- `freshness_ms` (now - updated_at)
Okno strojenia:
- start: **7 dni** TS (wystarczy do percentyli i pór doby),
- docelowo: 30+ dni (downsample 1m/5m) dla stabilniejszych reżimów.
Jak stroić:
- progi na percentylach (P90/P95), nie na średniej,
- osobne percentyle per “godzina doby” (płynność) i per “vol regime”.
### 8.2 Jakość egzekucji (czy entry/manage działa)
Źródła: `bot_events` (audyt) + snapshoty z momentu decyzji.
Mierz per kontrakt:
- `time_to_first_fill_ms`, `time_to_full_fill_ms`
- `fill_pct`, `avg_fill_price`
- `reprice_count`, `cancel_count`
- `expected_execution_bps` (z DLOB w chwili decyzji) vs `realized_execution_bps`
- “churn cost”: `tx_count` i (jeśli liczymy) `priority_fee` sumarycznie
Okno strojenia:
- 24h (szybki smoke po deployu),
- 7 dni (tuning),
- 30 dni (stabilizacja).
### 8.3 Wynik i ryzyko (czy strategia ma edge)
Mierz:
- `hold_time_s`
- reason exit: `tp|sl|time|regime|panic`
- MAE/MFE w bps w trakcie hold
- PnL (jeśli macie komplet danych) albo proxy w bps
Okno:
- 7 dni minimalnie, ale sensowniej 30+ dni (reżimy).
---
## 9) Następny krok implementacyjny (po akceptacji)
1) Dodać tabele TS dla warstw (min. 7 dni retencji).
2) Dodać tabele i logi kontraktów (`bot_contracts`, `bot_events`, opcjonalnie `bot_intents_ts`).
3) Dodać “executor” (observe → dry-run → live) oraz integrację Vast.

165
doc/k3s-runtime-map.md Normal file
View File

@@ -0,0 +1,165 @@
# k3s runtime map (VPS `qstack`) — co działa i po co
Ten dokument opisuje **aktualny runtime na VPS** (k3s) dla projektu `trade`: jakie komponenty działają w klastrze, jak płynie dane oraz które tabele/metrki są “źródłem prawdy” dla UI.
Zakładamy namespace: `trade-staging`.
## TL;DR (logika)
- **Dane są zbierane i liczone na backendzie** (k3s).
- UI (`trade-frontend`) **tylko wizualizuje** i proxyuje ruch (API + GraphQL + WS).
- Hasura to **jedyny GraphQL/WS** na “metrics/live” (subscriptions).
- Postgres/Timescale trzyma:
- ticki (`drift_ticks`) + candles (funkcja `get_drift_candles`)
- “latest” warstw DLOB (`dlob_*_latest`)
- (opcjonalnie) historię warstw (`dlob_*_ts`)
## Mapa (ruch z zewnątrz)
```
Internet
|
v
Ingress/Traefik
|
+--> trade-frontend (https://trade.mpabi.pl)
| - /api -> trade-api
| - /graphql -> hasura
| - /graphql-ws -> hasura (WS subscriptions; protokół graphql-ws)
|
+--> (opcjonalnie inne ingressy w tym samym klastrze)
```
### `trade-frontend` (UI + reverse-proxy)
- **Rola:** UI + proxy do usług w klastrze.
- **Dlaczego:** przeglądarka nie dostaje sekretów; token read jest wstrzykiwany serverside, a WS działa przez proxy.
- **Wejście:** HTTP/WS od użytkownika.
- **Wyjście:**
- `/api/*``trade-api`
- `/graphql` (HTTP) → `hasura`
- `/graphql-ws` (WS) → `hasura`
## Mapa (dane rynkowe — ticki / candles)
```
Solana RPC/WS (zewn.)
|
v
trade-ingestor
|
v
trade-api -> Postgres/Timescale (drift_ticks)
|
+--> /v1/chart (candles + wskaźniki liczone na backendzie)
|
v
Hasura (GraphQL query/subscriptions dla wybranych tabel)
```
### `trade-ingestor`
- **Rola:** pobiera dane (oracle/mark) i wysyła ticki do API.
- **Wyjście:** ticki zapisane do `drift_ticks` przez `trade-api`.
### `trade-api`
- **Rola:** API dla UI i algów (healthz, ticks, chart).
- **DB:** zapis do `drift_ticks`.
- **Agregacje:** candles (`get_drift_candles`) + wskaźniki (backend).
## Mapa (DLOB — orderbook + metryki warstw)
```
Solana RPC/WS (zewn.)
|
v
dlob-publisher ----> dlob-redis <---- dlob-server (/l2, /l3)
\
\ (opcjonalne źródło L2)
v
dlob-worker (kolektor L2)
|
v
Postgres/Hasura: dlob_l2_latest + dlob_stats_latest
|
+------------+------------+
| |
v v
dlob-depth-worker dlob-slippage-worker
(bands ±bps) (impact vs size USD)
| |
v v
Postgres/Hasura: dlob_depth_bps_latest Postgres/Hasura: dlob_slippage_latest
|
v
(opcjonalnie) dlob-ts-archiver
|
v
Postgres/Timescale: dlob_stats_ts / dlob_depth_bps_ts / dlob_slippage_ts
```
### `dlob-publisher`
- **Rola:** utrzymuje “żywy” DLOB (onchain) i publikuje snapshoty do Redis.
- **Wejście:** Solana RPC/WS.
- **Wyjście:** publikacja do `dlob-redis`.
### `dlob-redis`
- **Rola:** cache/pubsub pomiędzy publisherem i serwerem HTTP.
### `dlob-server`
- **Rola:** serwuje REST `/l2` i `/l3` na podstawie cache Redis (do debugowania i/lub jako źródło L2).
### `dlob-worker` (kolektor L2 + “basic stats”)
- **Rola:** pobiera snapshoty L2 i liczy podstawowe metryki (`dlob_stats_latest`).
- **Źródło L2:** w praktyce:
- albo zewnętrzne `https://dlob.drift.trade/l2`
- albo wewnętrzne `http://dlob-server:6969/l2` (jeśli tak ustawione)
- **Zapis do tabel:**
- `dlob_l2_latest` (raw L2)
- `dlob_stats_latest` (bid/ask/mid/spread/depth/imbalance)
### `dlob-depth-worker` (depth bands ±bps)
- **Rola:** liczy płynność w pasmach ±bps wokół mid.
- **Źródło:** `dlob_l2_latest`
- **Zapis:** `dlob_depth_bps_latest` (klucz: `market_name + band_bps`)
### `dlob-slippage-worker` (slippage vs size)
- **Rola:** symuluje market fill po L2 dla zadanych rozmiarów (USD) i liczy `impact_bps`.
- **Źródło:** `dlob_l2_latest`
- **Zapis:** `dlob_slippage_latest` (klucz: `market_name + side + size_usd`)
### `dlob-ts-archiver` (historia warstw)
- **Rola:** zapisuje “timeline” dla warstw do hypertabli Timescale (historia pod UI).
- **Źródło:** `dlob_stats_latest`, `dlob_depth_bps_latest`, `dlob_slippage_latest`
- **Zapis:** `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`
- **Retencja (startowo):** ~7 dni (policy w Timescale).
## Co UI realnie czyta (dla “nowych funkcji”)
- live/subscriptions:
- `dlob_stats_latest`
- `dlob_depth_bps_latest`
- `dlob_slippage_latest`
- (jeśli dołączymy w UI wykres “historia”):
- `dlob_stats_ts`
- `dlob_depth_bps_ts`
- `dlob_slippage_ts`
## Najczęstsze miejsca problemów (diagnostyka)
- Jeśli UI nie pokazuje warstw:
- sprawdź czy Hasura trackuje tabele i ma `public select` (bootstrap job),
- sprawdź, czy workery odświeżają `updated_at` w `dlob_*_latest`.
- Jeśli `dlob-worker` loguje 503:
- to zwykle problem na ścieżce `DLOB_HTTP_URL` (upstream/LB/IPv6) — wtedy przełącz na `http://dlob-server:6969`.
- Jeśli WS subscriptions nie łączą:
- sprawdź proxy `/graphql-ws` w `trade-frontend` i origin/CORS w Hasurze.

View File

@@ -0,0 +1,115 @@
# Kanoniczna architektura Drift: własny Solana RPC + własny DLOB
Poniżej jest krótka i konkretna notatka: **co da się wyciągnąć z własnego Solana RPC** oraz **po co jest własny DLOB** w kontekście Drift (perp) i metryk pod trading/SIM.
## 1) Własny Solana RPC — co z niego wyciągniesz
Z **własnego RPC** (full node, a do backfillu najlepiej archival) możesz pobrać **wszystkie dane kontowe i ryzyko**.
### Dane pozycji i konta (RPC)
- pozycja (long/short, size, entry)
- unrealized PnL
- realized PnL (z konta użytkownika / fills)
- margin, free collateral
- liquidation price
- health / margin ratio
- funding (naliczony + historyczny)
Źródło: **konta programu Drift** (User, PerpMarket, SpotMarket). Technicznie: subskrypcje kont + (jeśli potrzebne) `getProgramAccounts`.
### Fills / transakcje / fees (RPC)
- fill price
- fee (maker/taker)
- reduce / add
- tx fee + priority fee
Źródła:
- logi transakcji,
- eventy Drift,
- historia transakcji walleta.
Uwaga: backfill 7d+ jest ciężki bez archival RPC, ale nadal wykonalny (koszt/IO/limity).
### Ceny (RPC)
- oracle price
- mark price (ze stanu rynku)
W praktyce wystarczy RPC + subskrypcje kont.
## 2) Własny DLOB — po co i co daje
**DLOB jest off-chain**, ale jest budowany z **on-chain zleceń limit**.
Co daje DLOB (i to jest kluczowe do “close now” i slippage):
- best bid / best ask (BBO)
- mid price
- spread
- realistyczny slippage
- sensowne “close now cost” (na podstawie top-of-book / L2)
Bez DLOB zwykle zostaje heurystyka na mark/oracle + założony spread/slippage.
### Jak to zrobić praktycznie
Najprostsza opcja to uruchomienie serwisu DLOB (publisher/server) z Drift SDK, który:
- subskrybuje RPC/WS,
- buduje orderbook,
- wystawia API (BBO/depth itp.),
- a worker liczy metryki (spread/depth/slippage) i zapisuje je do DB.
W tym repo mamy opis aktualnego pipeline DLOB w `doc/dlob-services.md` oraz plan “RPC + Geyser/Yellowstone” w `doc/solana-rpc-geyser-setup.md`.
## 3) Mapowanie: metryki → źródło danych
| Metryka | RPC | DLOB |
| --- | --- | --- |
| unrealized PnL | ✅ | ❌ |
| realized / net PnL | ✅ | ❌ |
| fees / funding / tx | ✅ | ❌ |
| margin / liq / health | ✅ | ❌ |
| time in trade | ✅ | ❌ |
| best bid / ask | ❌ | ✅ |
| spread / mid | ❌ | ✅ |
| close now cost | ⚠️ heurystyka | ✅ |
| expected slippage | ⚠️ | ✅ |
## 4) 100% self-hosted (bez vendor lockin)
Da się zrobić w pełni self-hosted (bez Heliusa/cudzych API).
Prosty diagram:
```
[ Solana RPC (+ WS) ]
[ Drift SDK / subscriptions ]
[ DLOB (publisher/server) ]
[ Worker (metrics TS) ]
[ API / Monitor / SIM ]
[ UI (tylko rysuje) ]
```
## 5) Jedyny realny haczyk (operacyjnie)
- `getProgramAccounts` + websockety wymagają solidnego RPC.
- Tanie/limtowane RPC często:
- blokują/limitują GPA,
- ucinają payload,
- dropią WS.
Własny RPC = stabilność i przewidywalność na większej skali.
## 6) TL;DR
- Tak: wyciągniesz wszystko z własnego RPC + własnego DLOB.
- RPC = pozycja, PnL, ryzyko, funding.
- DLOB = bid/ask, spread, slippage, close-now.
- To pasuje idealnie pod scalping + SIM (backend liczy, UI tylko wyświetla).

211
doc/rpc/topol.html Normal file
View File

@@ -0,0 +1,211 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Professional Drift Trading Stack (Own Solana RPC + Own DLOB)</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
line-height: 1.5;
margin: 0;
padding: 32px 20px;
max-width: 980px;
margin-inline: auto;
}
header { margin-bottom: 24px; }
h1 { font-size: 1.6rem; margin: 0 0 8px; }
.subtitle { opacity: 0.85; margin: 0; }
.card {
border: 1px solid rgba(127,127,127,0.35);
border-radius: 14px;
padding: 18px 18px;
margin: 14px 0;
background: rgba(127,127,127,0.05);
}
h2 { font-size: 1.2rem; margin: 0 0 10px; }
h3 { font-size: 1.05rem; margin: 14px 0 8px; }
ul { margin: 8px 0 0 18px; }
li { margin: 6px 0; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.note {
border-left: 4px solid rgba(127,127,127,0.55);
padding: 10px 12px;
margin: 10px 0 0;
background: rgba(127,127,127,0.07);
border-radius: 10px;
}
.pill {
display: inline-block;
padding: 2px 10px;
border: 1px solid rgba(127,127,127,0.35);
border-radius: 999px;
font-size: 0.85rem;
opacity: 0.9;
}
</style>
</head>
<body>
<header>
<h1>Professional Drift Trading Stack</h1>
<p class="subtitle">
Own Solana RPC + Own Drift DLOB (Orderbook). Main rule:
<strong>keep the RPC box lean</strong>, put “trading services” on your second VPS.
<span class="pill">Target: min 10 markets</span>
</p>
</header>
<section class="card">
<h2>Overview</h2>
<p>
Yes — you can build a professional Drift trading stack with your own Solana RPC + your own DLOB,
but youll want a few supporting services around them. The main rule:
keep the RPC box lean, put “trading services” on your second VPS.
</p>
</section>
<section class="card">
<h2>On the Solana RPC server (dedicated) — keep it lean</h2>
<h3>Must-have</h3>
<ul>
<li>
<strong>Solana validator/RPC node</strong><br />
The base RPC your whole stack reads from / sends transactions through.
</li>
<li>
<strong>WireGuard</strong><br />
So RPC is reachable only privately (your second VPS + your admin).
</li>
<li>
<strong>Firewall (nftables/ufw)</strong><br />
Block RPC ports on public NIC; allow them only on WireGuard.
</li>
<li>
<strong>Time sync (chrony)</strong><br />
For stable networking, logs, and trading timestamps.
</li>
<li>
<strong>Monitoring exporters</strong>
<ul>
<li><strong>node_exporter</strong> (CPU/RAM/disk/iowait/network)</li>
<li><strong>solana-exporter</strong> (RPC/validator health via RPC)</li>
</ul>
</li>
<li>
<strong>Log + disk hygiene</strong>
<ul>
<li>logrotate/journald limits</li>
<li>NVMe health (smartmontools/nvme-cli)</li>
<li>alerts on disk filling / iowait</li>
</ul>
</li>
</ul>
<h3>Optional but “pro”</h3>
<ul>
<li>
<strong>Geyser streaming (Yellowstone gRPC plugin)</strong><br />
This gives ultra-low-latency streams of accounts/tx/slots compared to polling RPC.
Useful if you build your own real-time analytics pipeline.
<div class="note">
For Drift specifically, you can run without Geyser at the beginning,
but its the next step when you want “faster-than-RPC” feeds.
</div>
</li>
</ul>
</section>
<section class="card">
<h2>On the second VPS (your trading / app box) — where “pro trading” lives</h2>
<h3>Must-have</h3>
<ul>
<li>
<strong>Drift DLOB server (self-hosted)</strong><br />
This maintains the Drift decentralized orderbook view “fresh off your RPC” and exposes
REST + WS + gRPC/polling, plus health/metrics.
</li>
<li>
<strong>(Optional but common) Drift Gateway</strong><br />
A self-hosted API gateway to interact with Drift; handy for standardized API endpoints
around trading / market info.
</li>
<li>
<strong>Cache (Redis)</strong><br />
Cache top-of-book, funding, oracle snapshots, risk checks; protects your DLOB + RPC
from bursty bot load.
</li>
<li>
<strong>Metrics + dashboards</strong><br />
Prometheus + Grafana + Alertmanager
<div class="note">
Keep Grafana off the validator box; common ops guidance is to separate monitoring UI for safety.
</div>
</li>
<li>
<strong>Your trading services</strong>
<ul>
<li>strategy engine(s)</li>
<li>execution service (transaction builder/sender)</li>
<li>risk service (position limits, kill-switch, circuit breakers)</li>
</ul>
</li>
</ul>
<h3>Optional, depending on how “institutional” you want</h3>
<ul>
<li>
<strong>Database (Postgres/Timescale)</strong><br />
Persist fills, order events, PnL series, backtesting datasets.
</li>
<li>
<strong>Message bus (NATS/Kafka/Redis Streams)</strong><br />
Decouple ingestion (orderbook/events) from strategies/execution.
</li>
</ul>
</section>
<section class="card">
<h2>Cost model (since you asked “cost per request”)</h2>
<p>
With your own RPC, there is no per-request billing. The “cost” is:
</p>
<ul>
<li>fixed monthly servers (your €149/m + the second VPS),</li>
<li>and capacity (CPU/RAM/NVMe/bandwidth) consumed by:
<ul>
<li>DLOB syncing from RPC,</li>
<li>number of WS subscriptions,</li>
<li>how many markets you track.</li>
</ul>
</li>
</ul>
<p class="note">
DLOB exists specifically to reduce RPC load by serving orderbook/trade views to clients
instead of every client rebuilding it from chain.
</p>
</section>
<section class="card">
<h2>Minimal “pro” starting set (recommended)</h2>
<ul>
<li><strong>RPC box:</strong> Solana RPC + WireGuard + firewall + node_exporter + solana-exporter</li>
<li><strong>App VPS:</strong> DLOB server + Redis + Prometheus/Grafana + your bot services</li>
</ul>
<p class="note">
For <strong>min 10 markets</strong>, expect the first scaling pressure to come from
continuous streaming + decoding + caching (DLOB + Redis + your strategy/execution),
and from your RPCs WS load. Next step after the minimal set is usually:
better streaming (Geyser) or more RAM/NVMe depending on bottleneck.
</p>
</section>
<footer style="opacity:.75; margin-top: 22px;">
<small>Saved as HTML — you can paste this into a file like <code>drift-stack.html</code>.</small>
</footer>
</body>
</html>

View File

@@ -0,0 +1,284 @@
# Bare metal: Solana RPC (nonvoting) + Geyser/“Yellowstone” gRPC (Ubuntu 24.04)
Cel: postawić **jedną maszynę** jako **źródło danych onchain**:
- Solana `validator` w trybie **nonvoting** z **RPC + WS** (tylko prywatnie),
- **Geyser gRPC** (“Yellowstone”) jako stabilny, skalowalny feed account/tx/slot,
- serwisy tradingowe (DLOB/boty/DB/UI) działają **osobno** na VPS/k3s.
Ten dokument jest runbookiem. Nie zawiera sekretów.
---
## Powiązane dokumenty (DLOB + metryki + koszty)
Żeby spiąć “RPC/Geyser → dane → metryki → UI”, zobacz też:
- Mapa dokumentów o RPC/DLOB/metrykach: `doc/solana-rpc.md`
- DLOB (co działa w k3s, jakie tabele, skąd dane): `doc/dlob-services.md`
- DLOB basics (L1/L2/L3, pojęcia): `doc/dlob-basics.md`
- Ingest ticków / candles / źródła danych: `doc/workflow-api-ingest.md`
- Readiness do live tradingu (w tym plan pod własny RPC + streaming): `doc/trading-readiness.md`
- Czy da się bez własnego RPC / bez RPC w ogóle (mapowanie źródeł danych): `doc/drift-data-bez-solana-rpc.md`
- Kanoniczna architektura “własny RPC + własny DLOB” (co skąd bierzemy): `doc/rpc-dlob-kanoniczna-architektura.md`
- Koszty kontraktu: API compute/monitor (backend liczy, UI tylko rysuje): `doc/contract-cost-api.md`
- Kanoniczny payload eventów bota pod koszty/PnL (żeby agregacje działały): `doc/bot-events-cost-payload.md`
## 0) Założenia
- OS: **Ubuntu 24.04**
- Sprzęt: **Ryzen 9 9950X, 192GB RAM, 2× Gen5 NVMe, 1Gbps**
- Rola: **RPC node bez voting** (brak vote account)
- Prywatny dostęp: **WireGuard** między bare metal a k3s/VPS
---
## 1) Dlaczego RPC+WS i gRPC jednocześnie
- **RPC/WS** (HTTP + WebSocket) zostaje jako:
- wysyłka transakcji (place/cancel/close),
- odczyty adhoc i fallback.
- **Geyser/Yellowstone gRPC** jest preferowany jako:
- stabilny stream updates (account/slot/tx) dla DLOB/indexerów,
- mniejsze “rwanie” niż WS przy większej skali.
W praktyce: data plane = gRPC, execution plane = RPC.
---
## 2) Podział dysków (musthave)
Rekomendacja (żeby uniknąć I/O contention):
- NVMe #1: ledger / accounts
- mount: `/solana/ledger`
- NVMe #2: snapshots / logs / plugin state
- mount: `/solana/snapshots`
- ewentualnie: `/var/lib/yellowstone`
---
## 3) Porty (proponowane)
Publicznie:
- `22/tcp` (SSH) tylko z Twoich IP (allowlist)
Tylko po WireGuard (private):
- `8899/tcp` RPC HTTP
- `8900/tcp` RPC WS
- `10000/tcp` Geyser gRPC (Yellowstone)
Uwaga: dokładne porty Solany (gossip/TPU) są inne i zależą od flag; one zwykle muszą być publicznie osiągalne do sieci Solany, ale **RPC ma być private**.
---
## 4) WireGuard (skeleton)
Założenie: bare metal = `wg0 = 10.8.0.1`, k3s/VPS = `wg0 = 10.8.0.2`.
### 4.1 Bare metal: `/etc/wireguard/wg0.conf`
```ini
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <BARE_METAL_PRIVATE_KEY>
# opcjonalnie: NAT dla ruchu wychodzącego
# PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
# PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
PublicKey = <K3S_PUBLIC_KEY>
AllowedIPs = 10.8.0.2/32
```
### 4.2 VPS/k3s: `/etc/wireguard/wg0.conf`
```ini
[Interface]
Address = 10.8.0.2/24
PrivateKey = <K3S_PRIVATE_KEY>
[Peer]
PublicKey = <BARE_METAL_PUBLIC_KEY>
Endpoint = <BARE_METAL_PUBLIC_IP>:51820
AllowedIPs = 10.8.0.1/32
PersistentKeepalive = 25
```
Start:
```bash
sudo systemctl enable --now wg-quick@wg0
```
Test:
```bash
ping -c 2 10.8.0.1
```
---
## 5) Firewall: zasada “RPC i gRPC tylko private”
Wariant z `ufw` (przykład, dopasuj do swojego środowiska):
```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH najlepiej allowlist Twoje IP
sudo ufw allow 22/tcp
# WireGuard
sudo ufw allow 51820/udp
# RPC/WS/gRPC tylko na interfejsie wg0 (ufw ma ograniczone wsparcie; alternatywnie nftables)
# Minimalnie: nie otwieraj 8899/8900/10000 na publicznym NIC.
sudo ufw enable
sudo ufw status verbose
```
---
## 6) Instalacja i uruchomienie Solany (nonvoting)
### 6.1 Zasada bezpieczeństwa
- identity key i konfiguracja tylko na serwerze,
- nie commituj tego do gita,
- RPC ma być “private RPC”.
### 6.2 Flagi mogą się zmieniać
Solana bywa zmienna w detalach CLI. Zawsze weryfikuj:
```bash
solana-validator --help | less
```
### 6.3 Minimalny szkic uruchomienia (do uzupełnienia)
Poniżej jest “kształt” nie traktuj jako jedyny prawdziwy set flag:
```bash
solana-validator \
--identity /etc/solana/identity.json \
--ledger /solana/ledger \
--snapshots /solana/snapshots \
--rpc-port 8899 \
--rpc-bind-address 10.8.0.1 \
--private-rpc \
--ws-port 8900 \
--dynamic-port-range 8000-8020 \
--no-voting \
--entrypoint <ENTRYPOINT_1> \
--entrypoint <ENTRYPOINT_2> \
--entrypoint <ENTRYPOINT_3> \
--expected-genesis-hash <GENESIS_HASH> \
--wal-recovery-mode skip_any_corrupted_record
```
Uwagi:
- `--rpc-bind-address` ustaw na IP WireGuard (private).
- `--no-voting` = nonvoting.
- `--dynamic-port-range` i reszta portów zależą od Twojej polityki sieciowej.
### 6.4 systemd (skeleton)
Plik: `/etc/systemd/system/solana-validator.service`
```ini
[Unit]
Description=Solana Validator (non-voting, private RPC)
After=network-online.target wg-quick@wg0.service
Wants=network-online.target
[Service]
User=solana
Group=solana
LimitNOFILE=1048576
ExecStart=/usr/local/bin/solana-validator <ARGS...>
Restart=always
RestartSec=3
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
```
---
## 7) Geyser / “Yellowstone” gRPC
### 7.1 Co to jest
Geyser to plugin, który “wypycha” stream danych z runtimeu walidatora.
Yellowstone gRPC to popularny stack, który wystawia ten stream przez gRPC.
### 7.2 Model wdrożenia (recommended)
- plugin jest skonfigurowany przy starcie `solana-validator` (`--geyser-plugin-config <path>`),
- gRPC endpoint nasłuchuje na `10.8.0.1:10000` (private),
- klienci (k3s) subskrybują gRPC.
### 7.3 Konfiguracja pluginu (placeholder)
Dokładny format config zależy od wybranego pluginu/wersji.
Trzymaj config jako plik na serwerze, np. `/etc/solana/geyser-grpc.json`.
Wymagania, które chcemy mieć niezależnie od implementacji:
- bind na interfejsie WireGuard (`10.8.0.1`),
- opcjonalny auth/token dla klientów,
- limit/allowlist klientów,
- logi do journald + limitowanie.
Test (z VPS/k3s po WireGuard):
- port open: `nc -vz 10.8.0.1 10000` (albo `grpcurl` jeśli masz),
- stream slotów/health (zależy od klienta).
---
## 8) Integracja z naszym stackiem `trade`
### 8.1 Co zmieniamy w k3s
Aktualizujemy secreta z endpointami RPC/WS dla `dlob-publisher` i executora:
- `ENDPOINT=http://10.8.0.1:8899`
- `WS_ENDPOINT=ws://10.8.0.1:8900`
Docelowo dodamy również:
- `GEYSER_GRPC_URL=http://10.8.0.1:10000` (lub `grpc://...`) do collectorów.
### 8.2 Fallback
Zostawiamy fallback RPC endpoint (np. publiczny provider) dla:
- awarii bare-metala,
- bootstrapu,
- sanity check.
Executor ma zawsze mieć tryb degradacji:
- Vast down → `observe/off`,
- feed down → `panic` lub `off` zależnie od ryzyka.
---
## 9) Operacje i monitoring (musthave)
Mierz/alertuj:
- slot lag / “behind”,
- iowait + saturacja NVMe,
- disk fill (`ledger` rośnie),
- restart loop serwisów,
- liczba klientów gRPC / błędy streamu,
- RPC latencja / error rate.
Minimalne narzędzia:
- `node_exporter` + Prometheus/Grafana,
- logrotate/journald limity,
- `smartctl`/`nvme smart-log` dla NVMe.
---
## 10) Gotowość do startu (checklista)
- [ ] WireGuard działa (ping wg IP).
- [ ] RPC/WS/gRPC są dostępne tylko po WG.
- [ ] `solana-validator` trzyma sync, nie robi OOM, I/O stabilne.
- [ ] Geyser gRPC stream stabilny (brak częstych reconnect).
- [ ] `dlob-publisher` działa na nowych endpointach bez “No ws data … resubscribing”.

20
doc/solana-rpc.md Normal file
View File

@@ -0,0 +1,20 @@
# Solana RPC w tym projekcie (mapa dokumentów)
Ten plik jest “spisem treści” do dokumentów o **Solana RPC/WS**, **Geyser/Yellowstone gRPC** oraz tego, jak te źródła danych zasilają **DLOB + metryki + UI**.
## Runbooki i architektura
- Bare metal RPC (nonvoting) + Geyser/Yellowstone gRPC: `doc/solana-rpc-geyser-setup.md`
- “Kanoniczna” architektura selfhosted (RPC + DLOB → DB/Hasura → API → UI): `doc/rpc-dlob-kanoniczna-architektura.md`
- Czy da się bez własnego RPC / bez RPC w ogóle (mapowanie źródeł danych): `doc/drift-data-bez-solana-rpc.md`
## DLOB i warstwy danych
- Serwisy DLOB w k3s/VPS + przepływ danych: `doc/dlob-services.md`
- Pojęcia i metryki DLOB (L1/L2/L3, bps, slippage): `doc/dlob-basics.md`
## Metryki i koszty kontraktów (backend liczy, UI rysuje)
- API do liczenia kosztów “new contract” + monitor kontraktu: `doc/contract-cost-api.md`
- Słownik kosztów, PnL i pojęć (UI + backend): `doc/drift-costs.md`

View File

@@ -0,0 +1,111 @@
# Strategia: eskalacja horyzontu (1m → 5m → 15m → 30m → 1h) z bramkami ryzyka i kosztów
Cel: jeżeli trade nie domyka celu w krótkim oknie (np. **1m**), możemy **kontrolowanie** przejść na dłuższy horyzont (**5m/15m/30m/1h**) *bez wpadania w “przetrzymanie straty”*.
Klucz: to ma działać jako **state machine** z twardymi bramkami (gates), a nie jako “zostawię i zobaczę”.
---
## 1) Model: “czas na realizację celu” + eskalacja okna
### Parametry (na start)
- `target_bps` albo `target_usd` (np. +3 bps, +6 bps…)
- `ttl_per_mode` (time-to-live per tryb):
- `1m`: 60120 s
- `5m`: 510 min
- `15m`: 1530 min
- `30m`: 3060 min
- `1h`: 13 h
### Zasada
Jeśli w danym trybie **nie osiągniesz celu w TTL**, to:
- albo **zamykanie**,
- albo **eskalacja** do wyższego okna — *tylko* jeśli spełnione są bramki (ryzyko/koszty/struktura).
---
## 2) Bramki (gates) — kiedy eskalacja jest dozwolona
### Gate RISK (twarde)
Jeśli którykolwiek warunek jest niespełniony → **wyjście teraz** (close now).
Przykładowe progi:
- `health >= 0.70`
- `margin_ratio >= 0.15..0.20` (zależnie od dźwigni)
- odległość do `liq_price` >= `X%` albo >= `k * ATR`
- `max_drawdown` w oknie bieżącym nie przekracza limitu
### Gate COSTS (twarde)
- `close_now_cost_usd` nie “zjada” potencjału (np. koszty nie mogą przekroczyć `target_usd` albo `target_bps`)
- przy dłuższych trybach (30m/1h) uwzględnij wpływ funding:
- `funding_expected(next_window)` nie dominuje nad edge
### Gate STRUCTURE / EDGE (miękkie, ale zalecane)
Przykłady:
- trend/edge na wyższym oknie nie jest przeciw (np. 5m dla eskalacji 1m→5m)
- spread/slippage z DLOB nie rosną anormalnie
---
## 3) Logika przejść (state machine)
Stan początkowy: `mode=1m`.
Jeżeli `ttl_expired` i `target_not_hit`:
- jeśli `risk_ok && costs_ok && structure_ok` → awans do następnego trybu,
- inaczej → `close_now`.
Przejścia:
- `1m``5m`
- `5m``15m`
- `15m``30m`
- `30m``1h`
Ważne: awans = **zmiana reżimu** (inne TTL/target/gates), a nie “przeciąganie”.
---
## 4) Pułapka i dwa “hamulce”
Pułapka:
> “nie poszło w 1m, to poczekam 5m, jak nie to 15m…” → swing bez planu
### Hamulec A: limit eskalacji
- max 12 awanse na trade (np. `1m→5m→15m` i koniec)
### Hamulec B: limit straty per tryb
- jeśli w trybie 1m strata przekroczy `stop_1m` → wyjście
- nie wolno “przenosić” tej samej straty w nieskończoność do 1h
---
## 5) Jak to zaszyć w naszym backendzie (worker + API)
W `contract_metrics_ts` (lub w warstwie kontraktu) trzymaj:
- `mode_current`
- `mode_enter_ts`
- `ttl_s_for_mode`
- `target_bps_for_mode`
- `gates_passed`:
- `risk: boolean`
- `costs: boolean`
- `structure: boolean`
- (opcjonalnie) `escalations_used` / `escalations_remaining`
### SIM: “czy eskalacja ma sens”
Endpoint typu `POST /v1/simulate/escalate` (MVP) może zwracać:
- `expected_costs` (close now / next window)
- `risk_after` (health/margin/liq)
- `assumptions` (np. BBO+extra bps, fee tier)
---
## 6) TL;DR
- Tak, eskalacja czasu ma sens **jeśli jest kontrolowana**.
- Robimy to jako **state machine** z:
- twardym `RISK gate`,
- twardym `COST gate`,
- limitem eskalacji,
- stopem per tryb.

View File

@@ -0,0 +1,111 @@
# TODO przed zakupem bare metal (RPC+Geyser) — żeby dzień 0 poszedł gładko
Cel: zanim kupisz bare metal, dopinamy wszystko, co nie wymaga własnego RPC, żeby po zakupie:
- tylko postawić RPC+Geyser wg runbooka,
- przepiąć endpointy w k3s i zrobić rollout,
- zweryfikować stabilność feedu i gotowość do live.
---
## Status (staging / `trade.mpabi.pl`) + TODO bieżące
- [x] **Precomputed candles cache (TF: `1s..1d`, target `1024`/TF)** na backendzie (k3s) + worker liczący “ciągle”.
- [x] **DLOB slippage v2** (tabele v2 + dual-write), żeby obsłużyć “rozmiary USD” z częściami dziesiętnymi.
- [x] **Frontend (visualizer)**: dodane TF: `1s 3s 5s 15s 30s 1m 3m 5m 15m 30m 1h 4h 12h 1d` + szybkie przełączanie (abort poprzednich requestów).
- [x] **Wdrożenie na k3s**: zbudowany i wypchnięty nowy obraz `trade-frontend` + zaktualizowany `trade-deploy` (Argo rollout).
**Do zrobienia teraz (żeby „lokalny frontend” i staging działały spójnie):**
- [ ] **Sprawdzić `/graphql` (Hasura proxy) po sesji**: potwierdzić, że po `POST /auth/login` zapytania GraphQL działają i nie ma `Malformed Authorization header`.
- [ ] **Sprawdzić czasy przełączania TF w UI**: czy klik w TF tylko czyta cache i nie czeka na liczenie (ma być natychmiast).
- [ ] **Naprawić „kafelek” w headerze market** na 100% skali (overflow/ellipsis, czytelność liczb).
- [ ] **DLOB fullscreen w stack/layers**: upewnić się, że działa tak jak chart (fullscreen / exit) i że w stack mode jest czytelne.
- [ ] **Panel warstw**: dopracować UX (auto-hide + lock, DnD kolejności, suwaki opacity/brightness na warstwach) + skrócić formatki (więcej miejsca na wykresy).
- [ ] **“New contract estimate” live**: dodać toggle “auto refresh” i rysować wykresy time-series (1 px ~ 1s) tylko dla zmiennych (cena/impact/total), a stałe (fee) jako stałe wartości.
---
## A) Decyzje i parametry (bez kodu, ale blokują implementację)
- [ ] **Docelowe porty i adresacja WireGuard**:
- WG subnet (np. `10.8.0.0/24`), IP bare metal i IP k3s/VPS
- port WG (np. `51820/udp`)
- private bind dla: RPC `8899`, WS `8900`, gRPC `10000`
- [ ] **Polityka dostępu**:
- allowlist IP do SSH
- czy gRPC wymaga auth/token dla klientów
- [ ] **Retencja danych (start)**:
- TS: 7 dni “gęsto” (np. 15s) + czy robimy downsample 1m na dłużej
- [ ] **Model intent**:
- potwierdzone: offset (ticks/bps) + desired-state (jest w `doc/drift-perp-contract.md`)
- [ ] **Ryzyko (hard caps)**:
- max position USD, max reprices/min, max slippage/spread, freshness
---
## B) Dane i historia (żeby warstwy działały live+history)
- [ ] **DLOB TS tables**: `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`
- indeksy `(market_name, ts)` i retencja 7 dni
- [ ] **Archiver/collector**:
- worker, który zapisuje TS (z `*_latest` do `*_ts`), albo rozszerzenie istniejących workerów
- [ ] **Downsample (opcjonalnie, ale pro)**:
- continuous aggregates (Timescale) lub job 1m/5m
- [ ] **Hasura bootstrap**:
- track tabel TS + uprawnienia `select` (public) dla UI history
---
## C) Kontrakty bota i audyt (must-have przed live)
- [ ] **Schema**:
- `bot_contracts` (desired-state + status)
- `bot_events` (audit log)
- mapowanie: `decision_id`, `client_order_id`, `drift_order_id`, `tx_sig`
- [ ] **Observe-only executor** (k3s):
- buduje features i zapisuje `decision` do `bot_events`, bez składania tx
- [ ] **Reconcile logic (no trade yet)**:
- start → odczyt observed state z Drift → porównanie do DB → log “diff”
- [ ] **Kill-switch w executorze**:
- env var + DB flag + safety triggers (feed stale, RPC lag)
---
## D) Vast (GPU tylko na kilka godzin) — przygotowanie pod ephemeral training
- [ ] **Dataset export** (z k3s/DB):
- jeden plik `parquet/jsonl.zst` + `dataset_version` (hash)
- minimalny “train split / eval split”
- [ ] **Training entrypoint** (jedna komenda):
- skrypt/komenda, która: download dataset → train → eval → export
- [ ] **Artifact upload**:
- preferowane: scp na VPS/k3s albo Gitea Packages
- wersjonowanie: `model_version` + `dataset_version`
- [ ] **Predictor contract test**:
- walidator JSON schema `trade_intent`
- test: “intent TTL expired” + “gates fail” + “panic”
---
## E) UI (warstwy + live/history, bez liczenia w JS)
- [ ] **Tryb Live/History**:
- Live: subscriptions na `*_latest`
- History: query na `*_ts` + timeframe `tf`
- [ ] **Warstwy/Panele z `doc/stats.md`**:
- mapowanie 1:1 na tabele (brak obliczeń w UI)
- [ ] **Podgląd kontraktów**:
- panel “Contracts” z `bot_contracts` + “Event timeline” z `bot_events`
---
## F) Operacje (żeby bare metal nie stał się snowflake)
- [ ] **Sekrety i endpointy**:
- w k3s: secret `trade-dlob-rpc` / analogiczny na nowy endpoint (WG)
- fallback endpoint (np. public provider) jako opcjonalny drugi URL
- [ ] **Monitoring/alerty na k3s**:
- freshness DLOB/ticks, error rate workerów, restart loops
- [ ] **Checklist “Day 0”**:
- przejście krok po kroku wg `doc/solana-rpc-geyser-setup.md`
- smoke test: `dlob-publisher` bez reconnect storm

119
doc/trading-readiness.md Normal file
View File

@@ -0,0 +1,119 @@
# Trading readiness (staging → live) — checklista i brakujące elementy
Ten dokument odpowiada na pytanie: **czy obecny warsztat jest “już dobry do trade”** i co musi być dopięte, zanim przejdziemy z obserwacji do live tradingu.
W skrócie:
- Fundament jest dobry do **prototypowania i stagingu**.
- Do “pro trading” brakuje kilku krytycznych elementów bezpieczeństwa, audytu i historii danych.
---
## 1) Co już mamy (mocne strony)
- **k3s + GitOps/snapshoty**: każdy deploy to snapshot, rollback jednym ruchem (`doc/workflow.md`).
- **DLOB pipeline**: orderbook → statsy (spread/depth/slippage) pod UI/strategie (`doc/dlob-basics.md`, `doc/dlob-services.md`).
- **Ingest ticków do DB**: dane rynkowe w Postgres/Timescale + candles przez funkcję (`doc/workflow-api-ingest.md`).
- **UI/Visualizer**: warstwy i panele do obserwacji rynku (`doc/stats.md`).
- **Model plane separation**: Vast ma robić inference bez sekretów (docelowo) (`doc/drift-perp-contract.md` + `doc/vast-gpu-runbook.md`).
- **Plan pod własny RPC + streaming**: bare metal RPC + Geyser/Yellowstone gRPC (`doc/solana-rpc-geyser-setup.md`).
- **Wyjaśnienie “czy da się bez RPC”**: co możemy policzyć z DB i DLOB, a co wymaga feedu onchain (`doc/drift-data-bez-solana-rpc.md`).
To jest wystarczające do:
- obserwacji live,
- strojenia UI,
- budowy i testów pipelineu danych,
- “paper trading” / dry-run w executorze.
---
## 2) Co jest krytycznie brakujące do live tradingu
Poniższe punkty są “musthave” zanim bot zacznie realnie składać zlecenia na Drift.
### 2.1 Kontrakty bota + audyt (DB)
Wymagane tabele i log:
- `bot_contracts` (stan kontraktu / desired-state)
- `bot_events` (decision, order_sent, order_ack, fill, cancel, exit, error, panic)
- mapowanie: `decision_id -> client_order_id -> drift_order_id -> tx_sig`
Cel:
- da się odtworzyć “co poszło na chain”,
- UI pokazuje stan kontraktów (live + historia),
- łatwe debugowanie i strojenie.
### 2.2 Reconcile po restarcie (must-have)
Executor po starcie zawsze:
- czyta `bot_contracts` (desired),
- pobiera observed state z Drift (pozycje + open ordery),
- porównuje i wykonuje minimalne akcje korekcyjne.
Bez tego ryzykujesz:
- “ghost orders”,
- utrzymanie pozycji mimo utraty kontekstu.
### 2.3 Kill-switch + guardian poza klastrem (must-have)
Kill-switch w executorze to za mało, bo klaster/VPS może paść.
Wzorzec:
- osobny mały serwis `bot-guardian` poza głównym VPS/k3s,
- jeśli heartbeat executora jest stary → guardian robi `cancel_all` + `reduce_only close`.
### 2.4 Hard risk caps niezależne od modelu
Nawet jeśli Vast zwraca pełny `trade_intent`, executor musi egzekwować:
- `max_position_usd`, `max_leverage` (pośrednio), `max_orders_per_min`,
- `max_slippage_bps`, `max_spread_bps`, `freshness_max_ms`,
- circuit breakers (np. feed down, RPC lag, drawdown).
Model może proponować parametry, ale nie może omijać twardych limitów.
### 2.5 Historia danych (TS), retencja i downsample
`*_latest` jest świetne do live, ale do strojenia potrzebujesz TS:
- DLOB: `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts` (min. 7 dni)
- kontrakty: `bot_events_ts` / log z timestampem
Docelowo:
- trzymać gęste 7 dni,
- trzymać dłużej downsample (np. 1m/5m) pod analizy reżimów.
### 2.6 Monitoring i alerty (operacyjne)
Minimum alertów:
- feed freshness (DLOB/ticki),
- RPC slot lag / error rate,
- order reject rate,
- panic triggers,
- disk fill/iowait (RPC node).
---
## 3) “Go/No-Go” do pierwszego live (small size)
**Go** jeśli:
- kontrakty i eventy są w DB,
- reconcile działa (test restartu),
- kill-switch działa (test: panic → flat),
- mamy twarde limity ryzyka,
- mamy podstawowe alerty,
- mamy 7 dni TS pod progi (albo chociaż 24h na start) i znamy percentyle spread/slippage/depth.
**No-Go** jeśli:
- nie potrafimy deterministycznie zidentyfikować orderów (brak `client_order_id` / brak logów),
- restart executora może zostawić pozycję bez kontroli,
- nie ma niezależnego guardiana.
---
## 4) Proponowana kolejność implementacji (minimalny path)
1) `bot_contracts` + `bot_events` + UI podgląd kontraktów (read-only).
2) “observe-only executor” (bez tx) → loguje decyzje z modelu/reguł.
3) TS history: DLOB (7 dni) + podstawowe agregacje.
4) Dry-run/paper: executor wylicza i loguje order plan (bez tx).
5) Live minimal: mały size, limit/post-only + chase, twarde caps.
6) Guardian poza klastrem.
7) Geyser/Yellowstone (jeśli WS RPC nie trzyma stabilnie na skali).

123
doc/vast-gpu-runbook.md Normal file
View File

@@ -0,0 +1,123 @@
# Vast GPU (kilka godzin) — runbook do trenowania + eksportu modelu
Cel: używać Vast (GPU) jako **krótkiej sesji treningowej** (kilka godzin), a wynik (wagi/adapter) zapisać trwałe i wersjonowane.
W tym projekcie Vast:
- **nie dostaje sekretów tradingowych**,
- nie ma dostępu do kluczy,
- zwraca tylko `trade_intent`/predykcje.
---
## 1) Najważniejsza zasada przy krótkim wynajmie
Vast jest “ephemeral”. Żeby nie stracić pracy:
- wszystko musi być **reproducible** (konfig + kod + seed),
- model output musi być **mały** i łatwy do przeniesienia (preferuj LoRA/adaptery),
- checkpointy i final artifacts muszą być **uploadowane** poza maszynę (Gitea packages/S3/scp).
---
## 2) Co potrzebujemy mieć gotowe przed startem sesji
### 2.1 Kod i entrypoint
W repo powinien istnieć “one command training”, np.:
- `python -m train ...` albo `bash scripts/train.sh ...`
Zasada: komenda sama:
- pobiera dataset (albo czyta lokalny plik),
- trenuje,
- zapisuje `artifacts/`,
- robi upload.
### 2.2 Dataset (krótki transfer)
Dla sesji “kilka godzin” unikaj wielkich transferów.
Rekomendacja:
- zbuduj dataset jako plik (np. `jsonl`/`parquet`) i spakuj (`zstd`),
- trzymaj wersję datasetu (hash) i loguj ją do metryk.
Źródło danych (rekomendowane u nas):
- `Postgres/Timescale` w k3s → eksport do pliku (offline), potem upload.
### 2.3 Bazowy model
Masz 2 ścieżki:
- pobierasz z internetu (HF) na Vast (szybko, ale zależy od sieci),
- albo trzymasz bazę w swoim storage i ściągasz kontrolowanie.
Na start preferuj LoRA na umiarkowanym modelu i krótkiej sekwencji.
---
## 3) Co uruchamiamy na Vast
### 3.1 Kontener
Najprościej: jeden Docker image z zależnościami (PyTorch + libs).
Obraz powinien być **pinned** (digest) i gotowy do uruchomienia bez `pip install` w trakcie.
### 3.2 Konfiguracja treningu (config file)
Trzymaj config jako plik (np. YAML/JSON) i loguj go do artifactów:
- `model_name`, `model_version`
- `dataset_version` (hash)
- `seq_len`, `batch_size`, `grad_accum`, `lr`
- `lora_r`, `lora_alpha`, `lora_dropout` (jeśli LoRA)
- `seed`
### 3.3 Output
Preferowane outputy:
- LoRA adapter (mały): `adapter.safetensors` + config
- metryki treningu: `metrics.json`
- walidacja: `eval_report.json`
Opcjonalnie:
- export do ONNX/TensorRT jeśli planujesz inference poza GPU (zależne od modelu).
---
## 4) Gdzie trzymamy artefakty (żeby nie zniknęły)
Opcje (w kolejności prostoty):
1) **scp na VPS/k3s** (na start najprostsze)
2) **Gitea Packages** (jeśli chcesz wersjonować jako “package”)
3) **S3/MinIO** (najbardziej skalowalne)
Minimalne wymaganie: zawsze zapisuj `model_version` i `dataset_version` obok wag.
---
## 5) Jak to łączy się z executor / UI
Model na Vast zwraca `trade_intent` (patrz `doc/drift-perp-contract.md`).
Executor w k3s:
- buduje features z DB,
- woła predictor,
- waliduje gates,
- loguje:
- `decision_id`, `model_version`, `features_hash`, `intent`,
- outcome (fill/exit).
Jeśli Vast jest niedostępny:
- executor przechodzi w `observe/off` (nie handluje),
- UI nadal pokazuje warstwy rynku (DLOB/ticki).
---
## 6) Checklist “kilka godzin” (operacyjnie)
- [ ] Vast instance: GPU + wystarczająco VRAM + szybki disk.
- [ ] Pull docker image (pinned).
- [ ] Download dataset (jedna komenda).
- [ ] Train (z logowaniem metryk co N kroków).
- [ ] Eval (krótki).
- [ ] Export adapter/weights.
- [ ] Upload artifacts (scp / packages / S3).
- [ ] Zapisz `model_version` do DB/config (k3s) przed użyciem.

View File

@@ -0,0 +1,89 @@
# Visualizer UI: kafelki → warstwy (stack)
Ten dokument opisuje MVP dla przełączania układu UI w `apps/visualizer`:
- **Grid (kafelki / standard)**: obecny layout aplikacji.
- **Stack (warstwy / fullscreen)**: jedna aktywna warstwa jest na wierzchu (fullscreen), a kolejność warstw można ustawiać przez **DnD**.
## Zachowanie (MVP)
### Wejście / wyjście ze stack
- **Chart**: przycisk `Fullscreen` w pasku narzędzi wykresu przełącza tryb `grid``stack` (dla warstwy `chart`).
- **DLOB**: przycisk `Fullscreen` w nagłówku DLOB przełącza tryb `grid``stack` (dla warstwy `dlob`).
- Wyjście ze stack:
- przycisk `Back` w panelu warstw
- klawisz `Esc` **dwa razy** (w ciągu ~0.8s)
### Warstwy i DnD
- W stack pojawia się panel `Layers` jako **wysuwany drawer z boku**.
- Lista w panelu jest **od góry (top) do dołu (bottom)**:
- element na górze listy jest `active` (czyli jest “na wierzchu”).
- **DnD** na liście:
- przeciągnij element i upuść na inny — zmienisz kolejność (top/bottom).
- Kliknięcie elementu na liście przenosi go na wierzch (`active`).
### Warstwy kosztów (Costs)
- Są dwie osobne warstwy kosztów:
- `Costs (New)` — estymata nowego kontraktu (domyślnie **na samej górze**),
- `Costs (Active)` — monitoring puszczonego kontraktu (pojawia się i jest ustawiana **jeden poziom niżej** po wpisaniu `contract_id`).
- Możesz zmienić kolejność DnD (to nadpisuje domyślne ustawienie).
- Po pierwszym DnD aplikacja oznacza układ jako “manualny” i nie przestawia już automatycznie warstw przy pojawieniu się `contract_id`.
### Auto-hide + lock
- Domyślnie drawer ma **auto-hide** (chowa się po ~1s po zjechaniu kursorem z panelu).
- Przycisk `Auto/Locked`:
- `Auto` = auto-hide włączony,
- `Locked` = auto-hide wyłączony (drawer zostaje otwarty).
- Drawer otwiera się po najechaniu kursorem na wąski “hotspot” przy lewym brzegu ekranu.
### Opacity (UI)
- W drawerze są suwaki:
- `Backdrop` (przyciemnienie tła w stack)
- `Panel` (tło drawera)
- Wartości są zapisywane w localStorage (`trade.stackBackdropOpacity`, `trade.stackDrawerOpacity`).
### Opacity (warstwy)
- Każda warstwa ma swój suwak opacity (0100%):
- `0%` = warstwa niewidoczna,
- `100%` = pełna widoczność.
- DnD nadal ustawia kolejność (z-index), a interakcje trafiają do warstwy `active` (top).
- Wartości są zapisywane w localStorage (`trade.layerOpacity`).
### Jasność (warstwy)
- Każda warstwa ma suwak `brightness` (60180%).
- To jest filtr UI (nie wpływa na dane), przydatny gdy chcesz rozjaśnić wykres/DLOB w stack.
- Wartości są zapisywane w localStorage (`trade.layerBrightness`).
### Widoczność + lock (warstwy)
- Ikona “oka” przełącza widoczność warstwy (nie resetuje suwaka opacity).
- Ikona “kłódki” blokuje warstwę:
- nie da się jej przeciągać (DnD),
- suwak opacity jest wyłączony,
- klik w wiersz nie zmienia kolejności (nie “wypycha” na top).
- Ustawienia są zapisywane w localStorage (`trade.layerVisible`, `trade.layerLocked`).
## Implementacja (gdzie w kodzie)
- Sterowanie układem i panel `Layers`: `apps/visualizer/src/App.tsx`
- `trade.layoutMode` w localStorage: `'grid' | 'stack'`
- `trade.stackOrder` w localStorage: kolejność warstw (z-index; ostatni = top)
- `trade.stackOrderManual` w localStorage: czy użytkownik zmienił kolejność przez DnD (blokuje auto-przestawianie)
- `trade.stackPanelLocked` w localStorage: blokada auto-hide panelu warstw
- `trade.contractId` w localStorage: aktywny kontrakt do monitoringu
- Fullscreen chart przez warstwy: `apps/visualizer/src/features/chart/ChartPanel.tsx`
- `fullscreenOverride` + `onToggleFullscreenOverride` (fullscreen kontrolowany z zewnątrz, bez backdropu)
- Fullscreen DLOB: `apps/visualizer/src/features/market/DlobDashboard.tsx`
- `isFullscreen` + `onToggleFullscreen` (przycisk w nagłówku)
- Panel kosztów: `apps/visualizer/src/features/contracts/ContractCostsPanel.tsx`
## Co dalej (kolejne iteracje)
MVP nie robi jeszcze “prawdziwego” układu kafelków z:
- drag/resize okien w trybie stack,
- wyświetlaniem kilku warstw jednocześnie (np. jako nakładające się okna),
- przełączaniem grid→stack przez zoom kafelka (z animacją).
Proponowane następne kroki:
1) wydzielić `Pane` abstraction (id, title, render, hotkeys),
2) zrobić `grid` jako faktyczne kafelki (Chart, DLOB, Orderbook, TradeForm),
3) dodać “zoom” kafelka → wejście do stack,
4) dodać opcjonalnie drag/resize w stack (na start tylko z-index + focus).

View File

@@ -57,15 +57,15 @@ function timingSafeEqualBuf(a, b) {
function loadBasicAuth() {
const j = readJson(BASIC_AUTH_FILE);
const username = (j?.username || '').toString();
const password = (j?.password || '').toString();
const username = (j?.username || '').toString().trim();
const password = (j?.password || '').toString().trim();
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
return { username, password };
}
function loadApiReadToken() {
const j = readJson(API_READ_TOKEN_FILE);
const token = (j?.token || '').toString();
const token = (j?.token || '').toString().trim();
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
return token;
}