chore: initial import

This commit is contained in:
u1
2026-01-06 12:33:47 +01:00
commit ed37565e25
38 changed files with 5707 additions and 0 deletions

435
apps/visualizer/src/App.tsx Normal file
View File

@@ -0,0 +1,435 @@
import { useMemo } 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';
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 });
}
export default function App() {
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []);
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-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 [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
const [bottomTab, setBottomTab] = useLocalStorageState<
'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<number>('trade.form.price', 0);
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
const { candles, indicators, loading, error, refresh } = useChartData({
symbol,
source: source.trim() ? source : undefined,
tf,
limit,
pollMs,
});
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 (!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 };
}, [latest]);
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 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 ? (
'—'
) : (
<span className={changePct >= 0 ? 'pos' : 'neg'}>
{changePct >= 0 ? '+' : ''}
{changePct.toFixed(2)}%
</span>
),
},
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
{ key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' },
{ key: 'oi', label: 'Open Interest', value: '—' },
{ key: 'vol', label: '24h Volume', value: '—' },
{ key: 'details', label: 'Market Details', value: <a href="#">View</a> },
];
}, [latest?.close, latest?.oracle, changePct]);
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
return (
<AppShell
header={<TopNav active="trade" />}
top={<TickerBar items={topItems} />}
main={
<div className="tradeMain">
<Card
className="marketCard"
title={
<MarketHeader
market={symbol}
markets={markets}
onMarketChange={setSymbol}
leftSlot={
<label className="inlineField">
<span className="inlineField__label">Source</span>
<input
className="inlineField__input"
value={source}
onChange={(e) => setSource(e.target.value)}
placeholder="(any)"
/>
</label>
}
stats={stats}
rightSlot={
<div className="marketHeader__actions">
<label className="inlineField">
<span className="inlineField__label">Poll</span>
<input
className="inlineField__input"
value={pollMs}
type="number"
min={250}
step={250}
onChange={(e) => setPollMs(Number(e.target.value))}
/>
</label>
<label className="inlineField">
<span className="inlineField__label">Limit</span>
<input
className="inlineField__input"
value={limit}
type="number"
min={50}
step={50}
onChange={(e) => setLimit(Number(e.target.value))}
/>
</label>
<Button onClick={() => void refresh()} disabled={loading} type="button">
Refresh
</Button>
</div>
}
/>
}
>
{error ? <div className="uiError">{error}</div> : null}
</Card>
<ChartPanel
candles={candles}
indicators={indicators}
timeframe={tf}
onTimeframeChange={setTf}
showIndicators={showIndicators}
onToggleIndicators={() => setShowIndicators((v) => !v)}
seriesLabel={seriesLabel}
/>
<Card className="bottomCard">
<Tabs
items={[
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
{
id: 'orderHistory',
label: 'Order History',
content: <div className="placeholder">Order history (next)</div>,
},
{
id: 'positionHistory',
label: 'Position History',
content: <div className="placeholder">Position history (next)</div>,
},
]}
activeId={bottomTab}
onChange={setBottomTab}
/>
</Card>
</div>
}
sidebar={
<Card
className="orderbookCard"
title={
<div className="sideHead">
<div className="sideHead__title">Orderbook</div>
<div className="sideHead__subtitle">{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}</div>
</div>
}
>
<Tabs
items={[
{
id: 'orderbook',
label: 'Orderbook',
content: (
<div className="orderbook">
<div className="orderbook__header">
<span>Price</span>
<span className="orderbook__num">Size</span>
<span className="orderbook__num">Total</span>
</div>
<div className="orderbook__rows">
{orderbook.asks.map((r) => (
<div key={`a-${r.price}`} className="orderbookRow orderbookRow--ask">
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
</div>
))}
<div className="orderbookMid">
<span className="orderbookMid__price">{latest ? formatQty(latest.close, 3) : '—'}</span>
<span className="orderbookMid__label">mid</span>
</div>
{orderbook.bids.map((r) => (
<div key={`b-${r.price}`} className="orderbookRow orderbookRow--bid">
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
</div>
))}
</div>
</div>
),
},
{
id: 'trades',
label: 'Recent Trades',
content: (
<div className="trades">
<div className="trades__header">
<span>Time</span>
<span className="trades__num">Price</span>
<span className="trades__num">Size</span>
</div>
<div className="trades__rows">
{trades.map((t) => (
<div key={`${t.time}-${t.price}`} className="tradeRow">
<span className="tradeRow__time">
{new Date(t.time * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className={['tradeRow__price', t.side === 'buy' ? 'pos' : 'neg'].join(' ')}>
{formatQty(t.price, 3)}
</span>
<span className="tradeRow__num">{t.size == null ? '—' : formatQty(t.size, 2)}</span>
</div>
))}
</div>
</div>
),
},
]}
activeId={tab}
onChange={setTab}
/>
</Card>
}
rightbar={
<Card
className="tradeFormCard"
title={
<div className="tradeFormHead">
<div className="tradeFormHead__left">
<button className="chipBtn" type="button">
Cross
</button>
<button className="chipBtn" type="button">
20x
</button>
</div>
<div className="tradeFormHead__right">{symbol}</div>
</div>
}
>
<div className="tradeForm">
<div className="segmented">
<button
className={['segmented__btn', tradeSide === 'long' ? 'segmented__btn--activeLong' : ''].filter(Boolean).join(' ')}
type="button"
onClick={() => setTradeSide('long')}
>
Long
</button>
<button
className={['segmented__btn', tradeSide === 'short' ? 'segmented__btn--activeShort' : ''].filter(Boolean).join(' ')}
type="button"
onClick={() => setTradeSide('short')}
>
Short
</button>
</div>
<div className="tradeTabs">
<button
type="button"
className={['tradeTabs__btn', tradeOrderType === 'market' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
onClick={() => setTradeOrderType('market')}
>
Market
</button>
<button
type="button"
className={['tradeTabs__btn', tradeOrderType === 'limit' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
onClick={() => setTradeOrderType('limit')}
>
Limit
</button>
<button
type="button"
className={['tradeTabs__btn', tradeOrderType === 'other' ? 'tradeTabs__btn--active' : ''].filter(Boolean).join(' ')}
onClick={() => setTradeOrderType('other')}
>
Others
</button>
</div>
<div className="tradeFields">
<label className="formField">
<span className="formField__label">Price</span>
<input
className="formField__input"
value={tradeOrderType === 'market' ? '' : String(tradePrice)}
placeholder={tradeOrderType === 'market' ? formatQty(latest?.close ?? null, 3) : '0'}
disabled={tradeOrderType !== 'limit'}
onChange={(e) => setTradePrice(Number(e.target.value))}
inputMode="decimal"
/>
</label>
<label className="formField">
<span className="formField__label">Size</span>
<input
className="formField__input"
value={String(tradeSize)}
onChange={(e) => setTradeSize(Number(e.target.value))}
inputMode="decimal"
/>
</label>
</div>
<Button className="tradeCta" type="button" disabled>
Enable Trading
</Button>
<div className="tradeMeta">
<div className="tradeMeta__row">
<span className="tradeMeta__label">Order Value</span>
<span className="tradeMeta__value">{effectiveTradePrice ? formatUsd(effectiveTradePrice * tradeSize) : '—'}</span>
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Slippage (Dynamic)</span>
<span className="tradeMeta__value"></span>
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Margin Required</span>
<span className="tradeMeta__value"></span>
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Liq. Price</span>
<span className="tradeMeta__value"></span>
</div>
</div>
</div>
</Card>
}
/>
);
}