chore: initial import

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

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
**/node_modules
dist
**/dist
.env
tokens
**/tokens
*.log

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
.env
*.log
tokens/*.json
tokens/*.yml
tokens/*.yaml

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:20-slim AS build
WORKDIR /app/apps/visualizer
COPY apps/visualizer/package.json apps/visualizer/package-lock.json ./
RUN npm ci
COPY apps/visualizer/ ./
RUN npm run build
FROM node:20-slim
WORKDIR /app
RUN mkdir -p /tokens /srv
COPY --from=build /app/apps/visualizer/dist /srv
COPY services/frontend/server.mjs /app/services/frontend/server.mjs
ENV NODE_ENV=production
ENV PORT=8081
EXPOSE 8081
CMD ["node", "services/frontend/server.mjs"]

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# trade-frontend
Frontend (SPA) + prosty serwer (basic auth + proxy do `trade-api`).
## Dev
W tym repo app jest w `apps/visualizer/`.
```bash
cd apps/visualizer
npm ci
npm run dev
```
## Docker
```bash
docker build -t trade-frontend .
docker run --rm -p 8081:8081 trade-frontend
```

View File

@@ -0,0 +1,13 @@
# Default: UI reads ticks from the same-origin API proxy at `/api`.
VITE_API_URL=/api
# Fallback (optional): query Hasura directly (not recommended in browser).
VITE_HASURA_URL=http://localhost:8080/v1/graphql
# Optional (only if you intentionally query Hasura directly from the browser):
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
# VITE_HASURA_ADMIN_SECRET=devsecret
VITE_SYMBOL=PUMP-PERP
# Optional: filter by source (leave empty for all)
# VITE_SOURCE=drift_oracle
VITE_POLL_MS=1000
VITE_LIMIT=300

View 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
apps/visualizer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View 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"
}
}

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

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

View 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]);
}

View 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;
}

View 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>
);
}

View File

@@ -0,0 +1,182 @@
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 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>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Candle, ChartIndicators } from '../../lib/api';
import Card from '../../ui/Card';
import ChartSideToolbar from './ChartSideToolbar';
import ChartToolbar from './ChartToolbar';
import TradingChart from './TradingChart';
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
import type { IChartApi } from 'lightweight-charts';
type Props = {
candles: Candle[];
indicators: ChartIndicators;
timeframe: string;
onTimeframeChange: (tf: string) => void;
showIndicators: boolean;
onToggleIndicators: () => void;
seriesLabel: string;
};
export default function ChartPanel({
candles,
indicators,
timeframe,
onTimeframeChange,
showIndicators,
onToggleIndicators,
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 chartApiRef = useRef<IChartApi | null>(null);
const activeToolRef = useRef(activeTool);
const fibStartRef = useRef<FibAnchor | null>(fibStart);
const pendingMoveRef = useRef<FibAnchor | null>(null);
const rafRef = useRef<number | null>(null);
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(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (activeToolRef.current !== 'fib-retracement') return;
setFibStart(null);
setFibDraft(null);
setActiveTool('cursor');
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
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 });
}
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}
seriesLabel={seriesLabel}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
/>
</div>
<div className="chartCard__content">
<ChartSideToolbar
timeframe={timeframe}
activeTool={activeTool}
hasFib={fib != null || fibDraft != null}
onToolChange={setActiveTool}
onZoomIn={() => zoomTime(0.8)}
onZoomOut={() => zoomTime(1.25)}
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
onClearFib={() => {
setFib(null);
setFibStart(null);
setFibDraft(null);
}}
/>
<div className="chartCard__chart">
<TradingChart
candles={candles}
oracle={indicators.oracle}
sma20={indicators.sma20}
ema20={indicators.ema20}
bb20={indicators.bb20}
showIndicators={showIndicators}
fib={fibDraft ?? fib}
onReady={({ chart }) => {
chartApiRef.current = chart;
}}
onChartClick={(p) => {
if (activeTool !== 'fib-retracement') return;
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');
}}
onChartCrosshairMove={(p) => {
if (activeToolRef.current !== 'fib-retracement') return;
const start = fibStartRef.current;
if (!start) return;
pendingMoveRef.current = p;
if (rafRef.current != null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
const move = pendingMoveRef.current;
const start2 = fibStartRef.current;
if (!move || !start2) return;
setFibDraft({ a: start2, b: move });
});
}}
/>
</div>
</div>
</Card>
</>
);
}

View File

@@ -0,0 +1,220 @@
import { useEffect, useMemo, useState } from 'react';
import ChartToolMenu, { type ToolMenuSection } from './ChartToolMenu';
import {
IconBrush,
IconCrosshair,
IconCursor,
IconEye,
IconFib,
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;
onToolChange: (tool: ActiveTool) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onResetView: () => void;
onClearFib: () => void;
};
export default function ChartSideToolbar({
timeframe,
activeTool,
hasFib,
onToolChange,
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" title="Visibility" aria-label="Visibility" disabled>
<span className="chartToolBtn__icon" aria-hidden="true">
<IconEye />
</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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,51 @@
import Button from '../../ui/Button';
type Props = {
timeframe: string;
onTimeframeChange: (tf: string) => void;
showIndicators: boolean;
onToggleIndicators: () => void;
seriesLabel: string;
isFullscreen: boolean;
onToggleFullscreen: () => void;
};
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
export default function ChartToolbar({
timeframe,
onTimeframeChange,
showIndicators,
onToggleIndicators,
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={isFullscreen ? 'primary' : 'ghost'} onClick={onToggleFullscreen} type="button">
{isFullscreen ? 'Exit' : 'Fullscreen'}
</Button>
<div className="chartToolbar__meta">{seriesLabel}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
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;
};
class FibPaneRenderer implements IPrimitivePaneRenderer {
private readonly _getState: () => State;
constructor(getState: () => State) {
this._getState = getState;
}
draw(target: any) {
const { fib, series, chart } = this._getState();
if (!fib || !series || !chart) 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) => {
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 = '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(3 * horizontalPixelRatio));
context.fillStyle = 'rgba(147,197,253,0.95)';
context.beginPath();
context.arc(ax, ay, r, 0, Math.PI * 2);
context.fill();
context.beginPath();
context.arc(bx, by, r, 0, Math.PI * 2);
context.fill();
}
});
}
}
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 readonly _paneView: FibPaneView;
private readonly _paneViews: readonly IPrimitivePaneView[];
constructor() {
this._paneView = new FibPaneView(() => ({ fib: this._fib, series: this._series, chart: this._param?.chart ?? null }));
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();
}
}

View File

@@ -0,0 +1,259 @@
import { useEffect, useMemo, useRef } from 'react';
import {
CandlestickSeries,
ColorType,
CrosshairMode,
HistogramSeries,
type IChartApi,
type ISeriesApi,
LineStyle,
LineSeries,
createChart,
type UTCTimestamp,
type CandlestickData,
type HistogramData,
type LineData,
type WhitespaceData,
} from 'lightweight-charts';
import type { Candle, SeriesPoint } from '../../lib/api';
import { FibRetracementPrimitive, type FibAnchor, type FibRetracement } from './FibRetracementPrimitive';
type Props = {
candles: Candle[];
oracle?: SeriesPoint[];
sma20?: SeriesPoint[];
ema20?: SeriesPoint[];
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
showIndicators: boolean;
fib?: FibRetracement | null;
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
onChartClick?: (p: FibAnchor) => void;
onChartCrosshairMove?: (p: FibAnchor) => void;
};
type LinePoint = LineData | WhitespaceData;
function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp;
}
function toCandleData(candles: Candle[]): CandlestickData[] {
return candles.map((c) => ({
time: toTime(c.time),
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}));
}
function toVolumeData(candles: Candle[]): HistogramData[] {
return candles.map((c) => {
const up = c.close >= c.open;
return {
time: toTime(c.time),
value: c.volume ?? 0,
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
};
});
}
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
if (!points?.length) return [];
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
}
export default function TradingChart({
candles,
oracle,
sma20,
ema20,
bb20,
showIndicators,
fib,
onReady,
onChartClick,
onChartCrosshairMove,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
const onReadyRef = useRef<Props['onReady']>(onReady);
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>;
oracle?: ISeriesApi<'Line'>;
sma20?: ISeriesApi<'Line'>;
ema20?: ISeriesApi<'Line'>;
bbUpper?: ISeriesApi<'Line'>;
bbLower?: ISeriesApi<'Line'>;
bbMid?: ISeriesApi<'Line'>;
}>({});
const candleData = useMemo(() => toCandleData(candles), [candles]);
const volumeData = useMemo(() => toVolumeData(candles), [candles]);
const oracleData = useMemo(() => toLineSeries(oracle), [oracle]);
const smaData = useMemo(() => toLineSeries(sma20), [sma20]);
const emaData = useMemo(() => toLineSeries(ema20), [ema20]);
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
onChartClickRef.current = onChartClick;
}, [onChartClick]);
useEffect(() => {
onChartCrosshairMoveRef.current = onChartCrosshairMove;
}, [onChartCrosshairMove]);
useEffect(() => {
if (!containerRef.current) return;
if (chartRef.current) return;
const chart = createChart(containerRef.current, {
layout: {
background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' },
textColor: '#e6e9ef',
fontFamily:
'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif',
},
grid: {
vertLines: { color: 'rgba(255,255,255,0.06)' },
horzLines: { color: 'rgba(255,255,255,0.06)' },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
horzLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed },
},
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)' },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false },
handleScale: { mouseWheel: true, pinch: true },
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
});
chartRef.current = chart;
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#22c55e',
downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
});
const fibPrimitive = new FibRetracementPrimitive();
candleSeries.attachPrimitive(fibPrimitive);
fibPrimitiveRef.current = fibPrimitive;
fibPrimitive.setFib(fib ?? null);
const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: '',
color: 'rgba(255,255,255,0.15)',
});
volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
});
const oracleSeries = chart.addSeries(LineSeries, {
color: 'rgba(251,146,60,0.9)',
lineWidth: 1,
lineStyle: LineStyle.Dotted,
});
const smaSeries = chart.addSeries(LineSeries, { color: 'rgba(248,113,113,0.9)', lineWidth: 1 });
const emaSeries = chart.addSeries(LineSeries, { color: 'rgba(52,211,153,0.9)', lineWidth: 1 });
const bbUpperSeries = chart.addSeries(LineSeries, { color: 'rgba(250,204,21,0.6)', lineWidth: 1 });
const bbLowerSeries = chart.addSeries(LineSeries, { color: 'rgba(163,163,163,0.6)', lineWidth: 1 });
const bbMidSeries = chart.addSeries(LineSeries, {
color: 'rgba(250,204,21,0.35)',
lineWidth: 1,
lineStyle: LineStyle.Dashed,
});
seriesRef.current = {
candles: candleSeries,
volume: volumeSeries,
oracle: oracleSeries,
sma20: smaSeries,
ema20: emaSeries,
bbUpper: bbUpperSeries,
bbLower: bbLowerSeries,
bbMid: bbMidSeries,
};
onReadyRef.current?.({ chart, candles: candleSeries as ISeriesApi<'Candlestick', UTCTimestamp> });
const onClick = (param: any) => {
if (!param?.point) return;
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y);
if (price == null) return;
onChartClickRef.current?.({ logical: Number(logical), price: Number(price) });
};
chart.subscribeClick(onClick);
const onCrosshairMove = (param: any) => {
if (!param?.point) return;
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y);
if (price == null) return;
onChartCrosshairMoveRef.current?.({ logical: Number(logical), price: Number(price) });
};
chart.subscribeCrosshairMove(onCrosshairMove);
const ro = new ResizeObserver(() => {
if (!containerRef.current) return;
const { width, height } = containerRef.current.getBoundingClientRect();
chart.applyOptions({ width: Math.floor(width), height: Math.floor(height) });
});
ro.observe(containerRef.current);
return () => {
chart.unsubscribeClick(onClick);
chart.unsubscribeCrosshairMove(onCrosshairMove);
ro.disconnect();
if (fibPrimitiveRef.current) {
candleSeries.detachPrimitive(fibPrimitiveRef.current);
fibPrimitiveRef.current = null;
}
chart.remove();
chartRef.current = null;
seriesRef.current = {};
};
}, []);
useEffect(() => {
const s = seriesRef.current;
if (!s.candles || !s.volume) return;
s.candles.setData(candleData);
s.volume.setData(volumeData);
s.oracle?.setData(oracleData);
s.sma20?.setData(smaData);
s.ema20?.setData(emaData);
s.bbUpper?.setData(bbUpper);
s.bbLower?.setData(bbLower);
s.bbMid?.setData(bbMid);
s.sma20?.applyOptions({ visible: showIndicators });
s.ema20?.applyOptions({ visible: showIndicators });
s.bbUpper?.applyOptions({ visible: showIndicators });
s.bbLower?.applyOptions({ visible: showIndicators });
s.bbMid?.applyOptions({ visible: showIndicators });
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
useEffect(() => {
fibPrimitiveRef.current?.setFib(fib ?? null);
}, [fib]);
return <div className="tradingChart" ref={containerRef} />;
}

View File

@@ -0,0 +1,57 @@
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;
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 [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);
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, loading, error, refresh: fetchOnce }),
[candles, indicators, loading, error, fetchOnce]
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };
}

View 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>
);
}

View File

@@ -0,0 +1,62 @@
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="Drift">
<div className="topNav__brandMark" aria-hidden="true" />
<div className="topNav__brandName">Drift</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>
);
}

View File

@@ -0,0 +1,76 @@
export type Candle = {
time: number; // unix seconds
open: number;
high: number;
low: number;
close: number;
volume?: number;
oracle?: number | null;
};
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),
})),
indicators: json.indicators || {},
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
};
}

View 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 || 'http://localhost:8080/v1/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();
}

View 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;
}

View 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>
);

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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"]
}

View File

@@ -0,0 +1,43 @@
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));
const ROOT = path.resolve(DIR, '../..');
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;
}
}
const apiReadToken = readApiReadToken();
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: process.env.API_PROXY_TARGET || 'http://localhost:8787',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
});
},
},
},
},
});

View File

@@ -0,0 +1,273 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
const PORT = Number.parseInt(process.env.PORT || '8081', 10);
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
const APP_VERSION = String(process.env.APP_VERSION || 'v1').trim() || 'v1';
const BUILD_TIMESTAMP = String(process.env.BUILD_TIMESTAMP || '').trim() || undefined;
const STARTED_AT = new Date().toISOString();
const STATIC_DIR = process.env.STATIC_DIR || '/srv';
const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json';
const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json';
const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787';
function readJson(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
return JSON.parse(raw);
}
function timingSafeEqualStr(a, b) {
const aa = Buffer.from(String(a), 'utf8');
const bb = Buffer.from(String(b), 'utf8');
if (aa.length !== bb.length) return false;
return crypto.timingSafeEqual(aa, bb);
}
function loadBasicAuth() {
const j = readJson(BASIC_AUTH_FILE);
const username = (j?.username || '').toString();
const password = (j?.password || '').toString();
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
return { username, password };
}
function loadApiReadToken() {
const j = readJson(API_READ_TOKEN_FILE);
const token = (j?.token || '').toString();
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
return token;
}
function send(res, status, headers, body) {
res.statusCode = status;
for (const [k, v] of Object.entries(headers || {})) res.setHeader(k, v);
if (body == null) return void res.end();
res.end(body);
}
function basicAuthRequired(res) {
res.setHeader('www-authenticate', 'Basic realm="trade"');
send(res, 401, { 'content-type': 'text/plain; charset=utf-8' }, 'unauthorized');
}
function isAuthorized(req, creds) {
const auth = req.headers.authorization || '';
const m = String(auth).match(/^Basic\s+(.+)$/i);
if (!m?.[1]) return false;
let decoded;
try {
decoded = Buffer.from(m[1], 'base64').toString('utf8');
} catch {
return false;
}
const idx = decoded.indexOf(':');
if (idx < 0) return false;
const u = decoded.slice(0, idx);
const p = decoded.slice(idx + 1);
return timingSafeEqualStr(u, creds.username) && timingSafeEqualStr(p, creds.password);
}
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.txt': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
};
function contentTypeFor(filePath) {
return MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
}
function safePathFromUrlPath(urlPath) {
const decoded = decodeURIComponent(urlPath);
const cleaned = decoded.replace(/\0/g, '');
// strip leading slash so join() doesn't ignore STATIC_DIR
const rel = cleaned.replace(/^\/+/, '');
const normalized = path.normalize(rel);
// prevent traversal
if (normalized.startsWith('..') || path.isAbsolute(normalized)) return null;
return normalized;
}
function serveStatic(req, res) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
send(res, 405, { 'content-type': 'text/plain; charset=utf-8' }, 'method_not_allowed');
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const rel = safePathFromUrlPath(url.pathname);
if (rel == null) {
send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request');
return;
}
const root = path.resolve(STATIC_DIR);
const fileCandidate = path.resolve(root, rel);
if (!fileCandidate.startsWith(root)) {
send(res, 400, { 'content-type': 'text/plain; charset=utf-8' }, 'bad_request');
return;
}
const trySend = (filePath) => {
try {
const st = fs.statSync(filePath);
if (st.isDirectory()) return trySend(path.join(filePath, 'index.html'));
res.statusCode = 200;
res.setHeader('content-type', contentTypeFor(filePath));
res.setHeader('cache-control', filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000');
if (req.method === 'HEAD') return void res.end();
fs.createReadStream(filePath).pipe(res);
return true;
} catch {
return false;
}
};
// exact file, otherwise SPA fallback
if (trySend(fileCandidate)) return;
const indexPath = path.join(root, 'index.html');
if (trySend(indexPath)) return;
send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found');
}
function stripHopByHopHeaders(headers) {
const hop = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
const out = {};
for (const [k, v] of Object.entries(headers || {})) {
if (hop.has(k.toLowerCase())) continue;
out[k] = v;
}
return out;
}
function proxyApi(req, res, apiReadToken) {
const upstreamBase = new URL(API_UPSTREAM);
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const prefix = '/api';
const strippedPath = inUrl.pathname === prefix ? '/' : inUrl.pathname.startsWith(prefix + '/') ? inUrl.pathname.slice(prefix.length) : null;
if (strippedPath == null) {
send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found');
return;
}
const target = new URL(upstreamBase.toString());
target.pathname = strippedPath || '/';
target.search = inUrl.search;
const isHttps = target.protocol === 'https:';
const lib = isHttps ? https : http;
const headers = stripHopByHopHeaders(req.headers);
delete headers.authorization; // basic auth from client must not leak upstream
headers.host = target.host;
headers.authorization = `Bearer ${apiReadToken}`;
const upstreamReq = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
method: req.method,
path: target.pathname + target.search,
headers,
},
(upstreamRes) => {
const outHeaders = stripHopByHopHeaders(upstreamRes.headers);
res.writeHead(upstreamRes.statusCode || 502, outHeaders);
upstreamRes.pipe(res);
}
);
upstreamReq.on('error', (err) => {
if (!res.headersSent) {
send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`);
} else {
res.destroy();
}
});
req.pipe(upstreamReq);
}
function handler(req, res) {
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
send(
res,
200,
{ 'content-type': 'application/json; charset=utf-8' },
JSON.stringify({ ok: true, version: APP_VERSION, buildTimestamp: BUILD_TIMESTAMP, startedAt: STARTED_AT })
);
return;
}
let creds;
try {
creds = loadBasicAuth();
} catch (e) {
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
return;
}
if (!isAuthorized(req, creds)) {
basicAuthRequired(res);
return;
}
if (req.url?.startsWith('/api') && (req.url === '/api' || req.url.startsWith('/api/'))) {
let token;
try {
token = loadApiReadToken();
} catch (e) {
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
return;
}
proxyApi(req, res, token);
return;
}
serveStatic(req, res);
}
const server = http.createServer(handler);
server.listen(PORT, () => {
console.log(
JSON.stringify(
{
service: 'trade-frontend',
port: PORT,
staticDir: STATIC_DIR,
apiUpstream: API_UPSTREAM,
basicAuthFile: BASIC_AUTH_FILE,
apiReadTokenFile: API_READ_TOKEN_FILE,
},
null,
2
)
);
});