chore: initial trade-visualizer import
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Default: UI reads ticks from the same-origin API proxy at `/api`.
|
||||||
|
VITE_API_URL=/api
|
||||||
|
|
||||||
|
# Hasura GraphQL endpoint (supports subscriptions via WS).
|
||||||
|
# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
|
||||||
|
VITE_HASURA_URL=/graphql
|
||||||
|
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
|
||||||
|
# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws).
|
||||||
|
# VITE_HASURA_WS_URL=/graphql-ws
|
||||||
|
# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
|
||||||
|
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
||||||
|
VITE_SYMBOL=SOL-PERP
|
||||||
|
# Optional: filter by source (leave empty for all)
|
||||||
|
# VITE_SOURCE=drift_oracle
|
||||||
|
VITE_POLL_MS=1000
|
||||||
|
VITE_LIMIT=300
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local secrets/config (not committed)
|
||||||
|
tokens/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
33
README.md
Normal file
33
README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# trade-visualizer
|
||||||
|
|
||||||
|
Standalone local frontend (Vite + React) for the `trade` stack.
|
||||||
|
|
||||||
|
## Dev (proxy to VPS)
|
||||||
|
|
||||||
|
Default target is `https://trade.mpabi.pl`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional env:
|
||||||
|
|
||||||
|
- `API_PROXY_TARGET` (default: `https://trade.mpabi.pl`)
|
||||||
|
- `GRAPHQL_PROXY_TARGET` (default: same as `API_PROXY_TARGET`)
|
||||||
|
- `VITE_API_URL` (default: `/api`)
|
||||||
|
- `VITE_HASURA_URL` (default: `/graphql`)
|
||||||
|
|
||||||
|
The dev server proxies:
|
||||||
|
|
||||||
|
- `/api/*` to the upstream API
|
||||||
|
- `/graphql` + `/graphql-ws` to upstream Hasura (supports WS subscriptions)
|
||||||
|
- `/auth/*`, `/whoami`, `/logout` to upstream UI (session auth + cookie rewrite for localhost)
|
||||||
|
|
||||||
|
## 100% local
|
||||||
|
|
||||||
|
Point proxy to your local `trade-frontend` container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_PROXY_TARGET=http://localhost:8081 GRAPHQL_PROXY_TARGET=http://localhost:8081 npm run dev
|
||||||
|
```
|
||||||
24
__start
Normal file
24
__start
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
DEFAULT_PROXY_TARGET="${VISUALIZER_PROXY_TARGET:-${TRADE_UI_URL:-${TRADE_VPS_URL:-https://trade.mpabi.pl}}}"
|
||||||
|
export API_PROXY_TARGET="${API_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
|
||||||
|
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
|
||||||
|
export VITE_API_URL="${VITE_API_URL:-/api}"
|
||||||
|
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
||||||
|
|
||||||
|
if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then
|
||||||
|
if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then
|
||||||
|
export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json"
|
||||||
|
else
|
||||||
|
echo "Missing basic auth config for VPS proxy."
|
||||||
|
echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run dev
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Trade Visualizer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
1735
package-lock.json
generated
Normal file
1735
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "trade-visualizer",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
|
"chartjs-adapter-luxon": "^1.3.1",
|
||||||
|
"lightweight-charts": "^5.0.8",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
718
src/App.tsx
Normal file
718
src/App.tsx
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
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 >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||||
|
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||||
|
return `$${v.toPrecision(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQty(v: number | null | undefined, decimals: number): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompact(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`;
|
||||||
|
if (abs >= 1) return v.toFixed(2);
|
||||||
|
return v.toPrecision(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(scale: number): number {
|
||||||
|
return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function barCurve(scale01: number): number {
|
||||||
|
// Makes small rows visible without letting a single wall dominate.
|
||||||
|
return Math.sqrt(clamp01(scale01));
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties {
|
||||||
|
return {
|
||||||
|
['--ob-total-scale' as any]: barCurve(totalScale),
|
||||||
|
['--ob-level-scale' as any]: barCurve(levelScale),
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liquidityStyle(bid: number, ask: number): CSSProperties {
|
||||||
|
const max = Math.max(1e-9, bid, ask);
|
||||||
|
const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0;
|
||||||
|
const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0;
|
||||||
|
return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WhoamiResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
user?: string | null;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [user, setUser] = useState<string | null>(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 (
|
||||||
|
<div className="loginScreen">
|
||||||
|
<div className="loginCard" role="status" aria-label="Ładowanie">
|
||||||
|
Ładowanie…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <LoginScreen onLoggedIn={setUser} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <TradeApp user={user} onLogout={() => 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', true);
|
||||||
|
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<number>('trade.form.price', 0);
|
||||||
|
const [tradeSize, setTradeSize] = useLocalStorageState<number>('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: 10 });
|
||||||
|
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,
|
||||||
|
bestBid: dlobL2.bestBid,
|
||||||
|
bestAsk: dlobL2.bestAsk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: null as number | null };
|
||||||
|
const mid = latest.close;
|
||||||
|
const step = Math.max(mid * 0.00018, 0.0001);
|
||||||
|
const levels = 10;
|
||||||
|
|
||||||
|
const asksRaw = Array.from({ length: levels }, (_, i) => ({
|
||||||
|
price: mid + (i + 1) * step,
|
||||||
|
sizeBase: 0.1 + ((i * 7) % 15) * 0.1,
|
||||||
|
}));
|
||||||
|
const bidsRaw = Array.from({ length: levels }, (_, i) => ({
|
||||||
|
price: mid - (i + 1) * step,
|
||||||
|
sizeBase: 0.1 + ((i * 5) % 15) * 0.1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let askTotalBase = 0;
|
||||||
|
let askTotalUsd = 0;
|
||||||
|
const asks = asksRaw
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((r) => {
|
||||||
|
const sizeUsd = r.sizeBase * r.price;
|
||||||
|
askTotalBase += r.sizeBase;
|
||||||
|
askTotalUsd += sizeUsd;
|
||||||
|
return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd };
|
||||||
|
});
|
||||||
|
|
||||||
|
let bidTotalBase = 0;
|
||||||
|
let bidTotalUsd = 0;
|
||||||
|
const bids = bidsRaw.map((r) => {
|
||||||
|
const sizeUsd = r.sizeBase * r.price;
|
||||||
|
bidTotalBase += r.sizeBase;
|
||||||
|
bidTotalUsd += sizeUsd;
|
||||||
|
return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null };
|
||||||
|
}, [dlobL2, latest]);
|
||||||
|
|
||||||
|
const maxAskTotal = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of orderbook.asks) max = Math.max(max, (r as any).totalUsd || 0);
|
||||||
|
return max;
|
||||||
|
}, [orderbook.asks]);
|
||||||
|
|
||||||
|
const maxBidTotal = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of orderbook.bids) max = Math.max(max, (r as any).totalUsd || 0);
|
||||||
|
return max;
|
||||||
|
}, [orderbook.bids]);
|
||||||
|
|
||||||
|
const maxAskSize = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||||
|
return max;
|
||||||
|
}, [orderbook.asks]);
|
||||||
|
|
||||||
|
const maxBidSize = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||||
|
return max;
|
||||||
|
}, [orderbook.bids]);
|
||||||
|
|
||||||
|
const liquidity = useMemo(() => {
|
||||||
|
const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0;
|
||||||
|
const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0;
|
||||||
|
const bestBid = orderbook.bestBid;
|
||||||
|
const bestAsk = orderbook.bestAsk;
|
||||||
|
const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null;
|
||||||
|
const spreadPct =
|
||||||
|
spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null;
|
||||||
|
return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct };
|
||||||
|
}, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]);
|
||||||
|
|
||||||
|
const trades = useMemo(() => {
|
||||||
|
const slice = candles.slice(-24).reverse();
|
||||||
|
return slice.map((c) => {
|
||||||
|
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 ? (
|
||||||
|
'—'
|
||||||
|
) : (
|
||||||
|
<span className={changePct >= 0 ? 'pos' : 'neg'}>
|
||||||
|
{changePct >= 0 ? '+' : ''}
|
||||||
|
{changePct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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 ? <span className="neg">{dlobError}</span> : dlob?.updatedAt || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'l2',
|
||||||
|
label: 'L2',
|
||||||
|
value: dlobL2Connected ? 'live' : '—',
|
||||||
|
sub: dlobL2Error ? <span className="neg">{dlobL2Error}</span> : 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 (
|
||||||
|
<AppShell
|
||||||
|
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
||||||
|
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}
|
||||||
|
bucketSeconds={bucketSeconds}
|
||||||
|
seriesKey={seriesKey}
|
||||||
|
onTimeframeChange={setTf}
|
||||||
|
showIndicators={showIndicators}
|
||||||
|
onToggleIndicators={() => setShowIndicators((v) => !v)}
|
||||||
|
showBuild={showBuild}
|
||||||
|
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||||
|
seriesLabel={seriesLabel}
|
||||||
|
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card className="bottomCard">
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
id: 'dlob',
|
||||||
|
label: 'DLOB',
|
||||||
|
content: (
|
||||||
|
<DlobDashboard
|
||||||
|
market={symbol}
|
||||||
|
stats={dlob}
|
||||||
|
statsConnected={dlobConnected}
|
||||||
|
statsError={dlobError}
|
||||||
|
depthBands={depthBands}
|
||||||
|
depthBandsConnected={depthBandsConnected}
|
||||||
|
depthBandsError={depthBandsError}
|
||||||
|
slippageRows={slippageRows}
|
||||||
|
slippageConnected={slippageConnected}
|
||||||
|
slippageError={slippageError}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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…' : orderbook.mid != null ? formatUsd(orderbook.mid) : 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 (USD)</span>
|
||||||
|
<span className="orderbook__num">Total (USD)</span>
|
||||||
|
</div>
|
||||||
|
<div className="orderbook__rows">
|
||||||
|
{orderbook.asks.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`a-${r.price}`}
|
||||||
|
className="orderbookRow orderbookRow--ask"
|
||||||
|
style={orderbookRowBarStyle(
|
||||||
|
maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0,
|
||||||
|
maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||||
|
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||||||
|
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="orderbookMid">
|
||||||
|
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
|
||||||
|
<span className="orderbookMid__label">mid</span>
|
||||||
|
</div>
|
||||||
|
{orderbook.bids.map((r) => (
|
||||||
|
<div
|
||||||
|
key={`b-${r.price}`}
|
||||||
|
className="orderbookRow orderbookRow--bid"
|
||||||
|
style={orderbookRowBarStyle(
|
||||||
|
maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0,
|
||||||
|
maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||||
|
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||||||
|
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="orderbookMeta">
|
||||||
|
<div className="orderbookMeta__row">
|
||||||
|
<span className="muted">Spread</span>
|
||||||
|
<span className="orderbookMeta__val">
|
||||||
|
{liquidity.spreadAbs == null || liquidity.spreadPct == null
|
||||||
|
? '—'
|
||||||
|
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="orderbookMeta__row">
|
||||||
|
<span className="muted">Liquidity (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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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">
|
||||||
|
{slippageError ? (
|
||||||
|
<span className="neg">{slippageError}</span>
|
||||||
|
) : dynamicSlippage?.impactBps == null ? (
|
||||||
|
slippageConnected ? (
|
||||||
|
'—'
|
||||||
|
) : (
|
||||||
|
'offline'
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
|
||||||
|
<span className="muted">
|
||||||
|
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
|
||||||
|
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
|
||||||
|
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/hooks/useInterval.ts
Normal file
15
src/app/hooks/useInterval.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useInterval(callback: () => void, delayMs: number | null) {
|
||||||
|
const cbRef = useRef(callback);
|
||||||
|
cbRef.current = callback;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (delayMs == null) return;
|
||||||
|
if (!Number.isFinite(delayMs) || delayMs < 0) return;
|
||||||
|
|
||||||
|
const id = window.setInterval(() => cbRef.current(), delayMs);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [delayMs]);
|
||||||
|
}
|
||||||
|
|
||||||
24
src/app/hooks/useLocalStorageState.ts
Normal file
24
src/app/hooks/useLocalStorageState.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function useLocalStorageState<T>(key: string, initialValue: T) {
|
||||||
|
const [value, setValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (raw == null) return initialValue;
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue] as const;
|
||||||
|
}
|
||||||
|
|
||||||
188
src/components/PriceChart.tsx
Normal file
188
src/components/PriceChart.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import 'chartjs-adapter-luxon';
|
||||||
|
import type { DriftTick } from '../lib/hasura';
|
||||||
|
import { bollingerBands, ema, lastNonNull, macd, rsi, sma } from '../lib/indicators';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
ticks: DriftTick[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function toPoints(ticks: DriftTick[], values: Array<number | null> | number[]) {
|
||||||
|
return ticks.map((t, i) => ({
|
||||||
|
x: t.ts,
|
||||||
|
y: (values as any)[i] ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PriceChart({ ticks }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const chartRef = useRef<Chart | null>(null);
|
||||||
|
|
||||||
|
const oracleValues = useMemo(() => ticks.map((t) => t.oracle_price), [ticks]);
|
||||||
|
const markSeries = useMemo(() => ticks.map((t) => (t.mark_price == null ? null : t.mark_price)), [ticks]);
|
||||||
|
const basePriceValues = useMemo(
|
||||||
|
() => ticks.map((t) => (t.mark_price == null ? t.oracle_price : t.mark_price)),
|
||||||
|
[ticks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sma20 = useMemo(() => sma(basePriceValues, 20), [basePriceValues]);
|
||||||
|
const ema20 = useMemo(() => ema(basePriceValues, 20), [basePriceValues]);
|
||||||
|
const rsi14 = useMemo(() => rsi(basePriceValues, 14), [basePriceValues]);
|
||||||
|
const bb = useMemo(() => bollingerBands(basePriceValues, 20, 2), [basePriceValues]);
|
||||||
|
const macdOut = useMemo(() => macd(basePriceValues, 12, 26, 9), [basePriceValues]);
|
||||||
|
|
||||||
|
const latest = ticks[ticks.length - 1];
|
||||||
|
const valuesPanel = useMemo(() => {
|
||||||
|
if (!latest) return null;
|
||||||
|
const mark = latest.mark_price == null ? null : Number(latest.mark_price);
|
||||||
|
const oracle = latest.oracle_price;
|
||||||
|
return {
|
||||||
|
ts: latest.ts,
|
||||||
|
oracle,
|
||||||
|
mark,
|
||||||
|
spread: mark == null ? null : mark - oracle,
|
||||||
|
sma20: lastNonNull(sma20),
|
||||||
|
ema20: lastNonNull(ema20),
|
||||||
|
rsi14: lastNonNull(rsi14),
|
||||||
|
upper: lastNonNull(bb.upper),
|
||||||
|
lower: lastNonNull(bb.lower),
|
||||||
|
macd: lastNonNull(macdOut.macd),
|
||||||
|
signal: lastNonNull(macdOut.signal),
|
||||||
|
};
|
||||||
|
}, [latest, sma20, ema20, rsi14, bb.upper, bb.lower, macdOut.macd, macdOut.signal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
if (chartRef.current) return;
|
||||||
|
|
||||||
|
chartRef.current = new Chart(canvasRef.current, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Mark', data: [], borderColor: '#60a5fa', yAxisID: 'price', pointRadius: 0 },
|
||||||
|
{
|
||||||
|
label: 'Oracle',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#fb923c',
|
||||||
|
borderDash: [6, 4],
|
||||||
|
yAxisID: 'price',
|
||||||
|
pointRadius: 0,
|
||||||
|
},
|
||||||
|
{ label: 'SMA 20', data: [], borderColor: '#f87171', yAxisID: 'price', pointRadius: 0 },
|
||||||
|
{ label: 'EMA 20', data: [], borderColor: '#34d399', yAxisID: 'price', pointRadius: 0 },
|
||||||
|
{ label: 'RSI 14', data: [], borderColor: '#c084fc', yAxisID: 'rsi', pointRadius: 0 },
|
||||||
|
{ label: 'Upper BB', data: [], borderColor: '#fbbf24', yAxisID: 'price', pointRadius: 0 },
|
||||||
|
{ label: 'Lower BB', data: [], borderColor: '#a3a3a3', yAxisID: 'price', pointRadius: 0 },
|
||||||
|
{ label: 'MACD', data: [], borderColor: '#22d3ee', yAxisID: 'macd', pointRadius: 0 },
|
||||||
|
{ label: 'Signal', data: [], borderColor: '#f472b6', yAxisID: 'macd', pointRadius: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
animation: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: { color: '#e6e9ef' },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: { unit: 'second' },
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
},
|
||||||
|
rsi: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'left',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
},
|
||||||
|
macd: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'left',
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.destroy();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
|
||||||
|
const markPts = toPoints(ticks, markSeries);
|
||||||
|
const oraclePts = toPoints(ticks, oracleValues);
|
||||||
|
const smaPts = toPoints(ticks, sma20);
|
||||||
|
const emaPts = toPoints(ticks, ema20);
|
||||||
|
const rsiPts = toPoints(ticks, rsi14);
|
||||||
|
const upperPts = toPoints(ticks, bb.upper);
|
||||||
|
const lowerPts = toPoints(ticks, bb.lower);
|
||||||
|
const macdPts = toPoints(ticks, macdOut.macd);
|
||||||
|
const sigPts = toPoints(ticks, macdOut.signal);
|
||||||
|
|
||||||
|
chart.data.datasets[0].data = markPts as any;
|
||||||
|
chart.data.datasets[1].data = oraclePts as any;
|
||||||
|
chart.data.datasets[2].data = smaPts as any;
|
||||||
|
chart.data.datasets[3].data = emaPts as any;
|
||||||
|
chart.data.datasets[4].data = rsiPts as any;
|
||||||
|
chart.data.datasets[5].data = upperPts as any;
|
||||||
|
chart.data.datasets[6].data = lowerPts as any;
|
||||||
|
chart.data.datasets[7].data = macdPts as any;
|
||||||
|
chart.data.datasets[8].data = sigPts as any;
|
||||||
|
|
||||||
|
chart.update('none');
|
||||||
|
}, [ticks, markSeries, oracleValues, sma20, ema20, rsi14, bb.upper, bb.lower, macdOut.macd, macdOut.signal]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ position: 'relative' }}>
|
||||||
|
{valuesPanel && (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
width: 260,
|
||||||
|
background: 'rgba(0,0,0,0.35)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="muted" style={{ marginBottom: 6 }}>
|
||||||
|
latest @ {valuesPanel.ts}
|
||||||
|
</div>
|
||||||
|
<div>Oracle: {valuesPanel.oracle.toFixed(6)}</div>
|
||||||
|
<div>Mark: {valuesPanel.mark?.toFixed(6) ?? '-'}</div>
|
||||||
|
<div>Mark-Oracle: {valuesPanel.spread?.toFixed(6) ?? '-'}</div>
|
||||||
|
<div>SMA 20: {valuesPanel.sma20?.toFixed(6) ?? '-'}</div>
|
||||||
|
<div>EMA 20: {valuesPanel.ema20?.toFixed(6) ?? '-'}</div>
|
||||||
|
<div>RSI 14: {valuesPanel.rsi14?.toFixed(2) ?? '-'}</div>
|
||||||
|
<div>Upper BB: {valuesPanel.upper?.toFixed(6) ?? '-'}</div>
|
||||||
|
<div>Lower BB: {valuesPanel.lower?.toFixed(6) ?? '-'}</div>
|
||||||
|
<div>MACD: {valuesPanel.macd?.toFixed(6) ?? '-'}</div>
|
||||||
|
<div>Signal: {valuesPanel.signal?.toFixed(6) ?? '-'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas ref={canvasRef} height={420} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
src/features/chart/ChartIcons.tsx
Normal file
197
src/features/chart/ChartIcons.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
|
type IconProps = SVGAttributes<SVGSVGElement> & { title?: string };
|
||||||
|
|
||||||
|
function Svg({ title, children, ...rest }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden={title ? undefined : true}
|
||||||
|
role={title ? 'img' : 'presentation'}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{title ? <title>{title}</title> : null}
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCursor(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Cursor'} {...props}>
|
||||||
|
<path d="M4 2.5L13 9.2L9.1 10.2L11.3 15.5L9.6 16.3L7.4 11L4.8 14.3L4 2.5Z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCrosshair(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Crosshair'} {...props}>
|
||||||
|
<path d="M9 2.2V5.0M9 13.0V15.8M2.2 9H5.0M13.0 9H15.8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
||||||
|
<circle cx="9" cy="9" r="2.3" stroke="currentColor" strokeWidth="1.3" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPlus(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Plus'} {...props}>
|
||||||
|
<path d="M9 3.2V14.8M3.2 9H14.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTrendline(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Trendline'} {...props}>
|
||||||
|
<path d="M3 12.8L7.2 9.1L10.2 11L15 5.2" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
<circle cx="3" cy="12.8" r="1" fill="currentColor" />
|
||||||
|
<circle cx="7.2" cy="9.1" r="1" fill="currentColor" />
|
||||||
|
<circle cx="10.2" cy="11" r="1" fill="currentColor" />
|
||||||
|
<circle cx="15" cy="5.2" r="1" fill="currentColor" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconFib(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Fibonacci'} {...props}>
|
||||||
|
<path
|
||||||
|
d="M3 13.8C5.4 13.8 6.9 12.5 7.8 10.9C8.8 9.2 9.9 7.2 15 7.2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path d="M3.2 10.4H14.8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.8" />
|
||||||
|
<path d="M3.2 7.0H14.8" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.65" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconBrush(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Brush'} {...props}>
|
||||||
|
<path
|
||||||
|
d="M12.8 3.6C13.9 4.7 13.9 6.4 12.8 7.5L8.5 11.8L6.2 12.2L6.6 9.9L10.9 5.6C12 4.5 12 3.6 12.8 3.6Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M5.8 12.2C5.8 14.0 4.6 15.2 2.8 15.2C4.0 14.6 4.6 14.0 4.6 13.2C4.6 12.4 5.1 12.0 5.8 12.2Z" fill="currentColor" opacity="0.9" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconText(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Text'} {...props}>
|
||||||
|
<path d="M4 4.2H14M9 4.2V14.2" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSmile(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Emoji'} {...props}>
|
||||||
|
<circle cx="9" cy="9" r="6.2" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
<path d="M6.6 7.6H6.7M11.3 7.6H11.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
<path d="M6.4 10.5C7.2 11.8 8.2 12.4 9 12.4C9.8 12.4 10.8 11.8 11.6 10.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconRuler(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Ruler'} {...props}>
|
||||||
|
<path d="M4.2 12.8L12.8 4.2L14.8 6.2L6.2 14.8L4.2 12.8Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
|
||||||
|
<path d="M7.0 12.2L5.8 11.0M8.6 10.6L7.8 9.8M10.2 9.0L9.4 8.2M11.8 7.4L10.6 6.2" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" opacity="0.75" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconZoom(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Zoom'} {...props}>
|
||||||
|
<circle cx="8" cy="8" r="4.6" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
<path d="M11.4 11.4L15.0 15.0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||||
|
<path d="M8 6.3V9.7M6.3 8H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconZoomOut(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Zoom Out'} {...props}>
|
||||||
|
<circle cx="8" cy="8" r="4.6" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
<path d="M11.4 11.4L15.0 15.0" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||||
|
<path d="M6.3 8H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconLock(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Lock'} {...props}>
|
||||||
|
<path d="M5.6 8.0V6.6C5.6 4.7 7.1 3.2 9 3.2C10.9 3.2 12.4 4.7 12.4 6.6V8.0" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
<path d="M5.0 8.0H13.0V14.8H5.0V8.0Z" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconEye(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Visibility'} {...props}>
|
||||||
|
<path
|
||||||
|
d="M1.8 9.0C3.5 6.0 6.1 4.2 9.0 4.2C11.9 4.2 14.5 6.0 16.2 9.0C14.5 12.0 11.9 13.8 9.0 13.8C6.1 13.8 3.5 12.0 1.8 9.0Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="9" cy="9" r="2.1" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconLayers(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Layers'} {...props}>
|
||||||
|
<path
|
||||||
|
d="M3.0 6.2L9.0 3.2L15.0 6.2L9.0 9.2L3.0 6.2Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M3.0 9.2L9.0 12.2L15.0 9.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.85" />
|
||||||
|
<path d="M3.0 12.2L9.0 15.2L15.0 12.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.65" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconTrash(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Delete'} {...props}>
|
||||||
|
<path d="M5.2 5.6H12.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||||
|
<path d="M7.0 5.6V4.6C7.0 3.8 7.6 3.2 8.4 3.2H9.6C10.4 3.2 11.0 3.8 11.0 4.6V5.6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
<path d="M6.0 6.6L6.6 14.6H11.4L12.0 6.6" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" />
|
||||||
|
<path d="M7.8 8.3V13.0M10.2 8.3V13.0" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.85" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconResetView(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg title={props.title ?? 'Reset View'} {...props}>
|
||||||
|
<path
|
||||||
|
d="M13.8 8.0A5.8 5.8 0 1 0 9.0 14.8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path d="M13.9 3.8V8.0H9.7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
src/features/chart/ChartLayersPanel.tsx
Normal file
206
src/features/chart/ChartLayersPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { OverlayLayer } from './ChartPanel.types';
|
||||||
|
import { IconEye, IconLock, IconTrash } from './ChartIcons';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
layers: OverlayLayer[];
|
||||||
|
onRequestClose: () => void;
|
||||||
|
|
||||||
|
onToggleLayerVisible: (layerId: string) => void;
|
||||||
|
onToggleLayerLocked: (layerId: string) => void;
|
||||||
|
onSetLayerOpacity: (layerId: string, opacity: number) => void;
|
||||||
|
|
||||||
|
fibPresent: boolean;
|
||||||
|
fibSelected: boolean;
|
||||||
|
fibVisible: boolean;
|
||||||
|
fibLocked: boolean;
|
||||||
|
fibOpacity: number;
|
||||||
|
onSelectFib: () => void;
|
||||||
|
onToggleFibVisible: () => void;
|
||||||
|
onToggleFibLocked: () => void;
|
||||||
|
onSetFibOpacity: (opacity: number) => void;
|
||||||
|
onDeleteFib: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 1;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function opacityToPct(opacity: number): number {
|
||||||
|
return Math.round(clamp01(opacity) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctToOpacity(pct: number): number {
|
||||||
|
if (!Number.isFinite(pct)) return 1;
|
||||||
|
return clamp01(pct / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
title,
|
||||||
|
active,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['layersBtn', active ? 'layersBtn--active' : null].filter(Boolean).join(' ')}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpacitySlider({
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number) => void;
|
||||||
|
}) {
|
||||||
|
const pct = opacityToPct(value);
|
||||||
|
return (
|
||||||
|
<div className="layersOpacity">
|
||||||
|
<input
|
||||||
|
className="layersOpacity__range"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={pct}
|
||||||
|
onChange={(e) => onChange(pctToOpacity(Number(e.target.value)))}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<div className="layersOpacity__pct">{pct}%</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChartLayersPanel({
|
||||||
|
open,
|
||||||
|
layers,
|
||||||
|
onRequestClose,
|
||||||
|
onToggleLayerVisible,
|
||||||
|
onToggleLayerLocked,
|
||||||
|
onSetLayerOpacity,
|
||||||
|
fibPresent,
|
||||||
|
fibSelected,
|
||||||
|
fibVisible,
|
||||||
|
fibLocked,
|
||||||
|
fibOpacity,
|
||||||
|
onSelectFib,
|
||||||
|
onToggleFibVisible,
|
||||||
|
onToggleFibLocked,
|
||||||
|
onSetFibOpacity,
|
||||||
|
onDeleteFib,
|
||||||
|
}: Props) {
|
||||||
|
const drawingsLayer = useMemo(() => layers.find((l) => l.id === 'drawings'), [layers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={['chartLayersBackdrop', open ? 'chartLayersBackdrop--open' : null].filter(Boolean).join(' ')}
|
||||||
|
onClick={open ? onRequestClose : undefined}
|
||||||
|
/>
|
||||||
|
<div className={['chartLayersPanel', open ? 'chartLayersPanel--open' : null].filter(Boolean).join(' ')}>
|
||||||
|
<div className="chartLayersPanel__head">
|
||||||
|
<div className="chartLayersPanel__title">Layers</div>
|
||||||
|
<button type="button" className="chartLayersPanel__close" onClick={onRequestClose} aria-label="Close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chartLayersTable">
|
||||||
|
<div className="chartLayersRow chartLayersRow--head">
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon" title="Visible">
|
||||||
|
<IconEye />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon" title="Lock">
|
||||||
|
<IconLock />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">Name</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">Opacity</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{layers.map((layer) => (
|
||||||
|
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
|
||||||
|
<IconEye />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
|
||||||
|
<IconLock />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">
|
||||||
|
<div className="layersName layersName--layer">
|
||||||
|
{layer.name}
|
||||||
|
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">
|
||||||
|
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{drawingsLayer && fibPresent ? (
|
||||||
|
<div
|
||||||
|
className={['chartLayersRow', 'chartLayersRow--object', fibSelected ? 'chartLayersRow--selected' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={onSelectFib}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle visible" active={fibVisible} onClick={onToggleFibVisible}>
|
||||||
|
<IconEye />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
|
<IconButton title="Toggle lock" active={fibLocked} onClick={onToggleFibLocked}>
|
||||||
|
<IconLock />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--name">
|
||||||
|
<div className="layersName layersName--object">Fib Retracement</div>
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--opacity">
|
||||||
|
<OpacitySlider value={fibOpacity} onChange={onSetFibOpacity} />
|
||||||
|
</div>
|
||||||
|
<div className="chartLayersCell chartLayersCell--actions">
|
||||||
|
<IconButton title="Delete fib" onClick={onDeleteFib}>
|
||||||
|
<IconTrash />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
src/features/chart/ChartPanel.tsx
Normal file
470
src/features/chart/ChartPanel.tsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||||
|
import Card from '../../ui/Card';
|
||||||
|
import ChartLayersPanel from './ChartLayersPanel';
|
||||||
|
import ChartSideToolbar from './ChartSideToolbar';
|
||||||
|
import ChartToolbar from './ChartToolbar';
|
||||||
|
import TradingChart from './TradingChart';
|
||||||
|
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
||||||
|
import { LineStyle, type IChartApi } from 'lightweight-charts';
|
||||||
|
import type { OverlayLayer } from './ChartPanel.types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
candles: Candle[];
|
||||||
|
indicators: ChartIndicators;
|
||||||
|
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
|
||||||
|
timeframe: string;
|
||||||
|
bucketSeconds: number;
|
||||||
|
seriesKey: string;
|
||||||
|
onTimeframeChange: (tf: string) => void;
|
||||||
|
showIndicators: boolean;
|
||||||
|
onToggleIndicators: () => void;
|
||||||
|
showBuild: boolean;
|
||||||
|
onToggleBuild: () => void;
|
||||||
|
seriesLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FibDragMode = 'move' | 'edit-b';
|
||||||
|
|
||||||
|
type FibDrag = {
|
||||||
|
pointerId: number;
|
||||||
|
mode: FibDragMode;
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
start: FibAnchor;
|
||||||
|
origin: FibRetracement;
|
||||||
|
moved: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isEditableTarget(t: EventTarget | null): boolean {
|
||||||
|
if (!(t instanceof HTMLElement)) return false;
|
||||||
|
const tag = t.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
return t.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChartPanel({
|
||||||
|
candles,
|
||||||
|
indicators,
|
||||||
|
dlobQuotes,
|
||||||
|
timeframe,
|
||||||
|
bucketSeconds,
|
||||||
|
seriesKey,
|
||||||
|
onTimeframeChange,
|
||||||
|
showIndicators,
|
||||||
|
onToggleIndicators,
|
||||||
|
showBuild,
|
||||||
|
onToggleBuild,
|
||||||
|
seriesLabel,
|
||||||
|
}: Props) {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
||||||
|
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||||
|
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||||
|
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||||
|
const [layers, setLayers] = useState<OverlayLayer[]>([
|
||||||
|
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
|
||||||
|
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
||||||
|
]);
|
||||||
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
|
const [fibVisible, setFibVisible] = useState(true);
|
||||||
|
const [fibLocked, setFibLocked] = useState(false);
|
||||||
|
const [fibOpacity, setFibOpacity] = useState(1);
|
||||||
|
const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
|
||||||
|
const [priceAutoScale, setPriceAutoScale] = useState(true);
|
||||||
|
|
||||||
|
const chartApiRef = useRef<IChartApi | null>(null);
|
||||||
|
const activeToolRef = useRef(activeTool);
|
||||||
|
const fibStartRef = useRef<FibAnchor | null>(fibStart);
|
||||||
|
const pendingMoveRef = useRef<FibAnchor | null>(null);
|
||||||
|
const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
const spaceDownRef = useRef<boolean>(false);
|
||||||
|
const dragRef = useRef<FibDrag | null>(null);
|
||||||
|
const selectPointerRef = useRef<number | null>(null);
|
||||||
|
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
|
||||||
|
const fibRef = useRef<FibRetracement | null>(fib);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFullscreen) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
||||||
|
return () => document.body.classList.remove('chartFullscreen');
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
const cardClassName = useMemo(() => {
|
||||||
|
return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeToolRef.current = activeTool;
|
||||||
|
if (activeTool !== 'fib-retracement') {
|
||||||
|
setFibStart(null);
|
||||||
|
setFibDraft(null);
|
||||||
|
}
|
||||||
|
}, [activeTool]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fibStartRef.current = fibStart;
|
||||||
|
}, [fibStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedOverlayIdRef.current = selectedOverlayId;
|
||||||
|
}, [selectedOverlayId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fibRef.current = fib;
|
||||||
|
}, [fib]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (isEditableTarget(e.target)) return;
|
||||||
|
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
spaceDownRef.current = true;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (dragRef.current) {
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
setFibDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeToolRef.current === 'fib-retracement') {
|
||||||
|
setFibStart(null);
|
||||||
|
setFibDraft(null);
|
||||||
|
setActiveTool('cursor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOverlayIdRef.current) setSelectedOverlayId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (selectedOverlayIdRef.current === 'fib') {
|
||||||
|
clearFib();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
spaceDownRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
window.addEventListener('keyup', onKeyUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown);
|
||||||
|
window.removeEventListener('keyup', onKeyUp);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function zoomTime(factor: number) {
|
||||||
|
const chart = chartApiRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
const ts = chart.timeScale();
|
||||||
|
const range = ts.getVisibleLogicalRange();
|
||||||
|
if (!range) return;
|
||||||
|
const from = range.from as number;
|
||||||
|
const to = range.to as number;
|
||||||
|
const span = Math.max(5, (to - from) * factor);
|
||||||
|
const center = (from + to) / 2;
|
||||||
|
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 1;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
|
||||||
|
const quotesVisible = Boolean(quotesLayer?.visible);
|
||||||
|
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
|
||||||
|
|
||||||
|
const priceLines = useMemo(() => {
|
||||||
|
if (!quotesVisible) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'dlob-bid',
|
||||||
|
title: 'DLOB Bid',
|
||||||
|
price: dlobQuotes?.bid ?? null,
|
||||||
|
color: `rgba(34,197,94,${quotesOpacity})`,
|
||||||
|
lineStyle: LineStyle.Dotted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dlob-mid',
|
||||||
|
title: 'DLOB Mid',
|
||||||
|
price: dlobQuotes?.mid ?? null,
|
||||||
|
color: `rgba(230,233,239,${quotesOpacity})`,
|
||||||
|
lineStyle: LineStyle.Dashed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dlob-ask',
|
||||||
|
title: 'DLOB Ask',
|
||||||
|
price: dlobQuotes?.ask ?? null,
|
||||||
|
color: `rgba(239,68,68,${quotesOpacity})`,
|
||||||
|
lineStyle: LineStyle.Dotted,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]);
|
||||||
|
|
||||||
|
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
|
||||||
|
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFib() {
|
||||||
|
setFib(null);
|
||||||
|
setFibStart(null);
|
||||||
|
setFibDraft(null);
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
setSelectedOverlayId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFibFromDrag(drag: FibDrag, pointer: FibAnchor): FibRetracement {
|
||||||
|
if (drag.mode === 'edit-b') return { a: drag.origin.a, b: pointer };
|
||||||
|
const deltaLogical = pointer.logical - drag.start.logical;
|
||||||
|
const deltaPrice = pointer.price - drag.start.price;
|
||||||
|
return {
|
||||||
|
a: { logical: drag.origin.a.logical + deltaLogical, price: drag.origin.a.price + deltaPrice },
|
||||||
|
b: { logical: drag.origin.b.logical + deltaLogical, price: drag.origin.b.price + deltaPrice },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFrame() {
|
||||||
|
if (rafRef.current != null) return;
|
||||||
|
rafRef.current = window.requestAnimationFrame(() => {
|
||||||
|
rafRef.current = null;
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
const pendingDrag = pendingDragRef.current;
|
||||||
|
if (drag && pendingDrag) {
|
||||||
|
if (!drag.moved) {
|
||||||
|
const dx = pendingDrag.clientX - drag.startClientX;
|
||||||
|
const dy = pendingDrag.clientY - drag.startClientY;
|
||||||
|
if (dx * dx + dy * dy >= 16) drag.moved = true; // ~4px threshold
|
||||||
|
}
|
||||||
|
if (drag.moved) {
|
||||||
|
setFibDraft(computeFibFromDrag(drag, pendingDrag.anchor));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointer = pendingMoveRef.current;
|
||||||
|
if (!pointer) return;
|
||||||
|
if (activeToolRef.current !== 'fib-retracement') return;
|
||||||
|
|
||||||
|
const start2 = fibStartRef.current;
|
||||||
|
if (!start2) return;
|
||||||
|
setFibDraft({ a: start2, b: pointer });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawingsLayer =
|
||||||
|
layers.find((l) => l.id === 'drawings') ?? { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 };
|
||||||
|
const fibEffectiveVisible = fibVisible && drawingsLayer.visible;
|
||||||
|
const fibEffectiveOpacity = fibOpacity * drawingsLayer.opacity;
|
||||||
|
const fibEffectiveLocked = fibLocked || drawingsLayer.locked;
|
||||||
|
const fibSelected = selectedOverlayId === 'fib';
|
||||||
|
const fibRenderable = fibEffectiveVisible ? (fibDraft ?? fib) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedOverlayId !== 'fib') return;
|
||||||
|
if (!fib) {
|
||||||
|
setSelectedOverlayId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fibEffectiveVisible) setSelectedOverlayId(null);
|
||||||
|
}, [fib, fibEffectiveVisible, selectedOverlayId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||||
|
<Card className={cardClassName}>
|
||||||
|
<div className="chartCard__toolbar">
|
||||||
|
<ChartToolbar
|
||||||
|
timeframe={timeframe}
|
||||||
|
onTimeframeChange={onTimeframeChange}
|
||||||
|
showIndicators={showIndicators}
|
||||||
|
onToggleIndicators={onToggleIndicators}
|
||||||
|
showBuild={showBuild}
|
||||||
|
onToggleBuild={onToggleBuild}
|
||||||
|
priceAutoScale={priceAutoScale}
|
||||||
|
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||||
|
seriesLabel={seriesLabel}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="chartCard__content">
|
||||||
|
<ChartSideToolbar
|
||||||
|
timeframe={timeframe}
|
||||||
|
activeTool={activeTool}
|
||||||
|
hasFib={fib != null || fibDraft != null}
|
||||||
|
isLayersOpen={layersOpen}
|
||||||
|
onToolChange={setActiveTool}
|
||||||
|
onToggleLayers={() => setLayersOpen((v) => !v)}
|
||||||
|
onZoomIn={() => zoomTime(0.8)}
|
||||||
|
onZoomOut={() => zoomTime(1.25)}
|
||||||
|
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
|
||||||
|
onClearFib={clearFib}
|
||||||
|
/>
|
||||||
|
<div className="chartCard__chart">
|
||||||
|
<TradingChart
|
||||||
|
candles={candles}
|
||||||
|
oracle={indicators.oracle}
|
||||||
|
sma20={indicators.sma20}
|
||||||
|
ema20={indicators.ema20}
|
||||||
|
bb20={indicators.bb20}
|
||||||
|
showIndicators={showIndicators}
|
||||||
|
showBuild={showBuild}
|
||||||
|
bucketSeconds={bucketSeconds}
|
||||||
|
seriesKey={seriesKey}
|
||||||
|
priceLines={priceLines}
|
||||||
|
fib={fibRenderable}
|
||||||
|
fibOpacity={fibEffectiveOpacity}
|
||||||
|
fibSelected={fibSelected}
|
||||||
|
priceAutoScale={priceAutoScale}
|
||||||
|
onReady={({ chart }) => {
|
||||||
|
chartApiRef.current = chart;
|
||||||
|
}}
|
||||||
|
onChartClick={(p) => {
|
||||||
|
if (activeTool === 'fib-retracement') {
|
||||||
|
if (!fibStartRef.current) {
|
||||||
|
fibStartRef.current = p;
|
||||||
|
setFibStart(p);
|
||||||
|
setFibDraft({ a: p, b: p });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFib({ a: fibStartRef.current, b: p });
|
||||||
|
setFibStart(null);
|
||||||
|
fibStartRef.current = null;
|
||||||
|
setFibDraft(null);
|
||||||
|
setActiveTool('cursor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.target === 'chart') setSelectedOverlayId(null);
|
||||||
|
}}
|
||||||
|
onChartCrosshairMove={(p) => {
|
||||||
|
pendingMoveRef.current = p;
|
||||||
|
scheduleFrame();
|
||||||
|
}}
|
||||||
|
onPointerEvent={({ type, logical, price, target, event }) => {
|
||||||
|
const pointer: FibAnchor = { logical, price };
|
||||||
|
|
||||||
|
if (type === 'pointerdown') {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
if (spaceDownRef.current) return;
|
||||||
|
if (activeToolRef.current !== 'cursor') return;
|
||||||
|
if (target !== 'fib') return;
|
||||||
|
if (!fibRef.current) return;
|
||||||
|
if (!fibEffectiveVisible) return;
|
||||||
|
|
||||||
|
if (selectedOverlayIdRef.current !== 'fib') {
|
||||||
|
setSelectedOverlayId('fib');
|
||||||
|
selectPointerRef.current = event.pointerId;
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fibEffectiveLocked) {
|
||||||
|
selectPointerRef.current = event.pointerId;
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
mode: event.ctrlKey ? 'edit-b' : 'move',
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startClientY: event.clientY,
|
||||||
|
start: pointer,
|
||||||
|
origin: fibRef.current,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||||
|
setFibDraft(fibRef.current);
|
||||||
|
return { consume: true, capturePointer: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (drag && drag.pointerId === event.pointerId) {
|
||||||
|
if (type === 'pointermove') {
|
||||||
|
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
|
||||||
|
scheduleFrame();
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
if (type === 'pointerup' || type === 'pointercancel') {
|
||||||
|
if (drag.moved) setFib(computeFibFromDrag(drag, pointer));
|
||||||
|
dragRef.current = null;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
setFibDraft(null);
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectPointerRef.current != null && selectPointerRef.current === event.pointerId) {
|
||||||
|
if (type === 'pointermove') return { consume: true };
|
||||||
|
if (type === 'pointerup' || type === 'pointercancel') {
|
||||||
|
selectPointerRef.current = null;
|
||||||
|
return { consume: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChartLayersPanel
|
||||||
|
open={layersOpen}
|
||||||
|
layers={layers}
|
||||||
|
onRequestClose={() => setLayersOpen(false)}
|
||||||
|
onToggleLayerVisible={(layerId) => {
|
||||||
|
const layer = layers.find((l) => l.id === layerId);
|
||||||
|
if (!layer) return;
|
||||||
|
updateLayer(layerId, { visible: !layer.visible });
|
||||||
|
}}
|
||||||
|
onToggleLayerLocked={(layerId) => {
|
||||||
|
const layer = layers.find((l) => l.id === layerId);
|
||||||
|
if (!layer) return;
|
||||||
|
updateLayer(layerId, { locked: !layer.locked });
|
||||||
|
}}
|
||||||
|
onSetLayerOpacity={(layerId, opacity) => updateLayer(layerId, { opacity: clamp01(opacity) })}
|
||||||
|
fibPresent={fib != null}
|
||||||
|
fibSelected={fibSelected}
|
||||||
|
fibVisible={fibVisible}
|
||||||
|
fibLocked={fibLocked}
|
||||||
|
fibOpacity={fibOpacity}
|
||||||
|
onSelectFib={() => setSelectedOverlayId('fib')}
|
||||||
|
onToggleFibVisible={() => setFibVisible((v) => !v)}
|
||||||
|
onToggleFibLocked={() => setFibLocked((v) => !v)}
|
||||||
|
onSetFibOpacity={(opacity) => setFibOpacity(clamp01(opacity))}
|
||||||
|
onDeleteFib={clearFib}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/features/chart/ChartPanel.types.ts
Normal file
8
src/features/chart/ChartPanel.types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type OverlayLayer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
opacity: number; // 0..1
|
||||||
|
};
|
||||||
|
|
||||||
230
src/features/chart/ChartSideToolbar.tsx
Normal file
230
src/features/chart/ChartSideToolbar.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import ChartToolMenu, { type ToolMenuSection } from './ChartToolMenu';
|
||||||
|
import {
|
||||||
|
IconBrush,
|
||||||
|
IconCrosshair,
|
||||||
|
IconCursor,
|
||||||
|
IconFib,
|
||||||
|
IconLayers,
|
||||||
|
IconLock,
|
||||||
|
IconPlus,
|
||||||
|
IconRuler,
|
||||||
|
IconSmile,
|
||||||
|
IconText,
|
||||||
|
IconResetView,
|
||||||
|
IconTrash,
|
||||||
|
IconTrendline,
|
||||||
|
IconZoom,
|
||||||
|
IconZoomOut,
|
||||||
|
} from './ChartIcons';
|
||||||
|
|
||||||
|
type ActiveTool = 'cursor' | 'fib-retracement';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
timeframe: string;
|
||||||
|
activeTool: ActiveTool;
|
||||||
|
hasFib: boolean;
|
||||||
|
isLayersOpen: boolean;
|
||||||
|
onToolChange: (tool: ActiveTool) => void;
|
||||||
|
onToggleLayers: () => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onResetView: () => void;
|
||||||
|
onClearFib: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChartSideToolbar({
|
||||||
|
timeframe,
|
||||||
|
activeTool,
|
||||||
|
hasFib,
|
||||||
|
isLayersOpen,
|
||||||
|
onToolChange,
|
||||||
|
onToggleLayers,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onResetView,
|
||||||
|
onClearFib,
|
||||||
|
}: Props) {
|
||||||
|
const [openMenu, setOpenMenu] = useState<'fib' | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenu) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpenMenu(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [openMenu]);
|
||||||
|
|
||||||
|
const fibMenuSections: ToolMenuSection[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'fib',
|
||||||
|
title: 'FIBONACCI',
|
||||||
|
items: [
|
||||||
|
{ id: 'fib-retracement', label: 'Fib Retracement', icon: <IconFib />, shortcut: 'Click 2 points' },
|
||||||
|
{ id: 'fib-tb-ext', label: 'Trend-Based Fib Extension', icon: <IconTrendline /> },
|
||||||
|
{ id: 'fib-channel', label: 'Fib Channel', icon: <IconFib /> },
|
||||||
|
{ id: 'fib-time-zone', label: 'Fib Time Zone', icon: <IconFib /> },
|
||||||
|
{ id: 'fib-speed-fan', label: 'Fib Speed Resistance Fan', icon: <IconFib /> },
|
||||||
|
{ id: 'fib-tb-time', label: 'Trend-Based Fib Time', icon: <IconTrendline /> },
|
||||||
|
{ id: 'fib-circles', label: 'Fib Circles', icon: <IconFib /> },
|
||||||
|
{ id: 'fib-spiral', label: 'Fib Spiral', icon: <IconFib /> },
|
||||||
|
{ id: 'fib-speed-arcs', label: 'Fib Speed Resistance Arcs', icon: <IconFib /> },
|
||||||
|
{ id: 'fib-wedge', label: 'Fib Wedge', icon: <IconFib /> },
|
||||||
|
{ id: 'pitchfan', label: 'Pitchfan', icon: <IconTrendline /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gann',
|
||||||
|
title: 'GANN',
|
||||||
|
items: [
|
||||||
|
{ id: 'gann-box', label: 'Gann Box', icon: <IconTrendline /> },
|
||||||
|
{ id: 'gann-square-fixed', label: 'Gann Square Fixed', icon: <IconTrendline /> },
|
||||||
|
{ id: 'gann-fan', label: 'Gann Fan', icon: <IconTrendline /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chartTools">
|
||||||
|
<div className="chartSideToolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['chartToolBtn', activeTool === 'cursor' ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||||
|
title="Cursor"
|
||||||
|
aria-label="Cursor"
|
||||||
|
onClick={() => {
|
||||||
|
onToolChange('cursor');
|
||||||
|
setOpenMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconCursor />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Crosshair" aria-label="Crosshair" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconCrosshair />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Add" aria-label="Add" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconPlus />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Trendline" aria-label="Trendline" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconTrendline />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['chartToolBtn', activeTool === 'fib-retracement' ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||||
|
title="Fibonacci"
|
||||||
|
aria-label="Fibonacci"
|
||||||
|
onClick={() => setOpenMenu((m) => (m === 'fib' ? null : 'fib'))}
|
||||||
|
>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconFib />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Brush" aria-label="Brush" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconBrush />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Text" aria-label="Text" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconText />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Emoji" aria-label="Emoji" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconSmile />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Ruler" aria-label="Ruler" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconRuler />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="chartToolBtn__spacer" />
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Zoom In" aria-label="Zoom In" onClick={onZoomIn}>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconZoom />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Zoom Out" aria-label="Zoom Out" onClick={onZoomOut}>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconZoomOut />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Reset View" aria-label="Reset View" onClick={onResetView}>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconResetView />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" className="chartToolBtn" title="Lock" aria-label="Lock" disabled>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconLock />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chartToolBtn"
|
||||||
|
title="Clear Fib"
|
||||||
|
aria-label="Clear Fib"
|
||||||
|
onClick={onClearFib}
|
||||||
|
disabled={!hasFib}
|
||||||
|
>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconTrash />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={['chartToolBtn', isLayersOpen ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
|
||||||
|
title="Layers"
|
||||||
|
aria-label="Layers"
|
||||||
|
onClick={onToggleLayers}
|
||||||
|
>
|
||||||
|
<span className="chartToolBtn__icon" aria-hidden="true">
|
||||||
|
<IconLayers />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openMenu === 'fib' ? (
|
||||||
|
<>
|
||||||
|
<div className="chartToolMenuBackdrop" onClick={() => setOpenMenu(null)} />
|
||||||
|
<ChartToolMenu
|
||||||
|
timeframeLabel={timeframe}
|
||||||
|
sections={fibMenuSections}
|
||||||
|
onSelectItem={(id) => {
|
||||||
|
if (id === 'fib-retracement') onToolChange('fib-retracement');
|
||||||
|
setOpenMenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/features/chart/ChartToolMenu.tsx
Normal file
54
src/features/chart/ChartToolMenu.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type ToolMenuItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
shortcut?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolMenuSection = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
items: ToolMenuItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
timeframeLabel: string;
|
||||||
|
sections: ToolMenuSection[];
|
||||||
|
onSelectItem?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChartToolMenu({ timeframeLabel, sections, onSelectItem }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="chartToolMenu" role="dialog" aria-label="Chart tools">
|
||||||
|
<div className="chartToolMenu__top">
|
||||||
|
<div className="chartToolMenu__tf">{timeframeLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chartToolMenu__body">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<div key={s.id} className="chartToolMenu__section">
|
||||||
|
<div className="chartToolMenu__sectionTitle">{s.title}</div>
|
||||||
|
<div className="chartToolMenu__items">
|
||||||
|
{s.items.map((it) => (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
className="chartToolMenuItem"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectItem?.(it.id)}
|
||||||
|
>
|
||||||
|
<span className="chartToolMenuItem__icon" aria-hidden="true">
|
||||||
|
{it.icon}
|
||||||
|
</span>
|
||||||
|
<span className="chartToolMenuItem__label">{it.label}</span>
|
||||||
|
{it.shortcut ? <span className="chartToolMenuItem__shortcut">{it.shortcut}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/features/chart/ChartToolbar.tsx
Normal file
65
src/features/chart/ChartToolbar.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import Button from '../../ui/Button';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
timeframe: string;
|
||||||
|
onTimeframeChange: (tf: string) => void;
|
||||||
|
showIndicators: boolean;
|
||||||
|
onToggleIndicators: () => void;
|
||||||
|
showBuild: boolean;
|
||||||
|
onToggleBuild: () => void;
|
||||||
|
priceAutoScale: boolean;
|
||||||
|
onTogglePriceAutoScale: () => void;
|
||||||
|
seriesLabel: string;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeframes = ['3s', '5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||||
|
|
||||||
|
export default function ChartToolbar({
|
||||||
|
timeframe,
|
||||||
|
onTimeframeChange,
|
||||||
|
showIndicators,
|
||||||
|
onToggleIndicators,
|
||||||
|
showBuild,
|
||||||
|
onToggleBuild,
|
||||||
|
priceAutoScale,
|
||||||
|
onTogglePriceAutoScale,
|
||||||
|
seriesLabel,
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="chartToolbar">
|
||||||
|
<div className="chartToolbar__group">
|
||||||
|
{timeframes.map((tf) => (
|
||||||
|
<Button
|
||||||
|
key={tf}
|
||||||
|
size="sm"
|
||||||
|
variant={tf === timeframe ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => onTimeframeChange(tf)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tf}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chartToolbar__group">
|
||||||
|
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
||||||
|
Indicators
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={showBuild ? 'primary' : 'ghost'} onClick={onToggleBuild} type="button">
|
||||||
|
Build
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
|
||||||
|
Auto Scale
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
|
||||||
|
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||||
|
</Button>
|
||||||
|
<div className="chartToolbar__meta">{seriesLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
src/features/chart/FibRetracementPrimitive.ts
Normal file
233
src/features/chart/FibRetracementPrimitive.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import type {
|
||||||
|
IPrimitivePaneRenderer,
|
||||||
|
IPrimitivePaneView,
|
||||||
|
ISeriesApi,
|
||||||
|
ISeriesPrimitive,
|
||||||
|
SeriesAttachedParameter,
|
||||||
|
Time,
|
||||||
|
} from 'lightweight-charts';
|
||||||
|
|
||||||
|
export type FibAnchor = {
|
||||||
|
logical: number;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FibRetracement = {
|
||||||
|
a: FibAnchor;
|
||||||
|
b: FibAnchor;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FibLevel = {
|
||||||
|
ratio: number;
|
||||||
|
line: string;
|
||||||
|
fill: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVELS: FibLevel[] = [
|
||||||
|
{ ratio: 4.236, line: 'rgba(255, 45, 85, 0.95)', fill: 'rgba(255, 45, 85, 0.16)' },
|
||||||
|
{ ratio: 3.618, line: 'rgba(192, 132, 252, 0.95)', fill: 'rgba(192, 132, 252, 0.14)' },
|
||||||
|
{ ratio: 2.618, line: 'rgba(239, 68, 68, 0.92)', fill: 'rgba(239, 68, 68, 0.14)' },
|
||||||
|
{ ratio: 1.618, line: 'rgba(59, 130, 246, 0.92)', fill: 'rgba(59, 130, 246, 0.14)' },
|
||||||
|
{ ratio: 1.0, line: 'rgba(148, 163, 184, 0.92)', fill: 'rgba(59, 130, 246, 0.10)' },
|
||||||
|
{ ratio: 0.786, line: 'rgba(96, 165, 250, 0.92)', fill: 'rgba(96, 165, 250, 0.10)' },
|
||||||
|
{ ratio: 0.618, line: 'rgba(6, 182, 212, 0.92)', fill: 'rgba(6, 182, 212, 0.10)' },
|
||||||
|
{ ratio: 0.5, line: 'rgba(34, 197, 94, 0.92)', fill: 'rgba(34, 197, 94, 0.09)' },
|
||||||
|
{ ratio: 0.382, line: 'rgba(245, 158, 11, 0.92)', fill: 'rgba(245, 158, 11, 0.10)' },
|
||||||
|
{ ratio: 0.236, line: 'rgba(249, 115, 22, 0.92)', fill: 'rgba(249, 115, 22, 0.10)' },
|
||||||
|
{ ratio: 0.0, line: 'rgba(163, 163, 163, 0.85)', fill: 'rgba(163, 163, 163, 0.06)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatRatio(r: number): string {
|
||||||
|
if (Number.isInteger(r)) return String(r);
|
||||||
|
const s = r.toFixed(3);
|
||||||
|
return s.replace(/0+$/, '').replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(p: number): string {
|
||||||
|
if (!Number.isFinite(p)) return '—';
|
||||||
|
if (Math.abs(p) >= 1000) return p.toFixed(0);
|
||||||
|
if (Math.abs(p) >= 1) return p.toFixed(2);
|
||||||
|
return p.toPrecision(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
fib: FibRetracement | null;
|
||||||
|
series: ISeriesApi<'Candlestick', Time> | null;
|
||||||
|
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||||
|
selected: boolean;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class FibPaneRenderer implements IPrimitivePaneRenderer {
|
||||||
|
private readonly _getState: () => State;
|
||||||
|
|
||||||
|
constructor(getState: () => State) {
|
||||||
|
this._getState = getState;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(target: any) {
|
||||||
|
const { fib, series, chart, selected, opacity } = this._getState();
|
||||||
|
if (!fib || !series || !chart) return;
|
||||||
|
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
if (clampedOpacity <= 0) return;
|
||||||
|
|
||||||
|
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
|
||||||
|
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
|
||||||
|
if (x1 == null || x2 == null) return;
|
||||||
|
|
||||||
|
const xLeftMedia = Math.min(x1, x2);
|
||||||
|
const xRightMedia = Math.max(x1, x2);
|
||||||
|
const p0 = fib.a.price;
|
||||||
|
const p1 = fib.b.price;
|
||||||
|
const delta = p1 - p0;
|
||||||
|
|
||||||
|
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||||
|
context.save();
|
||||||
|
context.globalAlpha *= clampedOpacity;
|
||||||
|
try {
|
||||||
|
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
|
||||||
|
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
|
||||||
|
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
|
||||||
|
const w = xEnd - xStart;
|
||||||
|
|
||||||
|
const points = LEVELS.map((l) => {
|
||||||
|
const price = p0 + delta * l.ratio;
|
||||||
|
const y = series.priceToCoordinate(price);
|
||||||
|
return { ...l, price, y };
|
||||||
|
}).filter((p) => p.y != null) as Array<FibLevel & { price: number; y: number }>;
|
||||||
|
|
||||||
|
if (!points.length) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i += 1) {
|
||||||
|
const a = points[i];
|
||||||
|
const b = points[i + 1];
|
||||||
|
const ya = Math.round(a.y * verticalPixelRatio);
|
||||||
|
const yb = Math.round(b.y * verticalPixelRatio);
|
||||||
|
const top = Math.min(ya, yb);
|
||||||
|
const h = Math.abs(yb - ya);
|
||||||
|
if (h <= 0) continue;
|
||||||
|
context.fillStyle = a.fill;
|
||||||
|
context.fillRect(xStart, top, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineW = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||||
|
context.lineWidth = lineW;
|
||||||
|
|
||||||
|
const labelX = Math.round(Math.max(6, xLeftMedia - 8) * horizontalPixelRatio);
|
||||||
|
context.font = `${Math.max(10, Math.round(11 * verticalPixelRatio))}px system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif`;
|
||||||
|
context.textAlign = 'right';
|
||||||
|
context.textBaseline = 'middle';
|
||||||
|
|
||||||
|
for (const pt of points) {
|
||||||
|
const y = Math.round(pt.y * verticalPixelRatio);
|
||||||
|
context.strokeStyle = pt.line;
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(xStart, y);
|
||||||
|
context.lineTo(xEnd, y);
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
const label = `${formatRatio(pt.ratio)} (${formatPrice(pt.price)})`;
|
||||||
|
context.fillStyle = pt.line;
|
||||||
|
context.fillText(label, labelX, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const y0 = series.priceToCoordinate(p0);
|
||||||
|
const y1 = series.priceToCoordinate(p1);
|
||||||
|
if (y0 != null && y1 != null) {
|
||||||
|
const ax = Math.round(x1 * horizontalPixelRatio);
|
||||||
|
const ay = Math.round(y0 * verticalPixelRatio);
|
||||||
|
const bx = Math.round(x2 * horizontalPixelRatio);
|
||||||
|
const by = Math.round(y1 * verticalPixelRatio);
|
||||||
|
|
||||||
|
context.strokeStyle = selected ? 'rgba(250,204,21,0.65)' : 'rgba(226,232,240,0.55)';
|
||||||
|
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||||
|
context.setLineDash([Math.max(2, Math.round(5 * horizontalPixelRatio)), Math.max(2, Math.round(5 * horizontalPixelRatio))]);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(ax, ay);
|
||||||
|
context.lineTo(bx, by);
|
||||||
|
context.stroke();
|
||||||
|
context.setLineDash([]);
|
||||||
|
|
||||||
|
const r = Math.max(2, Math.round((selected ? 4 : 3) * horizontalPixelRatio));
|
||||||
|
context.fillStyle = selected ? 'rgba(250,204,21,0.95)' : 'rgba(147,197,253,0.95)';
|
||||||
|
if (selected) {
|
||||||
|
context.strokeStyle = 'rgba(15,23,42,0.85)';
|
||||||
|
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
|
||||||
|
}
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(ax, ay, r, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
if (selected) context.stroke();
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(bx, by, r, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
if (selected) context.stroke();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FibPaneView implements IPrimitivePaneView {
|
||||||
|
private readonly _renderer: FibPaneRenderer;
|
||||||
|
|
||||||
|
constructor(getState: () => State) {
|
||||||
|
this._renderer = new FibPaneRenderer(getState);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer() {
|
||||||
|
return this._renderer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
|
||||||
|
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||||
|
private _series: ISeriesApi<'Candlestick', Time> | null = null;
|
||||||
|
private _fib: FibRetracement | null = null;
|
||||||
|
private _selected = false;
|
||||||
|
private _opacity = 1;
|
||||||
|
private readonly _paneView: FibPaneView;
|
||||||
|
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._paneView = new FibPaneView(() => ({
|
||||||
|
fib: this._fib,
|
||||||
|
series: this._series,
|
||||||
|
chart: this._param?.chart ?? null,
|
||||||
|
selected: this._selected,
|
||||||
|
opacity: this._opacity,
|
||||||
|
}));
|
||||||
|
this._paneViews = [this._paneView];
|
||||||
|
}
|
||||||
|
|
||||||
|
attached(param: SeriesAttachedParameter<Time>) {
|
||||||
|
this._param = param;
|
||||||
|
this._series = param.series as ISeriesApi<'Candlestick', Time>;
|
||||||
|
}
|
||||||
|
|
||||||
|
detached() {
|
||||||
|
this._param = null;
|
||||||
|
this._series = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
paneViews() {
|
||||||
|
return this._paneViews;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFib(next: FibRetracement | null) {
|
||||||
|
this._fib = next;
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelected(next: boolean) {
|
||||||
|
this._selected = next;
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpacity(next: number) {
|
||||||
|
this._opacity = Number.isFinite(next) ? next : 1;
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
1217
src/features/chart/TradingChart.tsx
Normal file
1217
src/features/chart/TradingChart.tsx
Normal file
File diff suppressed because it is too large
Load Diff
59
src/features/chart/useChartData.ts
Normal file
59
src/features/chart/useChartData.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||||
|
import { fetchChart } from '../../lib/api';
|
||||||
|
import { useInterval } from '../../app/hooks/useInterval';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
symbol: string;
|
||||||
|
source?: string;
|
||||||
|
tf: string;
|
||||||
|
limit: number;
|
||||||
|
pollMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
candles: Candle[];
|
||||||
|
indicators: ChartIndicators;
|
||||||
|
meta: { tf: string; bucketSeconds: number } | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
|
||||||
|
const [candles, setCandles] = useState<Candle[]>([]);
|
||||||
|
const [indicators, setIndicators] = useState<ChartIndicators>({});
|
||||||
|
const [meta, setMeta] = useState<{ tf: string; bucketSeconds: number } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
|
||||||
|
const fetchOnce = useCallback(async () => {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchChart({ symbol, source, tf, limit });
|
||||||
|
setCandles(res.candles);
|
||||||
|
setIndicators(res.indicators);
|
||||||
|
setMeta(res.meta);
|
||||||
|
setError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
inFlight.current = false;
|
||||||
|
}
|
||||||
|
}, [symbol, source, tf, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchOnce();
|
||||||
|
}, [fetchOnce]);
|
||||||
|
|
||||||
|
useInterval(() => void fetchOnce(), pollMs);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }),
|
||||||
|
[candles, indicators, meta, loading, error, fetchOnce]
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/features/market/DlobDashboard.tsx
Normal file
137
src/features/market/DlobDashboard.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { DlobStats } from './useDlobStats';
|
||||||
|
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||||
|
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||||
|
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
||||||
|
import DlobSlippageChart from './DlobSlippageChart';
|
||||||
|
|
||||||
|
function formatUsd(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||||
|
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||||
|
return `$${v.toPrecision(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBps(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return `${v.toFixed(1)} bps`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return `${(v * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(connected: boolean, error: string | null): ReactNode {
|
||||||
|
if (error) return <span className="neg">{error}</span>;
|
||||||
|
return connected ? <span className="pos">live</span> : <span className="muted">offline</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DlobDashboard({
|
||||||
|
market,
|
||||||
|
stats,
|
||||||
|
statsConnected,
|
||||||
|
statsError,
|
||||||
|
depthBands,
|
||||||
|
depthBandsConnected,
|
||||||
|
depthBandsError,
|
||||||
|
slippageRows,
|
||||||
|
slippageConnected,
|
||||||
|
slippageError,
|
||||||
|
}: {
|
||||||
|
market: string;
|
||||||
|
stats: DlobStats | null;
|
||||||
|
statsConnected: boolean;
|
||||||
|
statsError: string | null;
|
||||||
|
depthBands: DlobDepthBandRow[];
|
||||||
|
depthBandsConnected: boolean;
|
||||||
|
depthBandsError: string | null;
|
||||||
|
slippageRows: DlobSlippageRow[];
|
||||||
|
slippageConnected: boolean;
|
||||||
|
slippageError: string | null;
|
||||||
|
}) {
|
||||||
|
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dlobDash">
|
||||||
|
<div className="dlobDash__head">
|
||||||
|
<div className="dlobDash__title">DLOB</div>
|
||||||
|
<div className="dlobDash__meta">
|
||||||
|
<span className="dlobDash__market">{market}</span>
|
||||||
|
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__statuses">
|
||||||
|
<div className="dlobStatus">
|
||||||
|
<span className="dlobStatus__label">stats</span>
|
||||||
|
<span className="dlobStatus__value">{statusLabel(statsConnected, statsError)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dlobStatus">
|
||||||
|
<span className="dlobStatus__label">depth bands</span>
|
||||||
|
<span className="dlobStatus__value">{statusLabel(depthBandsConnected, depthBandsError)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dlobStatus">
|
||||||
|
<span className="dlobStatus__label">slippage</span>
|
||||||
|
<span className="dlobStatus__value">{statusLabel(slippageConnected, slippageError)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__grid">
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Bid</div>
|
||||||
|
<div className="dlobKpi__value pos">{formatUsd(stats?.bestBid ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Ask</div>
|
||||||
|
<div className="dlobKpi__value neg">{formatUsd(stats?.bestAsk ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Mid</div>
|
||||||
|
<div className="dlobKpi__value">{formatUsd(stats?.mid ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Spread</div>
|
||||||
|
<div className="dlobKpi__value">{formatBps(stats?.spreadBps ?? null)}</div>
|
||||||
|
<div className="dlobKpi__sub muted">{formatUsd(stats?.spreadAbs ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Depth (bid/ask)</div>
|
||||||
|
<div className="dlobKpi__value">
|
||||||
|
<span className="pos">{formatUsd(stats?.depthBidUsd ?? null)}</span>{' '}
|
||||||
|
<span className="muted">/</span> <span className="neg">{formatUsd(stats?.depthAskUsd ?? null)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Imbalance</div>
|
||||||
|
<div className="dlobKpi__value">{formatPct(stats?.imbalance ?? null)}</div>
|
||||||
|
<div className="dlobKpi__sub muted">[-1..1]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__panes">
|
||||||
|
<div className="dlobDash__pane">
|
||||||
|
<DlobDepthBandsPanel rows={depthBands} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__pane">
|
||||||
|
<div className="dlobSlippage">
|
||||||
|
<div className="dlobSlippage__head">
|
||||||
|
<div className="dlobSlippage__title">Slippage (impact bps)</div>
|
||||||
|
<div className="dlobSlippage__meta muted">by size (USD)</div>
|
||||||
|
</div>
|
||||||
|
{slippageRows.length ? (
|
||||||
|
<div className="dlobSlippage__chartWrap">
|
||||||
|
<DlobSlippageChart rows={slippageRows} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dlobSlippage__empty muted">No slippage rows yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
75
src/features/market/DlobDepthBandsPanel.tsx
Normal file
75
src/features/market/DlobDepthBandsPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||||
|
|
||||||
|
function formatUsd(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||||
|
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||||
|
return `$${v.toPrecision(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return `${v.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandRowStyle(askScale: number, bidScale: number): CSSProperties {
|
||||||
|
const a = Number.isFinite(askScale) && askScale > 0 ? Math.min(1, askScale) : 0;
|
||||||
|
const b = Number.isFinite(bidScale) && bidScale > 0 ? Math.min(1, bidScale) : 0;
|
||||||
|
return { ['--ask-scale' as any]: a, ['--bid-scale' as any]: b } as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DlobDepthBandsPanel({ rows }: { rows: DlobDepthBandRow[] }) {
|
||||||
|
const sorted = useMemo(() => rows.slice().sort((a, b) => a.bandBps - b.bandBps), [rows]);
|
||||||
|
|
||||||
|
const maxUsd = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of sorted) {
|
||||||
|
if (r.askUsd != null && Number.isFinite(r.askUsd)) max = Math.max(max, r.askUsd);
|
||||||
|
if (r.bidUsd != null && Number.isFinite(r.bidUsd)) max = Math.max(max, r.bidUsd);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, [sorted]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dlobDepth">
|
||||||
|
<div className="dlobDepth__head">
|
||||||
|
<div className="dlobDepth__title">Depth (bands)</div>
|
||||||
|
<div className="dlobDepth__meta">±bps around mid</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDepth__table">
|
||||||
|
<div className="dlobDepthRow dlobDepthRow--head">
|
||||||
|
<span>Band</span>
|
||||||
|
<span className="dlobDepthRow__num">Ask USD</span>
|
||||||
|
<span className="dlobDepthRow__num">Bid USD</span>
|
||||||
|
<span className="dlobDepthRow__num">Bid %</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sorted.length ? (
|
||||||
|
sorted.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.bandBps}
|
||||||
|
className="dlobDepthRow"
|
||||||
|
style={bandRowStyle(maxUsd > 0 ? (r.askUsd || 0) / maxUsd : 0, maxUsd > 0 ? (r.bidUsd || 0) / maxUsd : 0)}
|
||||||
|
title={`band=${r.bandBps}bps bid=${r.bidUsd ?? '—'} ask=${r.askUsd ?? '—'} imbalance=${r.imbalance ?? '—'}`}
|
||||||
|
>
|
||||||
|
<span className="dlobDepthRow__band">{r.bandBps} bps</span>
|
||||||
|
<span className="dlobDepthRow__num neg">{formatUsd(r.askUsd)}</span>
|
||||||
|
<span className="dlobDepthRow__num pos">{formatUsd(r.bidUsd)}</span>
|
||||||
|
<span className="dlobDepthRow__num muted">
|
||||||
|
{r.imbalance == null || !Number.isFinite(r.imbalance)
|
||||||
|
? '—'
|
||||||
|
: formatPct(((r.imbalance + 1) / 2) * 100)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="dlobDepth__empty muted">No depth band rows yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/features/market/DlobSlippageChart.tsx
Normal file
112
src/features/market/DlobSlippageChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||||
|
|
||||||
|
type Point = { x: number; y: number | null };
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const chartRef = useRef<Chart | null>(null);
|
||||||
|
|
||||||
|
const { buy, sell, maxImpact } = useMemo(() => {
|
||||||
|
const buy: Point[] = [];
|
||||||
|
const sell: Point[] = [];
|
||||||
|
let maxImpact = 0;
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!Number.isFinite(r.sizeUsd) || r.sizeUsd <= 0) continue;
|
||||||
|
const y = r.impactBps == null || !Number.isFinite(r.impactBps) ? null : r.impactBps;
|
||||||
|
if (y != null) maxImpact = Math.max(maxImpact, y);
|
||||||
|
if (r.side === 'buy') buy.push({ x: r.sizeUsd, y });
|
||||||
|
if (r.side === 'sell') sell.push({ x: r.sizeUsd, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
buy.sort((a, b) => a.x - b.x);
|
||||||
|
sell.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
return { buy, sell, maxImpact };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
if (chartRef.current) return;
|
||||||
|
|
||||||
|
chartRef.current = new Chart(canvasRef.current, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Buy',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgba(34,197,94,0.9)',
|
||||||
|
backgroundColor: 'rgba(34,197,94,0.15)',
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.2,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sell',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgba(239,68,68,0.9)',
|
||||||
|
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.2,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
parsing: false,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#e6e9ef' } },
|
||||||
|
tooltip: { enabled: true },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
title: { display: true, text: 'Size (USD)', color: '#c7cbd4' },
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: Math.max(10, maxImpact * (1 + clamp01(0.15))),
|
||||||
|
title: { display: true, text: 'Impact (bps)', color: '#c7cbd4' },
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.destroy();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
}, [maxImpact]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
chart.data.datasets[0]!.data = buy as any;
|
||||||
|
chart.data.datasets[1]!.data = sell as any;
|
||||||
|
chart.update('none');
|
||||||
|
}, [buy, sell]);
|
||||||
|
|
||||||
|
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
|
||||||
|
}
|
||||||
|
|
||||||
28
src/features/market/MarketHeader.tsx
Normal file
28
src/features/market/MarketHeader.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import MarketSelect from './MarketSelect';
|
||||||
|
import MarketStatsRow, { StatItem } from './MarketStatsRow';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
market: string;
|
||||||
|
markets: string[];
|
||||||
|
onMarketChange: (next: string) => void;
|
||||||
|
leftSlot?: ReactNode;
|
||||||
|
stats: StatItem[];
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarketHeader({ market, markets, onMarketChange, leftSlot, stats, rightSlot }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="marketHeader">
|
||||||
|
<div className="marketHeader__left">
|
||||||
|
<MarketSelect value={market} options={markets} onChange={onMarketChange} />
|
||||||
|
{leftSlot ? <div className="marketHeader__slot">{leftSlot}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="marketHeader__mid">
|
||||||
|
<MarketStatsRow items={stats} />
|
||||||
|
</div>
|
||||||
|
<div className="marketHeader__right">{rightSlot}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
21
src/features/market/MarketSelect.tsx
Normal file
21
src/features/market/MarketSelect.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
options: string[];
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarketSelect({ value, options, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<label className="marketSelect">
|
||||||
|
<span className="marketSelect__label">Market</span>
|
||||||
|
<select className="marketSelect__input" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
27
src/features/market/MarketStatsRow.tsx
Normal file
27
src/features/market/MarketStatsRow.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type StatItem = {
|
||||||
|
key: string;
|
||||||
|
label: ReactNode;
|
||||||
|
value: ReactNode;
|
||||||
|
sub?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: StatItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarketStatsRow({ items }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="statsRow">
|
||||||
|
{items.map((it) => (
|
||||||
|
<div key={it.key} className="stat">
|
||||||
|
<div className="stat__label">{it.label}</div>
|
||||||
|
<div className="stat__value">{it.value}</div>
|
||||||
|
{it.sub ? <div className="stat__sub">{it.sub}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
134
src/features/market/useDlobDepthBands.ts
Normal file
134
src/features/market/useDlobDepthBands.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type DlobDepthBandRow = {
|
||||||
|
marketName: string;
|
||||||
|
bandBps: number;
|
||||||
|
midPrice: number | null;
|
||||||
|
bestBid: number | null;
|
||||||
|
bestAsk: number | null;
|
||||||
|
bidUsd: number | null;
|
||||||
|
askUsd: number | null;
|
||||||
|
bidBase: number | null;
|
||||||
|
askBase: number | null;
|
||||||
|
imbalance: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number.parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraRow = {
|
||||||
|
market_name: string;
|
||||||
|
band_bps: unknown;
|
||||||
|
mid_price?: unknown;
|
||||||
|
best_bid_price?: unknown;
|
||||||
|
best_ask_price?: unknown;
|
||||||
|
bid_usd?: unknown;
|
||||||
|
ask_usd?: unknown;
|
||||||
|
bid_base?: unknown;
|
||||||
|
ask_base?: unknown;
|
||||||
|
imbalance?: unknown;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_depth_bps_latest: HasuraRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobDepthBands(
|
||||||
|
marketName: string
|
||||||
|
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
|
||||||
|
const [rows, setRows] = useState<DlobDepthBandRow[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setRows([]);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobDepthBands($market: String!) {
|
||||||
|
dlob_depth_bps_latest(
|
||||||
|
where: { market_name: { _eq: $market } }
|
||||||
|
order_by: [{ band_bps: asc }]
|
||||||
|
) {
|
||||||
|
market_name
|
||||||
|
band_bps
|
||||||
|
mid_price
|
||||||
|
best_bid_price
|
||||||
|
best_ask_price
|
||||||
|
bid_usd
|
||||||
|
ask_usd
|
||||||
|
bid_base
|
||||||
|
ask_base
|
||||||
|
imbalance
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const out: DlobDepthBandRow[] = [];
|
||||||
|
for (const r of data?.dlob_depth_bps_latest || []) {
|
||||||
|
if (!r?.market_name) continue;
|
||||||
|
const bandBps = toInt(r.band_bps);
|
||||||
|
if (bandBps == null || bandBps <= 0) continue;
|
||||||
|
out.push({
|
||||||
|
marketName: r.market_name,
|
||||||
|
bandBps,
|
||||||
|
midPrice: toNum(r.mid_price),
|
||||||
|
bestBid: toNum(r.best_bid_price),
|
||||||
|
bestAsk: toNum(r.best_ask_price),
|
||||||
|
bidUsd: toNum(r.bid_usd),
|
||||||
|
askUsd: toNum(r.ask_usd),
|
||||||
|
bidBase: toNum(r.bid_base),
|
||||||
|
askBase: toNum(r.ask_base),
|
||||||
|
imbalance: toNum(r.imbalance),
|
||||||
|
updatedAt: r.updated_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRows(out);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket]);
|
||||||
|
|
||||||
|
return { rows, connected, error };
|
||||||
|
}
|
||||||
|
|
||||||
182
src/features/market/useDlobL2.ts
Normal file
182
src/features/market/useDlobL2.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type OrderbookRow = {
|
||||||
|
price: number;
|
||||||
|
sizeBase: number;
|
||||||
|
sizeUsd: number;
|
||||||
|
totalBase: number;
|
||||||
|
totalUsd: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DlobL2 = {
|
||||||
|
marketName: string;
|
||||||
|
bids: OrderbookRow[];
|
||||||
|
asks: OrderbookRow[];
|
||||||
|
bestBid: number | null;
|
||||||
|
bestAsk: number | null;
|
||||||
|
mid: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function envNumber(name: string): number | undefined {
|
||||||
|
const raw = (import.meta as any).env?.[name];
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJson(v: unknown): unknown {
|
||||||
|
if (typeof v !== 'string') return v;
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number): Array<{ price: number; size: number }> {
|
||||||
|
const v = normalizeJson(raw);
|
||||||
|
if (!Array.isArray(v)) return [];
|
||||||
|
|
||||||
|
const out: Array<{ price: number; size: number }> = [];
|
||||||
|
for (const item of v) {
|
||||||
|
const priceInt = toNum((item as any)?.price);
|
||||||
|
const sizeInt = toNum((item as any)?.size);
|
||||||
|
if (priceInt == null || sizeInt == null) continue;
|
||||||
|
|
||||||
|
const price = priceInt / pricePrecision;
|
||||||
|
const size = sizeInt / basePrecision;
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(size)) continue;
|
||||||
|
out.push({ price, size });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTotals(levels: Array<{ price: number; sizeBase: number }>): OrderbookRow[] {
|
||||||
|
let totalBase = 0;
|
||||||
|
let totalUsd = 0;
|
||||||
|
|
||||||
|
return levels.map((l) => {
|
||||||
|
const sizeUsd = l.sizeBase * l.price;
|
||||||
|
totalBase += l.sizeBase;
|
||||||
|
totalUsd += sizeUsd;
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: l.price,
|
||||||
|
sizeBase: l.sizeBase,
|
||||||
|
sizeUsd,
|
||||||
|
totalBase,
|
||||||
|
totalUsd,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraDlobL2Row = {
|
||||||
|
market_name: string;
|
||||||
|
bids?: unknown;
|
||||||
|
asks?: unknown;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_l2_latest: HasuraDlobL2Row[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobL2(
|
||||||
|
marketName: string,
|
||||||
|
opts?: { levels?: number }
|
||||||
|
): { l2: DlobL2 | null; connected: boolean; error: string | null } {
|
||||||
|
const [l2, setL2] = useState<DlobL2 | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]);
|
||||||
|
|
||||||
|
const pricePrecision = useMemo(() => envNumber('VITE_DLOB_PRICE_PRECISION') ?? 1_000_000, []);
|
||||||
|
const basePrecision = useMemo(() => envNumber('VITE_DLOB_BASE_PRECISION') ?? 1_000_000_000, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setL2(null);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobL2($market: String!) {
|
||||||
|
dlob_l2_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||||
|
market_name
|
||||||
|
bids
|
||||||
|
asks
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const row = data?.dlob_l2_latest?.[0];
|
||||||
|
if (!row?.market_name) return;
|
||||||
|
|
||||||
|
const bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.price - a.price)
|
||||||
|
.slice(0, levels)
|
||||||
|
.map((l) => ({ price: l.price, sizeBase: l.size }));
|
||||||
|
|
||||||
|
const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.price - b.price)
|
||||||
|
.slice(0, levels)
|
||||||
|
.map((l) => ({ price: l.price, sizeBase: l.size }));
|
||||||
|
|
||||||
|
// We compute totals from best -> worse.
|
||||||
|
// For UI we display asks with best ask closest to mid (at the bottom), so we reverse.
|
||||||
|
const bids = withTotals(bidsSorted);
|
||||||
|
const asks = withTotals(asksSorted).slice().reverse();
|
||||||
|
|
||||||
|
const bestBid = bidsSorted.length ? bidsSorted[0].price : null;
|
||||||
|
const bestAsk = asksSorted.length ? asksSorted[0].price : null;
|
||||||
|
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
|
||||||
|
|
||||||
|
setL2({
|
||||||
|
marketName: row.market_name,
|
||||||
|
bids,
|
||||||
|
asks,
|
||||||
|
bestBid,
|
||||||
|
bestAsk,
|
||||||
|
mid,
|
||||||
|
updatedAt: row.updated_at ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket, levels, pricePrecision, basePrecision]);
|
||||||
|
|
||||||
|
return { l2, connected, error };
|
||||||
|
}
|
||||||
138
src/features/market/useDlobSlippage.ts
Normal file
138
src/features/market/useDlobSlippage.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type DlobSlippageRow = {
|
||||||
|
marketName: string;
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
sizeUsd: number;
|
||||||
|
midPrice: number | null;
|
||||||
|
vwapPrice: number | null;
|
||||||
|
worstPrice: number | null;
|
||||||
|
filledUsd: number | null;
|
||||||
|
filledBase: number | null;
|
||||||
|
impactBps: number | null;
|
||||||
|
levelsConsumed: number | null;
|
||||||
|
fillPct: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number.parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraRow = {
|
||||||
|
market_name: string;
|
||||||
|
side: string;
|
||||||
|
size_usd: unknown;
|
||||||
|
mid_price?: unknown;
|
||||||
|
vwap_price?: unknown;
|
||||||
|
worst_price?: unknown;
|
||||||
|
filled_usd?: unknown;
|
||||||
|
filled_base?: unknown;
|
||||||
|
impact_bps?: unknown;
|
||||||
|
levels_consumed?: unknown;
|
||||||
|
fill_pct?: unknown;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_slippage_latest: HasuraRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
||||||
|
const [rows, setRows] = useState<DlobSlippageRow[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setRows([]);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobSlippage($market: String!) {
|
||||||
|
dlob_slippage_latest(
|
||||||
|
where: { market_name: { _eq: $market } }
|
||||||
|
order_by: [{ side: asc }, { size_usd: asc }]
|
||||||
|
) {
|
||||||
|
market_name
|
||||||
|
side
|
||||||
|
size_usd
|
||||||
|
mid_price
|
||||||
|
vwap_price
|
||||||
|
worst_price
|
||||||
|
filled_usd
|
||||||
|
filled_base
|
||||||
|
impact_bps
|
||||||
|
levels_consumed
|
||||||
|
fill_pct
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const out: DlobSlippageRow[] = [];
|
||||||
|
for (const r of data?.dlob_slippage_latest || []) {
|
||||||
|
if (!r?.market_name) continue;
|
||||||
|
const side = String(r.side || '').trim();
|
||||||
|
if (side !== 'buy' && side !== 'sell') continue;
|
||||||
|
const sizeUsd = toInt(r.size_usd);
|
||||||
|
if (sizeUsd == null || sizeUsd <= 0) continue;
|
||||||
|
out.push({
|
||||||
|
marketName: r.market_name,
|
||||||
|
side,
|
||||||
|
sizeUsd,
|
||||||
|
midPrice: toNum(r.mid_price),
|
||||||
|
vwapPrice: toNum(r.vwap_price),
|
||||||
|
worstPrice: toNum(r.worst_price),
|
||||||
|
filledUsd: toNum(r.filled_usd),
|
||||||
|
filledBase: toNum(r.filled_base),
|
||||||
|
impactBps: toNum(r.impact_bps),
|
||||||
|
levelsConsumed: toInt(r.levels_consumed),
|
||||||
|
fillPct: toNum(r.fill_pct),
|
||||||
|
updatedAt: r.updated_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRows(out);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket]);
|
||||||
|
|
||||||
|
return { rows, connected, error };
|
||||||
|
}
|
||||||
|
|
||||||
123
src/features/market/useDlobStats.ts
Normal file
123
src/features/market/useDlobStats.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type DlobStats = {
|
||||||
|
marketName: string;
|
||||||
|
markPrice: number | null;
|
||||||
|
oraclePrice: number | null;
|
||||||
|
bestBid: number | null;
|
||||||
|
bestAsk: number | null;
|
||||||
|
mid: number | null;
|
||||||
|
spreadAbs: number | null;
|
||||||
|
spreadBps: number | null;
|
||||||
|
depthBidBase: number | null;
|
||||||
|
depthAskBase: number | null;
|
||||||
|
depthBidUsd: number | null;
|
||||||
|
depthAskUsd: number | null;
|
||||||
|
imbalance: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraDlobStatsRow = {
|
||||||
|
market_name: string;
|
||||||
|
mark_price?: string | null;
|
||||||
|
oracle_price?: string | null;
|
||||||
|
best_bid_price?: string | null;
|
||||||
|
best_ask_price?: string | null;
|
||||||
|
mid_price?: string | null;
|
||||||
|
spread_abs?: string | null;
|
||||||
|
spread_bps?: string | null;
|
||||||
|
depth_bid_base?: string | null;
|
||||||
|
depth_ask_base?: string | null;
|
||||||
|
depth_bid_usd?: string | null;
|
||||||
|
depth_ask_usd?: string | null;
|
||||||
|
imbalance?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_stats_latest: HasuraDlobStatsRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } {
|
||||||
|
const [stats, setStats] = useState<DlobStats | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setStats(null);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobStats($market: String!) {
|
||||||
|
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||||
|
market_name
|
||||||
|
mark_price
|
||||||
|
oracle_price
|
||||||
|
best_bid_price
|
||||||
|
best_ask_price
|
||||||
|
mid_price
|
||||||
|
spread_abs
|
||||||
|
spread_bps
|
||||||
|
depth_bid_base
|
||||||
|
depth_ask_base
|
||||||
|
depth_bid_usd
|
||||||
|
depth_ask_usd
|
||||||
|
imbalance
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const row = data?.dlob_stats_latest?.[0];
|
||||||
|
if (!row?.market_name) return;
|
||||||
|
setStats({
|
||||||
|
marketName: row.market_name,
|
||||||
|
markPrice: toNum(row.mark_price),
|
||||||
|
oraclePrice: toNum(row.oracle_price),
|
||||||
|
bestBid: toNum(row.best_bid_price),
|
||||||
|
bestAsk: toNum(row.best_ask_price),
|
||||||
|
mid: toNum(row.mid_price),
|
||||||
|
spreadAbs: toNum(row.spread_abs),
|
||||||
|
spreadBps: toNum(row.spread_bps),
|
||||||
|
depthBidBase: toNum(row.depth_bid_base),
|
||||||
|
depthAskBase: toNum(row.depth_ask_base),
|
||||||
|
depthBidUsd: toNum(row.depth_bid_usd),
|
||||||
|
depthAskUsd: toNum(row.depth_ask_usd),
|
||||||
|
imbalance: toNum(row.imbalance),
|
||||||
|
updatedAt: row.updated_at ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket]);
|
||||||
|
|
||||||
|
return { stats, connected, error };
|
||||||
|
}
|
||||||
31
src/features/tickerbar/TickerBar.tsx
Normal file
31
src/features/tickerbar/TickerBar.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type TickerItem = {
|
||||||
|
key: string;
|
||||||
|
label: ReactNode;
|
||||||
|
changePct: number;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: TickerItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPct(v: number): string {
|
||||||
|
const s = v >= 0 ? '+' : '';
|
||||||
|
return `${s}${v.toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TickerBar({ items }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="tickerBar">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.key} className={['ticker', t.active ? 'ticker--active' : ''].filter(Boolean).join(' ')}>
|
||||||
|
<span className="ticker__label">{t.label}</span>
|
||||||
|
<span className={['ticker__pct', t.changePct >= 0 ? 'pos' : 'neg'].join(' ')}>{formatPct(t.changePct)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
52
src/features/ticks/useTicks.ts
Normal file
52
src/features/ticks/useTicks.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { DriftTick, fetchLatestTicks } from '../../lib/hasura';
|
||||||
|
import { useInterval } from '../../app/hooks/useInterval';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
symbol: string;
|
||||||
|
source?: string;
|
||||||
|
limit: number;
|
||||||
|
pollMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
ticks: DriftTick[];
|
||||||
|
latest: DriftTick | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTicks({ symbol, source, limit, pollMs }: Params): Result {
|
||||||
|
const [ticks, setTicks] = useState<DriftTick[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
|
||||||
|
const fetchOnce = useCallback(async () => {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const next = await fetchLatestTicks(symbol, limit, source?.trim() ? source : undefined);
|
||||||
|
setTicks(next);
|
||||||
|
setError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
inFlight.current = false;
|
||||||
|
}
|
||||||
|
}, [symbol, limit, source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchOnce();
|
||||||
|
}, [fetchOnce]);
|
||||||
|
|
||||||
|
useInterval(() => void fetchOnce(), pollMs);
|
||||||
|
|
||||||
|
const latest = useMemo(() => (ticks.length ? ticks[ticks.length - 1] : null), [ticks]);
|
||||||
|
|
||||||
|
return { ticks, latest, loading, error, refresh: fetchOnce };
|
||||||
|
}
|
||||||
|
|
||||||
23
src/layout/AppShell.tsx
Normal file
23
src/layout/AppShell.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
header?: ReactNode;
|
||||||
|
top?: ReactNode;
|
||||||
|
main: ReactNode;
|
||||||
|
sidebar?: ReactNode;
|
||||||
|
rightbar?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppShell({ header, top, main, sidebar, rightbar }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
{header ? <div className="shellHeader">{header}</div> : null}
|
||||||
|
{top ? <div className="shellTop">{top}</div> : null}
|
||||||
|
<div className="shellBody">
|
||||||
|
<div className="shellMain">{main}</div>
|
||||||
|
{sidebar ? <div className="shellSidebar">{sidebar}</div> : null}
|
||||||
|
{rightbar ? <div className="shellRightbar">{rightbar}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/layout/AuthStatus.tsx
Normal file
20
src/layout/AuthStatus.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: string;
|
||||||
|
onLogout: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthStatus({ user, onLogout }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="authStatus">
|
||||||
|
<div className="authStatus__user" aria-label="Zalogowany użytkownik">
|
||||||
|
<div className="authStatus__userLabel">Zalogowany</div>
|
||||||
|
<div className="authStatus__userName">{user}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" type="button" onClick={onLogout}>
|
||||||
|
Wyloguj
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/layout/LoginScreen.tsx
Normal file
90
src/layout/LoginScreen.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onLoggedIn: (user: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
user?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginScreen({ onLoggedIn }: Props) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const json = (await res.json().catch(() => null)) as LoginResponse | null;
|
||||||
|
const ok = Boolean(res.ok && json?.ok);
|
||||||
|
if (!ok) throw new Error(json?.error || 'invalid_credentials');
|
||||||
|
const u = typeof json?.user === 'string' ? json.user.trim() : '';
|
||||||
|
if (!u) throw new Error('bad_response');
|
||||||
|
setPassword('');
|
||||||
|
onLoggedIn(u);
|
||||||
|
} catch {
|
||||||
|
setError('Nieprawidłowy login lub hasło.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="loginScreen">
|
||||||
|
<div className="loginCard" role="dialog" aria-label="Logowanie">
|
||||||
|
<div className="loginCard__brand">
|
||||||
|
<div className="loginCard__mark" aria-hidden="true" />
|
||||||
|
<div className="loginCard__title">trade</div>
|
||||||
|
</div>
|
||||||
|
<div className="loginCard__subtitle">Zaloguj się, aby wejść do aplikacji.</div>
|
||||||
|
|
||||||
|
<form className="loginForm" onSubmit={(e) => void submit(e)}>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">Login</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input loginForm__input"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="np. mpabi"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">Hasło</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input loginForm__input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error ? <div className="loginForm__error">{error}</div> : null}
|
||||||
|
|
||||||
|
<div className="loginForm__actions">
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? 'Logowanie…' : 'Zaloguj'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
61
src/layout/TopNav.tsx
Normal file
61
src/layout/TopNav.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
|
type NavId = 'portfolio' | 'trade' | 'earn' | 'vaults' | 'leaderboard' | 'more';
|
||||||
|
|
||||||
|
const navItems: Array<{ id: NavId; label: string }> = [
|
||||||
|
{ id: 'portfolio', label: 'Portfolio' },
|
||||||
|
{ id: 'trade', label: 'Trade' },
|
||||||
|
{ id: 'earn', label: 'Earn' },
|
||||||
|
{ id: 'vaults', label: 'Vaults' },
|
||||||
|
{ id: 'leaderboard', label: 'Leaderboard' },
|
||||||
|
{ id: 'more', label: 'More' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active?: NavId;
|
||||||
|
onSelect?: (id: NavId) => void;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TopNav({ active = 'trade', onSelect, rightSlot }: Props) {
|
||||||
|
return (
|
||||||
|
<header className="topNav">
|
||||||
|
<div className="topNav__left">
|
||||||
|
<div className="topNav__brand" aria-label="Trade">
|
||||||
|
<div className="topNav__brandMark" aria-hidden="true" />
|
||||||
|
<div className="topNav__brandName">Trade</div>
|
||||||
|
</div>
|
||||||
|
<nav className="topNav__menu" aria-label="Primary">
|
||||||
|
{navItems.map((it) => (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
type="button"
|
||||||
|
className={['topNav__link', active === it.id ? 'topNav__link--active' : ''].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => onSelect?.(it.id)}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="topNav__right">
|
||||||
|
{rightSlot ?? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="primary" type="button">
|
||||||
|
Deposit
|
||||||
|
</Button>
|
||||||
|
<button className="topNav__iconBtn" type="button" aria-label="Settings">
|
||||||
|
⚙
|
||||||
|
</button>
|
||||||
|
<div className="topNav__account">
|
||||||
|
<div className="topNav__accountName">Main Account</div>
|
||||||
|
<div className="topNav__accountSub">visualizer</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/lib/api.ts
Normal file
96
src/lib/api.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
export type Candle = {
|
||||||
|
time: number; // unix seconds
|
||||||
|
open: number;
|
||||||
|
high: number;
|
||||||
|
low: number;
|
||||||
|
close: number;
|
||||||
|
volume?: number;
|
||||||
|
oracle?: number | null;
|
||||||
|
flow?: { up: number; down: number; flat: number };
|
||||||
|
flowRows?: number[];
|
||||||
|
flowMoves?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeriesPoint = {
|
||||||
|
time: number; // unix seconds
|
||||||
|
value: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChartIndicators = {
|
||||||
|
oracle?: SeriesPoint[];
|
||||||
|
sma20?: SeriesPoint[];
|
||||||
|
ema20?: SeriesPoint[];
|
||||||
|
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
||||||
|
rsi14?: SeriesPoint[];
|
||||||
|
macd?: { macd: SeriesPoint[]; signal: SeriesPoint[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChartResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
symbol?: string;
|
||||||
|
source?: string | null;
|
||||||
|
tf?: string;
|
||||||
|
bucketSeconds?: number;
|
||||||
|
candles?: Candle[];
|
||||||
|
indicators?: ChartIndicators;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getApiBaseUrl(): string {
|
||||||
|
const v = (import.meta as any).env?.VITE_API_URL;
|
||||||
|
if (v) return String(v);
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChart(params: {
|
||||||
|
symbol: string;
|
||||||
|
source?: string;
|
||||||
|
tf: string;
|
||||||
|
limit: number;
|
||||||
|
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
||||||
|
const base = getApiBaseUrl();
|
||||||
|
const u = new URL(base, window.location.origin);
|
||||||
|
u.pathname = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') + '/v1/chart' : '/v1/chart';
|
||||||
|
u.searchParams.set('symbol', params.symbol);
|
||||||
|
u.searchParams.set('tf', params.tf);
|
||||||
|
u.searchParams.set('limit', String(params.limit));
|
||||||
|
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
||||||
|
|
||||||
|
const res = await fetch(u.toString());
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||||
|
const json = JSON.parse(text) as ChartResponse;
|
||||||
|
if (!json.ok) throw new Error(json.error || 'API: error');
|
||||||
|
return {
|
||||||
|
candles: (json.candles || []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
time: Number(c.time),
|
||||||
|
open: Number(c.open),
|
||||||
|
high: Number(c.high),
|
||||||
|
low: Number(c.low),
|
||||||
|
close: Number(c.close),
|
||||||
|
volume: c.volume == null ? undefined : Number(c.volume),
|
||||||
|
oracle: c.oracle == null ? null : Number(c.oracle),
|
||||||
|
flow:
|
||||||
|
(c as any)?.flow && typeof (c as any).flow === 'object'
|
||||||
|
? {
|
||||||
|
up: Number((c as any).flow.up),
|
||||||
|
down: Number((c as any).flow.down),
|
||||||
|
flat: Number((c as any).flow.flat),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
flowRows: Array.isArray((c as any)?.flowRows)
|
||||||
|
? (c as any).flowRows.map((x: any) => Number(x))
|
||||||
|
: Array.isArray((c as any)?.flow_rows)
|
||||||
|
? (c as any).flow_rows.map((x: any) => Number(x))
|
||||||
|
: undefined,
|
||||||
|
flowMoves: Array.isArray((c as any)?.flowMoves)
|
||||||
|
? (c as any).flowMoves.map((x: any) => Number(x))
|
||||||
|
: Array.isArray((c as any)?.flow_moves)
|
||||||
|
? (c as any).flow_moves.map((x: any) => Number(x))
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
indicators: json.indicators || {},
|
||||||
|
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
||||||
|
};
|
||||||
|
}
|
||||||
181
src/lib/graphqlWs.ts
Normal file
181
src/lib/graphqlWs.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
type HeadersMap = Record<string, string>;
|
||||||
|
|
||||||
|
type SubscribeParams<T> = {
|
||||||
|
query: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
onData: (data: T) => void;
|
||||||
|
onError?: (err: string) => void;
|
||||||
|
onStatus?: (s: { connected: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function envString(name: string): string | undefined {
|
||||||
|
const v = (import.meta as any).env?.[name];
|
||||||
|
const s = v == null ? '' : String(v).trim();
|
||||||
|
return s ? s : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlHttpUrl(): string {
|
||||||
|
return envString('VITE_HASURA_URL') || '/graphql';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlWsUrl(): string {
|
||||||
|
const explicit = envString('VITE_HASURA_WS_URL');
|
||||||
|
if (explicit) {
|
||||||
|
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
|
||||||
|
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
|
||||||
|
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
|
||||||
|
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
|
||||||
|
return `${proto}//${host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpUrl = resolveGraphqlHttpUrl();
|
||||||
|
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
|
||||||
|
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
|
||||||
|
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
|
||||||
|
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`;
|
||||||
|
return `${proto}//${host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthHeaders(): HeadersMap | undefined {
|
||||||
|
const token = envString('VITE_HASURA_AUTH_TOKEN');
|
||||||
|
if (token) return { authorization: `Bearer ${token}` };
|
||||||
|
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
||||||
|
if (secret) return { 'x-hasura-admin-secret': secret };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsMessage =
|
||||||
|
| { type: 'connection_ack' | 'ka' | 'complete' }
|
||||||
|
| { type: 'connection_error'; payload?: any }
|
||||||
|
| { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } }
|
||||||
|
| { type: 'error'; id: string; payload?: any };
|
||||||
|
|
||||||
|
export type SubscriptionHandle = {
|
||||||
|
unsubscribe: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onStatus }: SubscribeParams<T>): SubscriptionHandle {
|
||||||
|
const wsUrl = resolveGraphqlWsUrl();
|
||||||
|
const headers = resolveAuthHeaders();
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let closed = false;
|
||||||
|
let started = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const subId = '1';
|
||||||
|
|
||||||
|
const emitError = (e: unknown) => {
|
||||||
|
const msg = typeof e === 'string' ? e : String((e as any)?.message || e);
|
||||||
|
onError?.(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setConnected = (connected: boolean) => onStatus?.({ connected });
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (!ws || started) return;
|
||||||
|
started = true;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: subId,
|
||||||
|
type: 'start',
|
||||||
|
payload: { query, variables: variables ?? {} },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
started = false;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl, 'graphql-ws');
|
||||||
|
} catch (e) {
|
||||||
|
emitError(e);
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
const payload = headers ? { headers } : {};
|
||||||
|
ws?.send(JSON.stringify({ type: 'connection_init', payload }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: WsMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(ev.data));
|
||||||
|
} catch (e) {
|
||||||
|
emitError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'connection_ack') {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'connection_error') {
|
||||||
|
emitError(msg.payload || 'connection_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'ka' || msg.type === 'complete') return;
|
||||||
|
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
emitError(msg.payload || 'subscription_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'data') {
|
||||||
|
const errors = msg.payload?.errors;
|
||||||
|
if (Array.isArray(errors) && errors.length) {
|
||||||
|
emitError(errors.map((e) => e.message).join(' | '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.payload?.data != null) onData(msg.payload.data as T);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
if (closed) return;
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
closed = true;
|
||||||
|
setConnected(false);
|
||||||
|
if (reconnectTimer != null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (!ws) return;
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
|
||||||
|
ws.send(JSON.stringify({ type: 'connection_terminate' }));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
115
src/lib/hasura.ts
Normal file
115
src/lib/hasura.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
export type DriftTick = {
|
||||||
|
ts: string;
|
||||||
|
market_index: number;
|
||||||
|
symbol: string;
|
||||||
|
oracle_price: number;
|
||||||
|
mark_price?: number | null;
|
||||||
|
oracle_slot?: number | null;
|
||||||
|
source?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphQLError = { message: string };
|
||||||
|
|
||||||
|
function getApiUrl(): string | undefined {
|
||||||
|
const v = (import.meta as any).env?.VITE_API_URL;
|
||||||
|
if (v) return String(v);
|
||||||
|
// Default to same-origin API proxy at /api (Vite dev server proxies /api -> trade-api).
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHasuraUrl(): string {
|
||||||
|
return (import.meta as any).env?.VITE_HASURA_URL || '/graphql';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthToken(): string | undefined {
|
||||||
|
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
|
||||||
|
return v ? String(v) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminSecret(): string | undefined {
|
||||||
|
const v = (import.meta as any).env?.VITE_HASURA_ADMIN_SECRET;
|
||||||
|
return v ? String(v) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasuraRequest<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
};
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
headers.authorization = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
const secret = getAdminSecret();
|
||||||
|
if (secret) headers['x-hasura-admin-secret'] = secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(getHasuraUrl(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(text) as { data?: T; errors?: GraphQLError[] };
|
||||||
|
if (json.errors?.length) throw new Error(json.errors.map((e) => e.message).join(' | '));
|
||||||
|
if (!json.data) throw new Error('Hasura: empty response');
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLatestTicks(symbol: string, limit: number, source?: string): Promise<DriftTick[]> {
|
||||||
|
const apiUrl = getApiUrl();
|
||||||
|
if (apiUrl) {
|
||||||
|
const u = new URL(apiUrl, window.location.origin);
|
||||||
|
u.pathname = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') + '/v1/ticks' : '/v1/ticks';
|
||||||
|
u.searchParams.set('symbol', symbol);
|
||||||
|
u.searchParams.set('limit', String(limit));
|
||||||
|
if (source && source.trim()) u.searchParams.set('source', source.trim());
|
||||||
|
|
||||||
|
const res = await fetch(u.toString());
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||||
|
const json = JSON.parse(text) as { ok?: boolean; ticks?: any[]; error?: string };
|
||||||
|
if (!json.ok) throw new Error(json.error || 'API: error');
|
||||||
|
|
||||||
|
return (json.ticks || []).map((t: any) => ({
|
||||||
|
ts: String(t.ts),
|
||||||
|
market_index: Number(t.market_index),
|
||||||
|
symbol: String(t.symbol),
|
||||||
|
oracle_price: Number(t.oracle_price),
|
||||||
|
mark_price: t.mark_price == null ? null : Number(t.mark_price),
|
||||||
|
oracle_slot: t.oracle_slot == null ? null : Number(t.oracle_slot),
|
||||||
|
source: t.source == null ? undefined : String(t.source),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: any = { symbol: { _eq: symbol } };
|
||||||
|
if (source && source.trim()) where.source = { _eq: source.trim() };
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query LatestTicks($where: drift_ticks_bool_exp!, $limit: Int!) {
|
||||||
|
drift_ticks(where: $where, order_by: {ts: desc}, limit: $limit) {
|
||||||
|
ts
|
||||||
|
market_index
|
||||||
|
symbol
|
||||||
|
oracle_price
|
||||||
|
mark_price
|
||||||
|
oracle_slot
|
||||||
|
source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const data = await hasuraRequest<{
|
||||||
|
drift_ticks: Array<Omit<DriftTick, 'oracle_price' | 'mark_price'> & { oracle_price: any; mark_price?: any }>;
|
||||||
|
}>(query, { where, limit });
|
||||||
|
|
||||||
|
return data.drift_ticks
|
||||||
|
.map((t) => ({
|
||||||
|
...t,
|
||||||
|
oracle_price: Number(t.oracle_price),
|
||||||
|
mark_price: t.mark_price == null ? null : Number(t.mark_price),
|
||||||
|
}))
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
136
src/lib/indicators.ts
Normal file
136
src/lib/indicators.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
function mean(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stddev(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
const m = mean(values);
|
||||||
|
const v = values.reduce((acc, x) => acc + (x - m) * (x - m), 0) / values.length;
|
||||||
|
return Math.sqrt(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sma(values: number[], period: number): Array<number | null> {
|
||||||
|
if (period <= 0) throw new Error('period must be > 0');
|
||||||
|
const out: Array<number | null> = new Array(values.length).fill(null);
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
sum += values[i];
|
||||||
|
if (i >= period) sum -= values[i - period];
|
||||||
|
if (i >= period - 1) out[i] = sum / period;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ema(values: number[], period: number): Array<number | null> {
|
||||||
|
if (period <= 0) throw new Error('period must be > 0');
|
||||||
|
const out: Array<number | null> = new Array(values.length).fill(null);
|
||||||
|
const k = 2 / (period + 1);
|
||||||
|
if (values.length < period) return out;
|
||||||
|
|
||||||
|
const first = mean(values.slice(0, period));
|
||||||
|
out[period - 1] = first;
|
||||||
|
let prev = first;
|
||||||
|
for (let i = period; i < values.length; i++) {
|
||||||
|
const next = values[i] * k + prev * (1 - k);
|
||||||
|
out[i] = next;
|
||||||
|
prev = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rsi(values: number[], period: number): Array<number | null> {
|
||||||
|
if (period <= 0) throw new Error('period must be > 0');
|
||||||
|
const out: Array<number | null> = new Array(values.length).fill(null);
|
||||||
|
if (values.length <= period) return out;
|
||||||
|
|
||||||
|
let gains = 0;
|
||||||
|
let losses = 0;
|
||||||
|
for (let i = 1; i <= period; i++) {
|
||||||
|
const change = values[i] - values[i - 1];
|
||||||
|
if (change >= 0) gains += change;
|
||||||
|
else losses -= change;
|
||||||
|
}
|
||||||
|
let avgGain = gains / period;
|
||||||
|
let avgLoss = losses / period;
|
||||||
|
|
||||||
|
const rs = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
|
||||||
|
out[period] = 100 - 100 / (1 + rs);
|
||||||
|
|
||||||
|
for (let i = period + 1; i < values.length; i++) {
|
||||||
|
const change = values[i] - values[i - 1];
|
||||||
|
const gain = Math.max(change, 0);
|
||||||
|
const loss = Math.max(-change, 0);
|
||||||
|
avgGain = (avgGain * (period - 1) + gain) / period;
|
||||||
|
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||||||
|
const rs2 = avgLoss === 0 ? Number.POSITIVE_INFINITY : avgGain / avgLoss;
|
||||||
|
out[i] = 100 - 100 / (1 + rs2);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bollingerBands(values: number[], period: number, stdDevMult: number) {
|
||||||
|
if (period <= 0) throw new Error('period must be > 0');
|
||||||
|
const upper: Array<number | null> = new Array(values.length).fill(null);
|
||||||
|
const lower: Array<number | null> = new Array(values.length).fill(null);
|
||||||
|
const mid = sma(values, period);
|
||||||
|
|
||||||
|
for (let i = period - 1; i < values.length; i++) {
|
||||||
|
const window = values.slice(i - period + 1, i + 1);
|
||||||
|
const sd = stddev(window);
|
||||||
|
const m = mid[i];
|
||||||
|
if (m == null) continue;
|
||||||
|
upper[i] = m + stdDevMult * sd;
|
||||||
|
lower[i] = m - stdDevMult * sd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { upper, lower, mid };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function macd(values: number[], fastPeriod = 12, slowPeriod = 26, signalPeriod = 9) {
|
||||||
|
const fast = ema(values, fastPeriod);
|
||||||
|
const slow = ema(values, slowPeriod);
|
||||||
|
const macdLine: Array<number | null> = values.map((_, i) => {
|
||||||
|
const f = fast[i];
|
||||||
|
const s = slow[i];
|
||||||
|
return f == null || s == null ? null : f - s;
|
||||||
|
});
|
||||||
|
|
||||||
|
// EMA over a nullable series, aligned by index.
|
||||||
|
const signal: Array<number | null> = new Array(values.length).fill(null);
|
||||||
|
const k = 2 / (signalPeriod + 1);
|
||||||
|
let seeded = false;
|
||||||
|
let prev = 0;
|
||||||
|
const buf: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < macdLine.length; i++) {
|
||||||
|
const v = macdLine[i];
|
||||||
|
if (v == null) continue;
|
||||||
|
|
||||||
|
if (!seeded) {
|
||||||
|
buf.push(v);
|
||||||
|
if (buf.length === signalPeriod) {
|
||||||
|
const first = mean(buf);
|
||||||
|
signal[i] = first;
|
||||||
|
prev = first;
|
||||||
|
seeded = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = v * k + prev * (1 - k);
|
||||||
|
signal[i] = next;
|
||||||
|
prev = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { macd: macdLine, signal };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastNonNull(values: Array<number | null>): number | null {
|
||||||
|
for (let i = values.length - 1; i >= 0; i--) {
|
||||||
|
const v = values[i];
|
||||||
|
if (v != null && Number.isFinite(v)) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
11
src/main.tsx
Normal file
11
src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
1673
src/styles.css
Normal file
1673
src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
20
src/ui/Button.tsx
Normal file
20
src/ui/Button.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'ghost';
|
||||||
|
type Size = 'sm' | 'md';
|
||||||
|
|
||||||
|
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
leftIcon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Button({ variant = 'primary', size = 'md', leftIcon, className, children, ...rest }: Props) {
|
||||||
|
const cls = ['uiButton', `uiButton--${variant}`, `uiButton--${size}`, className].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<button className={cls} {...rest}>
|
||||||
|
{leftIcon ? <span className="uiButton__icon">{leftIcon}</span> : null}
|
||||||
|
<span className="uiButton__label">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/ui/Card.tsx
Normal file
20
src/ui/Card.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'title'> & {
|
||||||
|
title?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Card({ title, right, children, className, ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<section className={['uiCard', className].filter(Boolean).join(' ')} {...rest}>
|
||||||
|
{title ? (
|
||||||
|
<div className="uiCard__head">
|
||||||
|
<div className="uiCard__title">{title}</div>
|
||||||
|
{right ? <div className="uiCard__right">{right}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="uiCard__body">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/ui/Tabs.tsx
Normal file
34
src/ui/Tabs.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type TabItem<T extends string> = {
|
||||||
|
id: T;
|
||||||
|
label: ReactNode;
|
||||||
|
content: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props<T extends string> = {
|
||||||
|
items: Array<TabItem<T>>;
|
||||||
|
activeId: T;
|
||||||
|
onChange: (id: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Tabs<T extends string>({ items, activeId, onChange }: Props<T>) {
|
||||||
|
return (
|
||||||
|
<div className="uiTabs">
|
||||||
|
<div className="uiTabs__bar">
|
||||||
|
{items.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={['uiTab', t.id === activeId ? 'uiTab--active' : ''].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => onChange(t.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="uiTabs__content">{items.find((t) => t.id === activeId)?.content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
||||||
206
vite.config.ts
Normal file
206
vite.config.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
// Standalone repo root
|
||||||
|
const ROOT = path.resolve(DIR);
|
||||||
|
|
||||||
|
type BasicAuth = { username: string; password: string };
|
||||||
|
|
||||||
|
function stripTrailingSlashes(p: string): string {
|
||||||
|
const out = p.replace(/\/+$/, '');
|
||||||
|
return out || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readApiReadToken(): string | undefined {
|
||||||
|
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
||||||
|
const p = path.join(ROOT, 'tokens', 'read.json');
|
||||||
|
if (!fs.existsSync(p)) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(p, 'utf8');
|
||||||
|
const json = JSON.parse(raw) as { token?: string };
|
||||||
|
return json?.token ? String(json.token) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const idx = raw.indexOf(':');
|
||||||
|
if (idx <= 0) return undefined;
|
||||||
|
const username = raw.slice(0, idx).trim();
|
||||||
|
const password = raw.slice(idx + 1);
|
||||||
|
if (!username || !password) return undefined;
|
||||||
|
return { username, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProxyBasicAuth(): BasicAuth | undefined {
|
||||||
|
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH);
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
|
const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim();
|
||||||
|
if (!fileRaw) return undefined;
|
||||||
|
|
||||||
|
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
|
||||||
|
if (!fs.existsSync(p)) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(p, 'utf8');
|
||||||
|
const json = JSON.parse(raw) as { username?: string; password?: string };
|
||||||
|
const username = typeof json?.username === 'string' ? json.username.trim() : '';
|
||||||
|
const password = typeof json?.password === 'string' ? json.password : '';
|
||||||
|
if (!username || !password) return undefined;
|
||||||
|
return { username, password };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiReadToken = readApiReadToken();
|
||||||
|
const proxyBasicAuth = readProxyBasicAuth();
|
||||||
|
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 parseUrl(v: string): URL | undefined {
|
||||||
|
try {
|
||||||
|
return new URL(v);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOrigin(u: URL | undefined): string | undefined {
|
||||||
|
if (!u) return undefined;
|
||||||
|
return `${u.protocol}//${u.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
||||||
|
const apiProxyOrigin = toOrigin(apiProxyTargetUrl);
|
||||||
|
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||||
|
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
||||||
|
|
||||||
|
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const u = new URL(apiTarget);
|
||||||
|
const p = stripTrailingSlashes(u.pathname || '/');
|
||||||
|
if (!p.endsWith('/api')) return undefined;
|
||||||
|
const basePath = p.slice(0, -'/api'.length) || '/';
|
||||||
|
u.pathname = basePath;
|
||||||
|
u.search = '';
|
||||||
|
u.hash = '';
|
||||||
|
const out = u.toString();
|
||||||
|
return out.endsWith('/') ? out.slice(0, -1) : out;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiProxyTarget =
|
||||||
|
process.env.FRONTEND_PROXY_TARGET ||
|
||||||
|
process.env.UI_PROXY_TARGET ||
|
||||||
|
process.env.AUTH_PROXY_TARGET ||
|
||||||
|
inferUiProxyTarget(apiProxyTarget) ||
|
||||||
|
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
|
||||||
|
const uiProxyOrigin = toOrigin(parseUrl(uiProxyTarget || ''));
|
||||||
|
|
||||||
|
const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget;
|
||||||
|
const graphqlProxyOrigin = toOrigin(parseUrl(graphqlProxyTarget || ''));
|
||||||
|
|
||||||
|
function applyProxyBasicAuth(proxyReq: any) {
|
||||||
|
if (!proxyBasicAuth) return false;
|
||||||
|
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||||
|
proxyReq.setHeader('Authorization', `Basic ${b64}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProxyOrigin(proxyReq: any, origin: string | undefined) {
|
||||||
|
if (!origin) return;
|
||||||
|
// Some upstreams (notably WS endpoints) validate Origin and may drop the connection when it doesn't match.
|
||||||
|
proxyReq.setHeader('Origin', origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
||||||
|
const v = proxyRes?.headers?.['set-cookie'];
|
||||||
|
if (!v) return;
|
||||||
|
const rewrite = (cookie: string) => {
|
||||||
|
let out = cookie.replace(/;\s*secure\b/gi, '');
|
||||||
|
out = out.replace(/;\s*domain=[^;]+/gi, '');
|
||||||
|
out = out.replace(/;\s*samesite=none\b/gi, '; SameSite=Lax');
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy: Record<string, any> = {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, apiProxyOrigin);
|
||||||
|
if (applyProxyBasicAuth(proxyReq)) return;
|
||||||
|
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
p.on('proxyReqWs', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, graphqlProxyOrigin);
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiProxyTarget) {
|
||||||
|
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||||
|
proxy[prefix] = {
|
||||||
|
target: uiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, uiProxyOrigin);
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
p.on('proxyRes', (proxyRes: any) => {
|
||||||
|
rewriteSetCookieForLocalDevHttp(proxyRes);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: false,
|
||||||
|
proxy,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user