feat(visualizer): add layers + fast timeframe switching
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ tokens/*
|
|||||||
!tokens/*.example.json
|
!tokens/*.example.json
|
||||||
!tokens/*.example.yml
|
!tokens/*.example.yml
|
||||||
!tokens/*.example.yaml
|
!tokens/*.example.yaml
|
||||||
|
gitea/token
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -6,19 +6,17 @@ ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|||||||
|
|
||||||
cd "${SCRIPT_DIR}"
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
export API_PROXY_TARGET="${API_PROXY_TARGET:-https://trade.mpabi.pl}"
|
DEFAULT_PROXY_TARGET="${VISUALIZER_PROXY_TARGET:-${TRADE_UI_URL:-${TRADE_VPS_URL:-https://trade.mpabi.pl}}}"
|
||||||
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-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_API_URL="${VITE_API_URL:-/api}"
|
||||||
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
||||||
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
|
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
|
||||||
|
|
||||||
if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then
|
# Safety: avoid passing stale auth env vars into Hasura WS unless explicitly enabled.
|
||||||
if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then
|
if [[ "${VISUALIZER_USE_HASURA_AUTH:-}" != "1" ]]; then
|
||||||
export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json"
|
unset VITE_HASURA_AUTH_TOKEN
|
||||||
else
|
unset VITE_HASURA_ADMIN_SECRET
|
||||||
echo "Missing basic auth config for VPS proxy."
|
|
||||||
echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from 'react';
|
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 { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
||||||
import AppShell from './layout/AppShell';
|
import AppShell from './layout/AppShell';
|
||||||
import ChartPanel from './features/chart/ChartPanel';
|
import ChartPanel from './features/chart/ChartPanel';
|
||||||
@@ -17,6 +17,78 @@ import { useDlobL2 } from './features/market/useDlobL2';
|
|||||||
import { useDlobSlippage } from './features/market/useDlobSlippage';
|
import { useDlobSlippage } from './features/market/useDlobSlippage';
|
||||||
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
|
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
|
||||||
import DlobDashboard from './features/market/DlobDashboard';
|
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 {
|
function envNumber(name: string, fallback: number): number {
|
||||||
const v = (import.meta as any).env?.[name];
|
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 {
|
function formatUsd(v: number | null | undefined): string {
|
||||||
if (v == null || !Number.isFinite(v)) return '—';
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
if (v >= 1000) return `$${v.toFixed(0)}`;
|
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||||
if (v >= 1) return `$${v.toFixed(2)}`;
|
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||||
return `$${v.toPrecision(4)}`;
|
return `$${v.toPrecision(4)}`;
|
||||||
}
|
}
|
||||||
@@ -42,9 +115,36 @@ function formatQty(v: number | null | undefined, decimals: number): string {
|
|||||||
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderbookBarStyle(scale: number): CSSProperties {
|
function formatCompact(v: number | null | undefined): string {
|
||||||
const s = Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
return { ['--ob-bar-scale' as any]: s } as CSSProperties;
|
const abs = Math.abs(v);
|
||||||
|
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`;
|
||||||
|
if (abs >= 1) return v.toFixed(2);
|
||||||
|
return v.toPrecision(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(scale: number): number {
|
||||||
|
return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function barCurve(scale01: number): number {
|
||||||
|
// Makes small rows visible without letting a single wall dominate.
|
||||||
|
return Math.sqrt(clamp01(scale01));
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties {
|
||||||
|
return {
|
||||||
|
['--ob-total-scale' as any]: barCurve(totalScale),
|
||||||
|
['--ob-level-scale' as any]: barCurve(levelScale),
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liquidityStyle(bid: number, ask: number): CSSProperties {
|
||||||
|
const max = Math.max(1e-9, bid, ask);
|
||||||
|
const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0;
|
||||||
|
const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0;
|
||||||
|
return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhoamiResponse = {
|
type WhoamiResponse = {
|
||||||
@@ -112,17 +212,28 @@ export default function App() {
|
|||||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
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 [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP'));
|
||||||
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
||||||
const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
|
const [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 [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
||||||
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
||||||
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
||||||
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false);
|
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true);
|
||||||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||||||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||||||
'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
'dlob' | 'costs' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||||
>('trade.bottomTab', 'positions');
|
>('trade.bottomTab', envString('VITE_BOTTOM_TAB', 'dlob') as any);
|
||||||
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
||||||
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
||||||
'trade.form.type',
|
'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 [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
||||||
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
||||||
|
|
||||||
|
const [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(() => {
|
useEffect(() => {
|
||||||
if (symbol === 'BONK-PERP') {
|
if (symbol === 'BONK-PERP') {
|
||||||
setSymbol('1MBONK-PERP');
|
setSymbol('1MBONK-PERP');
|
||||||
@@ -141,6 +459,13 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
}
|
}
|
||||||
}, [markets, setSymbol, symbol]);
|
}, [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({
|
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
||||||
symbol,
|
symbol,
|
||||||
source: source.trim() ? source : undefined,
|
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 { 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: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol);
|
||||||
const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(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;
|
first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null;
|
||||||
|
|
||||||
const orderbook = useMemo(() => {
|
const orderbook = useMemo(() => {
|
||||||
if (dlobL2) return { asks: dlobL2.asks, bids: dlobL2.bids, mid: dlobL2.mid as number | null };
|
if (dlobL2) {
|
||||||
if (!latest) return { asks: [], bids: [], mid: null as number | null };
|
return {
|
||||||
|
asks: dlobL2.asks,
|
||||||
|
bids: dlobL2.bids,
|
||||||
|
mid: dlobL2.mid as number | null,
|
||||||
|
bestBid: dlobL2.bestBid,
|
||||||
|
bestAsk: dlobL2.bestAsk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: null as number | null };
|
||||||
const mid = latest.close;
|
const mid = latest.close;
|
||||||
const step = Math.max(mid * 0.00018, 0.0001);
|
const step = Math.max(mid * 0.00018, 0.0001);
|
||||||
const levels = 14;
|
const levels = 10;
|
||||||
|
|
||||||
const asksRaw = Array.from({ length: levels }, (_, i) => ({
|
const asksRaw = Array.from({ length: levels }, (_, i) => ({
|
||||||
price: mid + (i + 1) * step,
|
price: mid + (i + 1) * step,
|
||||||
size: 0.1 + ((i * 7) % 15) * 0.1,
|
sizeBase: 0.1 + ((i * 7) % 15) * 0.1,
|
||||||
}));
|
}));
|
||||||
const bidsRaw = Array.from({ length: levels }, (_, i) => ({
|
const bidsRaw = Array.from({ length: levels }, (_, i) => ({
|
||||||
price: mid - (i + 1) * step,
|
price: mid - (i + 1) * step,
|
||||||
size: 0.1 + ((i * 5) % 15) * 0.1,
|
sizeBase: 0.1 + ((i * 5) % 15) * 0.1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let askTotal = 0;
|
let askTotalBase = 0;
|
||||||
|
let askTotalUsd = 0;
|
||||||
const asks = asksRaw
|
const asks = asksRaw
|
||||||
.slice()
|
.slice()
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
askTotal += r.size;
|
const sizeUsd = r.sizeBase * r.price;
|
||||||
return { ...r, total: askTotal };
|
askTotalBase += r.sizeBase;
|
||||||
|
askTotalUsd += sizeUsd;
|
||||||
|
return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd };
|
||||||
});
|
});
|
||||||
|
|
||||||
let bidTotal = 0;
|
let bidTotalBase = 0;
|
||||||
|
let bidTotalUsd = 0;
|
||||||
const bids = bidsRaw.map((r) => {
|
const bids = bidsRaw.map((r) => {
|
||||||
bidTotal += r.size;
|
const sizeUsd = r.sizeBase * r.price;
|
||||||
return { ...r, total: bidTotal };
|
bidTotalBase += r.sizeBase;
|
||||||
|
bidTotalUsd += sizeUsd;
|
||||||
|
return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { asks, bids, mid };
|
return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null };
|
||||||
}, [dlobL2, latest]);
|
}, [dlobL2, latest]);
|
||||||
|
|
||||||
const maxAskTotal = useMemo(() => {
|
const maxAskTotal = useMemo(() => {
|
||||||
let max = 0;
|
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;
|
return max;
|
||||||
}, [orderbook.asks]);
|
}, [orderbook.asks]);
|
||||||
|
|
||||||
const maxBidTotal = useMemo(() => {
|
const maxBidTotal = useMemo(() => {
|
||||||
let max = 0;
|
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;
|
return max;
|
||||||
}, [orderbook.bids]);
|
}, [orderbook.bids]);
|
||||||
|
|
||||||
|
const maxAskSize = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||||
|
return max;
|
||||||
|
}, [orderbook.asks]);
|
||||||
|
|
||||||
|
const maxBidSize = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||||
|
return max;
|
||||||
|
}, [orderbook.bids]);
|
||||||
|
|
||||||
|
const liquidity = useMemo(() => {
|
||||||
|
const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0;
|
||||||
|
const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0;
|
||||||
|
const bestBid = orderbook.bestBid;
|
||||||
|
const bestAsk = orderbook.bestAsk;
|
||||||
|
const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null;
|
||||||
|
const spreadPct =
|
||||||
|
spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null;
|
||||||
|
return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct };
|
||||||
|
}, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]);
|
||||||
|
|
||||||
const trades = useMemo(() => {
|
const trades = useMemo(() => {
|
||||||
const slice = candles.slice(-24).reverse();
|
const slice = candles.slice(-24).reverse();
|
||||||
return slice.map((c) => {
|
return slice.map((c) => {
|
||||||
@@ -294,6 +656,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<AppShell
|
<AppShell
|
||||||
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
||||||
top={<TickerBar items={topItems} />}
|
top={<TickerBar items={topItems} />}
|
||||||
@@ -366,6 +729,18 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
onToggleBuild={() => setShowBuild((v) => !v)}
|
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
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">
|
<Card className="bottomCard">
|
||||||
@@ -386,9 +761,16 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
slippageRows={slippageRows}
|
slippageRows={slippageRows}
|
||||||
slippageConnected={slippageConnected}
|
slippageConnected={slippageConnected}
|
||||||
slippageError={slippageError}
|
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: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
|
||||||
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
|
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
|
||||||
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
|
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
|
||||||
@@ -428,19 +810,22 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
<div className="orderbook">
|
<div className="orderbook">
|
||||||
<div className="orderbook__header">
|
<div className="orderbook__header">
|
||||||
<span>Price</span>
|
<span>Price</span>
|
||||||
<span className="orderbook__num">Size</span>
|
<span className="orderbook__num">Size (USD)</span>
|
||||||
<span className="orderbook__num">Total</span>
|
<span className="orderbook__num">Total (USD)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="orderbook__rows">
|
<div className="orderbook__rows">
|
||||||
{orderbook.asks.map((r) => (
|
{orderbook.asks.map((r) => (
|
||||||
<div
|
<div
|
||||||
key={`a-${r.price}`}
|
key={`a-${r.price}`}
|
||||||
className="orderbookRow orderbookRow--ask"
|
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__price">{formatQty(r.price, 3)}</span>
|
||||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="orderbookMid">
|
<div className="orderbookMid">
|
||||||
@@ -451,13 +836,37 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
<div
|
<div
|
||||||
key={`b-${r.price}`}
|
key={`b-${r.price}`}
|
||||||
className="orderbookRow orderbookRow--bid"
|
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__price">{formatQty(r.price, 3)}</span>
|
||||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div className="orderbookMeta">
|
||||||
|
<div className="orderbookMeta__row">
|
||||||
|
<span className="muted">Spread</span>
|
||||||
|
<span className="orderbookMeta__val">
|
||||||
|
{liquidity.spreadAbs == null || liquidity.spreadPct == null
|
||||||
|
? '—'
|
||||||
|
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="orderbookMeta__row">
|
||||||
|
<span className="muted">Liquidity (L1–L10)</span>
|
||||||
|
<span className="orderbookMeta__val">
|
||||||
|
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
|
||||||
|
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
|
||||||
|
<div className="liquidityBar__bid" />
|
||||||
|
<div className="liquidityBar__ask" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -622,5 +1031,308 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
</Card>
|
</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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||||
import Card from '../../ui/Card';
|
import Card from '../../ui/Card';
|
||||||
@@ -22,6 +23,9 @@ type Props = {
|
|||||||
showBuild: boolean;
|
showBuild: boolean;
|
||||||
onToggleBuild: () => void;
|
onToggleBuild: () => void;
|
||||||
seriesLabel: string;
|
seriesLabel: string;
|
||||||
|
fullscreenOverride?: boolean;
|
||||||
|
onToggleFullscreenOverride?: () => void;
|
||||||
|
fullscreenStyle?: CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FibDragMode = 'move' | 'edit-b';
|
type FibDragMode = 'move' | 'edit-b';
|
||||||
@@ -56,8 +60,16 @@ export default function ChartPanel({
|
|||||||
showBuild,
|
showBuild,
|
||||||
onToggleBuild,
|
onToggleBuild,
|
||||||
seriesLabel,
|
seriesLabel,
|
||||||
|
fullscreenOverride,
|
||||||
|
onToggleFullscreenOverride,
|
||||||
|
fullscreenStyle,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
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 [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
||||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||||
@@ -86,22 +98,29 @@ export default function ChartPanel({
|
|||||||
const fibRef = useRef<FibRetracement | null>(fib);
|
const fibRef = useRef<FibRetracement | null>(fib);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isExternalFullscreen) return;
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setIsFullscreen(false);
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [isFullscreen]);
|
}, [isExternalFullscreen, isFullscreen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isExternalFullscreen) return;
|
||||||
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
||||||
return () => document.body.classList.remove('chartFullscreen');
|
return () => document.body.classList.remove('chartFullscreen');
|
||||||
}, [isFullscreen]);
|
}, [isExternalFullscreen, isFullscreen]);
|
||||||
|
|
||||||
const cardClassName = useMemo(() => {
|
const cardClassName = useMemo(() => {
|
||||||
return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
return ['chartCard', effectiveFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
||||||
}, [isFullscreen]);
|
}, [effectiveFullscreen]);
|
||||||
|
|
||||||
|
const cardStyle = useMemo(() => {
|
||||||
|
if (!effectiveFullscreen) return undefined;
|
||||||
|
return fullscreenStyle;
|
||||||
|
}, [effectiveFullscreen, fullscreenStyle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeToolRef.current = activeTool;
|
activeToolRef.current = activeTool;
|
||||||
@@ -302,8 +321,8 @@ export default function ChartPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
{!isExternalFullscreen && isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||||
<Card className={cardClassName}>
|
<Card className={cardClassName} style={cardStyle}>
|
||||||
<div className="chartCard__toolbar">
|
<div className="chartCard__toolbar">
|
||||||
<ChartToolbar
|
<ChartToolbar
|
||||||
timeframe={timeframe}
|
timeframe={timeframe}
|
||||||
@@ -315,8 +334,8 @@ export default function ChartPanel({
|
|||||||
priceAutoScale={priceAutoScale}
|
priceAutoScale={priceAutoScale}
|
||||||
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={effectiveFullscreen}
|
||||||
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="chartCard__content">
|
<div className="chartCard__content">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Props = {
|
|||||||
onToggleFullscreen: () => void;
|
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({
|
export default function ChartToolbar({
|
||||||
timeframe,
|
timeframe,
|
||||||
|
|||||||
@@ -641,7 +641,7 @@ export default function TradingChart({
|
|||||||
const buildSlicesPrimitive = new BuildSlicesPrimitive();
|
const buildSlicesPrimitive = new BuildSlicesPrimitive();
|
||||||
volumeSeries.attachPrimitive(buildSlicesPrimitive);
|
volumeSeries.attachPrimitive(buildSlicesPrimitive);
|
||||||
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
|
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
|
||||||
buildSlicesPrimitive.setEnabled(!showBuildRef.current);
|
buildSlicesPrimitive.setEnabled(showBuildRef.current);
|
||||||
|
|
||||||
const buildHoverSeries = chart.addSeries(LineSeries, {
|
const buildHoverSeries = chart.addSeries(LineSeries, {
|
||||||
color: BUILD_FLAT_COLOR,
|
color: BUILD_FLAT_COLOR,
|
||||||
@@ -1127,7 +1127,7 @@ export default function TradingChart({
|
|||||||
|
|
||||||
const buildPrimitive = buildSlicesPrimitiveRef.current;
|
const buildPrimitive = buildSlicesPrimitiveRef.current;
|
||||||
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
|
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
|
||||||
buildPrimitive?.setEnabled(!showBuild);
|
buildPrimitive?.setEnabled(showBuild);
|
||||||
|
|
||||||
if (showBuild) {
|
if (showBuild) {
|
||||||
const hoverTime = hoverCandleTime;
|
const hoverTime = hoverCandleTime;
|
||||||
|
|||||||
@@ -27,33 +27,60 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inFlight = useRef(false);
|
const inFlight = useRef(false);
|
||||||
|
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;
|
inFlight.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
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);
|
setCandles(res.candles);
|
||||||
setIndicators(res.indicators);
|
setIndicators(res.indicators);
|
||||||
setMeta(res.meta);
|
setMeta(res.meta);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
|
if (requestIdRef.current === reqId) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
inFlight.current = false;
|
inFlight.current = false;
|
||||||
}
|
}
|
||||||
}, [symbol, source, tf, limit]);
|
}
|
||||||
|
},
|
||||||
|
[symbol, source, tf, limit]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchOnce();
|
void fetchOnce({ force: true });
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
}, [fetchOnce]);
|
}, [fetchOnce]);
|
||||||
|
|
||||||
useInterval(() => void fetchOnce(), pollMs);
|
useInterval(() => void fetchOnce({ force: false }), pollMs);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }),
|
() => ({ candles, indicators, meta, loading, error, refresh: () => fetchOnce({ force: true }) }),
|
||||||
[candles, indicators, meta, loading, error, fetchOnce]
|
[candles, indicators, meta, loading, error, fetchOnce]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
858
apps/visualizer/src/features/contracts/ContractCostsPanel.tsx
Normal file
858
apps/visualizer/src/features/contracts/ContractCostsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { DlobDepthBandRow } from './useDlobDepthBands';
|
|||||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||||
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
||||||
import DlobSlippageChart from './DlobSlippageChart';
|
import DlobSlippageChart from './DlobSlippageChart';
|
||||||
|
import Button from '../../ui/Button';
|
||||||
|
|
||||||
function formatUsd(v: number | null | undefined): string {
|
function formatUsd(v: number | null | undefined): string {
|
||||||
if (v == null || !Number.isFinite(v)) return '—';
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
@@ -39,6 +40,8 @@ export default function DlobDashboard({
|
|||||||
slippageRows,
|
slippageRows,
|
||||||
slippageConnected,
|
slippageConnected,
|
||||||
slippageError,
|
slippageError,
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
}: {
|
}: {
|
||||||
market: string;
|
market: string;
|
||||||
stats: DlobStats | null;
|
stats: DlobStats | null;
|
||||||
@@ -50,6 +53,8 @@ export default function DlobDashboard({
|
|||||||
slippageRows: DlobSlippageRow[];
|
slippageRows: DlobSlippageRow[];
|
||||||
slippageConnected: boolean;
|
slippageConnected: boolean;
|
||||||
slippageError: string | null;
|
slippageError: string | null;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
||||||
|
|
||||||
@@ -60,6 +65,16 @@ export default function DlobDashboard({
|
|||||||
<div className="dlobDash__meta">
|
<div className="dlobDash__meta">
|
||||||
<span className="dlobDash__market">{market}</span>
|
<span className="dlobDash__market">{market}</span>
|
||||||
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
|||||||
|
|
||||||
export type OrderbookRow = {
|
export type OrderbookRow = {
|
||||||
price: number;
|
price: number;
|
||||||
size: number;
|
sizeBase: number;
|
||||||
total: number;
|
sizeUsd: number;
|
||||||
|
totalBase: number;
|
||||||
|
totalUsd: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DlobL2 = {
|
export type DlobL2 = {
|
||||||
@@ -66,11 +68,22 @@ function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTotals(levels: Array<{ price: number; size: number }>): OrderbookRow[] {
|
function withTotals(levels: Array<{ price: number; sizeBase: number }>): OrderbookRow[] {
|
||||||
let total = 0;
|
let totalBase = 0;
|
||||||
|
let totalUsd = 0;
|
||||||
|
|
||||||
return levels.map((l) => {
|
return levels.map((l) => {
|
||||||
total += l.size;
|
const sizeUsd = l.sizeBase * l.price;
|
||||||
return { ...l, total };
|
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];
|
const row = data?.dlob_l2_latest?.[0];
|
||||||
if (!row?.market_name) return;
|
if (!row?.market_name) return;
|
||||||
|
|
||||||
const bidsRaw = parseLevels(row.bids, pricePrecision, basePrecision).slice(0, levels);
|
const bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision)
|
||||||
const asksRaw = parseLevels(row.asks, pricePrecision, basePrecision).slice(0, levels);
|
.slice()
|
||||||
|
.sort((a, b) => b.price - a.price)
|
||||||
|
.slice(0, levels)
|
||||||
|
.map((l) => ({ price: l.price, sizeBase: l.size }));
|
||||||
|
|
||||||
const bids = withTotals(bidsRaw);
|
const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision)
|
||||||
const asks = withTotals(asksRaw).slice().reverse();
|
.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;
|
// We compute totals from best -> worse.
|
||||||
const bestAsk = asksRaw.length ? asksRaw[0].price : null;
|
// 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;
|
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
|
||||||
|
|
||||||
setL2({
|
setL2({
|
||||||
|
|||||||
@@ -28,18 +28,6 @@ function toNum(v: unknown): number | null {
|
|||||||
return 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 = {
|
type HasuraRow = {
|
||||||
market_name: string;
|
market_name: string;
|
||||||
side: string;
|
side: string;
|
||||||
@@ -57,6 +45,7 @@ type HasuraRow = {
|
|||||||
|
|
||||||
type SubscriptionData = {
|
type SubscriptionData = {
|
||||||
dlob_slippage_latest: HasuraRow[];
|
dlob_slippage_latest: HasuraRow[];
|
||||||
|
dlob_slippage_latest_v2: HasuraRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
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 = `
|
const query = `
|
||||||
subscription DlobSlippage($market: String!) {
|
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(
|
dlob_slippage_latest(
|
||||||
where: { market_name: { _eq: $market } }
|
where: { market_name: { _eq: $market } }
|
||||||
order_by: [{ side: asc }, { size_usd: asc }]
|
order_by: [{ side: asc }, { size_usd: asc }]
|
||||||
@@ -105,11 +111,13 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
|
|||||||
onError: (e) => setError(e),
|
onError: (e) => setError(e),
|
||||||
onData: (data) => {
|
onData: (data) => {
|
||||||
const out: DlobSlippageRow[] = [];
|
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;
|
if (!r?.market_name) continue;
|
||||||
const side = String(r.side || '').trim();
|
const side = String(r.side || '').trim();
|
||||||
if (side !== 'buy' && side !== 'sell') continue;
|
if (side !== 'buy' && side !== 'sell') continue;
|
||||||
const sizeUsd = toInt(r.size_usd);
|
const sizeUsd = toNum(r.size_usd);
|
||||||
if (sizeUsd == null || sizeUsd <= 0) continue;
|
if (sizeUsd == null || sizeUsd <= 0) continue;
|
||||||
out.push({
|
out.push({
|
||||||
marketName: r.market_name,
|
marketName: r.market_name,
|
||||||
@@ -121,7 +129,10 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
|
|||||||
filledUsd: toNum(r.filled_usd),
|
filledUsd: toNum(r.filled_usd),
|
||||||
filledBase: toNum(r.filled_base),
|
filledBase: toNum(r.filled_base),
|
||||||
impactBps: toNum(r.impact_bps),
|
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),
|
fillPct: toNum(r.fill_pct),
|
||||||
updatedAt: r.updated_at ?? null,
|
updatedAt: r.updated_at ?? null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export async function fetchChart(params: {
|
|||||||
source?: string;
|
source?: string;
|
||||||
tf: string;
|
tf: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
const u = new URL(base, window.location.origin);
|
const u = new URL(base, window.location.origin);
|
||||||
@@ -56,7 +57,7 @@ export async function fetchChart(params: {
|
|||||||
u.searchParams.set('limit', String(params.limit));
|
u.searchParams.set('limit', String(params.limit));
|
||||||
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
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();
|
const text = await res.text();
|
||||||
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||||
const json = JSON.parse(text) as ChartResponse;
|
const json = JSON.parse(text) as ChartResponse;
|
||||||
@@ -79,8 +80,16 @@ export async function fetchChart(params: {
|
|||||||
flat: Number((c as any).flow.flat),
|
flat: Number((c as any).flow.flat),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
flowRows: Array.isArray((c as any)?.flowRows) ? (c as any).flowRows.map((x: any) => Number(x)) : undefined,
|
flowRows: Array.isArray((c as any)?.flowRows)
|
||||||
flowMoves: Array.isArray((c as any)?.flowMoves) ? (c as any).flowMoves.map((x: any) => Number(x)) : undefined,
|
? (c as any).flowRows.map((x: any) => Number(x))
|
||||||
|
: Array.isArray((c as any)?.flow_rows)
|
||||||
|
? (c as any).flow_rows.map((x: any) => Number(x))
|
||||||
|
: undefined,
|
||||||
|
flowMoves: Array.isArray((c as any)?.flowMoves)
|
||||||
|
? (c as any).flowMoves.map((x: any) => Number(x))
|
||||||
|
: Array.isArray((c as any)?.flow_moves)
|
||||||
|
? (c as any).flow_moves.map((x: any) => Number(x))
|
||||||
|
: undefined,
|
||||||
})),
|
})),
|
||||||
indicators: json.indicators || {},
|
indicators: json.indicators || {},
|
||||||
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
||||||
|
|||||||
@@ -43,13 +43,30 @@ function resolveGraphqlWsUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAuthHeaders(): HeadersMap | undefined {
|
function resolveAuthHeaders(): HeadersMap | undefined {
|
||||||
const token = envString('VITE_HASURA_AUTH_TOKEN');
|
const rawToken = envString('VITE_HASURA_AUTH_TOKEN');
|
||||||
if (token) return { authorization: `Bearer ${token}` };
|
if (rawToken) {
|
||||||
|
const bearer = normalizeBearerToken(rawToken);
|
||||||
|
if (bearer) return { authorization: `Bearer ${bearer}` };
|
||||||
|
}
|
||||||
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
||||||
if (secret) return { 'x-hasura-admin-secret': secret };
|
if (secret) return { 'x-hasura-admin-secret': secret };
|
||||||
return undefined;
|
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 WsMessage =
|
||||||
| { type: 'connection_ack' | 'ka' | 'complete' }
|
| { type: 'connection_ack' | 'ka' | 'complete' }
|
||||||
| { type: 'connection_error'; payload?: any }
|
| { type: 'connection_error'; payload?: any }
|
||||||
|
|||||||
@@ -23,7 +23,18 @@ function getHasuraUrl(): string {
|
|||||||
|
|
||||||
function getAuthToken(): string | undefined {
|
function getAuthToken(): string | undefined {
|
||||||
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
|
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 {
|
function getAdminSecret(): string | undefined {
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ a:hover {
|
|||||||
|
|
||||||
.marketHeader {
|
.marketHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,12 +480,16 @@ a:hover {
|
|||||||
|
|
||||||
.statsRow {
|
.statsRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
/* 7 stat tiles by default (Last/Oracle/Bid/Ask/Spread/DLOB/L2), but keep it responsive. */
|
||||||
gap: 10px;
|
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||||
|
column-gap: 14px;
|
||||||
|
row-gap: 10px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat__label {
|
.stat__label {
|
||||||
@@ -497,12 +501,18 @@ a:hover {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat__sub {
|
.stat__sub {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartCard {
|
.chartCard {
|
||||||
@@ -981,6 +991,526 @@ body.chartFullscreen {
|
|||||||
padding: 10px 2px;
|
padding: 10px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dlobDash {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__title {
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__market {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__statuses {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobStatus {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobStatus__label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi__label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi__value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi__sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__panes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__pane {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.bottomCard .uiCard__body {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1046,6 +1576,78 @@ body.chartFullscreen {
|
|||||||
padding: 2px 2px;
|
padding: 2px 2px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scaleX(var(--ob-total-scale, 0));
|
||||||
|
transition: transform 220ms ease-out;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
transform: scaleX(var(--ob-level-scale, 0));
|
||||||
|
transition: transform 220ms ease-out;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--ask::before {
|
||||||
|
transform-origin: left center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(239, 68, 68, 0.24) 0%,
|
||||||
|
rgba(239, 68, 68, 0.09) 60%,
|
||||||
|
rgba(239, 68, 68, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--bid::before {
|
||||||
|
transform-origin: right center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(34, 197, 94, 0) 0%,
|
||||||
|
rgba(34, 197, 94, 0.09) 40%,
|
||||||
|
rgba(34, 197, 94, 0.24) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--ask::after {
|
||||||
|
transform-origin: left center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(239, 68, 68, 0.36) 0%,
|
||||||
|
rgba(239, 68, 68, 0.12) 55%,
|
||||||
|
rgba(239, 68, 68, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--bid::after {
|
||||||
|
transform-origin: right center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(34, 197, 94, 0) 0%,
|
||||||
|
rgba(34, 197, 94, 0.12) 45%,
|
||||||
|
rgba(34, 197, 94, 0.36) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orderbookRow__num {
|
.orderbookRow__num {
|
||||||
@@ -1081,6 +1683,69 @@ body.chartFullscreen {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orderbookMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 2px 2px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookMeta__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookMeta__val {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
transform: translateX(-0.5px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar__bid,
|
||||||
|
.liquidityBar__ask {
|
||||||
|
height: 100%;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar__bid {
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.0) 0%, rgba(34, 197, 94, 0.35) 55%, rgba(34, 197, 94, 0.85) 100%);
|
||||||
|
transform-origin: right center;
|
||||||
|
transform: scaleX(var(--liq-bid, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar__ask {
|
||||||
|
background: linear-gradient(90deg, rgba(239, 68, 68, 0.85) 0%, rgba(239, 68, 68, 0.35) 45%, rgba(239, 68, 68, 0.0) 100%);
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: scaleX(var(--liq-ask, 0));
|
||||||
|
}
|
||||||
|
|
||||||
.trades {
|
.trades {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1309,6 +1974,215 @@ body.chartFullscreen {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.statsRow {
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,18 @@ function readProxyBasicAuth(): BasicAuth | undefined {
|
|||||||
|
|
||||||
const apiReadToken = readApiReadToken();
|
const apiReadToken = readApiReadToken();
|
||||||
const proxyBasicAuth = readProxyBasicAuth();
|
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 {
|
function parseUrl(v: string): URL | undefined {
|
||||||
try {
|
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 apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
||||||
|
const apiProxyOrigin = toOrigin(apiProxyTargetUrl);
|
||||||
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||||
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
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 {
|
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
||||||
try {
|
try {
|
||||||
@@ -97,7 +116,12 @@ const uiProxyTarget =
|
|||||||
process.env.AUTH_PROXY_TARGET ||
|
process.env.AUTH_PROXY_TARGET ||
|
||||||
inferUiProxyTarget(apiProxyTarget) ||
|
inferUiProxyTarget(apiProxyTarget) ||
|
||||||
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
|
(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) {
|
function applyProxyBasicAuth(proxyReq: any) {
|
||||||
if (!proxyBasicAuth) return false;
|
if (!proxyBasicAuth) return false;
|
||||||
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||||
@@ -105,6 +129,12 @@ function applyProxyBasicAuth(proxyReq: any) {
|
|||||||
return true;
|
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) {
|
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
||||||
const v = proxyRes?.headers?.['set-cookie'];
|
const v = proxyRes?.headers?.['set-cookie'];
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
@@ -124,13 +154,37 @@ const proxy: Record<string, any> = {
|
|||||||
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
||||||
configure: (p: any) => {
|
configure: (p: any) => {
|
||||||
p.on('proxyReq', (proxyReq: any) => {
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, apiProxyOrigin);
|
||||||
if (applyProxyBasicAuth(proxyReq)) return;
|
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) {
|
if (uiProxyTarget) {
|
||||||
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||||
proxy[prefix] = {
|
proxy[prefix] = {
|
||||||
@@ -138,6 +192,7 @@ if (uiProxyTarget) {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
configure: (p: any) => {
|
configure: (p: any) => {
|
||||||
p.on('proxyReq', (proxyReq: any) => {
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, uiProxyOrigin);
|
||||||
applyProxyBasicAuth(proxyReq);
|
applyProxyBasicAuth(proxyReq);
|
||||||
});
|
});
|
||||||
p.on('proxyRes', (proxyRes: any) => {
|
p.on('proxyRes', (proxyRes: any) => {
|
||||||
@@ -152,7 +207,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: false,
|
||||||
proxy,
|
proxy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,15 +57,15 @@ function timingSafeEqualBuf(a, b) {
|
|||||||
|
|
||||||
function loadBasicAuth() {
|
function loadBasicAuth() {
|
||||||
const j = readJson(BASIC_AUTH_FILE);
|
const j = readJson(BASIC_AUTH_FILE);
|
||||||
const username = (j?.username || '').toString();
|
const username = (j?.username || '').toString().trim();
|
||||||
const password = (j?.password || '').toString();
|
const password = (j?.password || '').toString().trim();
|
||||||
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
|
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
|
||||||
return { username, password };
|
return { username, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadApiReadToken() {
|
function loadApiReadToken() {
|
||||||
const j = readJson(API_READ_TOKEN_FILE);
|
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}`);
|
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user