import type { CSSProperties } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useLocalStorageState } from './app/hooks/useLocalStorageState'; import AppShell from './layout/AppShell'; import ChartPanel from './features/chart/ChartPanel'; import { useChartData } from './features/chart/useChartData'; import TickerBar from './features/tickerbar/TickerBar'; import Card from './ui/Card'; import Tabs from './ui/Tabs'; import MarketHeader from './features/market/MarketHeader'; import Button from './ui/Button'; import TopNav from './layout/TopNav'; import AuthStatus from './layout/AuthStatus'; import LoginScreen from './layout/LoginScreen'; import { useDlobStats } from './features/market/useDlobStats'; import { useDlobL2 } from './features/market/useDlobL2'; import { useDlobSlippage } from './features/market/useDlobSlippage'; import { useDlobDepthBands } from './features/market/useDlobDepthBands'; import DlobDashboard from './features/market/DlobDashboard'; function envNumber(name: string, fallback: number): number { const v = (import.meta as any).env?.[name]; if (v == null) return fallback; const n = Number(v); return Number.isFinite(n) ? n : fallback; } function envString(name: string, fallback: string): string { const v = (import.meta as any).env?.[name]; return v == null ? fallback : String(v); } function formatUsd(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return '—'; if (v >= 1000) return `$${v.toFixed(0)}`; if (v >= 1) return `$${v.toFixed(2)}`; return `$${v.toPrecision(4)}`; } function formatQty(v: number | null | undefined, decimals: number): string { if (v == null || !Number.isFinite(v)) return '—'; return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } function orderbookBarStyle(scale: number): CSSProperties { const s = Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0; return { ['--ob-bar-scale' as any]: s } as CSSProperties; } type WhoamiResponse = { ok?: boolean; user?: string | null; mode?: string; }; export default function App() { const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); useEffect(() => { let cancelled = false; setAuthLoading(true); fetch('/whoami', { cache: 'no-store' }) .then(async (res) => { const json = (await res.json().catch(() => null)) as WhoamiResponse | null; const u = typeof json?.user === 'string' ? json.user.trim() : ''; return u || null; }) .then((u) => { if (cancelled) return; setUser(u); }) .catch(() => { if (cancelled) return; setUser(null); }) .finally(() => { if (cancelled) return; setAuthLoading(false); }); return () => { cancelled = true; }; }, []); const logout = async () => { try { await fetch('/auth/logout', { method: 'POST' }); } finally { setUser(null); } }; if (authLoading) { return (
Ładowanie…
); } if (!user) { return ; } return void logout()} />; } function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []); const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP')); const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', '')); const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m')); const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000)); const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300)); const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true); const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [bottomTab, setBottomTab] = useLocalStorageState< 'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' >('trade.bottomTab', 'positions'); const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long'); const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>( 'trade.form.type', 'market' ); const [tradePrice, setTradePrice] = useLocalStorageState('trade.form.price', 0); const [tradeSize, setTradeSize] = useLocalStorageState('trade.form.size', 0.1); useEffect(() => { if (symbol === 'BONK-PERP') { setSymbol('1MBONK-PERP'); return; } if (!markets.includes(symbol)) { setSymbol('SOL-PERP'); } }, [markets, setSymbol, symbol]); const { candles, indicators, meta, loading, error, refresh } = useChartData({ symbol, source: source.trim() ? source : undefined, tf, limit, pollMs, }); const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol); const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 14 }); const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol); const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol); const latest = candles.length ? candles[candles.length - 1] : null; const first = candles.length ? candles[0] : null; const changePct = first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null; const orderbook = useMemo(() => { if (dlobL2) return { asks: dlobL2.asks, bids: dlobL2.bids, mid: dlobL2.mid as number | null }; if (!latest) return { asks: [], bids: [], mid: null as number | null }; const mid = latest.close; const step = Math.max(mid * 0.00018, 0.0001); const levels = 14; const asksRaw = Array.from({ length: levels }, (_, i) => ({ price: mid + (i + 1) * step, size: 0.1 + ((i * 7) % 15) * 0.1, })); const bidsRaw = Array.from({ length: levels }, (_, i) => ({ price: mid - (i + 1) * step, size: 0.1 + ((i * 5) % 15) * 0.1, })); let askTotal = 0; const asks = asksRaw .slice() .reverse() .map((r) => { askTotal += r.size; return { ...r, total: askTotal }; }); let bidTotal = 0; const bids = bidsRaw.map((r) => { bidTotal += r.size; return { ...r, total: bidTotal }; }); return { asks, bids, mid }; }, [dlobL2, latest]); const maxAskTotal = useMemo(() => { let max = 0; for (const r of orderbook.asks) max = Math.max(max, r.total || 0); return max; }, [orderbook.asks]); const maxBidTotal = useMemo(() => { let max = 0; for (const r of orderbook.bids) max = Math.max(max, r.total || 0); return max; }, [orderbook.bids]); const trades = useMemo(() => { const slice = candles.slice(-24).reverse(); return slice.map((c) => { const isBuy = c.close >= c.open; return { time: c.time, price: c.close, size: c.volume ?? null, side: isBuy ? ('buy' as const) : ('sell' as const), }; }); }, [candles]); const effectiveTradePrice = useMemo(() => { if (tradeOrderType === 'limit') return tradePrice; return latest?.close ?? tradePrice; }, [latest?.close, tradeOrderType, tradePrice]); const orderValueUsd = useMemo(() => { if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null; if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null; const v = effectiveTradePrice * tradeSize; return Number.isFinite(v) && v > 0 ? v : null; }, [effectiveTradePrice, tradeSize]); const dynamicSlippage = useMemo(() => { if (orderValueUsd == null) return null; const side = tradeSide === 'short' ? 'sell' : 'buy'; const rows = slippageRows.filter((r) => r.side === side).slice(); rows.sort((a, b) => a.sizeUsd - b.sizeUsd); if (!rows.length) return null; const biggest = rows[rows.length - 1]; const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest; return match; }, [orderValueUsd, slippageRows, tradeSide]); const topItems = useMemo( () => [ { key: 'BTC', label: 'BTC', changePct: 1.28, active: false }, { key: 'SOL', label: 'SOL', changePct: 1.89, active: false }, ], [] ); const stats = useMemo(() => { return [ { key: 'last', label: 'Last', value: formatUsd(latest?.close), sub: changePct == null ? ( '—' ) : ( = 0 ? 'pos' : 'neg'}> {changePct >= 0 ? '+' : ''} {changePct.toFixed(2)}% ), }, { key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) }, { key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) }, { key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) }, { key: 'spread', label: 'Spread', value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`, sub: formatUsd(dlob?.spreadAbs ?? null), }, { key: 'dlob', label: 'DLOB', value: dlobConnected ? 'live' : '—', sub: dlobError ? {dlobError} : dlob?.updatedAt || '—', }, { key: 'l2', label: 'L2', value: dlobL2Connected ? 'live' : '—', sub: dlobL2Error ? {dlobL2Error} : dlobL2?.updatedAt || '—', }, ]; }, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error]); const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []); const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]); const bucketSeconds = meta?.bucketSeconds ?? 60; return ( } />} top={} main={
Source setSource(e.target.value)} placeholder="(any)" /> } stats={stats} rightSlot={
} /> } > {error ?
{error}
: null}
setShowIndicators((v) => !v)} showBuild={showBuild} onToggleBuild={() => setShowBuild((v) => !v)} seriesLabel={seriesLabel} dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }} /> ), }, { id: 'positions', label: 'Positions', content:
Positions (next)
}, { id: 'orders', label: 'Orders', content:
Orders (next)
}, { id: 'balances', label: 'Balances', content:
Balances (next)
}, { id: 'orderHistory', label: 'Order History', content:
Order history (next)
, }, { id: 'positionHistory', label: 'Position History', content:
Position history (next)
, }, ]} activeId={bottomTab} onChange={setBottomTab} />
} sidebar={
Orderbook
{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}
} >
Price Size Total
{orderbook.asks.map((r) => (
0 ? r.total / maxAskTotal : 0)} > {formatQty(r.price, 3)} {formatQty(r.size, 2)} {formatQty(r.total, 2)}
))}
{formatQty(orderbook.mid, 3)} mid
{orderbook.bids.map((r) => (
0 ? r.total / maxBidTotal : 0)} > {formatQty(r.price, 3)} {formatQty(r.size, 2)} {formatQty(r.total, 2)}
))}
), }, { id: 'trades', label: 'Recent Trades', content: (
Time Price Size
{trades.map((t) => (
{new Date(t.time * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} {formatQty(t.price, 3)} {t.size == null ? '—' : formatQty(t.size, 2)}
))}
), }, ]} activeId={tab} onChange={setTab} />
} rightbar={
{symbol}
} >
Order Value {effectiveTradePrice ? formatUsd(effectiveTradePrice * tradeSize) : '—'}
Slippage (Dynamic) {slippageError ? ( {slippageError} ) : dynamicSlippage?.impactBps == null ? ( slippageConnected ? ( '—' ) : ( 'offline' ) ) : ( <> {dynamicSlippage.impactBps.toFixed(1)} bps{' '} ({dynamicSlippage.sizeUsd.toLocaleString()} USD) {dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9 ? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill` : ''} )}
Margin Required
Liq. Price
} /> ); }