chore: initial import
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
.env
|
||||
tokens
|
||||
**/tokens
|
||||
*.log
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
tokens/*.json
|
||||
tokens/*.yml
|
||||
tokens/*.yaml
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal 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
20
README.md
Normal 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
|
||||
```
|
||||
13
apps/visualizer/.env.example
Normal file
13
apps/visualizer/.env.example
Normal 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
|
||||
13
apps/visualizer/index.html
Normal file
13
apps/visualizer/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
apps/visualizer/package-lock.json
generated
Normal file
1735
apps/visualizer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
apps/visualizer/package.json
Normal file
25
apps/visualizer/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"
|
||||
}
|
||||
}
|
||||
435
apps/visualizer/src/App.tsx
Normal file
435
apps/visualizer/src/App.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
apps/visualizer/src/app/hooks/useInterval.ts
Normal file
15
apps/visualizer/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
apps/visualizer/src/app/hooks/useLocalStorageState.ts
Normal file
24
apps/visualizer/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
apps/visualizer/src/components/PriceChart.tsx
Normal file
188
apps/visualizer/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>
|
||||
);
|
||||
}
|
||||
182
apps/visualizer/src/features/chart/ChartIcons.tsx
Normal file
182
apps/visualizer/src/features/chart/ChartIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
apps/visualizer/src/features/chart/ChartPanel.tsx
Normal file
177
apps/visualizer/src/features/chart/ChartPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
220
apps/visualizer/src/features/chart/ChartSideToolbar.tsx
Normal file
220
apps/visualizer/src/features/chart/ChartSideToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/visualizer/src/features/chart/ChartToolMenu.tsx
Normal file
54
apps/visualizer/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>
|
||||
);
|
||||
}
|
||||
51
apps/visualizer/src/features/chart/ChartToolbar.tsx
Normal file
51
apps/visualizer/src/features/chart/ChartToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
apps/visualizer/src/features/chart/FibRetracementPrimitive.ts
Normal file
199
apps/visualizer/src/features/chart/FibRetracementPrimitive.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
259
apps/visualizer/src/features/chart/TradingChart.tsx
Normal file
259
apps/visualizer/src/features/chart/TradingChart.tsx
Normal 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} />;
|
||||
}
|
||||
57
apps/visualizer/src/features/chart/useChartData.ts
Normal file
57
apps/visualizer/src/features/chart/useChartData.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/visualizer/src/features/market/MarketHeader.tsx
Normal file
28
apps/visualizer/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
apps/visualizer/src/features/market/MarketSelect.tsx
Normal file
21
apps/visualizer/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
apps/visualizer/src/features/market/MarketStatsRow.tsx
Normal file
27
apps/visualizer/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>
|
||||
);
|
||||
}
|
||||
|
||||
31
apps/visualizer/src/features/tickerbar/TickerBar.tsx
Normal file
31
apps/visualizer/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
apps/visualizer/src/features/ticks/useTicks.ts
Normal file
52
apps/visualizer/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
apps/visualizer/src/layout/AppShell.tsx
Normal file
23
apps/visualizer/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>
|
||||
);
|
||||
}
|
||||
62
apps/visualizer/src/layout/TopNav.tsx
Normal file
62
apps/visualizer/src/layout/TopNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
76
apps/visualizer/src/lib/api.ts
Normal file
76
apps/visualizer/src/lib/api.ts
Normal 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) },
|
||||
};
|
||||
}
|
||||
|
||||
115
apps/visualizer/src/lib/hasura.ts
Normal file
115
apps/visualizer/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 || '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();
|
||||
}
|
||||
136
apps/visualizer/src/lib/indicators.ts
Normal file
136
apps/visualizer/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
apps/visualizer/src/main.tsx
Normal file
11
apps/visualizer/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>
|
||||
);
|
||||
|
||||
1011
apps/visualizer/src/styles.css
Normal file
1011
apps/visualizer/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
20
apps/visualizer/src/ui/Button.tsx
Normal file
20
apps/visualizer/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
apps/visualizer/src/ui/Card.tsx
Normal file
20
apps/visualizer/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
apps/visualizer/src/ui/Tabs.tsx
Normal file
34
apps/visualizer/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
apps/visualizer/tsconfig.json
Normal file
18
apps/visualizer/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"]
|
||||
}
|
||||
|
||||
43
apps/visualizer/vite.config.ts
Normal file
43
apps/visualizer/vite.config.ts
Normal 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}`);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
273
services/frontend/server.mjs
Normal file
273
services/frontend/server.mjs
Normal 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
|
||||
)
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user