Compare commits
34 Commits
main
...
feat/candl
| Author | SHA1 | Date | |
|---|---|---|---|
| fc26e8eac9 | |||
| b06fe7f9a4 | |||
| 89415f6793 | |||
| fc92392705 | |||
| b70257fc5f | |||
| ca9e44a41a | |||
| c1bc6f9e2f | |||
| 2a158334bf | |||
| bff6560f43 | |||
| 9d1ebba39d | |||
| 965774dfbd | |||
| fb307f0279 | |||
| 6904be4a51 | |||
| 912a78588d | |||
| 62baa9700e | |||
| fa0ff11b5a | |||
| 879f45aa5c | |||
| 5a9c2b0a85 | |||
| 9592d6ac16 | |||
| dff4d347ad | |||
| ae41f1a9de | |||
| 42e0a4d86d | |||
| a9ccc0b00e | |||
| 9420c89f52 | |||
| 545e1abfaa | |||
| 759173b5be | |||
| 194d596284 | |||
| 444f427420 | |||
| af267ad6c9 | |||
| f3c4a999c3 | |||
| 1c8a6900e8 | |||
| abaee44835 | |||
| f57366fad2 | |||
| b0c7806cb6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ tokens/*
|
|||||||
!tokens/*.example.json
|
!tokens/*.example.json
|
||||||
!tokens/*.example.yml
|
!tokens/*.example.yml
|
||||||
!tokens/*.example.yaml
|
!tokens/*.example.yaml
|
||||||
|
gitea/token
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -12,6 +12,21 @@ npm ci
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Dev z backendem na VPS (staging)
|
||||||
|
|
||||||
|
Najprościej: trzymaj `VITE_API_URL=/api` i podepnij Vite proxy do VPS (żeby nie bawić się w CORS i nie wkładać tokena do przeglądarki):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/visualizer
|
||||||
|
API_PROXY_TARGET=https://trade.mpabi.pl \
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite proxy’uje wtedy: `/api/*`, `/whoami`, `/auth/*`, `/logout` do VPS. Dodatkowo w dev usuwa `Secure` z `Set-Cookie`, żeby sesja działała na `http://localhost:5173`.
|
||||||
|
|
||||||
|
Jeśli staging jest dodatkowo chroniony basic auth (np. Traefik `basicAuth`), ustaw:
|
||||||
|
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
# Default: UI reads ticks from the same-origin API proxy at `/api`.
|
# Default: UI reads ticks from the same-origin API proxy at `/api`.
|
||||||
VITE_API_URL=/api
|
VITE_API_URL=/api
|
||||||
|
|
||||||
# Fallback (optional): query Hasura directly (not recommended in browser).
|
# Hasura GraphQL endpoint (supports subscriptions via WS).
|
||||||
VITE_HASURA_URL=http://localhost:8080/v1/graphql
|
# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
|
||||||
# Optional (only if you intentionally query Hasura directly from the browser):
|
VITE_HASURA_URL=/graphql
|
||||||
|
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
|
||||||
|
# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws).
|
||||||
|
# VITE_HASURA_WS_URL=/graphql-ws
|
||||||
|
# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
|
||||||
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
||||||
# VITE_HASURA_ADMIN_SECRET=devsecret
|
VITE_SYMBOL=SOL-PERP
|
||||||
VITE_SYMBOL=PUMP-PERP
|
|
||||||
# Optional: filter by source (leave empty for all)
|
# Optional: filter by source (leave empty for all)
|
||||||
# VITE_SOURCE=drift_oracle
|
# VITE_SOURCE=drift_oracle
|
||||||
VITE_POLL_MS=1000
|
VITE_POLL_MS=1000
|
||||||
|
|||||||
22
apps/visualizer/__start
Normal file
22
apps/visualizer/__start
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
|
||||||
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
DEFAULT_PROXY_TARGET="${VISUALIZER_PROXY_TARGET:-${TRADE_UI_URL:-${TRADE_VPS_URL:-https://trade.mpabi.pl}}}"
|
||||||
|
export API_PROXY_TARGET="${API_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
|
||||||
|
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
|
||||||
|
export VITE_API_URL="${VITE_API_URL:-/api}"
|
||||||
|
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
||||||
|
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
|
||||||
|
|
||||||
|
# Safety: avoid passing stale auth env vars into Hasura WS unless explicitly enabled.
|
||||||
|
if [[ "${VISUALIZER_USE_HASURA_AUTH:-}" != "1" ]]; then
|
||||||
|
unset VITE_HASURA_AUTH_TOKEN
|
||||||
|
unset VITE_HASURA_ADMIN_SECRET
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm run dev
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -142,38 +142,30 @@ export default function ChartLayersPanel({
|
|||||||
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{drawingsLayer ? (
|
{layers.map((layer) => (
|
||||||
<div className="chartLayersRow chartLayersRow--layer">
|
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
|
||||||
<div className="chartLayersCell chartLayersCell--icon">
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
<IconButton
|
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
|
||||||
title="Toggle visible"
|
|
||||||
active={drawingsLayer.visible}
|
|
||||||
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
|
|
||||||
>
|
|
||||||
<IconEye />
|
<IconEye />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="chartLayersCell chartLayersCell--icon">
|
<div className="chartLayersCell chartLayersCell--icon">
|
||||||
<IconButton
|
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
|
||||||
title="Toggle lock"
|
|
||||||
active={drawingsLayer.locked}
|
|
||||||
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
|
|
||||||
>
|
|
||||||
<IconLock />
|
<IconLock />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="chartLayersCell chartLayersCell--name">
|
<div className="chartLayersCell chartLayersCell--name">
|
||||||
<div className="layersName layersName--layer">
|
<div className="layersName layersName--layer">
|
||||||
{drawingsLayer.name}
|
{layer.name}
|
||||||
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span>
|
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chartLayersCell chartLayersCell--opacity">
|
<div className="chartLayersCell chartLayersCell--opacity">
|
||||||
<OpacitySlider value={drawingsLayer.opacity} onChange={(next) => onSetLayerOpacity(drawingsLayer.id, next)} />
|
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="chartLayersCell chartLayersCell--actions" />
|
<div className="chartLayersCell chartLayersCell--actions" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
))}
|
||||||
|
|
||||||
{drawingsLayer && fibPresent ? (
|
{drawingsLayer && fibPresent ? (
|
||||||
<div
|
<div
|
||||||
@@ -212,4 +204,3 @@ export default function ChartLayersPanel({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||||
import Card from '../../ui/Card';
|
import Card from '../../ui/Card';
|
||||||
@@ -6,17 +7,25 @@ import ChartSideToolbar from './ChartSideToolbar';
|
|||||||
import ChartToolbar from './ChartToolbar';
|
import ChartToolbar from './ChartToolbar';
|
||||||
import TradingChart from './TradingChart';
|
import TradingChart from './TradingChart';
|
||||||
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
||||||
import type { IChartApi } from 'lightweight-charts';
|
import { LineStyle, type IChartApi } from 'lightweight-charts';
|
||||||
import type { OverlayLayer } from './ChartPanel.types';
|
import type { OverlayLayer } from './ChartPanel.types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
candles: Candle[];
|
candles: Candle[];
|
||||||
indicators: ChartIndicators;
|
indicators: ChartIndicators;
|
||||||
|
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
|
||||||
timeframe: string;
|
timeframe: string;
|
||||||
|
bucketSeconds: number;
|
||||||
|
seriesKey: string;
|
||||||
onTimeframeChange: (tf: string) => void;
|
onTimeframeChange: (tf: string) => void;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
onToggleIndicators: () => void;
|
onToggleIndicators: () => void;
|
||||||
|
showBuild: boolean;
|
||||||
|
onToggleBuild: () => void;
|
||||||
seriesLabel: string;
|
seriesLabel: string;
|
||||||
|
fullscreenOverride?: boolean;
|
||||||
|
onToggleFullscreenOverride?: () => void;
|
||||||
|
fullscreenStyle?: CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FibDragMode = 'move' | 'edit-b';
|
type FibDragMode = 'move' | 'edit-b';
|
||||||
@@ -41,18 +50,32 @@ function isEditableTarget(t: EventTarget | null): boolean {
|
|||||||
export default function ChartPanel({
|
export default function ChartPanel({
|
||||||
candles,
|
candles,
|
||||||
indicators,
|
indicators,
|
||||||
|
dlobQuotes,
|
||||||
timeframe,
|
timeframe,
|
||||||
|
bucketSeconds,
|
||||||
|
seriesKey,
|
||||||
onTimeframeChange,
|
onTimeframeChange,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
onToggleIndicators,
|
onToggleIndicators,
|
||||||
|
showBuild,
|
||||||
|
onToggleBuild,
|
||||||
seriesLabel,
|
seriesLabel,
|
||||||
|
fullscreenOverride,
|
||||||
|
onToggleFullscreenOverride,
|
||||||
|
fullscreenStyle,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const isExternalFullscreen = typeof fullscreenOverride === 'boolean';
|
||||||
|
const effectiveFullscreen = isExternalFullscreen ? (fullscreenOverride as boolean) : isFullscreen;
|
||||||
|
const toggleFullscreen = isExternalFullscreen
|
||||||
|
? onToggleFullscreenOverride ?? (() => {})
|
||||||
|
: () => setIsFullscreen((v) => !v);
|
||||||
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
||||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||||
const [layers, setLayers] = useState<OverlayLayer[]>([
|
const [layers, setLayers] = useState<OverlayLayer[]>([
|
||||||
|
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
|
||||||
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
||||||
]);
|
]);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
@@ -75,22 +98,29 @@ export default function ChartPanel({
|
|||||||
const fibRef = useRef<FibRetracement | null>(fib);
|
const fibRef = useRef<FibRetracement | null>(fib);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isExternalFullscreen) return;
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setIsFullscreen(false);
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [isFullscreen]);
|
}, [isExternalFullscreen, isFullscreen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isExternalFullscreen) return;
|
||||||
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
||||||
return () => document.body.classList.remove('chartFullscreen');
|
return () => document.body.classList.remove('chartFullscreen');
|
||||||
}, [isFullscreen]);
|
}, [isExternalFullscreen, isFullscreen]);
|
||||||
|
|
||||||
const cardClassName = useMemo(() => {
|
const cardClassName = useMemo(() => {
|
||||||
return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
return ['chartCard', effectiveFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
||||||
}, [isFullscreen]);
|
}, [effectiveFullscreen]);
|
||||||
|
|
||||||
|
const cardStyle = useMemo(() => {
|
||||||
|
if (!effectiveFullscreen) return undefined;
|
||||||
|
return fullscreenStyle;
|
||||||
|
}, [effectiveFullscreen, fullscreenStyle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeToolRef.current = activeTool;
|
activeToolRef.current = activeTool;
|
||||||
@@ -188,6 +218,37 @@ export default function ChartPanel({
|
|||||||
return Math.max(0, Math.min(1, v));
|
return Math.max(0, Math.min(1, v));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
|
||||||
|
const quotesVisible = Boolean(quotesLayer?.visible);
|
||||||
|
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
|
||||||
|
|
||||||
|
const priceLines = useMemo(() => {
|
||||||
|
if (!quotesVisible) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'dlob-bid',
|
||||||
|
title: 'DLOB Bid',
|
||||||
|
price: dlobQuotes?.bid ?? null,
|
||||||
|
color: `rgba(34,197,94,${quotesOpacity})`,
|
||||||
|
lineStyle: LineStyle.Dotted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dlob-mid',
|
||||||
|
title: 'DLOB Mid',
|
||||||
|
price: dlobQuotes?.mid ?? null,
|
||||||
|
color: `rgba(230,233,239,${quotesOpacity})`,
|
||||||
|
lineStyle: LineStyle.Dashed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dlob-ask',
|
||||||
|
title: 'DLOB Ask',
|
||||||
|
price: dlobQuotes?.ask ?? null,
|
||||||
|
color: `rgba(239,68,68,${quotesOpacity})`,
|
||||||
|
lineStyle: LineStyle.Dotted,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]);
|
||||||
|
|
||||||
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
|
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
|
||||||
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
||||||
}
|
}
|
||||||
@@ -234,6 +295,7 @@ export default function ChartPanel({
|
|||||||
const pointer = pendingMoveRef.current;
|
const pointer = pendingMoveRef.current;
|
||||||
if (!pointer) return;
|
if (!pointer) return;
|
||||||
if (activeToolRef.current !== 'fib-retracement') return;
|
if (activeToolRef.current !== 'fib-retracement') return;
|
||||||
|
|
||||||
const start2 = fibStartRef.current;
|
const start2 = fibStartRef.current;
|
||||||
if (!start2) return;
|
if (!start2) return;
|
||||||
setFibDraft({ a: start2, b: pointer });
|
setFibDraft({ a: start2, b: pointer });
|
||||||
@@ -259,19 +321,21 @@ export default function ChartPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
{!isExternalFullscreen && isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||||
<Card className={cardClassName}>
|
<Card className={cardClassName} style={cardStyle}>
|
||||||
<div className="chartCard__toolbar">
|
<div className="chartCard__toolbar">
|
||||||
<ChartToolbar
|
<ChartToolbar
|
||||||
timeframe={timeframe}
|
timeframe={timeframe}
|
||||||
onTimeframeChange={onTimeframeChange}
|
onTimeframeChange={onTimeframeChange}
|
||||||
showIndicators={showIndicators}
|
showIndicators={showIndicators}
|
||||||
onToggleIndicators={onToggleIndicators}
|
onToggleIndicators={onToggleIndicators}
|
||||||
|
showBuild={showBuild}
|
||||||
|
onToggleBuild={onToggleBuild}
|
||||||
priceAutoScale={priceAutoScale}
|
priceAutoScale={priceAutoScale}
|
||||||
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={effectiveFullscreen}
|
||||||
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="chartCard__content">
|
<div className="chartCard__content">
|
||||||
@@ -295,6 +359,10 @@ export default function ChartPanel({
|
|||||||
ema20={indicators.ema20}
|
ema20={indicators.ema20}
|
||||||
bb20={indicators.bb20}
|
bb20={indicators.bb20}
|
||||||
showIndicators={showIndicators}
|
showIndicators={showIndicators}
|
||||||
|
showBuild={showBuild}
|
||||||
|
bucketSeconds={bucketSeconds}
|
||||||
|
seriesKey={seriesKey}
|
||||||
|
priceLines={priceLines}
|
||||||
fib={fibRenderable}
|
fib={fibRenderable}
|
||||||
fibOpacity={fibEffectiveOpacity}
|
fibOpacity={fibEffectiveOpacity}
|
||||||
fibSelected={fibSelected}
|
fibSelected={fibSelected}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ type Props = {
|
|||||||
onTimeframeChange: (tf: string) => void;
|
onTimeframeChange: (tf: string) => void;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
onToggleIndicators: () => void;
|
onToggleIndicators: () => void;
|
||||||
|
showBuild: boolean;
|
||||||
|
onToggleBuild: () => void;
|
||||||
priceAutoScale: boolean;
|
priceAutoScale: boolean;
|
||||||
onTogglePriceAutoScale: () => void;
|
onTogglePriceAutoScale: () => void;
|
||||||
seriesLabel: string;
|
seriesLabel: string;
|
||||||
@@ -12,13 +14,15 @@ type Props = {
|
|||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
const timeframes = ['1s', '3s', '5s', '15s', '30s', '1m', '3m', '5m', '15m', '30m', '1h', '4h', '12h', '1d'] as const;
|
||||||
|
|
||||||
export default function ChartToolbar({
|
export default function ChartToolbar({
|
||||||
timeframe,
|
timeframe,
|
||||||
onTimeframeChange,
|
onTimeframeChange,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
onToggleIndicators,
|
onToggleIndicators,
|
||||||
|
showBuild,
|
||||||
|
onToggleBuild,
|
||||||
priceAutoScale,
|
priceAutoScale,
|
||||||
onTogglePriceAutoScale,
|
onTogglePriceAutoScale,
|
||||||
seriesLabel,
|
seriesLabel,
|
||||||
@@ -45,6 +49,9 @@ export default function ChartToolbar({
|
|||||||
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
|
||||||
Indicators
|
Indicators
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant={showBuild ? 'primary' : 'ghost'} onClick={onToggleBuild} type="button">
|
||||||
|
Build
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
|
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
|
||||||
Auto Scale
|
Auto Scale
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
CandlestickSeries,
|
CandlestickSeries,
|
||||||
ColorType,
|
ColorType,
|
||||||
CrosshairMode,
|
CrosshairMode,
|
||||||
HistogramSeries,
|
HistogramSeries,
|
||||||
|
type IPrimitivePaneRenderer,
|
||||||
|
type IPrimitivePaneView,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
|
type ISeriesPrimitive,
|
||||||
LineStyle,
|
LineStyle,
|
||||||
LineSeries,
|
LineSeries,
|
||||||
|
type SeriesAttachedParameter,
|
||||||
|
type Time,
|
||||||
createChart,
|
createChart,
|
||||||
type UTCTimestamp,
|
type UTCTimestamp,
|
||||||
type CandlestickData,
|
type CandlestickData,
|
||||||
@@ -25,6 +30,18 @@ type Props = {
|
|||||||
ema20?: SeriesPoint[];
|
ema20?: SeriesPoint[];
|
||||||
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
bb20?: { upper: SeriesPoint[]; lower: SeriesPoint[]; mid: SeriesPoint[] };
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
|
showBuild: boolean;
|
||||||
|
bucketSeconds: number;
|
||||||
|
seriesKey: string;
|
||||||
|
priceLines?: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
price: number | null;
|
||||||
|
color: string;
|
||||||
|
lineWidth?: number;
|
||||||
|
lineStyle?: LineStyle;
|
||||||
|
axisLabelVisible?: boolean;
|
||||||
|
}>;
|
||||||
fib?: FibRetracement | null;
|
fib?: FibRetracement | null;
|
||||||
fibOpacity?: number;
|
fibOpacity?: number;
|
||||||
fibSelected?: boolean;
|
fibSelected?: boolean;
|
||||||
@@ -44,11 +61,30 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type LinePoint = LineData | WhitespaceData;
|
type LinePoint = LineData | WhitespaceData;
|
||||||
|
type BuildSample = { t: number; v: number };
|
||||||
|
|
||||||
|
const BUILD_UP_COLOR = '#22c55e';
|
||||||
|
const BUILD_DOWN_COLOR = '#ef4444';
|
||||||
|
const BUILD_FLAT_COLOR = '#60a5fa';
|
||||||
|
const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)';
|
||||||
|
const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)';
|
||||||
|
const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)';
|
||||||
|
|
||||||
function toTime(t: number): UTCTimestamp {
|
function toTime(t: number): UTCTimestamp {
|
||||||
return t as UTCTimestamp;
|
return t as UTCTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveBucketSeconds(bucketSeconds: number, candles: Candle[]): number {
|
||||||
|
if (Number.isFinite(bucketSeconds) && bucketSeconds > 0) return bucketSeconds;
|
||||||
|
if (candles.length >= 2) {
|
||||||
|
const last = candles[candles.length - 1]?.time;
|
||||||
|
const prev = candles[candles.length - 2]?.time;
|
||||||
|
const delta = typeof last === 'number' && typeof prev === 'number' ? last - prev : 0;
|
||||||
|
if (Number.isFinite(delta) && delta > 0) return delta;
|
||||||
|
}
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
|
||||||
function samplePriceFromCandles(candles: Candle[]): number | null {
|
function samplePriceFromCandles(candles: Candle[]): number | null {
|
||||||
for (let i = candles.length - 1; i >= 0; i -= 1) {
|
for (let i = candles.length - 1; i >= 0; i -= 1) {
|
||||||
const close = candles[i]?.close;
|
const close = candles[i]?.close;
|
||||||
@@ -82,18 +118,371 @@ function toCandleData(candles: Candle[]): CandlestickData[] {
|
|||||||
|
|
||||||
function toVolumeData(candles: Candle[]): HistogramData[] {
|
function toVolumeData(candles: Candle[]): HistogramData[] {
|
||||||
return candles.map((c) => {
|
return candles.map((c) => {
|
||||||
const up = c.close >= c.open;
|
|
||||||
return {
|
return {
|
||||||
time: toTime(c.time),
|
time: toTime(c.time),
|
||||||
value: c.volume ?? 0,
|
value: c.volume ?? 0,
|
||||||
color: up ? 'rgba(34,197,94,0.35)' : 'rgba(239,68,68,0.35)',
|
color: 'rgba(148,163,184,0.22)',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
||||||
if (!points?.length) return [];
|
if (!points?.length) return [];
|
||||||
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
|
return points.map((p) =>
|
||||||
|
p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorForDelta(delta: number): string {
|
||||||
|
if (delta > 0) return BUILD_UP_COLOR;
|
||||||
|
if (delta < 0) return BUILD_DOWN_COLOR;
|
||||||
|
return BUILD_FLAT_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sliceColorForDelta(delta: number): string {
|
||||||
|
if (delta > 0) return BUILD_UP_SLICE;
|
||||||
|
if (delta < 0) return BUILD_DOWN_SLICE;
|
||||||
|
return BUILD_FLAT_SLICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SliceDir = -1 | 0 | 1;
|
||||||
|
|
||||||
|
function dirForDelta(delta: number): SliceDir {
|
||||||
|
if (delta > 0) return 1;
|
||||||
|
if (delta < 0) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeltaSeriesForCandle(candle: Candle, bs: number, samples: BuildSample[] | undefined): LinePoint[] {
|
||||||
|
const eps = 1e-3;
|
||||||
|
const startT = candle.time + eps;
|
||||||
|
const endT = candle.time + bs - eps;
|
||||||
|
if (!(endT > startT)) return [];
|
||||||
|
|
||||||
|
const points: BuildSample[] = [{ t: startT, v: 0 }];
|
||||||
|
let lastT = startT;
|
||||||
|
for (const p of samples || []) {
|
||||||
|
let t = p.t;
|
||||||
|
if (t <= lastT + eps) t = lastT + eps;
|
||||||
|
if (t >= endT) break;
|
||||||
|
points.push({ t, v: p.v });
|
||||||
|
lastT = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDelta = candle.close - candle.open;
|
||||||
|
if (endT > lastT + eps) {
|
||||||
|
points.push({ t: endT, v: finalDelta });
|
||||||
|
} else if (points.length) {
|
||||||
|
points[points.length - 1] = { ...points[points.length - 1]!, v: finalDelta };
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: LinePoint[] = [{ time: toTime(candle.time) } as WhitespaceData];
|
||||||
|
out.push({ time: toTime(points[0]!.t), value: points[0]!.v } as LineData);
|
||||||
|
|
||||||
|
let lastLineIdx = out.length - 1;
|
||||||
|
let lastVal = points[0]!.v;
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const v = points[i]!.v;
|
||||||
|
const prev = out[lastLineIdx] as LineData;
|
||||||
|
out[lastLineIdx] = { ...prev, color: colorForDelta(v - lastVal) } as LineData;
|
||||||
|
out.push({ time: toTime(points[i]!.t), value: v } as LineData);
|
||||||
|
lastLineIdx = out.length - 1;
|
||||||
|
lastVal = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildSlicesState = {
|
||||||
|
enabled: boolean;
|
||||||
|
candles: Candle[];
|
||||||
|
bucketSeconds: number;
|
||||||
|
samples: Map<number, BuildSample[]>;
|
||||||
|
series: ISeriesApi<'Histogram', Time> | null;
|
||||||
|
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
|
||||||
|
private readonly _getState: () => BuildSlicesState;
|
||||||
|
|
||||||
|
constructor(getState: () => BuildSlicesState) {
|
||||||
|
this._getState = getState;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(target: any) {
|
||||||
|
const { enabled, candles, bucketSeconds, samples, series, chart } = this._getState();
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!candles.length || !series || !chart) return;
|
||||||
|
|
||||||
|
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||||
|
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
|
||||||
|
|
||||||
|
const yBase = series.priceToCoordinate(0);
|
||||||
|
if (yBase == null) return;
|
||||||
|
|
||||||
|
const xs = candles.map((c) => chart.timeScale().timeToCoordinate(toTime(c.time)));
|
||||||
|
|
||||||
|
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||||
|
const yBottomPx = Math.round(yBase * verticalPixelRatio);
|
||||||
|
const lastIdx = xs.length - 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < candles.length; i += 1) {
|
||||||
|
const x = xs[i];
|
||||||
|
if (x == null) continue;
|
||||||
|
if (!Number.isFinite(x)) continue;
|
||||||
|
|
||||||
|
const c = candles[i]!;
|
||||||
|
|
||||||
|
const start = c.time;
|
||||||
|
const end = start + bs;
|
||||||
|
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const progressT = isCurrent ? Math.min(end, Math.max(start, now)) : end;
|
||||||
|
|
||||||
|
let spacing = 0;
|
||||||
|
const prevX = i > 0 ? xs[i - 1] : null;
|
||||||
|
const nextX = i < lastIdx ? xs[i + 1] : null;
|
||||||
|
if (prevX != null && Number.isFinite(prevX)) spacing = x - prevX;
|
||||||
|
if (nextX != null && Number.isFinite(nextX)) {
|
||||||
|
const s2 = nextX - x;
|
||||||
|
spacing = spacing > 0 ? Math.min(spacing, s2) : s2;
|
||||||
|
}
|
||||||
|
if (!(spacing > 0)) spacing = 6;
|
||||||
|
|
||||||
|
const barWidthCss = Math.max(1, spacing * 0.9);
|
||||||
|
const barWidthPx = Math.max(1, Math.round(barWidthCss * horizontalPixelRatio));
|
||||||
|
const xCenterPx = Math.round(x * horizontalPixelRatio);
|
||||||
|
const xLeftPx = Math.round(xCenterPx - barWidthPx / 2);
|
||||||
|
|
||||||
|
const volumeValue = typeof c.volume === 'number' && Number.isFinite(c.volume) ? c.volume : 0;
|
||||||
|
if (!(volumeValue > 0)) continue;
|
||||||
|
const yTop = series.priceToCoordinate(volumeValue);
|
||||||
|
if (yTop == null) continue;
|
||||||
|
const yTopPx = Math.round(yTop * verticalPixelRatio);
|
||||||
|
if (!(yBottomPx > yTopPx)) continue;
|
||||||
|
const barHeightPx = yBottomPx - yTopPx;
|
||||||
|
|
||||||
|
const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx));
|
||||||
|
const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx));
|
||||||
|
const w = x1 - x0;
|
||||||
|
if (!(w > 0)) continue;
|
||||||
|
|
||||||
|
// Prefer server-provided `flowRows`: brick-by-brick direction per time slice inside the candle.
|
||||||
|
// Fallback to `flow` (3 shares) or net candle direction.
|
||||||
|
const rowsFromApi = Array.isArray((c as any).flowRows) ? ((c as any).flowRows as any[]) : null;
|
||||||
|
const rowDirs = rowsFromApi?.length ? rowsFromApi : null;
|
||||||
|
|
||||||
|
if (rowDirs) {
|
||||||
|
const rows = Math.max(1, rowDirs.length);
|
||||||
|
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
|
||||||
|
const movesFromApi = Array.isArray((c as any).flowMoves) ? ((c as any).flowMoves as any[]) : null;
|
||||||
|
const rowMoves = movesFromApi && movesFromApi.length === rows ? movesFromApi : null;
|
||||||
|
let maxMove = 0;
|
||||||
|
if (rowMoves) {
|
||||||
|
for (let r = 0; r < progressRows; r += 1) {
|
||||||
|
const v = Number(rowMoves[r]);
|
||||||
|
if (Number.isFinite(v) && v > maxMove) maxMove = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sepPx = 1; // black separator line between steps
|
||||||
|
const sepColor = 'rgba(0,0,0,0.75)';
|
||||||
|
|
||||||
|
// Blue (flat) bricks have a constant pixel height (unit).
|
||||||
|
// Up/down bricks are scaled by |Δ| in their dt.
|
||||||
|
const minNonFlatPx = 1;
|
||||||
|
let unitFlatPx = 2;
|
||||||
|
|
||||||
|
let flatCount = 0;
|
||||||
|
let nonFlatCount = 0;
|
||||||
|
for (let r = 0; r < progressRows; r += 1) {
|
||||||
|
const dirRaw = rowDirs[r];
|
||||||
|
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||||
|
if (dir === 0) flatCount += 1;
|
||||||
|
else nonFlatCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sepTotal = Math.max(0, progressRows - 1) * sepPx;
|
||||||
|
if (flatCount > 0) {
|
||||||
|
const maxUnit = Math.floor((barHeightPx - sepTotal - nonFlatCount * minNonFlatPx) / flatCount);
|
||||||
|
unitFlatPx = Math.max(1, Math.min(unitFlatPx, maxUnit));
|
||||||
|
} else {
|
||||||
|
unitFlatPx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonFlatAvailable = barHeightPx - sepTotal - flatCount * unitFlatPx;
|
||||||
|
if (!Number.isFinite(nonFlatAvailable) || nonFlatAvailable < 0) nonFlatAvailable = 0;
|
||||||
|
|
||||||
|
const baseNonFlat = nonFlatCount * minNonFlatPx;
|
||||||
|
let extra = nonFlatAvailable - baseNonFlat;
|
||||||
|
if (!Number.isFinite(extra) || extra < 0) extra = 0;
|
||||||
|
|
||||||
|
let sumMove = 0;
|
||||||
|
if (nonFlatCount > 0) {
|
||||||
|
for (let r = 0; r < progressRows; r += 1) {
|
||||||
|
const dirRaw = rowDirs[r];
|
||||||
|
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||||
|
if (dir === 0) continue;
|
||||||
|
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
||||||
|
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
||||||
|
sumMove += mv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(flatCount + nonFlatCount > 0) || barHeightPx <= sepTotal) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack bricks bottom-up (earliest slices at the bottom).
|
||||||
|
let usedExtra = 0;
|
||||||
|
let nonFlatSeen = 0;
|
||||||
|
let y = yBottomPx;
|
||||||
|
for (let r = 0; r < progressRows; r += 1) {
|
||||||
|
const dirRaw = rowDirs[r];
|
||||||
|
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||||
|
const isLast = r === progressRows - 1;
|
||||||
|
|
||||||
|
let h = 0;
|
||||||
|
if (dir === 0) {
|
||||||
|
h = unitFlatPx;
|
||||||
|
} else {
|
||||||
|
nonFlatSeen += 1;
|
||||||
|
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
||||||
|
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
||||||
|
const share = sumMove > 0 ? mv / sumMove : 1 / nonFlatCount;
|
||||||
|
const wantExtra = Math.floor(extra * share);
|
||||||
|
const isLastNonFlat = nonFlatSeen === nonFlatCount;
|
||||||
|
const add = isLastNonFlat ? Math.max(0, extra - usedExtra) : Math.max(0, wantExtra);
|
||||||
|
usedExtra += add;
|
||||||
|
h = minNonFlatPx + add;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h <= 0) continue;
|
||||||
|
y -= h;
|
||||||
|
context.fillStyle = sliceColorForDelta(dir);
|
||||||
|
context.fillRect(x0, y, w, h);
|
||||||
|
|
||||||
|
if (!isLast) {
|
||||||
|
y -= sepPx;
|
||||||
|
context.fillStyle = sepColor;
|
||||||
|
context.fillRect(x0, y, w, sepPx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f: any = (c as any).flow;
|
||||||
|
let upShare = typeof f?.up === 'number' ? f.up : Number(f?.up);
|
||||||
|
let downShare = typeof f?.down === 'number' ? f.down : Number(f?.down);
|
||||||
|
let flatShare = typeof f?.flat === 'number' ? f.flat : Number(f?.flat);
|
||||||
|
|
||||||
|
if (!Number.isFinite(upShare)) upShare = 0;
|
||||||
|
if (!Number.isFinite(downShare)) downShare = 0;
|
||||||
|
if (!Number.isFinite(flatShare)) flatShare = 0;
|
||||||
|
upShare = Math.max(0, upShare);
|
||||||
|
downShare = Math.max(0, downShare);
|
||||||
|
flatShare = Math.max(0, flatShare);
|
||||||
|
|
||||||
|
let sum = upShare + downShare + flatShare;
|
||||||
|
if (!(sum > 0)) {
|
||||||
|
const overallDir = dirForDelta(c.close - c.open);
|
||||||
|
upShare = overallDir > 0 ? 1 : 0;
|
||||||
|
downShare = overallDir < 0 ? 1 : 0;
|
||||||
|
flatShare = overallDir === 0 ? 1 : 0;
|
||||||
|
sum = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
upShare /= sum;
|
||||||
|
downShare /= sum;
|
||||||
|
flatShare /= sum;
|
||||||
|
|
||||||
|
const downH = Math.floor(barHeightPx * downShare);
|
||||||
|
const flatH = Math.floor(barHeightPx * flatShare);
|
||||||
|
const upH = Math.max(0, barHeightPx - downH - flatH);
|
||||||
|
|
||||||
|
let y = yBottomPx;
|
||||||
|
|
||||||
|
if (downH > 0) {
|
||||||
|
context.fillStyle = sliceColorForDelta(-1);
|
||||||
|
context.fillRect(x0, y - downH, w, downH);
|
||||||
|
y -= downH;
|
||||||
|
}
|
||||||
|
if (flatH > 0) {
|
||||||
|
context.fillStyle = sliceColorForDelta(0);
|
||||||
|
context.fillRect(x0, y - flatH, w, flatH);
|
||||||
|
y -= flatH;
|
||||||
|
}
|
||||||
|
if (upH > 0) {
|
||||||
|
context.fillStyle = sliceColorForDelta(1);
|
||||||
|
context.fillRect(x0, y - upH, w, upH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BuildSlicesPaneView implements IPrimitivePaneView {
|
||||||
|
private readonly _renderer: BuildSlicesPaneRenderer;
|
||||||
|
|
||||||
|
constructor(getState: () => BuildSlicesState) {
|
||||||
|
this._renderer = new BuildSlicesPaneRenderer(getState);
|
||||||
|
}
|
||||||
|
|
||||||
|
zOrder() {
|
||||||
|
return 'top';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer() {
|
||||||
|
return this._renderer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BuildSlicesPrimitive implements ISeriesPrimitive<Time> {
|
||||||
|
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||||
|
private _series: ISeriesApi<'Histogram', Time> | null = null;
|
||||||
|
private _enabled = true;
|
||||||
|
private _candles: Candle[] = [];
|
||||||
|
private _bucketSeconds = 0;
|
||||||
|
private _samples: Map<number, BuildSample[]> = new Map();
|
||||||
|
private readonly _paneView: BuildSlicesPaneView;
|
||||||
|
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._paneView = new BuildSlicesPaneView(() => ({
|
||||||
|
enabled: this._enabled,
|
||||||
|
candles: this._candles,
|
||||||
|
bucketSeconds: this._bucketSeconds,
|
||||||
|
samples: this._samples,
|
||||||
|
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<'Histogram', Time>;
|
||||||
|
}
|
||||||
|
|
||||||
|
detached() {
|
||||||
|
this._param = null;
|
||||||
|
this._series = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
paneViews() {
|
||||||
|
return this._paneViews;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(next: boolean) {
|
||||||
|
this._enabled = Boolean(next);
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(next: { candles: Candle[]; bucketSeconds: number; samples: Map<number, BuildSample[]> }) {
|
||||||
|
this._candles = Array.isArray(next.candles) ? next.candles : [];
|
||||||
|
this._bucketSeconds = Number.isFinite(next.bucketSeconds) ? next.bucketSeconds : 0;
|
||||||
|
this._samples = next.samples;
|
||||||
|
this._param?.requestUpdate();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TradingChart({
|
export default function TradingChart({
|
||||||
@@ -103,6 +492,10 @@ export default function TradingChart({
|
|||||||
ema20,
|
ema20,
|
||||||
bb20,
|
bb20,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
|
showBuild,
|
||||||
|
bucketSeconds,
|
||||||
|
seriesKey,
|
||||||
|
priceLines,
|
||||||
fib,
|
fib,
|
||||||
fibOpacity = 1,
|
fibOpacity = 1,
|
||||||
fibSelected = false,
|
fibSelected = false,
|
||||||
@@ -119,14 +512,23 @@ export default function TradingChart({
|
|||||||
const fibOpacityRef = useRef<number>(fibOpacity);
|
const fibOpacityRef = useRef<number>(fibOpacity);
|
||||||
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||||
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||||
|
const showBuildRef = useRef<boolean>(showBuild);
|
||||||
const onReadyRef = useRef<Props['onReady']>(onReady);
|
const onReadyRef = useRef<Props['onReady']>(onReady);
|
||||||
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
||||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||||
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
||||||
const capturedOverlayPointerRef = useRef<number | null>(null);
|
const capturedOverlayPointerRef = useRef<number | null>(null);
|
||||||
|
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
|
||||||
|
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
|
||||||
|
const buildKeyRef = useRef<string | null>(null);
|
||||||
|
const lastBuildCandleStartRef = useRef<number | null>(null);
|
||||||
|
const hoverCandleTimeRef = useRef<number | null>(null);
|
||||||
|
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
|
||||||
|
const priceLinesRef = useRef<Map<string, any>>(new Map());
|
||||||
const seriesRef = useRef<{
|
const seriesRef = useRef<{
|
||||||
candles?: ISeriesApi<'Candlestick'>;
|
candles?: ISeriesApi<'Candlestick'>;
|
||||||
volume?: ISeriesApi<'Histogram'>;
|
volume?: ISeriesApi<'Histogram'>;
|
||||||
|
buildHover?: ISeriesApi<'Line'>;
|
||||||
oracle?: ISeriesApi<'Line'>;
|
oracle?: ISeriesApi<'Line'>;
|
||||||
sma20?: ISeriesApi<'Line'>;
|
sma20?: ISeriesApi<'Line'>;
|
||||||
ema20?: ISeriesApi<'Line'>;
|
ema20?: ISeriesApi<'Line'>;
|
||||||
@@ -177,6 +579,14 @@ export default function TradingChart({
|
|||||||
priceAutoScaleRef.current = priceAutoScale;
|
priceAutoScaleRef.current = priceAutoScale;
|
||||||
}, [priceAutoScale]);
|
}, [priceAutoScale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showBuildRef.current = showBuild;
|
||||||
|
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
|
||||||
|
hoverCandleTimeRef.current = null;
|
||||||
|
setHoverCandleTime(null);
|
||||||
|
}
|
||||||
|
}, [showBuild, hoverCandleTime]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
if (chartRef.current) return;
|
if (chartRef.current) return;
|
||||||
@@ -225,7 +635,27 @@ export default function TradingChart({
|
|||||||
color: 'rgba(255,255,255,0.15)',
|
color: 'rgba(255,255,255,0.15)',
|
||||||
});
|
});
|
||||||
volumeSeries.priceScale().applyOptions({
|
volumeSeries.priceScale().applyOptions({
|
||||||
scaleMargins: { top: 0.82, bottom: 0 },
|
scaleMargins: { top: 0.88, bottom: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildSlicesPrimitive = new BuildSlicesPrimitive();
|
||||||
|
volumeSeries.attachPrimitive(buildSlicesPrimitive);
|
||||||
|
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
|
||||||
|
buildSlicesPrimitive.setEnabled(showBuildRef.current);
|
||||||
|
|
||||||
|
const buildHoverSeries = chart.addSeries(LineSeries, {
|
||||||
|
color: BUILD_FLAT_COLOR,
|
||||||
|
lineWidth: 2,
|
||||||
|
priceFormat,
|
||||||
|
priceScaleId: 'build',
|
||||||
|
lastValueVisible: false,
|
||||||
|
priceLineVisible: false,
|
||||||
|
crosshairMarkerVisible: false,
|
||||||
|
});
|
||||||
|
buildHoverSeries.priceScale().applyOptions({
|
||||||
|
scaleMargins: { top: 0.72, bottom: 0.12 },
|
||||||
|
visible: false,
|
||||||
|
borderVisible: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const oracleSeries = chart.addSeries(LineSeries, {
|
const oracleSeries = chart.addSeries(LineSeries, {
|
||||||
@@ -249,6 +679,7 @@ export default function TradingChart({
|
|||||||
seriesRef.current = {
|
seriesRef.current = {
|
||||||
candles: candleSeries,
|
candles: candleSeries,
|
||||||
volume: volumeSeries,
|
volume: volumeSeries,
|
||||||
|
buildHover: buildHoverSeries,
|
||||||
oracle: oracleSeries,
|
oracle: oracleSeries,
|
||||||
sma20: smaSeries,
|
sma20: smaSeries,
|
||||||
ema20: emaSeries,
|
ema20: emaSeries,
|
||||||
@@ -298,7 +729,23 @@ export default function TradingChart({
|
|||||||
chart.subscribeClick(onClick);
|
chart.subscribeClick(onClick);
|
||||||
|
|
||||||
const onCrosshairMove = (param: any) => {
|
const onCrosshairMove = (param: any) => {
|
||||||
if (!param?.point) return;
|
if (!param?.point) {
|
||||||
|
if (showBuildRef.current && hoverCandleTimeRef.current != null) {
|
||||||
|
hoverCandleTimeRef.current = null;
|
||||||
|
setHoverCandleTime(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBuildRef.current) {
|
||||||
|
const t = typeof param?.time === 'number' ? Number(param.time) : null;
|
||||||
|
const next = t != null && Number.isFinite(t) ? t : null;
|
||||||
|
if (hoverCandleTimeRef.current !== next) {
|
||||||
|
hoverCandleTimeRef.current = next;
|
||||||
|
setHoverCandleTime(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
||||||
if (logical == null) return;
|
if (logical == null) return;
|
||||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||||
@@ -543,15 +990,75 @@ export default function TradingChart({
|
|||||||
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
||||||
fibPrimitiveRef.current = null;
|
fibPrimitiveRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (buildSlicesPrimitiveRef.current) {
|
||||||
|
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
|
||||||
|
buildSlicesPrimitiveRef.current = null;
|
||||||
|
}
|
||||||
|
const lines = priceLinesRef.current;
|
||||||
|
for (const line of Array.from(lines.values())) {
|
||||||
|
try {
|
||||||
|
candleSeries.removePriceLine(line);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.clear();
|
||||||
chart.remove();
|
chart.remove();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
seriesRef.current = {};
|
seriesRef.current = {};
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const candlesSeries = seriesRef.current.candles;
|
||||||
|
if (!candlesSeries) return;
|
||||||
|
|
||||||
|
const desired = (priceLines || []).filter((l) => l.price != null && Number.isFinite(l.price));
|
||||||
|
const desiredIds = new Set(desired.map((l) => l.id));
|
||||||
|
|
||||||
|
const map = priceLinesRef.current;
|
||||||
|
for (const [id, line] of Array.from(map.entries())) {
|
||||||
|
if (desiredIds.has(id)) continue;
|
||||||
|
try {
|
||||||
|
candlesSeries.removePriceLine(line);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
map.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const spec of desired) {
|
||||||
|
const opts: any = {
|
||||||
|
price: spec.price,
|
||||||
|
color: spec.color,
|
||||||
|
title: spec.title,
|
||||||
|
lineWidth: spec.lineWidth ?? 1,
|
||||||
|
lineStyle: spec.lineStyle ?? LineStyle.Dotted,
|
||||||
|
axisLabelVisible: spec.axisLabelVisible ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = map.get(spec.id);
|
||||||
|
if (!existing) {
|
||||||
|
try {
|
||||||
|
const created = candlesSeries.createPriceLine(opts);
|
||||||
|
map.set(spec.id, created);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
existing.applyOptions(opts);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [priceLines]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = seriesRef.current;
|
const s = seriesRef.current;
|
||||||
if (!s.candles || !s.volume) return;
|
if (!s.candles || !s.volume || !s.buildHover) return;
|
||||||
s.candles.setData(candleData);
|
s.candles.setData(candleData);
|
||||||
s.volume.setData(volumeData);
|
s.volume.setData(volumeData);
|
||||||
s.oracle?.setData(oracleData);
|
s.oracle?.setData(oracleData);
|
||||||
@@ -561,17 +1068,111 @@ export default function TradingChart({
|
|||||||
s.bbLower?.setData(bbLower);
|
s.bbLower?.setData(bbLower);
|
||||||
s.bbMid?.setData(bbMid);
|
s.bbMid?.setData(bbMid);
|
||||||
|
|
||||||
|
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||||
|
const eps = 1e-3;
|
||||||
|
const maxPointsPerCandle = 600;
|
||||||
|
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
|
||||||
|
const map = buildSamplesRef.current;
|
||||||
|
|
||||||
|
if (buildKeyRef.current !== seriesKey) {
|
||||||
|
map.clear();
|
||||||
|
buildKeyRef.current = seriesKey;
|
||||||
|
lastBuildCandleStartRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleStarts = new Set(candles.map((c) => c.time));
|
||||||
|
for (const start of Array.from(map.keys())) {
|
||||||
|
if (!visibleStarts.has(start)) map.delete(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = candles[candles.length - 1];
|
||||||
|
if (last) {
|
||||||
|
const prevStart = lastBuildCandleStartRef.current;
|
||||||
|
if (prevStart != null && prevStart !== last.time) {
|
||||||
|
const prevCandle = candles.find((c) => c.time === prevStart);
|
||||||
|
if (prevCandle) {
|
||||||
|
const endT = prevStart + bs - eps;
|
||||||
|
const finalDelta = prevCandle.close - prevCandle.open;
|
||||||
|
const list = map.get(prevStart) ?? [];
|
||||||
|
const lastT = list.length ? list[list.length - 1]!.t : -Infinity;
|
||||||
|
if (endT > lastT + eps) {
|
||||||
|
list.push({ t: endT, v: finalDelta });
|
||||||
|
} else if (list.length) {
|
||||||
|
list[list.length - 1] = { t: lastT, v: finalDelta };
|
||||||
|
}
|
||||||
|
map.set(prevStart, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = last.time;
|
||||||
|
const endT = start + bs - eps;
|
||||||
|
const delta = last.close - last.open;
|
||||||
|
const nowT = Date.now() / 1000;
|
||||||
|
const tClamped = Math.min(endT, Math.max(start + eps, nowT));
|
||||||
|
const list = map.get(start) ?? [];
|
||||||
|
if (list.length) {
|
||||||
|
const lastPt = list[list.length - 1]!;
|
||||||
|
if (tClamped - lastPt.t < minStep) {
|
||||||
|
list[list.length - 1] = { t: tClamped, v: delta };
|
||||||
|
} else {
|
||||||
|
const t = Math.min(endT, Math.max(lastPt.t + eps, tClamped));
|
||||||
|
if (t > lastPt.t) list.push({ t, v: delta });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list.push({ t: tClamped, v: delta });
|
||||||
|
}
|
||||||
|
map.set(start, list);
|
||||||
|
lastBuildCandleStartRef.current = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPrimitive = buildSlicesPrimitiveRef.current;
|
||||||
|
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
|
||||||
|
buildPrimitive?.setEnabled(showBuild);
|
||||||
|
|
||||||
|
if (showBuild) {
|
||||||
|
const hoverTime = hoverCandleTime;
|
||||||
|
const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime);
|
||||||
|
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
|
||||||
|
|
||||||
|
if (hoverData.length) {
|
||||||
|
s.buildHover.applyOptions({ visible: true });
|
||||||
|
s.buildHover.setData(hoverData);
|
||||||
|
} else {
|
||||||
|
s.buildHover.applyOptions({ visible: false });
|
||||||
|
s.buildHover.setData([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.buildHover.applyOptions({ visible: false });
|
||||||
|
s.buildHover.setData([]);
|
||||||
|
}
|
||||||
|
|
||||||
s.sma20?.applyOptions({ visible: showIndicators });
|
s.sma20?.applyOptions({ visible: showIndicators });
|
||||||
s.ema20?.applyOptions({ visible: showIndicators });
|
s.ema20?.applyOptions({ visible: showIndicators });
|
||||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||||
s.bbLower?.applyOptions({ visible: showIndicators });
|
s.bbLower?.applyOptions({ visible: showIndicators });
|
||||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||||
}, [candleData, volumeData, oracleData, smaData, emaData, bbUpper, bbLower, bbMid, showIndicators]);
|
}, [
|
||||||
|
candleData,
|
||||||
|
volumeData,
|
||||||
|
oracleData,
|
||||||
|
smaData,
|
||||||
|
emaData,
|
||||||
|
bbUpper,
|
||||||
|
bbLower,
|
||||||
|
bbMid,
|
||||||
|
showIndicators,
|
||||||
|
showBuild,
|
||||||
|
candles,
|
||||||
|
bucketSeconds,
|
||||||
|
seriesKey,
|
||||||
|
hoverCandleTime,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = seriesRef.current;
|
const s = seriesRef.current;
|
||||||
if (!s.candles) return;
|
if (!s.candles) return;
|
||||||
s.candles.applyOptions({ priceFormat });
|
s.candles.applyOptions({ priceFormat });
|
||||||
|
s.buildHover?.applyOptions({ priceFormat });
|
||||||
s.oracle?.applyOptions({ priceFormat });
|
s.oracle?.applyOptions({ priceFormat });
|
||||||
s.sma20?.applyOptions({ priceFormat });
|
s.sma20?.applyOptions({ priceFormat });
|
||||||
s.ema20?.applyOptions({ priceFormat });
|
s.ema20?.applyOptions({ priceFormat });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Params = {
|
|||||||
type Result = {
|
type Result = {
|
||||||
candles: Candle[];
|
candles: Candle[];
|
||||||
indicators: ChartIndicators;
|
indicators: ChartIndicators;
|
||||||
|
meta: { tf: string; bucketSeconds: number } | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -22,36 +23,64 @@ type Result = {
|
|||||||
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
|
export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Result {
|
||||||
const [candles, setCandles] = useState<Candle[]>([]);
|
const [candles, setCandles] = useState<Candle[]>([]);
|
||||||
const [indicators, setIndicators] = useState<ChartIndicators>({});
|
const [indicators, setIndicators] = useState<ChartIndicators>({});
|
||||||
|
const [meta, setMeta] = useState<{ tf: string; bucketSeconds: number } | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inFlight = useRef(false);
|
const inFlight = useRef(false);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const requestIdRef = useRef(0);
|
||||||
|
|
||||||
const fetchOnce = useCallback(async () => {
|
const fetchOnce = useCallback(
|
||||||
if (inFlight.current) return;
|
async ({ force }: { force: boolean }) => {
|
||||||
inFlight.current = true;
|
if (!force && inFlight.current) return;
|
||||||
setLoading(true);
|
|
||||||
try {
|
// On timeframe/params change we want an immediate response — abort the older request.
|
||||||
const res = await fetchChart({ symbol, source, tf, limit });
|
if (force && abortRef.current) abortRef.current.abort();
|
||||||
setCandles(res.candles);
|
|
||||||
setIndicators(res.indicators);
|
const ctrl = new AbortController();
|
||||||
setError(null);
|
abortRef.current = ctrl;
|
||||||
} catch (e: any) {
|
|
||||||
setError(String(e?.message || e));
|
const reqId = requestIdRef.current + 1;
|
||||||
} finally {
|
requestIdRef.current = reqId;
|
||||||
setLoading(false);
|
|
||||||
inFlight.current = false;
|
inFlight.current = true;
|
||||||
}
|
setLoading(true);
|
||||||
}, [symbol, source, tf, limit]);
|
|
||||||
|
try {
|
||||||
|
const res = await fetchChart({ symbol, source, tf, limit, signal: ctrl.signal });
|
||||||
|
if (requestIdRef.current !== reqId) return; // stale response
|
||||||
|
setCandles(res.candles);
|
||||||
|
setIndicators(res.indicators);
|
||||||
|
setMeta(res.meta);
|
||||||
|
setError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Aborts are expected during fast tf switching.
|
||||||
|
const name = String(e?.name || '');
|
||||||
|
const msg = String(e?.message || e);
|
||||||
|
if (name === 'AbortError' || msg.toLowerCase().includes('abort')) return;
|
||||||
|
if (requestIdRef.current !== reqId) return;
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
if (requestIdRef.current === reqId) {
|
||||||
|
setLoading(false);
|
||||||
|
inFlight.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[symbol, source, tf, limit]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchOnce();
|
void fetchOnce({ force: true });
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
}, [fetchOnce]);
|
}, [fetchOnce]);
|
||||||
|
|
||||||
useInterval(() => void fetchOnce(), pollMs);
|
useInterval(() => void fetchOnce({ force: false }), pollMs);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ candles, indicators, loading, error, refresh: fetchOnce }),
|
() => ({ candles, indicators, meta, loading, error, refresh: () => fetchOnce({ force: true }) }),
|
||||||
[candles, indicators, loading, error, fetchOnce]
|
[candles, indicators, meta, loading, error, fetchOnce]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
858
apps/visualizer/src/features/contracts/ContractCostsPanel.tsx
Normal file
858
apps/visualizer/src/features/contracts/ContractCostsPanel.tsx
Normal file
@@ -0,0 +1,858 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import 'chartjs-adapter-luxon';
|
||||||
|
import { useLocalStorageState } from '../../app/hooks/useLocalStorageState';
|
||||||
|
import Button from '../../ui/Button';
|
||||||
|
|
||||||
|
type ContractMonitorResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string;
|
||||||
|
contract?: any;
|
||||||
|
eventsCount?: number;
|
||||||
|
costs?: {
|
||||||
|
tradeFeeUsd: number;
|
||||||
|
txFeeUsd: number;
|
||||||
|
slippageUsd: number;
|
||||||
|
fundingUsd: number;
|
||||||
|
realizedPnlUsd: number;
|
||||||
|
totalCostsUsd: number;
|
||||||
|
netPnlUsd: number;
|
||||||
|
txCount: number;
|
||||||
|
fillCount: number;
|
||||||
|
cancelCount: number;
|
||||||
|
modifyCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
};
|
||||||
|
series?: Array<{
|
||||||
|
ts: string;
|
||||||
|
tradeFeeUsd: number;
|
||||||
|
txFeeUsd: number;
|
||||||
|
slippageUsd: number;
|
||||||
|
fundingUsd: number;
|
||||||
|
totalCostsUsd: number;
|
||||||
|
realizedPnlUsd: number;
|
||||||
|
netPnlUsd: number;
|
||||||
|
}> | null;
|
||||||
|
closeEstimate?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CostEstimateResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string;
|
||||||
|
input?: any;
|
||||||
|
dlob?: any;
|
||||||
|
breakdown?: {
|
||||||
|
trade_fee_usd: number;
|
||||||
|
slippage_usd: number;
|
||||||
|
tx_fee_usd: number;
|
||||||
|
expected_modify_usd: number;
|
||||||
|
total_usd: number;
|
||||||
|
total_bps: number;
|
||||||
|
breakeven_bps: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatUsd(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
const abs = Math.abs(v);
|
||||||
|
if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (abs >= 1000) return `$${(v / 1000).toFixed(1)}K`;
|
||||||
|
return `$${v.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBps(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return `${v.toFixed(2)} bps`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(v: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(v)) return min;
|
||||||
|
return Math.min(max, Math.max(min, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniLineChart({
|
||||||
|
title,
|
||||||
|
points,
|
||||||
|
valueKey,
|
||||||
|
format,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
points: Array<Record<string, any>>;
|
||||||
|
valueKey: string;
|
||||||
|
format?: (v: number | null | undefined) => string;
|
||||||
|
}) {
|
||||||
|
const values = useMemo(() => {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const p of points) {
|
||||||
|
const v = Number(p?.[valueKey]);
|
||||||
|
if (Number.isFinite(v)) out.push(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [points, valueKey]);
|
||||||
|
|
||||||
|
const last = values.length ? values[values.length - 1] : null;
|
||||||
|
const min = values.length ? Math.min(...values) : 0;
|
||||||
|
const max = values.length ? Math.max(...values) : 1;
|
||||||
|
const span = max - min || 1;
|
||||||
|
const w = 360;
|
||||||
|
const h = 84;
|
||||||
|
const pad = 6;
|
||||||
|
|
||||||
|
const d = useMemo(() => {
|
||||||
|
if (!values.length) return '';
|
||||||
|
const step = values.length > 1 ? (w - pad * 2) / (values.length - 1) : 0;
|
||||||
|
let path = '';
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
const x = pad + i * step;
|
||||||
|
const y = pad + ((max - values[i]) / span) * (h - pad * 2);
|
||||||
|
path += i === 0 ? `M ${x.toFixed(2)} ${y.toFixed(2)}` : ` L ${x.toFixed(2)} ${y.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}, [h, max, span, values, w]);
|
||||||
|
|
||||||
|
const cls = last != null && last >= 0 ? 'pos' : 'neg';
|
||||||
|
const fmt = format || formatUsd;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="costChart">
|
||||||
|
<div className="costChart__head">
|
||||||
|
<span className="costChart__title">{title}</span>
|
||||||
|
<span className={['costChart__value', cls].join(' ')}>{fmt(last)}</span>
|
||||||
|
</div>
|
||||||
|
{values.length ? (
|
||||||
|
<svg className="costChart__svg" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
||||||
|
<path d={d} fill="none" stroke="rgba(168, 85, 247, 0.9)" strokeWidth="2" />
|
||||||
|
<path d={`M ${pad} ${h - pad} H ${w - pad}`} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
No series yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EstimatePoint = {
|
||||||
|
ts: number;
|
||||||
|
tradeFeeUsd: number;
|
||||||
|
slippageUsd: number;
|
||||||
|
txFeeUsd: number;
|
||||||
|
modifyUsd: number;
|
||||||
|
totalUsd: number;
|
||||||
|
breakevenBps: number;
|
||||||
|
totalBps: number;
|
||||||
|
midPrice: number | null;
|
||||||
|
vwapPrice: number | null;
|
||||||
|
impactBps: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useWindowedEstimateSeries(points: EstimatePoint[], windowSec: number) {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const windowMs = Math.max(10, Math.min(3600, Math.floor(windowSec))) * 1000;
|
||||||
|
const startMs = nowMs - windowMs;
|
||||||
|
|
||||||
|
return useMemo(() => points.filter((p) => p.ts >= startMs && p.ts <= nowMs + 2000), [nowMs, points, startMs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EstimateChart({
|
||||||
|
title,
|
||||||
|
points,
|
||||||
|
windowSec,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
points: EstimatePoint[];
|
||||||
|
windowSec: number;
|
||||||
|
kind: 'price' | 'costUsd' | 'costBps';
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const chartRef = useRef<Chart | null>(null);
|
||||||
|
const windowed = useWindowedEstimateSeries(points, windowSec);
|
||||||
|
|
||||||
|
const datasets = useMemo(() => {
|
||||||
|
const toXY = (key: keyof EstimatePoint) =>
|
||||||
|
windowed.map((p) => ({
|
||||||
|
x: p.ts,
|
||||||
|
y: (p[key] as any) == null ? null : Number(p[key] as any),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (kind === 'price') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Mid',
|
||||||
|
data: toXY('midPrice'),
|
||||||
|
borderColor: 'rgba(96,165,250,0.95)',
|
||||||
|
backgroundColor: 'rgba(96,165,250,0.10)',
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.15,
|
||||||
|
yAxisID: 'price',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'VWAP (quote)',
|
||||||
|
data: toXY('vwapPrice'),
|
||||||
|
borderColor: 'rgba(168,85,247,0.95)',
|
||||||
|
backgroundColor: 'rgba(168,85,247,0.10)',
|
||||||
|
pointRadius: 0,
|
||||||
|
borderDash: [6, 4],
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.15,
|
||||||
|
yAxisID: 'price',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'costBps') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Impact (bps)',
|
||||||
|
data: toXY('impactBps'),
|
||||||
|
borderColor: 'rgba(34,197,94,0.95)',
|
||||||
|
backgroundColor: 'rgba(34,197,94,0.10)',
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.15,
|
||||||
|
yAxisID: 'bps',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total (bps)',
|
||||||
|
data: toXY('totalBps'),
|
||||||
|
borderColor: 'rgba(239,68,68,0.95)',
|
||||||
|
backgroundColor: 'rgba(239,68,68,0.10)',
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.15,
|
||||||
|
yAxisID: 'bps',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Total',
|
||||||
|
data: toXY('totalUsd'),
|
||||||
|
borderColor: 'rgba(168,85,247,0.95)',
|
||||||
|
backgroundColor: 'rgba(168,85,247,0.10)',
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.15,
|
||||||
|
yAxisID: 'usd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Slippage',
|
||||||
|
data: toXY('slippageUsd'),
|
||||||
|
borderColor: 'rgba(34,197,94,0.95)',
|
||||||
|
backgroundColor: 'rgba(34,197,94,0.10)',
|
||||||
|
pointRadius: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.15,
|
||||||
|
yAxisID: 'usd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tx',
|
||||||
|
data: toXY('txFeeUsd'),
|
||||||
|
borderColor: 'rgba(96,165,250,0.95)',
|
||||||
|
backgroundColor: 'rgba(96,165,250,0.10)',
|
||||||
|
pointRadius: 0,
|
||||||
|
borderDash: [6, 4],
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.15,
|
||||||
|
yAxisID: 'usd',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [kind, windowed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
if (chartRef.current) return;
|
||||||
|
|
||||||
|
chartRef.current = new Chart(canvasRef.current, {
|
||||||
|
type: 'line',
|
||||||
|
data: { datasets: datasets as any },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
parsing: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: '#e6e9ef',
|
||||||
|
font: { size: 13, weight: '600' },
|
||||||
|
boxWidth: 10,
|
||||||
|
boxHeight: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
titleFont: { size: 13, weight: '700' },
|
||||||
|
bodyFont: { size: 13, weight: '600' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'time',
|
||||||
|
time: { unit: 'second' },
|
||||||
|
// Time labels are already readable; keep them slightly smaller than the rest.
|
||||||
|
ticks: { color: '#c7cbd4', font: { size: 11, weight: '600' } },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
display: kind === 'price',
|
||||||
|
},
|
||||||
|
usd: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
display: kind === 'costUsd',
|
||||||
|
},
|
||||||
|
bps: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
display: kind === 'costBps',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.destroy();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [kind]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
chart.data.datasets = datasets as any;
|
||||||
|
chart.update('none');
|
||||||
|
}, [datasets]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={['costChart', 'costChart--big', `costChart--${kind}`].join(' ')}>
|
||||||
|
<div className="costChart__head">
|
||||||
|
<span className="costChart__title">{title}</span>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
{windowSec}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="costChart__canvas">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
const text = await res.text();
|
||||||
|
let json: any = null;
|
||||||
|
try {
|
||||||
|
json = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = json?.error || text || `HTTP ${res.status}`;
|
||||||
|
throw new Error(String(msg));
|
||||||
|
}
|
||||||
|
return (json ?? {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContractCostsPanel({
|
||||||
|
market,
|
||||||
|
view,
|
||||||
|
}: {
|
||||||
|
market: string;
|
||||||
|
view?: 'both' | 'active' | 'new';
|
||||||
|
}) {
|
||||||
|
const [contractId, setContractId] = useLocalStorageState<string>('trade.contractId', '');
|
||||||
|
const [autoPoll, setAutoPoll] = useLocalStorageState<boolean>('trade.contractMonitor.autoPoll', true);
|
||||||
|
const [pollMs, setPollMs] = useLocalStorageState<number>('trade.contractMonitor.pollMs', 1500);
|
||||||
|
|
||||||
|
const [monitor, setMonitor] = useState<ContractMonitorResponse | null>(null);
|
||||||
|
const [monitorLoading, setMonitorLoading] = useState(false);
|
||||||
|
const [monitorError, setMonitorError] = useState<string | null>(null);
|
||||||
|
const lastMonitorAtRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const normalizedContractId = useMemo(() => contractId.trim(), [contractId]);
|
||||||
|
|
||||||
|
const loadMonitor = async () => {
|
||||||
|
const id = normalizedContractId;
|
||||||
|
if (!id) return;
|
||||||
|
setMonitorLoading(true);
|
||||||
|
setMonitorError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchJson<ContractMonitorResponse>(
|
||||||
|
`/api/v1/contracts/${encodeURIComponent(id)}/monitor?eventsLimit=2000&series=1&seriesMax=600`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'cache-control': 'no-store' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setMonitor(data);
|
||||||
|
lastMonitorAtRef.current = Date.now();
|
||||||
|
} catch (e: any) {
|
||||||
|
setMonitor(null);
|
||||||
|
setMonitorError(String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setMonitorLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoPoll) return;
|
||||||
|
if (!normalizedContractId) return;
|
||||||
|
const ms = clampNumber(pollMs, 250, 30_000);
|
||||||
|
void loadMonitor();
|
||||||
|
const t = window.setInterval(() => void loadMonitor(), ms);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [autoPoll, normalizedContractId, pollMs]);
|
||||||
|
|
||||||
|
const [notionalUsd, setNotionalUsd] = useLocalStorageState<number>('trade.newContract.notionalUsd', 10);
|
||||||
|
const [side, setSide] = useLocalStorageState<'long' | 'short'>('trade.newContract.side', 'long');
|
||||||
|
const [orderType, setOrderType] = useLocalStorageState<'market' | 'limit'>('trade.newContract.orderType', 'market');
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useLocalStorageState<boolean>('trade.newContract.advancedOpen', false);
|
||||||
|
const [feeTakerBps, setFeeTakerBps] = useLocalStorageState<number>('trade.newContract.feeTakerBps', 5);
|
||||||
|
const [feeMakerBps, setFeeMakerBps] = useLocalStorageState<number>('trade.newContract.feeMakerBps', 0);
|
||||||
|
const [txFeeUsdEst, setTxFeeUsdEst] = useLocalStorageState<number>('trade.newContract.txFeeUsdEst', 0);
|
||||||
|
const [expectedReprices, setExpectedReprices] = useLocalStorageState<number>('trade.newContract.expectedReprices', 0);
|
||||||
|
|
||||||
|
const [estimate, setEstimate] = useState<CostEstimateResponse | null>(null);
|
||||||
|
const [estimateLoading, setEstimateLoading] = useState(false);
|
||||||
|
const [estimateError, setEstimateError] = useState<string | null>(null);
|
||||||
|
const [autoEstimate, setAutoEstimate] = useLocalStorageState<boolean>('trade.newContract.autoEstimate', true);
|
||||||
|
const [estimateWindowSec, setEstimateWindowSec] = useLocalStorageState<number>('trade.newContract.estimateWindowSec', 300);
|
||||||
|
const estimateInFlightRef = useRef(false);
|
||||||
|
const [estimateSeries, setEstimateSeries] = useState<EstimatePoint[]>([]);
|
||||||
|
|
||||||
|
const runEstimate = async () => {
|
||||||
|
setEstimateLoading(true);
|
||||||
|
setEstimateError(null);
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
market_name: market,
|
||||||
|
notional_usd: notionalUsd,
|
||||||
|
side,
|
||||||
|
order_type: orderType,
|
||||||
|
};
|
||||||
|
if (advancedOpen) {
|
||||||
|
body.fee_taker_bps = feeTakerBps;
|
||||||
|
body.fee_maker_bps = feeMakerBps;
|
||||||
|
body.tx_fee_usd_est = txFeeUsdEst;
|
||||||
|
body.expected_reprices_per_entry = expectedReprices;
|
||||||
|
}
|
||||||
|
const data = await fetchJson<CostEstimateResponse>('/api/v1/contracts/costs/estimate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setEstimate(data);
|
||||||
|
if (data?.breakdown) {
|
||||||
|
const b = data.breakdown;
|
||||||
|
const midPriceRaw = data?.dlob?.mid_price;
|
||||||
|
const vwapPriceRaw = data?.dlob?.vwap_price;
|
||||||
|
const impactBpsRaw = data?.dlob?.impact_bps;
|
||||||
|
const midPrice = midPriceRaw == null ? null : Number(midPriceRaw);
|
||||||
|
const vwapPrice = vwapPriceRaw == null ? null : Number(vwapPriceRaw);
|
||||||
|
const impactBps = impactBpsRaw == null ? null : Number(impactBpsRaw);
|
||||||
|
setEstimateSeries((prev) => {
|
||||||
|
const next = [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
ts: Date.now(),
|
||||||
|
tradeFeeUsd: Number(b.trade_fee_usd ?? 0) || 0,
|
||||||
|
slippageUsd: Number(b.slippage_usd ?? 0) || 0,
|
||||||
|
txFeeUsd: Number(b.tx_fee_usd ?? 0) || 0,
|
||||||
|
modifyUsd: Number(b.expected_modify_usd ?? 0) || 0,
|
||||||
|
totalUsd: Number(b.total_usd ?? 0) || 0,
|
||||||
|
breakevenBps: Number(b.breakeven_bps ?? 0) || 0,
|
||||||
|
totalBps: Number(b.total_bps ?? b.breakeven_bps ?? 0) || 0,
|
||||||
|
midPrice: Number.isFinite(midPrice) ? midPrice : null,
|
||||||
|
vwapPrice: Number.isFinite(vwapPrice) ? vwapPrice : null,
|
||||||
|
impactBps: Number.isFinite(impactBps) ? impactBps : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return next.slice(-4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setEstimate(null);
|
||||||
|
setEstimateError(String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setEstimateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tickEstimate = async () => {
|
||||||
|
if (estimateInFlightRef.current) return;
|
||||||
|
estimateInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
await runEstimate();
|
||||||
|
} finally {
|
||||||
|
estimateInFlightRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoEstimate) return;
|
||||||
|
void tickEstimate();
|
||||||
|
const t = window.setInterval(() => void tickEstimate(), 1000);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
autoEstimate,
|
||||||
|
market,
|
||||||
|
notionalUsd,
|
||||||
|
side,
|
||||||
|
orderType,
|
||||||
|
advancedOpen,
|
||||||
|
feeTakerBps,
|
||||||
|
feeMakerBps,
|
||||||
|
txFeeUsdEst,
|
||||||
|
expectedReprices,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [mode, setMode] = useLocalStorageState<'both' | 'active' | 'new'>('trade.costsPanel.mode', 'both');
|
||||||
|
const effectiveMode = view || mode;
|
||||||
|
|
||||||
|
const showActive = effectiveMode === 'both' || effectiveMode === 'active';
|
||||||
|
const showNew = effectiveMode === 'both' || effectiveMode === 'new';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={['costsPanel', view ? 'costsPanel--stack' : null].filter(Boolean).join(' ')}>
|
||||||
|
{!view ? (
|
||||||
|
<div className="costsPanel__toolbar">
|
||||||
|
<span className="muted">View</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={mode === 'both' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => setMode('both')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Both
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={mode === 'active' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => setMode('active')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={mode === 'new' ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => setMode('new')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={['costsPanel__grid', !showActive || !showNew ? 'costsPanel__grid--single' : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
{showActive ? (
|
||||||
|
<section className="costsCard">
|
||||||
|
<div className="costsCard__head">
|
||||||
|
<div className="costsCard__title">Active contract</div>
|
||||||
|
<div className="costsCard__actions">
|
||||||
|
<Button size="sm" variant={autoPoll ? 'primary' : 'ghost'} onClick={() => setAutoPoll((v) => !v)} type="button">
|
||||||
|
Auto
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => void loadMonitor()} type="button" disabled={!normalizedContractId || monitorLoading}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="costsForm">
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">contract_id</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input"
|
||||||
|
value={contractId}
|
||||||
|
onChange={(e) => setContractId(e.target.value)}
|
||||||
|
placeholder="uuid…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">poll ms</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input"
|
||||||
|
type="number"
|
||||||
|
min={250}
|
||||||
|
step={250}
|
||||||
|
value={pollMs}
|
||||||
|
onChange={(e) => setPollMs(Number(e.target.value))}
|
||||||
|
disabled={!autoPoll}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{monitorError ? <div className="uiError">{monitorError}</div> : null}
|
||||||
|
|
||||||
|
<div className="costsKpis">
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Fees</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(monitor?.costs?.tradeFeeUsd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Tx</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(monitor?.costs?.txFeeUsd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Slippage</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(monitor?.costs?.slippageUsd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Funding</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(monitor?.costs?.fundingUsd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Total costs</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(monitor?.costs?.totalCostsUsd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Net PnL</div>
|
||||||
|
<div className={['costKpi__value', (monitor?.costs?.netPnlUsd ?? 0) >= 0 ? 'pos' : 'neg'].join(' ')}>
|
||||||
|
{formatUsd(monitor?.costs?.netPnlUsd ?? null)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="costsMeta muted">
|
||||||
|
<div>events: {monitor?.eventsCount ?? '—'}</div>
|
||||||
|
<div>tx: {monitor?.costs?.txCount ?? '—'}</div>
|
||||||
|
<div>fills: {monitor?.costs?.fillCount ?? '—'}</div>
|
||||||
|
<div>cancel: {monitor?.costs?.cancelCount ?? '—'}</div>
|
||||||
|
<div>modify: {monitor?.costs?.modifyCount ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="costsCard__subhead">PnL / costs over time</div>
|
||||||
|
<div className="costCharts">
|
||||||
|
<MiniLineChart title="Net PnL" points={monitor?.series || []} valueKey="netPnlUsd" />
|
||||||
|
<MiniLineChart title="Total costs" points={monitor?.series || []} valueKey="totalCostsUsd" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="costsCard__subhead">Close now (DLOB quote)</div>
|
||||||
|
<div className="costsClose">
|
||||||
|
<div className="costsClose__row">
|
||||||
|
<span className="muted">buy impact</span>
|
||||||
|
<span>{formatBps(monitor?.closeEstimate?.buy?.impact_bps ?? null)}</span>
|
||||||
|
<span className="muted">vwap</span>
|
||||||
|
<span>{monitor?.closeEstimate?.buy?.vwap_price ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="costsClose__row">
|
||||||
|
<span className="muted">sell impact</span>
|
||||||
|
<span>{formatBps(monitor?.closeEstimate?.sell?.impact_bps ?? null)}</span>
|
||||||
|
<span className="muted">vwap</span>
|
||||||
|
<span>{monitor?.closeEstimate?.sell?.vwap_price ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showNew ? (
|
||||||
|
<section className="costsCard costsCard--new">
|
||||||
|
<div className="costsCard__head">
|
||||||
|
<div className="costsCard__title">New contract estimate</div>
|
||||||
|
<div className="costsCard__actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={autoEstimate ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => setAutoEstimate((v) => !v)}
|
||||||
|
type="button"
|
||||||
|
title="Auto refresh (1s)"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={() => void runEstimate()} type="button" disabled={estimateLoading}>
|
||||||
|
Estimate
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={advancedOpen ? 'primary' : 'ghost'} onClick={() => setAdvancedOpen((v) => !v)} type="button">
|
||||||
|
Advanced
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="costsNewLayout">
|
||||||
|
<div className="costsNewCharts">
|
||||||
|
<div className="costsMeta costsMeta--new">
|
||||||
|
<span className="muted">Live window</span>
|
||||||
|
<select
|
||||||
|
className="inlineField__input"
|
||||||
|
value={estimateWindowSec}
|
||||||
|
onChange={(e) => setEstimateWindowSec(Number(e.target.value))}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
title="Chart window"
|
||||||
|
>
|
||||||
|
<option value={60}>60s</option>
|
||||||
|
<option value={300}>5m</option>
|
||||||
|
<option value={900}>15m</option>
|
||||||
|
<option value={3600}>1h</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="costCharts costCharts--new">
|
||||||
|
<EstimateChart
|
||||||
|
title="Price (mid vs vwap)"
|
||||||
|
points={estimateSeries}
|
||||||
|
windowSec={estimateWindowSec}
|
||||||
|
kind="price"
|
||||||
|
/>
|
||||||
|
<EstimateChart title="Costs (bps)" points={estimateSeries} windowSec={estimateWindowSec} kind="costBps" />
|
||||||
|
<EstimateChart title="Costs (USD)" points={estimateSeries} windowSec={estimateWindowSec} kind="costUsd" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="costsNewSide">
|
||||||
|
<div className="costsForm costsForm--newSide">
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">market</span>
|
||||||
|
<input className="inlineField__input" value={market} disabled />
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">notional</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input"
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step={0.01}
|
||||||
|
value={notionalUsd}
|
||||||
|
onChange={(e) => setNotionalUsd(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">side</span>
|
||||||
|
<select className="inlineField__input" value={side} onChange={(e) => setSide(e.target.value as any)}>
|
||||||
|
<option value="long">long</option>
|
||||||
|
<option value="short">short</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">order</span>
|
||||||
|
<select
|
||||||
|
className="inlineField__input"
|
||||||
|
value={orderType}
|
||||||
|
onChange={(e) => setOrderType(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="market">market (taker)</option>
|
||||||
|
<option value="limit">limit/post-only (maker)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{advancedOpen ? (
|
||||||
|
<div className="costsForm costsForm--newSide">
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">taker bps</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input"
|
||||||
|
type="number"
|
||||||
|
step={0.1}
|
||||||
|
value={feeTakerBps}
|
||||||
|
onChange={(e) => setFeeTakerBps(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">maker bps</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input"
|
||||||
|
type="number"
|
||||||
|
step={0.1}
|
||||||
|
value={feeMakerBps}
|
||||||
|
onChange={(e) => setFeeMakerBps(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">tx usd est</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input"
|
||||||
|
type="number"
|
||||||
|
step={0.001}
|
||||||
|
value={txFeeUsdEst}
|
||||||
|
onChange={(e) => setTxFeeUsdEst(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inlineField">
|
||||||
|
<span className="inlineField__label">reprices</span>
|
||||||
|
<input
|
||||||
|
className="inlineField__input"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={expectedReprices}
|
||||||
|
onChange={(e) => setExpectedReprices(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
Defaults: backend computes fee/tx/modify estimates (use “Advanced” to override).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{estimateError ? <div className="uiError">{estimateError}</div> : null}
|
||||||
|
|
||||||
|
<div className="costsKpis costsKpis--newSide">
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Total</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.total_usd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Breakeven</div>
|
||||||
|
<div className="costKpi__value">{formatBps(estimate?.breakdown?.breakeven_bps ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Fee</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.trade_fee_usd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Slippage</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.slippage_usd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Tx</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.tx_fee_usd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="costKpi">
|
||||||
|
<div className="costKpi__label">Modify</div>
|
||||||
|
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.expected_modify_usd ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="costsCard__subhead">DLOB quote used</div>
|
||||||
|
<div className="costsClose">
|
||||||
|
<div className="costsClose__row">
|
||||||
|
<span className="muted">size</span>
|
||||||
|
<span>{estimate?.dlob?.size_usd ?? '—'}</span>
|
||||||
|
<span className="muted">impact</span>
|
||||||
|
<span>{formatBps(estimate?.dlob?.impact_bps ?? null)}</span>
|
||||||
|
<span className="muted">fill</span>
|
||||||
|
<span>{estimate?.dlob?.fill_pct == null ? '—' : `${Number(estimate.dlob.fill_pct).toFixed(0)}%`}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
151
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { DlobStats } from './useDlobStats';
|
||||||
|
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||||
|
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||||
|
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
||||||
|
import DlobSlippageChart from './DlobSlippageChart';
|
||||||
|
import Button from '../../ui/Button';
|
||||||
|
|
||||||
|
function formatUsd(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||||
|
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||||
|
return `$${v.toPrecision(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBps(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return `${v.toFixed(1)} bps`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return `${(v * 100).toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(connected: boolean, error: string | null): ReactNode {
|
||||||
|
if (error) return <span className="neg">{error}</span>;
|
||||||
|
return connected ? <span className="pos">live</span> : <span className="muted">offline</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DlobDashboard({
|
||||||
|
market,
|
||||||
|
stats,
|
||||||
|
statsConnected,
|
||||||
|
statsError,
|
||||||
|
depthBands,
|
||||||
|
depthBandsConnected,
|
||||||
|
depthBandsError,
|
||||||
|
slippageRows,
|
||||||
|
slippageConnected,
|
||||||
|
slippageError,
|
||||||
|
isFullscreen,
|
||||||
|
onToggleFullscreen,
|
||||||
|
}: {
|
||||||
|
market: string;
|
||||||
|
stats: DlobStats | null;
|
||||||
|
statsConnected: boolean;
|
||||||
|
statsError: string | null;
|
||||||
|
depthBands: DlobDepthBandRow[];
|
||||||
|
depthBandsConnected: boolean;
|
||||||
|
depthBandsError: string | null;
|
||||||
|
slippageRows: DlobSlippageRow[];
|
||||||
|
slippageConnected: boolean;
|
||||||
|
slippageError: string | null;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
onToggleFullscreen?: () => void;
|
||||||
|
}) {
|
||||||
|
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dlobDash">
|
||||||
|
<div className="dlobDash__head">
|
||||||
|
<div className="dlobDash__title">DLOB</div>
|
||||||
|
<div className="dlobDash__meta">
|
||||||
|
<span className="dlobDash__market">{market}</span>
|
||||||
|
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
|
||||||
|
{onToggleFullscreen ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isFullscreen ? 'primary' : 'ghost'}
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__statuses">
|
||||||
|
<div className="dlobStatus">
|
||||||
|
<span className="dlobStatus__label">stats</span>
|
||||||
|
<span className="dlobStatus__value">{statusLabel(statsConnected, statsError)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dlobStatus">
|
||||||
|
<span className="dlobStatus__label">depth bands</span>
|
||||||
|
<span className="dlobStatus__value">{statusLabel(depthBandsConnected, depthBandsError)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dlobStatus">
|
||||||
|
<span className="dlobStatus__label">slippage</span>
|
||||||
|
<span className="dlobStatus__value">{statusLabel(slippageConnected, slippageError)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__grid">
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Bid</div>
|
||||||
|
<div className="dlobKpi__value pos">{formatUsd(stats?.bestBid ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Ask</div>
|
||||||
|
<div className="dlobKpi__value neg">{formatUsd(stats?.bestAsk ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Mid</div>
|
||||||
|
<div className="dlobKpi__value">{formatUsd(stats?.mid ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Spread</div>
|
||||||
|
<div className="dlobKpi__value">{formatBps(stats?.spreadBps ?? null)}</div>
|
||||||
|
<div className="dlobKpi__sub muted">{formatUsd(stats?.spreadAbs ?? null)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Depth (bid/ask)</div>
|
||||||
|
<div className="dlobKpi__value">
|
||||||
|
<span className="pos">{formatUsd(stats?.depthBidUsd ?? null)}</span>{' '}
|
||||||
|
<span className="muted">/</span> <span className="neg">{formatUsd(stats?.depthAskUsd ?? null)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlobKpi">
|
||||||
|
<div className="dlobKpi__label">Imbalance</div>
|
||||||
|
<div className="dlobKpi__value">{formatPct(stats?.imbalance ?? null)}</div>
|
||||||
|
<div className="dlobKpi__sub muted">[-1..1]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__panes">
|
||||||
|
<div className="dlobDash__pane">
|
||||||
|
<DlobDepthBandsPanel rows={depthBands} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDash__pane">
|
||||||
|
<div className="dlobSlippage">
|
||||||
|
<div className="dlobSlippage__head">
|
||||||
|
<div className="dlobSlippage__title">Slippage (impact bps)</div>
|
||||||
|
<div className="dlobSlippage__meta muted">by size (USD)</div>
|
||||||
|
</div>
|
||||||
|
{slippageRows.length ? (
|
||||||
|
<div className="dlobSlippage__chartWrap">
|
||||||
|
<DlobSlippageChart rows={slippageRows} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dlobSlippage__empty muted">No slippage rows yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||||
|
|
||||||
|
function formatUsd(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||||
|
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||||
|
return `$${v.toPrecision(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPct(v: number | null | undefined): string {
|
||||||
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
|
return `${v.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandRowStyle(askScale: number, bidScale: number): CSSProperties {
|
||||||
|
const a = Number.isFinite(askScale) && askScale > 0 ? Math.min(1, askScale) : 0;
|
||||||
|
const b = Number.isFinite(bidScale) && bidScale > 0 ? Math.min(1, bidScale) : 0;
|
||||||
|
return { ['--ask-scale' as any]: a, ['--bid-scale' as any]: b } as CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DlobDepthBandsPanel({ rows }: { rows: DlobDepthBandRow[] }) {
|
||||||
|
const sorted = useMemo(() => rows.slice().sort((a, b) => a.bandBps - b.bandBps), [rows]);
|
||||||
|
|
||||||
|
const maxUsd = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
for (const r of sorted) {
|
||||||
|
if (r.askUsd != null && Number.isFinite(r.askUsd)) max = Math.max(max, r.askUsd);
|
||||||
|
if (r.bidUsd != null && Number.isFinite(r.bidUsd)) max = Math.max(max, r.bidUsd);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, [sorted]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dlobDepth">
|
||||||
|
<div className="dlobDepth__head">
|
||||||
|
<div className="dlobDepth__title">Depth (bands)</div>
|
||||||
|
<div className="dlobDepth__meta">±bps around mid</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dlobDepth__table">
|
||||||
|
<div className="dlobDepthRow dlobDepthRow--head">
|
||||||
|
<span>Band</span>
|
||||||
|
<span className="dlobDepthRow__num">Ask USD</span>
|
||||||
|
<span className="dlobDepthRow__num">Bid USD</span>
|
||||||
|
<span className="dlobDepthRow__num">Bid %</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sorted.length ? (
|
||||||
|
sorted.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.bandBps}
|
||||||
|
className="dlobDepthRow"
|
||||||
|
style={bandRowStyle(maxUsd > 0 ? (r.askUsd || 0) / maxUsd : 0, maxUsd > 0 ? (r.bidUsd || 0) / maxUsd : 0)}
|
||||||
|
title={`band=${r.bandBps}bps bid=${r.bidUsd ?? '—'} ask=${r.askUsd ?? '—'} imbalance=${r.imbalance ?? '—'}`}
|
||||||
|
>
|
||||||
|
<span className="dlobDepthRow__band">{r.bandBps} bps</span>
|
||||||
|
<span className="dlobDepthRow__num neg">{formatUsd(r.askUsd)}</span>
|
||||||
|
<span className="dlobDepthRow__num pos">{formatUsd(r.bidUsd)}</span>
|
||||||
|
<span className="dlobDepthRow__num muted">
|
||||||
|
{r.imbalance == null || !Number.isFinite(r.imbalance)
|
||||||
|
? '—'
|
||||||
|
: formatPct(((r.imbalance + 1) / 2) * 100)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="dlobDepth__empty muted">No depth band rows yet.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
111
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||||
|
|
||||||
|
type Point = { x: number; y: number | null };
|
||||||
|
|
||||||
|
function clamp01(v: number): number {
|
||||||
|
if (!Number.isFinite(v)) return 0;
|
||||||
|
return Math.max(0, Math.min(1, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const chartRef = useRef<Chart | null>(null);
|
||||||
|
|
||||||
|
const { buy, sell, maxImpact } = useMemo(() => {
|
||||||
|
const buy: Point[] = [];
|
||||||
|
const sell: Point[] = [];
|
||||||
|
let maxImpact = 0;
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!Number.isFinite(r.sizeUsd) || r.sizeUsd <= 0) continue;
|
||||||
|
const y = r.impactBps == null || !Number.isFinite(r.impactBps) ? null : r.impactBps;
|
||||||
|
if (y != null) maxImpact = Math.max(maxImpact, y);
|
||||||
|
if (r.side === 'buy') buy.push({ x: r.sizeUsd, y });
|
||||||
|
if (r.side === 'sell') sell.push({ x: r.sizeUsd, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
buy.sort((a, b) => a.x - b.x);
|
||||||
|
sell.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
return { buy, sell, maxImpact };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
if (chartRef.current) return;
|
||||||
|
|
||||||
|
chartRef.current = new Chart(canvasRef.current, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Buy',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgba(34,197,94,0.9)',
|
||||||
|
backgroundColor: 'rgba(34,197,94,0.15)',
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.2,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sell',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgba(239,68,68,0.9)',
|
||||||
|
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.2,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
parsing: false,
|
||||||
|
interaction: { mode: 'nearest', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#e6e9ef' } },
|
||||||
|
tooltip: { enabled: true },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
title: { display: true, text: 'Size (USD)', color: '#c7cbd4' },
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
beginAtZero: true,
|
||||||
|
suggestedMax: Math.max(10, maxImpact * (1 + clamp01(0.15))),
|
||||||
|
title: { display: true, text: 'Impact (bps)', color: '#c7cbd4' },
|
||||||
|
ticks: { color: '#c7cbd4' },
|
||||||
|
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.destroy();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
}, [maxImpact]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!chart) return;
|
||||||
|
chart.data.datasets[0]!.data = buy as any;
|
||||||
|
chart.data.datasets[1]!.data = sell as any;
|
||||||
|
chart.update('none');
|
||||||
|
}, [buy, sell]);
|
||||||
|
|
||||||
|
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
|
||||||
|
}
|
||||||
133
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
133
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type DlobDepthBandRow = {
|
||||||
|
marketName: string;
|
||||||
|
bandBps: number;
|
||||||
|
midPrice: number | null;
|
||||||
|
bestBid: number | null;
|
||||||
|
bestAsk: number | null;
|
||||||
|
bidUsd: number | null;
|
||||||
|
askUsd: number | null;
|
||||||
|
bidBase: number | null;
|
||||||
|
askBase: number | null;
|
||||||
|
imbalance: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number.parseInt(s, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraRow = {
|
||||||
|
market_name: string;
|
||||||
|
band_bps: unknown;
|
||||||
|
mid_price?: unknown;
|
||||||
|
best_bid_price?: unknown;
|
||||||
|
best_ask_price?: unknown;
|
||||||
|
bid_usd?: unknown;
|
||||||
|
ask_usd?: unknown;
|
||||||
|
bid_base?: unknown;
|
||||||
|
ask_base?: unknown;
|
||||||
|
imbalance?: unknown;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_depth_bps_latest: HasuraRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobDepthBands(
|
||||||
|
marketName: string
|
||||||
|
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
|
||||||
|
const [rows, setRows] = useState<DlobDepthBandRow[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setRows([]);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobDepthBands($market: String!) {
|
||||||
|
dlob_depth_bps_latest(
|
||||||
|
where: { market_name: { _eq: $market } }
|
||||||
|
order_by: [{ band_bps: asc }]
|
||||||
|
) {
|
||||||
|
market_name
|
||||||
|
band_bps
|
||||||
|
mid_price
|
||||||
|
best_bid_price
|
||||||
|
best_ask_price
|
||||||
|
bid_usd
|
||||||
|
ask_usd
|
||||||
|
bid_base
|
||||||
|
ask_base
|
||||||
|
imbalance
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const out: DlobDepthBandRow[] = [];
|
||||||
|
for (const r of data?.dlob_depth_bps_latest || []) {
|
||||||
|
if (!r?.market_name) continue;
|
||||||
|
const bandBps = toInt(r.band_bps);
|
||||||
|
if (bandBps == null || bandBps <= 0) continue;
|
||||||
|
out.push({
|
||||||
|
marketName: r.market_name,
|
||||||
|
bandBps,
|
||||||
|
midPrice: toNum(r.mid_price),
|
||||||
|
bestBid: toNum(r.best_bid_price),
|
||||||
|
bestAsk: toNum(r.best_ask_price),
|
||||||
|
bidUsd: toNum(r.bid_usd),
|
||||||
|
askUsd: toNum(r.ask_usd),
|
||||||
|
bidBase: toNum(r.bid_base),
|
||||||
|
askBase: toNum(r.ask_base),
|
||||||
|
imbalance: toNum(r.imbalance),
|
||||||
|
updatedAt: r.updated_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRows(out);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket]);
|
||||||
|
|
||||||
|
return { rows, connected, error };
|
||||||
|
}
|
||||||
182
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
182
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type OrderbookRow = {
|
||||||
|
price: number;
|
||||||
|
sizeBase: number;
|
||||||
|
sizeUsd: number;
|
||||||
|
totalBase: number;
|
||||||
|
totalUsd: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DlobL2 = {
|
||||||
|
marketName: string;
|
||||||
|
bids: OrderbookRow[];
|
||||||
|
asks: OrderbookRow[];
|
||||||
|
bestBid: number | null;
|
||||||
|
bestAsk: number | null;
|
||||||
|
mid: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function envNumber(name: string): number | undefined {
|
||||||
|
const raw = (import.meta as any).env?.[name];
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeJson(v: unknown): unknown {
|
||||||
|
if (typeof v !== 'string') return v;
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(s);
|
||||||
|
} catch {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number): Array<{ price: number; size: number }> {
|
||||||
|
const v = normalizeJson(raw);
|
||||||
|
if (!Array.isArray(v)) return [];
|
||||||
|
|
||||||
|
const out: Array<{ price: number; size: number }> = [];
|
||||||
|
for (const item of v) {
|
||||||
|
const priceInt = toNum((item as any)?.price);
|
||||||
|
const sizeInt = toNum((item as any)?.size);
|
||||||
|
if (priceInt == null || sizeInt == null) continue;
|
||||||
|
|
||||||
|
const price = priceInt / pricePrecision;
|
||||||
|
const size = sizeInt / basePrecision;
|
||||||
|
if (!Number.isFinite(price) || !Number.isFinite(size)) continue;
|
||||||
|
out.push({ price, size });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTotals(levels: Array<{ price: number; sizeBase: number }>): OrderbookRow[] {
|
||||||
|
let totalBase = 0;
|
||||||
|
let totalUsd = 0;
|
||||||
|
|
||||||
|
return levels.map((l) => {
|
||||||
|
const sizeUsd = l.sizeBase * l.price;
|
||||||
|
totalBase += l.sizeBase;
|
||||||
|
totalUsd += sizeUsd;
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: l.price,
|
||||||
|
sizeBase: l.sizeBase,
|
||||||
|
sizeUsd,
|
||||||
|
totalBase,
|
||||||
|
totalUsd,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraDlobL2Row = {
|
||||||
|
market_name: string;
|
||||||
|
bids?: unknown;
|
||||||
|
asks?: unknown;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_l2_latest: HasuraDlobL2Row[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobL2(
|
||||||
|
marketName: string,
|
||||||
|
opts?: { levels?: number }
|
||||||
|
): { l2: DlobL2 | null; connected: boolean; error: string | null } {
|
||||||
|
const [l2, setL2] = useState<DlobL2 | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]);
|
||||||
|
|
||||||
|
const pricePrecision = useMemo(() => envNumber('VITE_DLOB_PRICE_PRECISION') ?? 1_000_000, []);
|
||||||
|
const basePrecision = useMemo(() => envNumber('VITE_DLOB_BASE_PRECISION') ?? 1_000_000_000, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setL2(null);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobL2($market: String!) {
|
||||||
|
dlob_l2_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||||
|
market_name
|
||||||
|
bids
|
||||||
|
asks
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const row = data?.dlob_l2_latest?.[0];
|
||||||
|
if (!row?.market_name) return;
|
||||||
|
|
||||||
|
const bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.price - a.price)
|
||||||
|
.slice(0, levels)
|
||||||
|
.map((l) => ({ price: l.price, sizeBase: l.size }));
|
||||||
|
|
||||||
|
const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.price - b.price)
|
||||||
|
.slice(0, levels)
|
||||||
|
.map((l) => ({ price: l.price, sizeBase: l.size }));
|
||||||
|
|
||||||
|
// We compute totals from best -> worse.
|
||||||
|
// For UI we display asks with best ask closest to mid (at the bottom), so we reverse.
|
||||||
|
const bids = withTotals(bidsSorted);
|
||||||
|
const asks = withTotals(asksSorted).slice().reverse();
|
||||||
|
|
||||||
|
const bestBid = bidsSorted.length ? bidsSorted[0].price : null;
|
||||||
|
const bestAsk = asksSorted.length ? asksSorted[0].price : null;
|
||||||
|
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
|
||||||
|
|
||||||
|
setL2({
|
||||||
|
marketName: row.market_name,
|
||||||
|
bids,
|
||||||
|
asks,
|
||||||
|
bestBid,
|
||||||
|
bestAsk,
|
||||||
|
mid,
|
||||||
|
updatedAt: row.updated_at ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket, levels, pricePrecision, basePrecision]);
|
||||||
|
|
||||||
|
return { l2, connected, error };
|
||||||
|
}
|
||||||
148
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
148
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type DlobSlippageRow = {
|
||||||
|
marketName: string;
|
||||||
|
side: 'buy' | 'sell';
|
||||||
|
sizeUsd: number;
|
||||||
|
midPrice: number | null;
|
||||||
|
vwapPrice: number | null;
|
||||||
|
worstPrice: number | null;
|
||||||
|
filledUsd: number | null;
|
||||||
|
filledBase: number | null;
|
||||||
|
impactBps: number | null;
|
||||||
|
levelsConsumed: number | null;
|
||||||
|
fillPct: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraRow = {
|
||||||
|
market_name: string;
|
||||||
|
side: string;
|
||||||
|
size_usd: unknown;
|
||||||
|
mid_price?: unknown;
|
||||||
|
vwap_price?: unknown;
|
||||||
|
worst_price?: unknown;
|
||||||
|
filled_usd?: unknown;
|
||||||
|
filled_base?: unknown;
|
||||||
|
impact_bps?: unknown;
|
||||||
|
levels_consumed?: unknown;
|
||||||
|
fill_pct?: unknown;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_slippage_latest: HasuraRow[];
|
||||||
|
dlob_slippage_latest_v2: HasuraRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
||||||
|
const [rows, setRows] = useState<DlobSlippageRow[]>([]);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setRows([]);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobSlippage($market: String!) {
|
||||||
|
dlob_slippage_latest_v2(
|
||||||
|
where: { market_name: { _eq: $market } }
|
||||||
|
order_by: [{ side: asc }, { size_usd: asc }]
|
||||||
|
) {
|
||||||
|
market_name
|
||||||
|
side
|
||||||
|
size_usd
|
||||||
|
mid_price
|
||||||
|
vwap_price
|
||||||
|
worst_price
|
||||||
|
filled_usd
|
||||||
|
filled_base
|
||||||
|
impact_bps
|
||||||
|
levels_consumed
|
||||||
|
fill_pct
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
dlob_slippage_latest(
|
||||||
|
where: { market_name: { _eq: $market } }
|
||||||
|
order_by: [{ side: asc }, { size_usd: asc }]
|
||||||
|
) {
|
||||||
|
market_name
|
||||||
|
side
|
||||||
|
size_usd
|
||||||
|
mid_price
|
||||||
|
vwap_price
|
||||||
|
worst_price
|
||||||
|
filled_usd
|
||||||
|
filled_base
|
||||||
|
impact_bps
|
||||||
|
levels_consumed
|
||||||
|
fill_pct
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const out: DlobSlippageRow[] = [];
|
||||||
|
const v2 = data?.dlob_slippage_latest_v2 || [];
|
||||||
|
const src = v2.length ? v2 : data?.dlob_slippage_latest || [];
|
||||||
|
for (const r of src) {
|
||||||
|
if (!r?.market_name) continue;
|
||||||
|
const side = String(r.side || '').trim();
|
||||||
|
if (side !== 'buy' && side !== 'sell') continue;
|
||||||
|
const sizeUsd = toNum(r.size_usd);
|
||||||
|
if (sizeUsd == null || sizeUsd <= 0) continue;
|
||||||
|
out.push({
|
||||||
|
marketName: r.market_name,
|
||||||
|
side,
|
||||||
|
sizeUsd,
|
||||||
|
midPrice: toNum(r.mid_price),
|
||||||
|
vwapPrice: toNum(r.vwap_price),
|
||||||
|
worstPrice: toNum(r.worst_price),
|
||||||
|
filledUsd: toNum(r.filled_usd),
|
||||||
|
filledBase: toNum(r.filled_base),
|
||||||
|
impactBps: toNum(r.impact_bps),
|
||||||
|
levelsConsumed: (() => {
|
||||||
|
const v = toNum(r.levels_consumed);
|
||||||
|
return v == null ? null : Math.trunc(v);
|
||||||
|
})(),
|
||||||
|
fillPct: toNum(r.fill_pct),
|
||||||
|
updatedAt: r.updated_at ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRows(out);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket]);
|
||||||
|
|
||||||
|
return { rows, connected, error };
|
||||||
|
}
|
||||||
123
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
123
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||||
|
|
||||||
|
export type DlobStats = {
|
||||||
|
marketName: string;
|
||||||
|
markPrice: number | null;
|
||||||
|
oraclePrice: number | null;
|
||||||
|
bestBid: number | null;
|
||||||
|
bestAsk: number | null;
|
||||||
|
mid: number | null;
|
||||||
|
spreadAbs: number | null;
|
||||||
|
spreadBps: number | null;
|
||||||
|
depthBidBase: number | null;
|
||||||
|
depthAskBase: number | null;
|
||||||
|
depthBidUsd: number | null;
|
||||||
|
depthAskUsd: number | null;
|
||||||
|
imbalance: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toNum(v: unknown): number | null {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const s = v.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HasuraDlobStatsRow = {
|
||||||
|
market_name: string;
|
||||||
|
mark_price?: string | null;
|
||||||
|
oracle_price?: string | null;
|
||||||
|
best_bid_price?: string | null;
|
||||||
|
best_ask_price?: string | null;
|
||||||
|
mid_price?: string | null;
|
||||||
|
spread_abs?: string | null;
|
||||||
|
spread_bps?: string | null;
|
||||||
|
depth_bid_base?: string | null;
|
||||||
|
depth_ask_base?: string | null;
|
||||||
|
depth_bid_usd?: string | null;
|
||||||
|
depth_ask_usd?: string | null;
|
||||||
|
imbalance?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscriptionData = {
|
||||||
|
dlob_stats_latest: HasuraDlobStatsRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } {
|
||||||
|
const [stats, setStats] = useState<DlobStats | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedMarket) {
|
||||||
|
setStats(null);
|
||||||
|
setError(null);
|
||||||
|
setConnected(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
subscription DlobStats($market: String!) {
|
||||||
|
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||||
|
market_name
|
||||||
|
mark_price
|
||||||
|
oracle_price
|
||||||
|
best_bid_price
|
||||||
|
best_ask_price
|
||||||
|
mid_price
|
||||||
|
spread_abs
|
||||||
|
spread_bps
|
||||||
|
depth_bid_base
|
||||||
|
depth_ask_base
|
||||||
|
depth_bid_usd
|
||||||
|
depth_ask_usd
|
||||||
|
imbalance
|
||||||
|
updated_at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||||
|
query,
|
||||||
|
variables: { market: normalizedMarket },
|
||||||
|
onStatus: ({ connected }) => setConnected(connected),
|
||||||
|
onError: (e) => setError(e),
|
||||||
|
onData: (data) => {
|
||||||
|
const row = data?.dlob_stats_latest?.[0];
|
||||||
|
if (!row?.market_name) return;
|
||||||
|
setStats({
|
||||||
|
marketName: row.market_name,
|
||||||
|
markPrice: toNum(row.mark_price),
|
||||||
|
oraclePrice: toNum(row.oracle_price),
|
||||||
|
bestBid: toNum(row.best_bid_price),
|
||||||
|
bestAsk: toNum(row.best_ask_price),
|
||||||
|
mid: toNum(row.mid_price),
|
||||||
|
spreadAbs: toNum(row.spread_abs),
|
||||||
|
spreadBps: toNum(row.spread_bps),
|
||||||
|
depthBidBase: toNum(row.depth_bid_base),
|
||||||
|
depthAskBase: toNum(row.depth_ask_base),
|
||||||
|
depthBidUsd: toNum(row.depth_bid_usd),
|
||||||
|
depthAskUsd: toNum(row.depth_ask_usd),
|
||||||
|
imbalance: toNum(row.imbalance),
|
||||||
|
updatedAt: row.updated_at ?? null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => sub.unsubscribe();
|
||||||
|
}, [normalizedMarket]);
|
||||||
|
|
||||||
|
return { stats, connected, error };
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ export type Candle = {
|
|||||||
close: number;
|
close: number;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
oracle?: number | null;
|
oracle?: number | null;
|
||||||
|
flow?: { up: number; down: number; flat: number };
|
||||||
|
flowRows?: number[];
|
||||||
|
flowMoves?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SeriesPoint = {
|
export type SeriesPoint = {
|
||||||
@@ -44,6 +47,7 @@ export async function fetchChart(params: {
|
|||||||
source?: string;
|
source?: string;
|
||||||
tf: string;
|
tf: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
const u = new URL(base, window.location.origin);
|
const u = new URL(base, window.location.origin);
|
||||||
@@ -53,7 +57,7 @@ export async function fetchChart(params: {
|
|||||||
u.searchParams.set('limit', String(params.limit));
|
u.searchParams.set('limit', String(params.limit));
|
||||||
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
||||||
|
|
||||||
const res = await fetch(u.toString());
|
const res = await fetch(u.toString(), { signal: params.signal });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||||
const json = JSON.parse(text) as ChartResponse;
|
const json = JSON.parse(text) as ChartResponse;
|
||||||
@@ -68,9 +72,26 @@ export async function fetchChart(params: {
|
|||||||
close: Number(c.close),
|
close: Number(c.close),
|
||||||
volume: c.volume == null ? undefined : Number(c.volume),
|
volume: c.volume == null ? undefined : Number(c.volume),
|
||||||
oracle: c.oracle == null ? null : Number(c.oracle),
|
oracle: c.oracle == null ? null : Number(c.oracle),
|
||||||
|
flow:
|
||||||
|
(c as any)?.flow && typeof (c as any).flow === 'object'
|
||||||
|
? {
|
||||||
|
up: Number((c as any).flow.up),
|
||||||
|
down: Number((c as any).flow.down),
|
||||||
|
flat: Number((c as any).flow.flat),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
flowRows: Array.isArray((c as any)?.flowRows)
|
||||||
|
? (c as any).flowRows.map((x: any) => Number(x))
|
||||||
|
: Array.isArray((c as any)?.flow_rows)
|
||||||
|
? (c as any).flow_rows.map((x: any) => Number(x))
|
||||||
|
: undefined,
|
||||||
|
flowMoves: Array.isArray((c as any)?.flowMoves)
|
||||||
|
? (c as any).flowMoves.map((x: any) => Number(x))
|
||||||
|
: Array.isArray((c as any)?.flow_moves)
|
||||||
|
? (c as any).flow_moves.map((x: any) => Number(x))
|
||||||
|
: undefined,
|
||||||
})),
|
})),
|
||||||
indicators: json.indicators || {},
|
indicators: json.indicators || {},
|
||||||
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
198
apps/visualizer/src/lib/graphqlWs.ts
Normal file
198
apps/visualizer/src/lib/graphqlWs.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
type HeadersMap = Record<string, string>;
|
||||||
|
|
||||||
|
type SubscribeParams<T> = {
|
||||||
|
query: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
onData: (data: T) => void;
|
||||||
|
onError?: (err: string) => void;
|
||||||
|
onStatus?: (s: { connected: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function envString(name: string): string | undefined {
|
||||||
|
const v = (import.meta as any).env?.[name];
|
||||||
|
const s = v == null ? '' : String(v).trim();
|
||||||
|
return s ? s : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlHttpUrl(): string {
|
||||||
|
return envString('VITE_HASURA_URL') || '/graphql';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGraphqlWsUrl(): string {
|
||||||
|
const explicit = envString('VITE_HASURA_WS_URL');
|
||||||
|
if (explicit) {
|
||||||
|
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
|
||||||
|
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
|
||||||
|
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
|
||||||
|
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
|
||||||
|
return `${proto}//${host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpUrl = resolveGraphqlHttpUrl();
|
||||||
|
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
|
||||||
|
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
|
||||||
|
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
|
||||||
|
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`;
|
||||||
|
return `${proto}//${host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthHeaders(): HeadersMap | undefined {
|
||||||
|
const rawToken = envString('VITE_HASURA_AUTH_TOKEN');
|
||||||
|
if (rawToken) {
|
||||||
|
const bearer = normalizeBearerToken(rawToken);
|
||||||
|
if (bearer) return { authorization: `Bearer ${bearer}` };
|
||||||
|
}
|
||||||
|
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
||||||
|
if (secret) return { 'x-hasura-admin-secret': secret };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBearerToken(raw: string): string | undefined {
|
||||||
|
const trimmed = String(raw || '').trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const m = trimmed.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const token = (m ? m[1] : trimmed).trim();
|
||||||
|
if (!token) return undefined;
|
||||||
|
const parts = token.split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return undefined;
|
||||||
|
if (parts.length > 1) {
|
||||||
|
console.warn('VITE_HASURA_AUTH_TOKEN contains whitespace; using the first segment only.');
|
||||||
|
}
|
||||||
|
return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsMessage =
|
||||||
|
| { type: 'connection_ack' | 'ka' | 'complete' }
|
||||||
|
| { type: 'connection_error'; payload?: any }
|
||||||
|
| { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } }
|
||||||
|
| { type: 'error'; id: string; payload?: any };
|
||||||
|
|
||||||
|
export type SubscriptionHandle = {
|
||||||
|
unsubscribe: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onStatus }: SubscribeParams<T>): SubscriptionHandle {
|
||||||
|
const wsUrl = resolveGraphqlWsUrl();
|
||||||
|
const headers = resolveAuthHeaders();
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let closed = false;
|
||||||
|
let started = false;
|
||||||
|
let reconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const subId = '1';
|
||||||
|
|
||||||
|
const emitError = (e: unknown) => {
|
||||||
|
const msg = typeof e === 'string' ? e : String((e as any)?.message || e);
|
||||||
|
onError?.(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setConnected = (connected: boolean) => onStatus?.({ connected });
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (!ws || started) return;
|
||||||
|
started = true;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: subId,
|
||||||
|
type: 'start',
|
||||||
|
payload: { query, variables: variables ?? {} },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
if (closed) return;
|
||||||
|
started = false;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl, 'graphql-ws');
|
||||||
|
} catch (e) {
|
||||||
|
emitError(e);
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
const payload = headers ? { headers } : {};
|
||||||
|
ws?.send(JSON.stringify({ type: 'connection_init', payload }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg: WsMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(ev.data));
|
||||||
|
} catch (e) {
|
||||||
|
emitError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'connection_ack') {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'connection_error') {
|
||||||
|
emitError(msg.payload || 'connection_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'ka' || msg.type === 'complete') return;
|
||||||
|
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
emitError(msg.payload || 'subscription_error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'data') {
|
||||||
|
const errors = msg.payload?.errors;
|
||||||
|
if (Array.isArray(errors) && errors.length) {
|
||||||
|
emitError(errors.map((e) => e.message).join(' | '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.payload?.data != null) onData(msg.payload.data as T);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
if (closed) return;
|
||||||
|
reconnectTimer = window.setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
closed = true;
|
||||||
|
setConnected(false);
|
||||||
|
if (reconnectTimer != null) {
|
||||||
|
window.clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (!ws) return;
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
|
||||||
|
ws.send(JSON.stringify({ type: 'connection_terminate' }));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,12 +18,23 @@ function getApiUrl(): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHasuraUrl(): string {
|
function getHasuraUrl(): string {
|
||||||
return (import.meta as any).env?.VITE_HASURA_URL || 'http://localhost:8080/v1/graphql';
|
return (import.meta as any).env?.VITE_HASURA_URL || '/graphql';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthToken(): string | undefined {
|
function getAuthToken(): string | undefined {
|
||||||
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
|
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
|
||||||
return v ? String(v) : undefined;
|
const raw = v ? String(v) : '';
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const m = trimmed.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const token = (m ? m[1] : trimmed).trim();
|
||||||
|
if (!token) return undefined;
|
||||||
|
const parts = token.split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return undefined;
|
||||||
|
if (parts.length > 1) {
|
||||||
|
console.warn('VITE_HASURA_AUTH_TOKEN contains whitespace; using the first segment only.');
|
||||||
|
}
|
||||||
|
return parts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAdminSecret(): string | undefined {
|
function getAdminSecret(): string | undefined {
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ a:hover {
|
|||||||
|
|
||||||
.marketHeader {
|
.marketHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,12 +480,16 @@ a:hover {
|
|||||||
|
|
||||||
.statsRow {
|
.statsRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
/* 7 stat tiles by default (Last/Oracle/Bid/Ask/Spread/DLOB/L2), but keep it responsive. */
|
||||||
gap: 10px;
|
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||||
|
column-gap: 14px;
|
||||||
|
row-gap: 10px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat__label {
|
.stat__label {
|
||||||
@@ -497,12 +501,18 @@ a:hover {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat__sub {
|
.stat__sub {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartCard {
|
.chartCard {
|
||||||
@@ -981,6 +991,526 @@ body.chartFullscreen {
|
|||||||
padding: 10px 2px;
|
padding: 10px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dlobDash {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__title {
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__market {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__statuses {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobStatus {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobStatus__label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi__label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi__value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobKpi__sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__panes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDash__pane {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsPanel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsPanel--stack {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsPanel--stack .costsPanel__grid {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsPanel__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsPanel__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsPanel__grid--single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard {
|
||||||
|
background: rgba(0, 0, 0, 0.20);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard--new {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard--new .costsCard__subhead {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard__title {
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard__subhead {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsForm {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsForm--new {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsForm--newSide {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsKpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsKpis--new {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsKpis--newSide {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsNewLayout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 420px;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsNewCharts {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsNewSide {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costKpi {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard--new .costKpi {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard--new .costKpi__label {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsCard--new .costKpi__value {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costKpi__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.costKpi__value {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsMeta--new {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsClose {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsClose__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto auto auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costCharts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costCharts--new {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-areas:
|
||||||
|
"price price"
|
||||||
|
"bps usd";
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart--big {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart--price {
|
||||||
|
grid-area: price;
|
||||||
|
}
|
||||||
|
.costChart--costBps {
|
||||||
|
grid-area: bps;
|
||||||
|
}
|
||||||
|
.costChart--costUsd {
|
||||||
|
grid-area: usd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__canvas {
|
||||||
|
height: 280px;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart--price .costChart__canvas {
|
||||||
|
height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart--costBps .costChart__canvas,
|
||||||
|
.costChart--costUsd .costChart__canvas {
|
||||||
|
height: 180px;
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__canvas canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costChart__svg--time {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.costsPanel__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.costCharts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.costsForm--new {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.costsKpis--new {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.costCharts--new {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"price"
|
||||||
|
"bps"
|
||||||
|
"usd";
|
||||||
|
}
|
||||||
|
.costChart--price .costChart__canvas {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costsNewLayout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepth {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepth__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepth__title {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepth__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepth__table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.9fr 1fr 1fr 0.7fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow::before,
|
||||||
|
.dlobDepthRow::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow::before {
|
||||||
|
transform: scaleX(var(--ask-scale, 0));
|
||||||
|
transform-origin: left center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(239, 68, 68, 0.18) 0%,
|
||||||
|
rgba(239, 68, 68, 0.06) 60%,
|
||||||
|
rgba(239, 68, 68, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow::after {
|
||||||
|
transform: scaleX(var(--bid-scale, 0));
|
||||||
|
transform-origin: right center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(34, 197, 94, 0) 0%,
|
||||||
|
rgba(34, 197, 94, 0.06) 40%,
|
||||||
|
rgba(34, 197, 94, 0.18) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow--head {
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow--head::before,
|
||||||
|
.dlobDepthRow--head::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepthRow__num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobDepth__empty {
|
||||||
|
padding: 8px 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobSlippage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobSlippage__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobSlippage__title {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobSlippage__chartWrap {
|
||||||
|
height: 220px;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobSlippageChart {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dlobSlippage__empty {
|
||||||
|
padding: 8px 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.bottomCard .uiCard__body {
|
.bottomCard .uiCard__body {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1046,6 +1576,78 @@ body.chartFullscreen {
|
|||||||
padding: 2px 2px;
|
padding: 2px 2px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow > * {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scaleX(var(--ob-total-scale, 0));
|
||||||
|
transition: transform 220ms ease-out;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
transform: scaleX(var(--ob-level-scale, 0));
|
||||||
|
transition: transform 220ms ease-out;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--ask::before {
|
||||||
|
transform-origin: left center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(239, 68, 68, 0.24) 0%,
|
||||||
|
rgba(239, 68, 68, 0.09) 60%,
|
||||||
|
rgba(239, 68, 68, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--bid::before {
|
||||||
|
transform-origin: right center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(34, 197, 94, 0) 0%,
|
||||||
|
rgba(34, 197, 94, 0.09) 40%,
|
||||||
|
rgba(34, 197, 94, 0.24) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--ask::after {
|
||||||
|
transform-origin: left center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(239, 68, 68, 0.36) 0%,
|
||||||
|
rgba(239, 68, 68, 0.12) 55%,
|
||||||
|
rgba(239, 68, 68, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookRow--bid::after {
|
||||||
|
transform-origin: right center;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(34, 197, 94, 0) 0%,
|
||||||
|
rgba(34, 197, 94, 0.12) 45%,
|
||||||
|
rgba(34, 197, 94, 0.36) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.orderbookRow__num {
|
.orderbookRow__num {
|
||||||
@@ -1081,6 +1683,69 @@ body.chartFullscreen {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orderbookMeta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 2px 2px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookMeta__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderbookMeta__val {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar {
|
||||||
|
position: relative;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
transform: translateX(-0.5px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar__bid,
|
||||||
|
.liquidityBar__ask {
|
||||||
|
height: 100%;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar__bid {
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.0) 0%, rgba(34, 197, 94, 0.35) 55%, rgba(34, 197, 94, 0.85) 100%);
|
||||||
|
transform-origin: right center;
|
||||||
|
transform: scaleX(var(--liq-bid, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.liquidityBar__ask {
|
||||||
|
background: linear-gradient(90deg, rgba(239, 68, 68, 0.85) 0%, rgba(239, 68, 68, 0.35) 45%, rgba(239, 68, 68, 0.0) 100%);
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: scaleX(var(--liq-ask, 0));
|
||||||
|
}
|
||||||
|
|
||||||
.trades {
|
.trades {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1309,6 +1974,215 @@ body.chartFullscreen {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.statsRow {
|
.statsRow {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.stackMode {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1990;
|
||||||
|
background: rgba(0, 0, 0, var(--stack-backdrop-opacity, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackDrawerHotspot {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 96px;
|
||||||
|
z-index: 4050;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackDrawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: min(460px, calc(100vw - 24px));
|
||||||
|
z-index: 4000;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackDrawer--closed {
|
||||||
|
transform: translateX(calc(-100% - 20px));
|
||||||
|
opacity: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackDrawer.uiCard {
|
||||||
|
box-shadow: 0 30px 120px rgba(0, 0, 0, 0.55);
|
||||||
|
pointer-events: auto;
|
||||||
|
background: rgba(10, 11, 16, var(--stack-drawer-opacity, 0.92));
|
||||||
|
border-color: rgba(255, 255, 255, 0.10);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__sliders {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__sliderRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 68px 1fr 46px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__slider {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__sliderValue {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__layerOpacity {
|
||||||
|
margin-left: 8px;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__layerOpacityValue {
|
||||||
|
width: 42px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__layerBrightness {
|
||||||
|
margin-left: 8px;
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__layerBrightnessValue {
|
||||||
|
width: 46px;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__iconBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
color: rgba(230, 233, 239, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__iconBtn:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__item--locked {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__item--hidden .stackPanel__label {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__item:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.16);
|
||||||
|
background: rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__item--active {
|
||||||
|
border-color: rgba(168, 85, 247, 0.45);
|
||||||
|
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.12), 0 18px 50px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__drag {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__label {
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackPanel__badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(168, 85, 247, 0.14);
|
||||||
|
border: 1px solid rgba(168, 85, 247, 0.25);
|
||||||
|
color: rgba(230, 233, 239, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackLayer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2500;
|
||||||
|
background: transparent;
|
||||||
|
padding: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackLayer__body {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackLayer__card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import react from '@vitejs/plugin-react';
|
|||||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = path.resolve(DIR, '../..');
|
const ROOT = path.resolve(DIR, '../..');
|
||||||
|
|
||||||
|
type BasicAuth = { username: string; password: string };
|
||||||
|
|
||||||
|
function stripTrailingSlashes(p: string): string {
|
||||||
|
const out = p.replace(/\/+$/, '');
|
||||||
|
return out || '/';
|
||||||
|
}
|
||||||
|
|
||||||
function readApiReadToken(): string | undefined {
|
function readApiReadToken(): string | undefined {
|
||||||
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
||||||
const p = path.join(ROOT, 'tokens', 'read.json');
|
const p = path.join(ROOT, 'tokens', 'read.json');
|
||||||
@@ -20,24 +27,187 @@ function readApiReadToken(): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const idx = raw.indexOf(':');
|
||||||
|
if (idx <= 0) return undefined;
|
||||||
|
const username = raw.slice(0, idx).trim();
|
||||||
|
const password = raw.slice(idx + 1);
|
||||||
|
if (!username || !password) return undefined;
|
||||||
|
return { username, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProxyBasicAuth(): BasicAuth | undefined {
|
||||||
|
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH);
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
|
const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim();
|
||||||
|
if (!fileRaw) return undefined;
|
||||||
|
|
||||||
|
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
|
||||||
|
if (!fs.existsSync(p)) return undefined;
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(p, 'utf8');
|
||||||
|
const json = JSON.parse(raw) as { username?: string; password?: string };
|
||||||
|
const username = typeof json?.username === 'string' ? json.username.trim() : '';
|
||||||
|
const password = typeof json?.password === 'string' ? json.password : '';
|
||||||
|
if (!username || !password) return undefined;
|
||||||
|
return { username, password };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const apiReadToken = readApiReadToken();
|
const apiReadToken = readApiReadToken();
|
||||||
|
const proxyBasicAuth = readProxyBasicAuth();
|
||||||
|
const apiProxyTarget =
|
||||||
|
process.env.API_PROXY_TARGET ||
|
||||||
|
process.env.VISUALIZER_PROXY_TARGET ||
|
||||||
|
process.env.TRADE_UI_URL ||
|
||||||
|
process.env.TRADE_VPS_URL ||
|
||||||
|
'https://trade.mpabi.pl';
|
||||||
|
|
||||||
|
function isLocalHost(hostname: string | undefined): boolean {
|
||||||
|
const h = String(hostname || '').trim().toLowerCase();
|
||||||
|
if (!h) return false;
|
||||||
|
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUrl(v: string): URL | undefined {
|
||||||
|
try {
|
||||||
|
return new URL(v);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOrigin(u: URL | undefined): string | undefined {
|
||||||
|
if (!u) return undefined;
|
||||||
|
return `${u.protocol}//${u.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
||||||
|
const apiProxyOrigin = toOrigin(apiProxyTargetUrl);
|
||||||
|
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||||
|
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
||||||
|
const apiProxyIsLocal = isLocalHost(apiProxyTargetUrl?.hostname);
|
||||||
|
const apiProxyForceBearer = process.env.API_PROXY_FORCE_BEARER === '1' || process.env.API_PROXY_USE_READ_TOKEN === '1';
|
||||||
|
|
||||||
|
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const u = new URL(apiTarget);
|
||||||
|
const p = stripTrailingSlashes(u.pathname || '/');
|
||||||
|
if (!p.endsWith('/api')) return undefined;
|
||||||
|
const basePath = p.slice(0, -'/api'.length) || '/';
|
||||||
|
u.pathname = basePath;
|
||||||
|
u.search = '';
|
||||||
|
u.hash = '';
|
||||||
|
const out = u.toString();
|
||||||
|
return out.endsWith('/') ? out.slice(0, -1) : out;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiProxyTarget =
|
||||||
|
process.env.FRONTEND_PROXY_TARGET ||
|
||||||
|
process.env.UI_PROXY_TARGET ||
|
||||||
|
process.env.AUTH_PROXY_TARGET ||
|
||||||
|
inferUiProxyTarget(apiProxyTarget) ||
|
||||||
|
(apiProxyTargetUrl && apiProxyTargetPath === '/' ? stripTrailingSlashes(apiProxyTargetUrl.toString()) : undefined);
|
||||||
|
const uiProxyOrigin = toOrigin(parseUrl(uiProxyTarget || ''));
|
||||||
|
|
||||||
|
const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget;
|
||||||
|
const graphqlProxyOrigin = toOrigin(parseUrl(graphqlProxyTarget || ''));
|
||||||
|
const graphqlProxyBasicAuthEnabled =
|
||||||
|
process.env.GRAPHQL_PROXY_BASIC_AUTH === '1' || process.env.HASURA_PROXY_BASIC_AUTH === '1';
|
||||||
|
function applyProxyBasicAuth(proxyReq: any) {
|
||||||
|
if (!proxyBasicAuth) return false;
|
||||||
|
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||||
|
proxyReq.setHeader('Authorization', `Basic ${b64}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProxyOrigin(proxyReq: any, origin: string | undefined) {
|
||||||
|
if (!origin) return;
|
||||||
|
// Some upstreams (notably WS endpoints) validate Origin and may drop the connection when it doesn't match.
|
||||||
|
proxyReq.setHeader('Origin', origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
||||||
|
const v = proxyRes?.headers?.['set-cookie'];
|
||||||
|
if (!v) return;
|
||||||
|
const rewrite = (cookie: string) => {
|
||||||
|
let out = cookie.replace(/;\s*secure\b/gi, '');
|
||||||
|
out = out.replace(/;\s*domain=[^;]+/gi, '');
|
||||||
|
out = out.replace(/;\s*samesite=none\b/gi, '; SameSite=Lax');
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxy: Record<string, any> = {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, apiProxyOrigin);
|
||||||
|
if (applyProxyBasicAuth(proxyReq)) return;
|
||||||
|
if ((apiProxyIsLocal || apiProxyForceBearer) && apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||||
|
});
|
||||||
|
p.on('proxyReqWs', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, apiProxyOrigin);
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (graphqlProxyTarget) {
|
||||||
|
for (const prefix of ['/graphql', '/graphql-ws']) {
|
||||||
|
proxy[prefix] = {
|
||||||
|
target: graphqlProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, graphqlProxyOrigin);
|
||||||
|
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
p.on('proxyReqWs', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, graphqlProxyOrigin);
|
||||||
|
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uiProxyTarget) {
|
||||||
|
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||||
|
proxy[prefix] = {
|
||||||
|
target: uiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
configure: (p: any) => {
|
||||||
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
|
applyProxyOrigin(proxyReq, uiProxyOrigin);
|
||||||
|
applyProxyBasicAuth(proxyReq);
|
||||||
|
});
|
||||||
|
p.on('proxyRes', (proxyRes: any) => {
|
||||||
|
rewriteSetCookieForLocalDevHttp(proxyRes);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: false,
|
||||||
proxy: {
|
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}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
39
doc/candles-cache.md
Normal file
39
doc/candles-cache.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Candles cache: precompute wszystkich timeframe (1s…1d)
|
||||||
|
|
||||||
|
Cel: przełączanie `tf` w UI ma być natychmiastowe. Backend ma **ciągle liczyć** i **przechowywać** świeczki dla wszystkich timeframe:
|
||||||
|
|
||||||
|
`1s 3s 5s 15s 30s 1m 3m 5m 15m 30m 1h 4h 12h 1d`
|
||||||
|
|
||||||
|
## Jak to działa
|
||||||
|
|
||||||
|
1) Ticki (append-only) lądują w `drift_ticks`.
|
||||||
|
2) Worker `candles-cache-worker`:
|
||||||
|
- liczy świeczki dla **każdego** `bucket_seconds` bezpośrednio z `drift_ticks`,
|
||||||
|
- trzyma w DB “ostatnie N” świec (domyślnie `N=1024`) per `(symbol, source, tf)`,
|
||||||
|
- jeśli danych historycznych jest mniej (np. brak wielu dni) — zapisuje tylko to, co istnieje,
|
||||||
|
- robi backfill/warmup przy starcie i potem dopisuje “na bieżąco” w pętli.
|
||||||
|
3) API `GET /v1/chart` czyta **cache-first** z `drift_candles_cache` (fallback do on-demand funkcji, jeśli cache pusty).
|
||||||
|
|
||||||
|
## Tabela
|
||||||
|
|
||||||
|
- `drift_candles_cache` (Timescale hypertable, partycjonowanie po `bucket`)
|
||||||
|
- `bucket_seconds` = długość świecy w sekundach
|
||||||
|
- `source=''` oznacza “(any)” (brak filtra po źródle ticków)
|
||||||
|
|
||||||
|
## Worker
|
||||||
|
|
||||||
|
Plik: `services/candles-worker/candles-cache-worker.mjs`
|
||||||
|
|
||||||
|
Env:
|
||||||
|
- `CANDLES_SYMBOLS` (np. `SOL-PERP,PUMP-PERP`)
|
||||||
|
- `CANDLES_SOURCES` (np. `any,drift_oracle`)
|
||||||
|
- `CANDLES_TFS` (np. `1s,3s,5s,15s,...,1d`)
|
||||||
|
- `CANDLES_TARGET_POINTS` (default `1024`)
|
||||||
|
- `CANDLES_BACKFILL_DAYS` (opcjonalnie: wymusza minimalny warmup “co najmniej X dni”)
|
||||||
|
- `CANDLES_POLL_MS` (default `5000`)
|
||||||
|
|
||||||
|
## Dlaczego to jest szybkie
|
||||||
|
|
||||||
|
- najcięższe agregacje są robione raz i utrzymywane “na bieżąco”,
|
||||||
|
- przełączenie `tf` to tylko query po gotowych wierszach (`order_by bucket desc limit N`),
|
||||||
|
- “flow/brick stack” w `/v1/chart` jest liczone z cache “point candles” (np. `1s/3s/5s/15s/…`) bez skanowania `drift_ticks`.
|
||||||
216
doc/dlob-basics.md
Normal file
216
doc/dlob-basics.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# DLOB + L1…L10 — podstawy (co jest czym i gdzie to liczymy)
|
||||||
|
|
||||||
|
Ten dokument wyjaśnia pojęcia:
|
||||||
|
- **DLOB** (Drift Limit Order Book),
|
||||||
|
- **L1 / L2 / L3** oraz potoczne **L1…L10**,
|
||||||
|
- na jakich warstwach w naszym stacku powstają dane i metryki,
|
||||||
|
- gdzie “pracuje AI” (modele/strategie) vs gdzie jest execution (order placement).
|
||||||
|
|
||||||
|
## Co to jest DLOB
|
||||||
|
|
||||||
|
**DLOB** = *Decentralized Limit Order Book* w Drift.
|
||||||
|
|
||||||
|
W praktyce: to jest **księga zleceń** dla rynku (np. `SOL-PERP`):
|
||||||
|
- **bids** = zlecenia kupna (po stronie bid),
|
||||||
|
- **asks** = zlecenia sprzedaży (po stronie ask).
|
||||||
|
|
||||||
|
Księga ma wiele “poziomów” cenowych; przy każdej cenie stoi pewna ilość (size).
|
||||||
|
|
||||||
|
## L1 / L2 / L3 (format i sens)
|
||||||
|
|
||||||
|
### L1 (Top of Book)
|
||||||
|
L1 to skrót od “top of book”:
|
||||||
|
- **best bid** = najwyższa cena kupna (pierwszy poziom po stronie bid),
|
||||||
|
- **best ask** = najniższa cena sprzedaży (pierwszy poziom po stronie ask).
|
||||||
|
|
||||||
|
Z L1 najczęściej liczysz:
|
||||||
|
- **spread** = `best_ask - best_bid`,
|
||||||
|
- **mid** = `(best_bid + best_ask) / 2`.
|
||||||
|
|
||||||
|
### L2 (zagregowane poziomy)
|
||||||
|
L2 to lista poziomów (levels) po obu stronach:
|
||||||
|
- `bids: [{ price, size }, ...]` (zwykle posortowane malejąco po `price`)
|
||||||
|
- `asks: [{ price, size }, ...]` (zwykle posortowane rosnąco po `price`)
|
||||||
|
|
||||||
|
To jest najpopularniejszy “orderbook UI”: słupki/heat per poziom ceny.
|
||||||
|
|
||||||
|
### L3 (pojedyncze zlecenia)
|
||||||
|
L3 to “niezagregowane” dane: pojedyncze zlecenia (większy wolumen danych).
|
||||||
|
U nas pod UI i metryki zazwyczaj wystarcza L2.
|
||||||
|
|
||||||
|
## L1…L10 (co to znaczy w praktyce)
|
||||||
|
|
||||||
|
**L1…L10** to potoczne określenie:
|
||||||
|
> “pierwsze 10 poziomów z L2 najbliżej top of book”.
|
||||||
|
|
||||||
|
To nie jest osobny format; to po prostu wycinek L2.
|
||||||
|
|
||||||
|
W naszym stacku “ile leveli bierzemy” kontroluje:
|
||||||
|
- `DLOB_DEPTH` (np. 10 → “L1…L10”).
|
||||||
|
|
||||||
|
## Jak to działa w naszym stacku (warstwy)
|
||||||
|
|
||||||
|
Poniżej “łańcuch” od źródła do metryk:
|
||||||
|
|
||||||
|
### Warstwa A: On-chain → DLOB w pamięci (VPS/k3s)
|
||||||
|
Komponent: `dlob-publisher`.
|
||||||
|
|
||||||
|
- Łączy się do Solany przez `ENDPOINT` (HTTP RPC) i `WS_ENDPOINT` (WebSocket).
|
||||||
|
- Subskrybuje konta/zdarzenia i buduje DLOB (orderbook) w pamięci.
|
||||||
|
- Publikuje snapshoty do Redis (u nas: `dlob-redis`).
|
||||||
|
|
||||||
|
To jest najbliżej źródła i zwykle najbardziej “real-time”.
|
||||||
|
|
||||||
|
### Warstwa B: Cache + REST API (VPS/k3s)
|
||||||
|
Komponenty: `dlob-redis` + `dlob-server`.
|
||||||
|
|
||||||
|
- `dlob-redis` trzyma snapshoty/publish.
|
||||||
|
- `dlob-server` udostępnia HTTP:
|
||||||
|
- `GET /l2?marketName=SOL-PERP&depth=10` → L2 (bids/asks + best bid/ask itp.)
|
||||||
|
- `GET /l3?...` → L3 (jeśli potrzebujesz)
|
||||||
|
|
||||||
|
To jest warstwa dystrybucji danych “w klastrze”, żeby inne serwisy nie musiały gadać bezpośrednio z Solaną.
|
||||||
|
|
||||||
|
Uwaga o rynkach:
|
||||||
|
- `dlob-publisher` ładuje rynki wg `PERP_MARKETS_TO_LOAD` (indeksy) / `SPOT_MARKETS_TO_LOAD`.
|
||||||
|
- Jeśli rynek nie jest załadowany przez publisher, `dlob-server` nie rozpozna `marketName`.
|
||||||
|
|
||||||
|
### Warstwa C: Metryki w DB/Hasura (VPS/k3s)
|
||||||
|
Komponenty: `dlob-worker`, `dlob-depth-worker`, `dlob-slippage-worker`.
|
||||||
|
|
||||||
|
To są “workery pod UI/AI”, które liczą metryki i zapisują je do Postgresa (Hasura).
|
||||||
|
|
||||||
|
#### `dlob-worker` (collector + basic stats)
|
||||||
|
Wejście:
|
||||||
|
- odpytuje `dlob-server` po HTTP `/l2` (źródło L2),
|
||||||
|
- rynki: `DLOB_MARKETS`,
|
||||||
|
- głębokość (ile leveli): `DLOB_DEPTH`,
|
||||||
|
- częstotliwość: `DLOB_POLL_MS`.
|
||||||
|
|
||||||
|
Wyjście (upsert do DB):
|
||||||
|
- `dlob_l2_latest` = snapshot L2 “latest” per market,
|
||||||
|
- `dlob_stats_latest` = pochodne metryki liczone z top‑N leveli (N=`DLOB_DEPTH`), m.in.:
|
||||||
|
- `mid_price`, `spread_abs`, `spread_bps`,
|
||||||
|
- `depth_bid_*` / `depth_ask_*`,
|
||||||
|
- `imbalance`.
|
||||||
|
|
||||||
|
Czyli: jeśli pytasz “gdzie liczymy L1…L10 metryki” → tutaj (w `dlob-worker`), bo bierze top‑N leveli z L2.
|
||||||
|
|
||||||
|
#### `dlob-depth-worker` (depth w bandach bps)
|
||||||
|
Wejście:
|
||||||
|
- czyta z DB `dlob_l2_latest` (czyli już “przetworzone” L2).
|
||||||
|
|
||||||
|
Wyjście:
|
||||||
|
- `dlob_depth_bps_latest` = płynność w pasmach wokół mid (np. ±5/10/20/50/100/200 bps).
|
||||||
|
|
||||||
|
To nie jest “L1…L10”, tylko “ile płynności mieści się w oknie cenowym” wokół mid.
|
||||||
|
|
||||||
|
#### `dlob-slippage-worker` (slippage vs size)
|
||||||
|
Wejście:
|
||||||
|
- czyta z DB `dlob_l2_latest`.
|
||||||
|
|
||||||
|
Wyjście:
|
||||||
|
- `dlob_slippage_latest` = symulacja wykonania zlecenia (market) po L2 dla progów `DLOB_SLIPPAGE_SIZES_USD`.
|
||||||
|
|
||||||
|
To jest bardzo użyteczne jako feature do strategii (“ile kosztuje wejście/wyjście teraz dla X USD”).
|
||||||
|
|
||||||
|
## Gdzie “pracuje AI” (TFT itp.)
|
||||||
|
|
||||||
|
AI/strategia powinna pracować na warstwie “features”, a nie na surowych subskrypcjach Solany:
|
||||||
|
|
||||||
|
Najczęstszy zestaw wejść dla modelu:
|
||||||
|
- candles/ticki (np. `drift_ticks` + `get_drift_candles(...)`),
|
||||||
|
- bieżące statsy z DLOB:
|
||||||
|
- `dlob_stats_latest` (mid/spread/depth/imbalance),
|
||||||
|
- `dlob_depth_bps_latest` (depth w bandach),
|
||||||
|
- `dlob_slippage_latest` (slippage vs size),
|
||||||
|
- opcjonalnie pełny snapshot L2 (z `dlob_l2_latest`), jeśli model potrzebuje “kształtu” książki.
|
||||||
|
|
||||||
|
Kluczowa zasada bezpieczeństwa:
|
||||||
|
- **Model (np. na Vast)** może sugerować “desired state” (wejść/wyjść/parametry),
|
||||||
|
- **Executor na VPS** zawsze odpowiada za:
|
||||||
|
- risk checks,
|
||||||
|
- składanie/cancel/close,
|
||||||
|
- klucze prywatne i podpisywanie transakcji,
|
||||||
|
- kill switch.
|
||||||
|
|
||||||
|
## Szybki słownik (1-liner)
|
||||||
|
|
||||||
|
- **bid**: kupno, zielona strona książki
|
||||||
|
- **ask**: sprzedaż, czerwona strona książki
|
||||||
|
- **best bid / best ask (L1)**: top-of-book
|
||||||
|
- **spread**: koszt “wejścia/wyjścia natychmiast” (ask-bid)
|
||||||
|
- **mid**: punkt odniesienia między bid/ask
|
||||||
|
- **L2**: lista poziomów `{price,size}`
|
||||||
|
- **L1…L10**: top 10 poziomów z L2 (u nas kontrolowane przez `DLOB_DEPTH`)
|
||||||
|
|
||||||
|
## Jak liczymy “liquidity” i “kasa” (USD) w metrykach
|
||||||
|
|
||||||
|
W UI/DB słowo “liquidity” zwykle oznacza **depth**: “ile wolumenu stoi w orderbooku blisko ceny”.
|
||||||
|
U nas trzymamy to rozdzielnie dla bid/ask oraz w dwóch wariantach:
|
||||||
|
|
||||||
|
### A) Top‑N leveli (np. L1…L10) — `dlob_stats_latest`
|
||||||
|
Liczone w `dlob-worker` na podstawie L2 z `/l2`:
|
||||||
|
|
||||||
|
- Bierzemy pierwsze `N = DLOB_DEPTH` leveli z `bids` i `asks`.
|
||||||
|
- Każdy level ma:
|
||||||
|
- `price = price_int / PRICE_PRECISION`
|
||||||
|
- `size_base = size_int / BASE_PRECISION`
|
||||||
|
- “kasa” (notional) na tym levelu: `size_usd = size_base * price`
|
||||||
|
- Sumujemy po levelach:
|
||||||
|
- `depth_bid_base = Σ size_base` (po stronie bid),
|
||||||
|
- `depth_bid_usd = Σ (size_base * price)` (po stronie bid),
|
||||||
|
- analogicznie `depth_ask_base`, `depth_ask_usd` (po stronie ask).
|
||||||
|
|
||||||
|
To odpowiada intuicji “ile jest płynności na L1…LN”.
|
||||||
|
|
||||||
|
### B) Okno cenowe w bps od mid — `dlob_depth_bps_latest`
|
||||||
|
Liczone w `dlob-depth-worker` na podstawie `dlob_l2_latest`:
|
||||||
|
|
||||||
|
- Dla pasma `band_bps` wyznaczamy:
|
||||||
|
- `minBidPrice = mid * (1 - band_bps/10_000)`
|
||||||
|
- `maxAskPrice = mid * (1 + band_bps/10_000)`
|
||||||
|
- Sumujemy wszystkie levele, które mieszczą się w tym oknie:
|
||||||
|
- bids: `price >= minBidPrice`
|
||||||
|
- asks: `price <= maxAskPrice`
|
||||||
|
- Liczymy sumy:
|
||||||
|
- `bid_base`, `bid_usd`, `ask_base`, `ask_usd` tak jak wyżej (`usd = base * price`).
|
||||||
|
|
||||||
|
To odpowiada intuicji “ile płynności jest *blisko* ceny w ±X bps”.
|
||||||
|
|
||||||
|
### Ważne doprecyzowanie
|
||||||
|
|
||||||
|
Te liczby to **notional z orderbooka** (ile “stoi” na poziomach cenowych).
|
||||||
|
Nie są to “pieniądze w kontrakcie”, tylko przybliżenie kosztu/pojemności wykonania przy danej cenie i bez przesunięcia rynku.
|
||||||
|
|
||||||
|
## Spec: Orderbook UI (L1…L10 + “liquidity bars”)
|
||||||
|
|
||||||
|
Wizualizacja orderbooka (jak na screenach) jest oparta o L2 i pokazuje tylko top‑N leveli:
|
||||||
|
- `N` = liczba leveli wyświetlanych na stronę (np. 10 → “L1…L10”).
|
||||||
|
|
||||||
|
### Kolumny / wartości
|
||||||
|
|
||||||
|
Na każdym levelu liczymy:
|
||||||
|
- `size_usd = size_base * price`
|
||||||
|
|
||||||
|
W UI pokazujemy:
|
||||||
|
- `Size (USD)` = `size_usd` dla danego poziomu,
|
||||||
|
- `Total (USD)` = suma skumulowana od best‑price “w głąb” (cumulative):
|
||||||
|
- bids: kumulacja od best bid w dół,
|
||||||
|
- asks: kumulacja od best ask w górę (w UI zwykle best ask jest bliżej środka).
|
||||||
|
|
||||||
|
### “Liquidity bars” (znormalizowane słupki tła)
|
||||||
|
|
||||||
|
Żeby “na oko” widzieć gdzie stoi płynność:
|
||||||
|
|
||||||
|
1) **Level bar (per‑poziom)** — normalizacja do największego `size_usd` w widocznych levelach danej strony:
|
||||||
|
- `level_scale = size_usd / max(size_usd w widoku)`
|
||||||
|
2) **Total bar (cumulative)** — normalizacja do największego `total_usd` w widocznych levelach danej strony:
|
||||||
|
- `total_scale = total_usd / max(total_usd w widoku)`
|
||||||
|
|
||||||
|
Żeby duże “ściany” nie zabijały kontrastu, warto użyć krzywej:
|
||||||
|
- `scale_curved = sqrt(clamp01(scale))`
|
||||||
|
|
||||||
|
Interpretacja:
|
||||||
|
- **level bar** = “ile stoi na tym poziomie”,
|
||||||
|
- **total bar** = “ile stoi łącznie do tego poziomu”.
|
||||||
153
doc/dlob-services.md
Normal file
153
doc/dlob-services.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Serwisy DLOB na VPS (k3s / `trade-staging`)
|
||||||
|
|
||||||
|
Ten dokument opisuje rolę serwisów “DLOB” uruchomionych w namespace `trade-staging` oraz ich przepływ danych.
|
||||||
|
|
||||||
|
## Czy `dlob-worker` pracuje na VPS?
|
||||||
|
|
||||||
|
Tak — wszystkie serwisy wymienione niżej działają **na VPS** jako Deploymenty w klastrze k3s, w namespace `trade-staging`.
|
||||||
|
|
||||||
|
## Czy na VPS jest GraphQL/WS dla stats i orderbook?
|
||||||
|
|
||||||
|
Tak — **GraphQL wystawia Hasura** (na VPS w k3s), a nie `dlob-server`.
|
||||||
|
|
||||||
|
- Dane L2 i liczone statsy są zapisane do Postgresa jako tabele `dlob_*_latest` i są dostępne przez Hasurę jako GraphQL (query + subscriptions).
|
||||||
|
- Z zewnątrz korzystamy przez frontend (proxy) pod:
|
||||||
|
- HTTP: `https://trade.rv32i.pl/graphql`
|
||||||
|
- WS: `wss://trade.rv32i.pl/graphql` (subskrypcje, protokół `graphql-ws`)
|
||||||
|
|
||||||
|
`dlob-server` wystawia **REST** (np. `/l2`, `/l3`) w klastrze; to jest źródło danych dla workerów albo do debugowania.
|
||||||
|
|
||||||
|
## TL;DR: kto co robi
|
||||||
|
|
||||||
|
### `dlob-worker`
|
||||||
|
- **Rola:** kolektor L2 + wyliczenie “basic stats”.
|
||||||
|
- **Wejście:** HTTP L2 z `DLOB_HTTP_URL` (u nas obecnie `https://dlob.drift.trade`, ale można przełączyć na `http://dlob-server:6969`).
|
||||||
|
- **Wyjście:** upsert do Hasury (Postgres) tabel:
|
||||||
|
- `dlob_l2_latest` (raw snapshot L2, JSON leveli)
|
||||||
|
- `dlob_stats_latest` (pochodne: best bid/ask, mid, spread, depth, imbalance, itp.)
|
||||||
|
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 500 ms).
|
||||||
|
|
||||||
|
### `dlob-slippage-worker`
|
||||||
|
- **Rola:** symulacja slippage vs rozmiar zlecenia na podstawie L2.
|
||||||
|
- **Wejście:** czyta z Hasury `dlob_l2_latest` (dla listy rynków).
|
||||||
|
- **Wyjście:** upsert do Hasury tabeli `dlob_slippage_latest` (m.in. `impact_bps`, `vwap_price`, `worst_price`, `fill_pct`).
|
||||||
|
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 1000 ms); rozmiary w `DLOB_SLIPPAGE_SIZES_USD`.
|
||||||
|
|
||||||
|
### `dlob-depth-worker`
|
||||||
|
- **Rola:** metryki “głębokości” w pasmach ±bps wokół mid.
|
||||||
|
- **Wejście:** czyta z Hasury `dlob_l2_latest`.
|
||||||
|
- **Wyjście:** upsert do Hasury tabeli `dlob_depth_bps_latest` (per `(market_name, band_bps)`).
|
||||||
|
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 1000 ms); pasma w `DLOB_DEPTH_BPS_BANDS`.
|
||||||
|
|
||||||
|
### `dlob-publisher`
|
||||||
|
- **Rola:** utrzymuje “żywy” DLOB na podstawie subskrypcji on-chain i publikuje snapshoty do Redis.
|
||||||
|
- **Wejście:** Solana RPC/WS (`ENDPOINT`, `WS_ENDPOINT` z secreta `trade-dlob-rpc`), Drift SDK; konfiguracja rynków np. `PERP_MARKETS_TO_LOAD`.
|
||||||
|
- **Wyjście:** zapis/publish do `dlob-redis` (cache / pubsub / streamy), z którego korzysta serwer HTTP (i ewentualnie WS manager).
|
||||||
|
|
||||||
|
### `dlob-server`
|
||||||
|
- **Rola:** HTTP API do danych DLOB (np. `/l2`, `/l3`) serwowane z cache Redis.
|
||||||
|
- **Wejście:** `dlob-redis` + slot subscriber (do oceny “świeżości” danych).
|
||||||
|
- **Wyjście:** endpoint HTTP w klastrze (Service `dlob-server:6969`), który może być źródłem dla `dlob-worker` (gdy `DLOB_HTTP_URL=http://dlob-server:6969`).
|
||||||
|
|
||||||
|
### `dlob-redis`
|
||||||
|
- **Rola:** Redis (u nas single-node “cluster mode”) jako **cache i kanał komunikacji** między `dlob-publisher` a `dlob-server`.
|
||||||
|
- **Uwagi:** to “klej” między komponentami publish/serve; bez niego publisher i server nie współpracują.
|
||||||
|
|
||||||
|
## Jak to się spina (przepływ danych)
|
||||||
|
|
||||||
|
1) `dlob-publisher` (on-chain) → publikuje snapshoty do `dlob-redis`.
|
||||||
|
2) `dlob-server` → serwuje `/l2` i `/l3` z `dlob-redis` (HTTP w klastrze).
|
||||||
|
3) `dlob-worker` → pobiera L2 (obecnie z `https://dlob.drift.trade`; opcjonalnie z `dlob-server`) i zapisuje “latest” do Hasury/DB.
|
||||||
|
4) `dlob-slippage-worker` + `dlob-depth-worker` → liczą agregaty z `dlob_l2_latest` i zapisują do Hasury/DB (pod UI).
|
||||||
|
|
||||||
|
## Co to jest L1 / L2 / L3 (orderbook)
|
||||||
|
|
||||||
|
- `L1` (top-of-book): tylko najlepszy bid i najlepszy ask (czasem też spread).
|
||||||
|
- `L2` (Level 2): **zagregowane poziomy cenowe** po stronie bid/ask — lista leveli `{ price, size }`, gdzie `size` to suma wolumenu na danej cenie (to jest typowy “orderbook UI” i baza pod spread/depth/imbalance).
|
||||||
|
- `L3` (Level 3): **niezagregowane, pojedyncze zlecenia** (każde osobno, zwykle z dodatkowymi polami/identyfikatorami). Większy wolumen danych; przydatne do “pro” analiz i debugowania mikrostruktury.
|
||||||
|
|
||||||
|
W tym stacku:
|
||||||
|
- `dlob-server` udostępnia REST endpointy `/l2` i `/l3`.
|
||||||
|
- Hasura/DB trzyma “latest” snapshot L2 w `dlob_l2_latest` oraz metryki w `dlob_stats_latest` / `dlob_depth_bps_latest` / `dlob_slippage_latest`.
|
||||||
|
|
||||||
|
## Słownik pojęć (bid/ask/spread i metryki)
|
||||||
|
|
||||||
|
### Podstawy orderbooka
|
||||||
|
|
||||||
|
- **Bid**: zlecenia kupna (chęć kupna). W orderbooku “bid side”.
|
||||||
|
- **Ask**: zlecenia sprzedaży (chęć sprzedaży). W orderbooku “ask side”.
|
||||||
|
- **Best bid / best ask**: najlepsza (najwyższa) cena kupna i najlepsza (najniższa) cena sprzedaży na topie księgi (L1).
|
||||||
|
- **Spread**: różnica pomiędzy `best_ask` a `best_bid`. Im mniejszy spread, tym “taniej” wejść/wyjść (mniej kosztów natychmiastowej realizacji).
|
||||||
|
- **Mid price**: cena “po środku”: `(best_bid + best_ask) / 2`. Używana jako punkt odniesienia do bps i slippage.
|
||||||
|
- **Level**: pojedynczy poziom cenowy w L2 (np. `price=100.00`, `size=12.3`).
|
||||||
|
- **Size**: ilość/płynność na poziomie (zwykle w jednostkach “base asset”).
|
||||||
|
- **Base / Quote**:
|
||||||
|
- `base` = instrument bazowy (np. SOL),
|
||||||
|
- `quote` = waluta wyceny (często USD).
|
||||||
|
|
||||||
|
## Kolory w UI (visualizer)
|
||||||
|
|
||||||
|
- `bid` / “buy side” = zielony (`.pos`, `#22c55e`)
|
||||||
|
- `ask` / “sell side” = czerwony (`.neg`, `#ef4444`)
|
||||||
|
- “flat” / brak zmiany = niebieski (`#60a5fa`) — używany m.in. w “brick stack” pod świecami
|
||||||
|
|
||||||
|
### Jednostki i skróty
|
||||||
|
|
||||||
|
- **bps (basis points)**: 1 bps = 0.01% = `0.0001`. Np. 25 bps = 0.25%.
|
||||||
|
- **USD**: u nas wiele wartości jest przeliczanych do USD (np. `size_base * price`).
|
||||||
|
|
||||||
|
### Metryki “stats” (np. `dlob_stats_latest`)
|
||||||
|
|
||||||
|
- `spread_abs` (USD): `best_ask - best_bid`.
|
||||||
|
- `spread_bps` (bps): `(spread_abs / mid_price) * 10_000`.
|
||||||
|
- `depth_levels`: ile leveli (top‑N) z każdej strony braliśmy do liczenia “depth”.
|
||||||
|
- `depth_bid_base` / `depth_ask_base`: suma `size` po top‑N levelach bid/ask (w base).
|
||||||
|
- `depth_bid_usd` / `depth_ask_usd`: suma `size_base * price` po top‑N levelach (w USD).
|
||||||
|
- `imbalance` ([-1..1]): miara asymetrii płynności:
|
||||||
|
- `(depth_bid_usd - depth_ask_usd) / (depth_bid_usd + depth_ask_usd)`
|
||||||
|
- >0 = relatywnie więcej płynności po bid, <0 = po ask.
|
||||||
|
- `oracle_price`: cena z oracla (np. Pyth) jako punkt odniesienia.
|
||||||
|
- `mark_price`: “mark” z rynku/perp (cena referencyjna dla rozliczeń); różni się od oracle/top-of-book.
|
||||||
|
|
||||||
|
### Metryki “depth bands” (np. `dlob_depth_bps_latest`)
|
||||||
|
|
||||||
|
- `band_bps`: szerokość pasma wokół `mid_price` (np. 5/10/20/50/100/200 bps).
|
||||||
|
- `bid_usd` / `ask_usd`: płynność po danej stronie, ale **tylko z poziomów mieszczących się w oknie ±`band_bps`** wokół mid.
|
||||||
|
- `imbalance`: jak wyżej, ale liczony per band.
|
||||||
|
|
||||||
|
### Metryki “slippage” (np. `dlob_slippage_latest`)
|
||||||
|
|
||||||
|
To jest symulacja “gdybym teraz zrobił market order o rozmiarze X” na podstawie L2.
|
||||||
|
|
||||||
|
- `size_usd`: docelowy rozmiar zlecenia w USD.
|
||||||
|
- `vwap_price`: średnia cena realizacji (Volume Weighted Average Price) dla symulowanego fill.
|
||||||
|
- `impact_bps`: koszt/odchylenie względem `mid_price` wyrażone w bps (zwykle na bazie `vwap` vs `mid`).
|
||||||
|
- `worst_price`: najgorsza cena dotknięta podczas “zjadania” kolejnych leveli.
|
||||||
|
- `filled_usd` / `filled_base`: ile realnie udało się wypełnić (może być < docelowego, jeśli brakuje płynności).
|
||||||
|
- `fill_pct`: procent wypełnienia (100% = pełny fill).
|
||||||
|
- `levels_consumed`: ile leveli zostało “zjedzonych” podczas fill.
|
||||||
|
|
||||||
|
### Metadane czasu (“świeżość”)
|
||||||
|
|
||||||
|
- `ts`: timestamp źródła (czas snapshotu).
|
||||||
|
- `slot`: slot Solany, z którego pochodzi snapshot (monotoniczny “numer czasu” chaina).
|
||||||
|
- `updated_at`: kiedy nasz worker zapisał/odświeżył rekord w DB (do oceny, czy dane są świeże).
|
||||||
|
|
||||||
|
## Szybka diagnostyka na VPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy | grep -E 'dlob-(worker|slippage-worker|depth-worker|publisher|server|redis)'
|
||||||
|
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-worker --tail=80
|
||||||
|
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-publisher --tail=80
|
||||||
|
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-server --tail=80
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ważna uwaga (źródło L2 w `dlob-worker`)
|
||||||
|
|
||||||
|
Jeśli chcesz, żeby `dlob-worker` polegał na **naszym** stacku (własny RPC + `dlob-publisher` + `dlob-server`), ustaw:
|
||||||
|
|
||||||
|
- `DLOB_HTTP_URL=http://dlob-server:6969`
|
||||||
|
|
||||||
|
Aktualnie w `trade-staging` jest ustawione:
|
||||||
|
|
||||||
|
- `DLOB_HTTP_URL=https://dlob.drift.trade`
|
||||||
120
doc/drift-costs.md
Normal file
120
doc/drift-costs.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Drift Perp: koszty wejścia/edycji/wyjścia (stan na 2026-01-31)
|
||||||
|
|
||||||
|
Ten dokument zbiera **wszystkie realne składowe kosztu** przy handlu perps na Drift, żebyśmy mogli je liczyć na backendzie i wizualizować w UI.
|
||||||
|
|
||||||
|
## 1) Składowe kosztu (per trade / per pozycja)
|
||||||
|
|
||||||
|
### A. Opłata transakcyjna Drift (maker/taker)
|
||||||
|
- **Taker fee**: procent od **notional** (wartości pozycji w USD/USDC).
|
||||||
|
- **Maker fee**: zwykle **ujemny** (rebate) dla zleceń maker (np. post-only), zgodnie z aktualnym cennikiem.
|
||||||
|
- Stawki zależą od wolumenu 30D oraz stakingu DRIFT (dodatkowe zniżki / większe rebate).
|
||||||
|
- W **High Leverage Mode** taker fee może być podbite (np. 2× najniższy tier).
|
||||||
|
> TODO: potwierdzić aktualne stawki fee (z Drift SDK / on-chain) i zapisać je jako “source of truth” dla backendu.
|
||||||
|
|
||||||
|
**Wzór (pojedynczy fill):**
|
||||||
|
- `notional = |size_base| * fill_price`
|
||||||
|
- `trade_fee_usd = notional * fee_rate` (dla maker `fee_rate` może być < 0)
|
||||||
|
|
||||||
|
### B. Slippage / spread (koszt rynkowy)
|
||||||
|
To nie jest fee protokołu, ale realny koszt wejścia/wyjścia:
|
||||||
|
- `slippage_cost_usd ≈ (fill_price - mid_price) * size_base` (znak zależy od long/short)
|
||||||
|
- U nas to powinno być liczone z DLOB (L2 + symulacja fill).
|
||||||
|
|
||||||
|
### C. Funding (koszt/zarobek w czasie trzymania pozycji)
|
||||||
|
- Funding jest naliczany w czasie i realizowany przy akcjach użytkownika (trade/deposit/withdraw) – w praktyce dla krótkich holdingów (minuty–1h) zwykle jest małym składnikiem, ale nie zawsze zerowym.
|
||||||
|
|
||||||
|
**Wzór (upraszczając):**
|
||||||
|
- `funding_usd ≈ Σ (position_notional_usd * funding_rate_interval)`
|
||||||
|
|
||||||
|
### D. P&L settlement / “unsettled P&L” (wpływ na withdraw)
|
||||||
|
- Żeby **wypłacić zysk**, czasem trzeba wykonać `settlePNL` (rozlicza P&L do P&L Pool; nie zamyka pozycji, tylko zmienia cost basis).
|
||||||
|
- Jeśli brakuje środków w per-market P&L Pool, zysk może być częściowo **unsettled** i nie będzie w pełni wypłacalny od razu.
|
||||||
|
|
||||||
|
### E. Liquidation penalty (jeśli konto spadnie poniżej maintenance)
|
||||||
|
- Przy wejściu w liquidację protokół najpierw anuluje otwarte ordery/LP, a następnie liquidator może redukować pozycje.
|
||||||
|
- “Penalty/fee” jest ustawiana per-market i zwykle jest wyższa niż zwykły taker fee (żeby dać rebate liquidatorowi).
|
||||||
|
|
||||||
|
### F. Koszt sieci Solana (per instrukcja / per tx)
|
||||||
|
To koszt “infrastrukturalny” każdej akcji on-chain (order, cancel, modify, settlePNL, deposit/withdraw, close).
|
||||||
|
- **Base fee**: 5000 lamports per signature (minimum).
|
||||||
|
- **Priority fee**: opcjonalny, zależy od congestion.
|
||||||
|
- Jednorazowo może dojść **rent/account creation** (np. token account), jeśli czegoś brakuje.
|
||||||
|
|
||||||
|
## 2) “Ile kosztuje” konkretna akcja (checklista)
|
||||||
|
|
||||||
|
### Wejście w pozycję (open / increase)
|
||||||
|
1) **Solana tx fee** (base + ewentualnie priority)
|
||||||
|
2) **Drift trading fee** (maker/taker) od notional
|
||||||
|
3) **Slippage/spread** (z DLOB)
|
||||||
|
4) (w tle) funding zaczyna naliczać się w czasie
|
||||||
|
|
||||||
|
### Zmiana pozycji (increase/decrease/flip)
|
||||||
|
To po prostu kolejny trade:
|
||||||
|
- znowu `tx fee + trading fee + slippage`
|
||||||
|
- oraz często realizacja funding (zależy od tego czy funding został zaktualizowany)
|
||||||
|
|
||||||
|
### Wyjście z pozycji (close)
|
||||||
|
1) `tx fee`
|
||||||
|
2) `trading fee` (druga strona round-trip)
|
||||||
|
3) `slippage`
|
||||||
|
4) **realized PnL** = różnica cen ± funding − fees
|
||||||
|
5) jeśli chcesz wypłacić: możliwe `settlePNL` oraz limit z P&L pool
|
||||||
|
|
||||||
|
### Edycja zlecenia (modify)
|
||||||
|
Zwykle koszt to:
|
||||||
|
- `tx fee` (czasem modify = cancel+place, zależnie od ścieżki w kliencie)
|
||||||
|
- brak trading fee, jeśli nie było fill
|
||||||
|
|
||||||
|
### Cancel zlecenia
|
||||||
|
- `tx fee`
|
||||||
|
- brak trading fee (jeśli 0 fill)
|
||||||
|
|
||||||
|
### Monitorowanie zysku / risk (PnL, margin, health)
|
||||||
|
On-chain: bez kosztu, jeśli tylko czytasz RPC/indexera.
|
||||||
|
Koszt pojawia się dopiero przy akcjach typu trade/cancel/settle/withdraw.
|
||||||
|
|
||||||
|
## 3) Przykład liczbowy (taker, round-trip)
|
||||||
|
|
||||||
|
Załóż:
|
||||||
|
- `notional = 10,000 USDC`
|
||||||
|
- `taker_fee_rate = 0.0350%` (PRZYKŁAD – realna stawka zależy od tieru)
|
||||||
|
|
||||||
|
Wtedy:
|
||||||
|
- wejście: `10,000 * 0.00035 = 3.50 USDC`
|
||||||
|
- wyjście: `3.50 USDC`
|
||||||
|
- razem fee (bez slippage/funding): `7.00 USDC` + 2× Solana tx fee (+ priority jeśli ustawisz).
|
||||||
|
|
||||||
|
## 4) Co musimy znać, żeby liczyć to “dokładnie” w backendzie
|
||||||
|
|
||||||
|
Minimalny zestaw wejść:
|
||||||
|
- market (np. `SOL-PERP`)
|
||||||
|
- order type (market/limit/post-only), przewidywany fill path (taker vs maker)
|
||||||
|
- notional/size, przewidywany fill (DLOB simulation)
|
||||||
|
- fee tier użytkownika + staking/discounty + ew. “fee adjusted markets”
|
||||||
|
- funding history + horyzont (np. 1h/4h/24h/7d)
|
||||||
|
- czy chcemy uwzględniać `settlePNL` oraz status “unsettled PnL” przed withdraw
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Słownik (kluczowe pojęcia w UI/API)
|
||||||
|
|
||||||
|
Poniżej jest skrót pojęć, których używamy w warstwach “Costs (New)” i “Costs (Active)”:
|
||||||
|
|
||||||
|
- `notional` — wartość pozycji w USD (np. 10 USD); na tym liczymy bps i fee.
|
||||||
|
- `bps` (basis points) — punkty bazowe: `1 bps = 0.01% = 0.0001`.
|
||||||
|
Przeliczenie na koszt: `koszt_usd ≈ notional_usd * bps / 10_000`.
|
||||||
|
- `fee` — opłata protokołu Drift (maker/taker) od `notional`; zwykle stała dla danego trybu/tieru.
|
||||||
|
- `tx fee` — koszt transakcji na Solanie (base fee + ewentualny priority fee).
|
||||||
|
- `slippage` — koszt rynkowy wejścia/wyjścia, bo wykonujesz się gorzej niż `mid` (zależy od płynności).
|
||||||
|
- `impact (bps)` — slippage wyrażony w bps (dla danego notionalu).
|
||||||
|
- `spread` — różnica `best_ask - best_bid`; “minimalny” koszt natychmiastowego wejścia/wyjścia w płytkim booku.
|
||||||
|
- `mid` — `(best_bid + best_ask) / 2`; punkt odniesienia ceny z orderbooka.
|
||||||
|
- `VWAP` — średnia cena wykonania dla danego rozmiaru (symulacja fill po L2).
|
||||||
|
- `breakeven (bps)` — minimalny ruch ceny (w bps), żeby koszty się zwróciły (wyjść na 0).
|
||||||
|
- `PnL` (profit and loss) — zysk/strata:
|
||||||
|
- `unrealized PnL` — “na papierze”, gdy pozycja jest otwarta (zależy od ceny teraz),
|
||||||
|
- `realized PnL` — zrealizowany po zamknięciu (lub częściowym zamknięciu) pozycji,
|
||||||
|
- `net PnL` — PnL po odjęciu kosztów (`fee + tx + slippage + funding`).
|
||||||
|
- `funding` — okresowa płatność long↔short; koszt albo zysk zależny od rynku i czasu trzymania.
|
||||||
|
- `close now` — estymata kosztu natychmiastowego zamknięcia pozycji (zwykle po przeciwnej stronie booka).
|
||||||
|
- `modify` / `reprice` — koszt “zarządzania zleceniem” (cancel+place itp.), głównie `tx fee` (czasem wielokrotnie).
|
||||||
109
doc/drift-data-bez-solana-rpc.md
Normal file
109
doc/drift-data-bez-solana-rpc.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Drift / Solana: czy mamy dostęp do danych bez Solana RPC?
|
||||||
|
|
||||||
|
Pytanie ma dwa znaczenia — rozdzielmy je jasno:
|
||||||
|
|
||||||
|
1) **bez własnego (bare metal) RPC** — czyli nie utrzymujemy swojego `solana-validator --rpc`, ale korzystamy z dostawcy RPC albo zewnętrznych serwisów,
|
||||||
|
2) **bez żadnego RPC w ogóle** — czyli nikt w naszym systemie nie pyta Solany o stan on‑chain.
|
||||||
|
|
||||||
|
TL;DR:
|
||||||
|
- **Bez własnego RPC**: tak, da się na start (hosted RPC +/lub serwisy zewnętrzne).
|
||||||
|
- **Bez żadnego RPC**: tylko częściowo (dane “rynkowe” można brać z zewnętrznego DLOB), ale **stan konta/pozycji/fille/funding** i tak pochodzi z chaina, więc ktoś musi mieć RPC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Co z Twojego “speca” da się mieć bez własnego RPC?
|
||||||
|
|
||||||
|
Poniżej mapowanie kategorii danych (z Twojego opisu A–F) na źródła:
|
||||||
|
|
||||||
|
### B) Prices / microstructure (oracle/mark/BBO, “close now”)
|
||||||
|
|
||||||
|
**Da się bez własnego RPC**:
|
||||||
|
- Tak. W naszym stacku te dane mogą pochodzić z pipeline DLOB (`dlob_*_latest`) i ticków (`drift_ticks`), które są już w DB i dostępne przez Hasurę / `trade-api`.
|
||||||
|
|
||||||
|
**Da się bez żadnego RPC w naszej infra** (czyli “my nie łączymy się do RPC”):
|
||||||
|
- Częściowo tak, jeśli polegamy na zewnętrznym źródle L2/BBO (np. `https://dlob.drift.trade`) — ale to źródło i tak jest zasilane przez czyjeś RPC.
|
||||||
|
|
||||||
|
### A) Position snapshot (pozycja: base, entry, side)
|
||||||
|
|
||||||
|
**Bez własnego RPC**:
|
||||||
|
- Tak, jeśli mamy **jakikolwiek** komponent (executor/collector) korzystający z hosted RPC (Helius/QuickNode/itp.) i zapisujący snapshot pozycji do DB.
|
||||||
|
|
||||||
|
**Bez żadnego RPC**:
|
||||||
|
- Praktycznie nie (pozycja jest stanem konta on‑chain). Wyjątek: jeśli Twój executor/bot sam utrzymuje lokalny stan i zapisuje go do DB — ale po restarcie i tak potrzebujesz reconcile z chaina (czyli RPC).
|
||||||
|
|
||||||
|
### C) Account risk (margin/liquidation/health)
|
||||||
|
|
||||||
|
**Bez własnego RPC**:
|
||||||
|
- Tak, jeśli collector liczy to na backendzie z danych Drift (przez hosted RPC) i zapisuje do TS (`contract_metrics_ts` / analogicznie).
|
||||||
|
|
||||||
|
**Bez żadnego RPC**:
|
||||||
|
- Nie, bo margin/liq zależy od stanu konta i parametrów rynku on‑chain.
|
||||||
|
|
||||||
|
### D) Fills / trades (realized PnL + fees + slippage)
|
||||||
|
|
||||||
|
**Bez własnego RPC**:
|
||||||
|
- Tak, jeśli:
|
||||||
|
- executor składa zlecenia i loguje fille do `bot_events` (to już mamy jako koncept), albo
|
||||||
|
- collector subskrybuje eventy transakcji / kont przez hosted RPC i zapisuje fille do DB.
|
||||||
|
|
||||||
|
**Bez żadnego RPC**:
|
||||||
|
- Tylko jeśli fille są już zapisane w DB (np. przez bota). Na bieżąco — ktoś musi je wyciągać z chaina.
|
||||||
|
|
||||||
|
### E) Funding / payments
|
||||||
|
|
||||||
|
**Bez własnego RPC**:
|
||||||
|
- Tak, ale ktoś musi pobierać funding rate / funding payment (hosted RPC lub inny feed) i zapisywać do DB.
|
||||||
|
|
||||||
|
**Bez żadnego RPC**:
|
||||||
|
- Jak wyżej: tylko z historii zapisanej w DB; na żywo potrzebujesz źródła z chaina.
|
||||||
|
|
||||||
|
### F) Order lifecycle costs (cancel/replace/tx)
|
||||||
|
|
||||||
|
**Bez własnego RPC**:
|
||||||
|
- Tak, jeśli executor:
|
||||||
|
- loguje akcje (create/cancel/replace) i ich koszty (`tx_fee_usd`, priority fee) do `bot_events`, albo
|
||||||
|
- collector wyciąga metryki tx z RPC i mapuje do orderów.
|
||||||
|
|
||||||
|
**Bez żadnego RPC**:
|
||||||
|
- Tylko retrospektywnie (jeśli już w DB).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Co to znaczy praktycznie dla architektury “backend liczy, UI tylko wyświetla”?
|
||||||
|
|
||||||
|
UI/Visualizer **może działać bez bezpośredniego kontaktu z RPC** (łączy się do `trade-api` + Hasura).
|
||||||
|
|
||||||
|
Natomiast backend “compute” (k3s) ma dwie opcje zasilania:
|
||||||
|
|
||||||
|
1) **Hosted RPC** (na start)
|
||||||
|
- pro: szybciej, taniej, mniej ops,
|
||||||
|
- con: limit subskrypcji/WS, możliwe rwania, vendor lock‑in.
|
||||||
|
|
||||||
|
2) **Własny RPC + Geyser/Yellowstone** (docelowo)
|
||||||
|
- pro: kontrola, stabilność na większej skali, streaming “pro”,
|
||||||
|
- con: koszt i ops (dyski/IO, tuning, monitoring).
|
||||||
|
|
||||||
|
W obu przypadkach “backend liczy” działa tak samo — różni się tylko źródło surowych danych.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Co już mamy w DB “bez RPC w UI”
|
||||||
|
|
||||||
|
Z obecnego pipeline (VPS/k3s) mamy “rynkowe” dane pod BBO/slippage:
|
||||||
|
- `dlob_l2_latest`, `dlob_stats_latest`, `dlob_slippage_latest`, `dlob_depth_bps_latest` (+ TS przez `*_ts`)
|
||||||
|
- `drift_ticks` (ticki/ceny)
|
||||||
|
|
||||||
|
To wystarcza do:
|
||||||
|
- estymat wejścia/wyjścia (“close now”),
|
||||||
|
- wykresów spread/slippage/depth,
|
||||||
|
- części SIM (model slippage/fee) **bez** znajomości pełnego stanu konta.
|
||||||
|
|
||||||
|
Do pełnego `contract_metrics_ts` (PnL + risk) brakuje nam jeszcze stałego feedu:
|
||||||
|
- pozycji konta + margin/liq,
|
||||||
|
- filli i funding (albo z chaina, albo z logów executora).
|
||||||
|
|
||||||
|
## Zobacz też
|
||||||
|
|
||||||
|
- “Kanoniczna” architektura w pełni self-hosted (RPC + DLOB): `doc/rpc-dlob-kanoniczna-architektura.md`
|
||||||
|
- Runbook: bare metal RPC + Geyser/Yellowstone gRPC: `doc/solana-rpc-geyser-setup.md`
|
||||||
|
- Mapa dokumentów o RPC/DLOB/metrykach: `doc/solana-rpc.md`
|
||||||
262
doc/drift-perp-contract.md
Normal file
262
doc/drift-perp-contract.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# Drift PERP “kontrakt bota” (SOL-PERP) — spec intent → egzekucja → audyt
|
||||||
|
|
||||||
|
Ten dokument definiuje **przyszłościowy** kontrakt między:
|
||||||
|
- **Vast (model/transformer na GPU)**: generuje *trade intent* (bez sekretów),
|
||||||
|
- **k3s/VPS (executor)**: waliduje ryzyko, wystawia i prowadzi zlecenia na Drift, loguje zdarzenia,
|
||||||
|
- **UI (visualizer)**: tylko wizualizuje warstwy i stan kontraktów (live + historia).
|
||||||
|
|
||||||
|
Kluczowa zasada: **model nigdy nie ma kluczy** i nie “handluje”. Handluje tylko executor w k3s.
|
||||||
|
|
||||||
|
Powiązane:
|
||||||
|
- Strategia “eskalacja horyzontu” (1m→5m→15m→30m→1h z bramkami): `doc/strategy-eskalacja-horyzontu.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Co nazywamy “kontraktem” (u nas vs Drift)
|
||||||
|
|
||||||
|
Na Drift istnieją:
|
||||||
|
- **orders** (zlecenia): limit/market/trigger, post-only, reduce-only, IOC/GTC itd.
|
||||||
|
- **position** (pozycja): rozmiar, kierunek, średnia cena, PnL itd.
|
||||||
|
- **konto/margin**: collateral i health.
|
||||||
|
|
||||||
|
W naszym systemie **kontrakt bota** to byt aplikacyjny (DB + logika), który:
|
||||||
|
1) opisuje *intent* (wejście + prowadzenie + wyjście),
|
||||||
|
2) mapuje intent na 1..N orderów na Drift,
|
||||||
|
3) jest **idempotentny** (nie dubluje orderów po restarcie),
|
||||||
|
4) jest **modyfikowalny** (cancel+place / zmiana desired state),
|
||||||
|
5) jest **kończony** (exit policy lub kill-switch),
|
||||||
|
6) jest **audytowalny** (pełny log decyzji i akcji).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Wybór “lepszy i przyszłościowy”
|
||||||
|
|
||||||
|
### A) Cena jako offset, nie absolutna cena (recommended)
|
||||||
|
|
||||||
|
Model zwraca cenę wejścia/wyjścia jako **offset** (ticks/bps) względem top-of-book / mid, a nie jako `limit_price`.
|
||||||
|
|
||||||
|
Dlaczego:
|
||||||
|
- odporniejsze na latency (cena się przesuwa, offset pozostaje sensowny),
|
||||||
|
- łatwiejsze “reprice” (edit policy jest naturalna),
|
||||||
|
- mniejsze ryzyko “starej ceny” przy krótkim TTL.
|
||||||
|
|
||||||
|
Executor i tak zna:
|
||||||
|
- `best_bid/best_ask/mid`,
|
||||||
|
- tick size i step size,
|
||||||
|
- aktualne gates (spread/slippage/depth/freshness).
|
||||||
|
|
||||||
|
### B) Desired-state jako rdzeń (recommended)
|
||||||
|
|
||||||
|
Kontrakt jest prowadzony jako **desired-state loop**:
|
||||||
|
- model/kontrakt mówi “co chcę mieć” (np. `target_exposure_usd`),
|
||||||
|
- executor porównuje “observed vs desired” i wykonuje minimalne akcje.
|
||||||
|
|
||||||
|
To upraszcza:
|
||||||
|
- edycję (zmiana target, update policy),
|
||||||
|
- reconcile po restarcie,
|
||||||
|
- panic exit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Role: Vast vs executor (k3s)
|
||||||
|
|
||||||
|
### Vast (model) zwraca
|
||||||
|
- kompletny **trade_intent**: parametry wejścia/prowadzenia/wyjścia,
|
||||||
|
- sugestie gates (np. spread/slippage/depth), **ale** nie może ich omijać,
|
||||||
|
- `confidence/urgency` (metadata).
|
||||||
|
|
||||||
|
### Executor (k3s) jest “single source of execution”
|
||||||
|
- waliduje gates i limity,
|
||||||
|
- normalizuje do tick/step,
|
||||||
|
- nadaje idempotentne `client_order_id`,
|
||||||
|
- składa/canceluje/zamyka (reduce-only),
|
||||||
|
- prowadzi state machine,
|
||||||
|
- loguje eventy i mierzy koszty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Spec: `trade_intent` (Vast → k3s)
|
||||||
|
|
||||||
|
Format jest wersjonowany: `intent_schema_version`.
|
||||||
|
|
||||||
|
### 4.1 Minimalny szkielet
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"intent_schema_version": 1,
|
||||||
|
"decision_id": "ulid-or-uuid",
|
||||||
|
"bot_id": "bot-sol-perp-01",
|
||||||
|
"ts": "2026-01-31T00:00:00.000Z",
|
||||||
|
"ttl_ms": 15000,
|
||||||
|
|
||||||
|
"market_name": "SOL-PERP",
|
||||||
|
"subaccount_id": 0,
|
||||||
|
|
||||||
|
"mode": "enter|manage|exit|panic",
|
||||||
|
"confidence": 0.0,
|
||||||
|
"urgency": 0.0,
|
||||||
|
|
||||||
|
"desired": {
|
||||||
|
"target_exposure_usd": 0,
|
||||||
|
"max_position_usd": 200,
|
||||||
|
"min_trade_usd": 5
|
||||||
|
},
|
||||||
|
|
||||||
|
"entry": {
|
||||||
|
"side": "long|short",
|
||||||
|
"order_type": "post_only_limit|limit|market",
|
||||||
|
"size_usd": 25,
|
||||||
|
"limit_offset": { "ref": "best_bid|best_ask|mid", "ticks": 1 },
|
||||||
|
"time_in_force": "GTC|IOC",
|
||||||
|
"cancel_if_not_filled_ms": 8000
|
||||||
|
},
|
||||||
|
|
||||||
|
"manage": {
|
||||||
|
"reprice_after_ms": 750,
|
||||||
|
"reprice_offset_ticks": 1,
|
||||||
|
"max_reprices_per_min": 30,
|
||||||
|
"cooldown_ms": 250
|
||||||
|
},
|
||||||
|
|
||||||
|
"exit": {
|
||||||
|
"max_hold_s": 180,
|
||||||
|
"stop_loss_bps": 25,
|
||||||
|
"take_profit_bps": 35,
|
||||||
|
"exit_order_type": "reduce_only_limit|reduce_only_market",
|
||||||
|
"exit_limit_offset": { "ref": "best_bid|best_ask|mid", "ticks": 1 }
|
||||||
|
},
|
||||||
|
|
||||||
|
"gates": {
|
||||||
|
"freshness_max_ms": 1500,
|
||||||
|
"max_spread_bps": 10,
|
||||||
|
"max_slippage_bps": 25,
|
||||||
|
"min_depth_topn_usd": 5000,
|
||||||
|
"min_depth_band": { "band_bps": 20, "min_usd": 8000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Zasady interpretacji
|
||||||
|
|
||||||
|
- `ttl_ms`: po tym czasie executor ma prawo *zignorować* intent (stary sygnał).
|
||||||
|
- `mode`:
|
||||||
|
- `enter`: wolno otwierać/rozszerzać pozycję,
|
||||||
|
- `manage`: tylko zarządzanie już istniejącą pozycją/ordreami (bez zwiększania ryzyka),
|
||||||
|
- `exit`: przejście do `target_exposure_usd=0` i zamykanie,
|
||||||
|
- `panic`: natychmiast cancel + close (reduce-only), potem `off`.
|
||||||
|
- `desired.target_exposure_usd` jest źródłem prawdy, ale executor ma **hard cap** `max_position_usd` (model może sugerować, executor egzekwuje).
|
||||||
|
- `limit_offset`:
|
||||||
|
- `ref=best_bid` dla wejścia long (maker),
|
||||||
|
- `ref=best_ask` dla wejścia short,
|
||||||
|
- ticks/bps są zaokrąglane do tick size rynku.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Mapowanie `trade_intent` → Drift order params (PERP)
|
||||||
|
|
||||||
|
Executor buduje “order template” (pseudopola):
|
||||||
|
|
||||||
|
- `marketIndex` (wynik mapowania `market_name`)
|
||||||
|
- `direction`: `long|short`
|
||||||
|
- `orderType`: `market|limit` (+ `trigger*` jeśli później dodamy SL/TP jako ordery trigger)
|
||||||
|
- `baseAssetAmount` (z `size_usd` przeliczone do base i zaokrąglone do `baseStepSize`)
|
||||||
|
- `price` (dla limit): z `limit_offset` + aktualne top-of-book, zaokrąglone do `priceTickSize`
|
||||||
|
- `postOnly`: true, jeśli `order_type=post_only_limit`
|
||||||
|
- `reduceOnly`: true na wyjściu (`exit_order_type=reduce_only_*`)
|
||||||
|
- `immediateOrCancel`: true, jeśli `time_in_force=IOC`
|
||||||
|
- `clientOrderId` / `userOrderId`: deterministycznie z `decision_id` (patrz niżej)
|
||||||
|
|
||||||
|
### 5.1 Idempotencja: `client_order_id`
|
||||||
|
|
||||||
|
Wymaganie: po restarcie executora nie może dojść do “double order”.
|
||||||
|
|
||||||
|
Zasada:
|
||||||
|
- każde wejście/wyjście ma stabilne `client_order_id` wywiedzione z `decision_id`,
|
||||||
|
- jeśli Drift nie wspiera pełnego “modify”, executor robi `cancel + place` ale zachowuje spójne ID (np. `decision_id` + suffix `-r1`, `-r2` dla reprices).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) State machine kontraktu (minimal)
|
||||||
|
|
||||||
|
Rekomendowane stany:
|
||||||
|
- `off` (nie handluje)
|
||||||
|
- `pending_entry` (intent zaakceptowany, order wysłany)
|
||||||
|
- `entered` (pozycja ≠ 0 lub entry fill)
|
||||||
|
- `managing` (utrzymuje desired state / repricing)
|
||||||
|
- `exiting` (reduce-only close w toku)
|
||||||
|
- `closed` (pozycja 0, brak orderów)
|
||||||
|
- `rejected` (gates fail / TTL expired)
|
||||||
|
- `panic` (cancel+close; potem `off`)
|
||||||
|
|
||||||
|
Każda zmiana stanu = event do DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Co UI wizualizuje (warstwy) a co liczy backend
|
||||||
|
|
||||||
|
UI nie liczy. UI:
|
||||||
|
- subskrybuje `*_latest` (live),
|
||||||
|
- pobiera `*_ts` (historia),
|
||||||
|
- renderuje warstwy i kontrakty.
|
||||||
|
|
||||||
|
Backend liczy:
|
||||||
|
- DLOB: `dlob_*_latest` + (docelowo) `dlob_*_ts`
|
||||||
|
- ticks/candles: `drift_ticks` + `get_drift_candles(...)`
|
||||||
|
- kontrakty: `bot_intents` / `bot_contracts` / `bot_events` (+ TS wersje)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Metryki do strojenia (co mierzyć i jakie okna czasowe)
|
||||||
|
|
||||||
|
Cel: stroić gates, politykę repricing i parametry exit.
|
||||||
|
|
||||||
|
### 8.1 Mikrostruktura (gates) — z czego stroimy progi
|
||||||
|
Źródła: `dlob_stats_*`, `dlob_depth_*`, `dlob_slippage_*`.
|
||||||
|
|
||||||
|
Mierz (per market, live + TS):
|
||||||
|
- `spread_bps`
|
||||||
|
- `impact_bps` dla docelowych `size_usd` (buy/sell)
|
||||||
|
- `depth_bid_usd`, `depth_ask_usd` (top‑N)
|
||||||
|
- depth w bandach (`band_bps`): `bid_usd/ask_usd`
|
||||||
|
- `freshness_ms` (now - updated_at)
|
||||||
|
|
||||||
|
Okno strojenia:
|
||||||
|
- start: **7 dni** TS (wystarczy do percentyli i pór doby),
|
||||||
|
- docelowo: 30+ dni (downsample 1m/5m) dla stabilniejszych reżimów.
|
||||||
|
|
||||||
|
Jak stroić:
|
||||||
|
- progi na percentylach (P90/P95), nie na średniej,
|
||||||
|
- osobne percentyle per “godzina doby” (płynność) i per “vol regime”.
|
||||||
|
|
||||||
|
### 8.2 Jakość egzekucji (czy entry/manage działa)
|
||||||
|
Źródła: `bot_events` (audyt) + snapshoty z momentu decyzji.
|
||||||
|
|
||||||
|
Mierz per kontrakt:
|
||||||
|
- `time_to_first_fill_ms`, `time_to_full_fill_ms`
|
||||||
|
- `fill_pct`, `avg_fill_price`
|
||||||
|
- `reprice_count`, `cancel_count`
|
||||||
|
- `expected_execution_bps` (z DLOB w chwili decyzji) vs `realized_execution_bps`
|
||||||
|
- “churn cost”: `tx_count` i (jeśli liczymy) `priority_fee` sumarycznie
|
||||||
|
|
||||||
|
Okno strojenia:
|
||||||
|
- 24h (szybki smoke po deployu),
|
||||||
|
- 7 dni (tuning),
|
||||||
|
- 30 dni (stabilizacja).
|
||||||
|
|
||||||
|
### 8.3 Wynik i ryzyko (czy strategia ma edge)
|
||||||
|
Mierz:
|
||||||
|
- `hold_time_s`
|
||||||
|
- reason exit: `tp|sl|time|regime|panic`
|
||||||
|
- MAE/MFE w bps w trakcie hold
|
||||||
|
- PnL (jeśli macie komplet danych) albo proxy w bps
|
||||||
|
|
||||||
|
Okno:
|
||||||
|
- 7 dni minimalnie, ale sensowniej 30+ dni (reżimy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Następny krok implementacyjny (po akceptacji)
|
||||||
|
|
||||||
|
1) Dodać tabele TS dla warstw (min. 7 dni retencji).
|
||||||
|
2) Dodać tabele i logi kontraktów (`bot_contracts`, `bot_events`, opcjonalnie `bot_intents_ts`).
|
||||||
|
3) Dodać “executor” (observe → dry-run → live) oraz integrację Vast.
|
||||||
165
doc/k3s-runtime-map.md
Normal file
165
doc/k3s-runtime-map.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# k3s runtime map (VPS `qstack`) — co działa i po co
|
||||||
|
|
||||||
|
Ten dokument opisuje **aktualny runtime na VPS** (k3s) dla projektu `trade`: jakie komponenty działają w klastrze, jak płynie dane oraz które tabele/metrki są “źródłem prawdy” dla UI.
|
||||||
|
|
||||||
|
Zakładamy namespace: `trade-staging`.
|
||||||
|
|
||||||
|
## TL;DR (logika)
|
||||||
|
|
||||||
|
- **Dane są zbierane i liczone na backendzie** (k3s).
|
||||||
|
- UI (`trade-frontend`) **tylko wizualizuje** i proxy’uje ruch (API + GraphQL + WS).
|
||||||
|
- Hasura to **jedyny GraphQL/WS** na “metrics/live” (subscriptions).
|
||||||
|
- Postgres/Timescale trzyma:
|
||||||
|
- ticki (`drift_ticks`) + candles (funkcja `get_drift_candles`)
|
||||||
|
- “latest” warstw DLOB (`dlob_*_latest`)
|
||||||
|
- (opcjonalnie) historię warstw (`dlob_*_ts`)
|
||||||
|
|
||||||
|
## Mapa (ruch z zewnątrz)
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Ingress/Traefik
|
||||||
|
|
|
||||||
|
+--> trade-frontend (https://trade.mpabi.pl)
|
||||||
|
| - /api -> trade-api
|
||||||
|
| - /graphql -> hasura
|
||||||
|
| - /graphql-ws -> hasura (WS subscriptions; protokół graphql-ws)
|
||||||
|
|
|
||||||
|
+--> (opcjonalnie inne ingressy w tym samym klastrze)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `trade-frontend` (UI + reverse-proxy)
|
||||||
|
|
||||||
|
- **Rola:** UI + proxy do usług w klastrze.
|
||||||
|
- **Dlaczego:** przeglądarka nie dostaje sekretów; token read jest wstrzykiwany server‑side, a WS działa przez proxy.
|
||||||
|
- **Wejście:** HTTP/WS od użytkownika.
|
||||||
|
- **Wyjście:**
|
||||||
|
- `/api/*` → `trade-api`
|
||||||
|
- `/graphql` (HTTP) → `hasura`
|
||||||
|
- `/graphql-ws` (WS) → `hasura`
|
||||||
|
|
||||||
|
## Mapa (dane rynkowe — ticki / candles)
|
||||||
|
|
||||||
|
```
|
||||||
|
Solana RPC/WS (zewn.)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
trade-ingestor
|
||||||
|
|
|
||||||
|
v
|
||||||
|
trade-api -> Postgres/Timescale (drift_ticks)
|
||||||
|
|
|
||||||
|
+--> /v1/chart (candles + wskaźniki liczone na backendzie)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Hasura (GraphQL query/subscriptions dla wybranych tabel)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `trade-ingestor`
|
||||||
|
|
||||||
|
- **Rola:** pobiera dane (oracle/mark) i wysyła ticki do API.
|
||||||
|
- **Wyjście:** ticki zapisane do `drift_ticks` przez `trade-api`.
|
||||||
|
|
||||||
|
### `trade-api`
|
||||||
|
|
||||||
|
- **Rola:** API dla UI i algów (healthz, ticks, chart).
|
||||||
|
- **DB:** zapis do `drift_ticks`.
|
||||||
|
- **Agregacje:** candles (`get_drift_candles`) + wskaźniki (backend).
|
||||||
|
|
||||||
|
## Mapa (DLOB — orderbook + metryki warstw)
|
||||||
|
|
||||||
|
```
|
||||||
|
Solana RPC/WS (zewn.)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
dlob-publisher ----> dlob-redis <---- dlob-server (/l2, /l3)
|
||||||
|
\
|
||||||
|
\ (opcjonalne źródło L2)
|
||||||
|
v
|
||||||
|
dlob-worker (kolektor L2)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Postgres/Hasura: dlob_l2_latest + dlob_stats_latest
|
||||||
|
|
|
||||||
|
+------------+------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
dlob-depth-worker dlob-slippage-worker
|
||||||
|
(bands ±bps) (impact vs size USD)
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
Postgres/Hasura: dlob_depth_bps_latest Postgres/Hasura: dlob_slippage_latest
|
||||||
|
|
|
||||||
|
v
|
||||||
|
(opcjonalnie) dlob-ts-archiver
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Postgres/Timescale: dlob_stats_ts / dlob_depth_bps_ts / dlob_slippage_ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### `dlob-publisher`
|
||||||
|
|
||||||
|
- **Rola:** utrzymuje “żywy” DLOB (on‑chain) i publikuje snapshoty do Redis.
|
||||||
|
- **Wejście:** Solana RPC/WS.
|
||||||
|
- **Wyjście:** publikacja do `dlob-redis`.
|
||||||
|
|
||||||
|
### `dlob-redis`
|
||||||
|
|
||||||
|
- **Rola:** cache/pubsub pomiędzy publisherem i serwerem HTTP.
|
||||||
|
|
||||||
|
### `dlob-server`
|
||||||
|
|
||||||
|
- **Rola:** serwuje REST `/l2` i `/l3` na podstawie cache Redis (do debugowania i/lub jako źródło L2).
|
||||||
|
|
||||||
|
### `dlob-worker` (kolektor L2 + “basic stats”)
|
||||||
|
|
||||||
|
- **Rola:** pobiera snapshoty L2 i liczy podstawowe metryki (`dlob_stats_latest`).
|
||||||
|
- **Źródło L2:** w praktyce:
|
||||||
|
- albo zewnętrzne `https://dlob.drift.trade/l2`
|
||||||
|
- albo wewnętrzne `http://dlob-server:6969/l2` (jeśli tak ustawione)
|
||||||
|
- **Zapis do tabel:**
|
||||||
|
- `dlob_l2_latest` (raw L2)
|
||||||
|
- `dlob_stats_latest` (bid/ask/mid/spread/depth/imbalance)
|
||||||
|
|
||||||
|
### `dlob-depth-worker` (depth bands ±bps)
|
||||||
|
|
||||||
|
- **Rola:** liczy płynność w pasmach ±bps wokół mid.
|
||||||
|
- **Źródło:** `dlob_l2_latest`
|
||||||
|
- **Zapis:** `dlob_depth_bps_latest` (klucz: `market_name + band_bps`)
|
||||||
|
|
||||||
|
### `dlob-slippage-worker` (slippage vs size)
|
||||||
|
|
||||||
|
- **Rola:** symuluje market fill po L2 dla zadanych rozmiarów (USD) i liczy `impact_bps`.
|
||||||
|
- **Źródło:** `dlob_l2_latest`
|
||||||
|
- **Zapis:** `dlob_slippage_latest` (klucz: `market_name + side + size_usd`)
|
||||||
|
|
||||||
|
### `dlob-ts-archiver` (historia warstw)
|
||||||
|
|
||||||
|
- **Rola:** zapisuje “timeline” dla warstw do hypertabli Timescale (historia pod UI).
|
||||||
|
- **Źródło:** `dlob_stats_latest`, `dlob_depth_bps_latest`, `dlob_slippage_latest`
|
||||||
|
- **Zapis:** `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`
|
||||||
|
- **Retencja (startowo):** ~7 dni (policy w Timescale).
|
||||||
|
|
||||||
|
## Co UI realnie czyta (dla “nowych funkcji”)
|
||||||
|
|
||||||
|
- live/subscriptions:
|
||||||
|
- `dlob_stats_latest`
|
||||||
|
- `dlob_depth_bps_latest`
|
||||||
|
- `dlob_slippage_latest`
|
||||||
|
- (jeśli dołączymy w UI wykres “historia”):
|
||||||
|
- `dlob_stats_ts`
|
||||||
|
- `dlob_depth_bps_ts`
|
||||||
|
- `dlob_slippage_ts`
|
||||||
|
|
||||||
|
## Najczęstsze miejsca problemów (diagnostyka)
|
||||||
|
|
||||||
|
- Jeśli UI nie pokazuje warstw:
|
||||||
|
- sprawdź czy Hasura trackuje tabele i ma `public select` (bootstrap job),
|
||||||
|
- sprawdź, czy workery odświeżają `updated_at` w `dlob_*_latest`.
|
||||||
|
- Jeśli `dlob-worker` loguje 503:
|
||||||
|
- to zwykle problem na ścieżce `DLOB_HTTP_URL` (upstream/LB/IPv6) — wtedy przełącz na `http://dlob-server:6969`.
|
||||||
|
- Jeśli WS subscriptions nie łączą:
|
||||||
|
- sprawdź proxy `/graphql-ws` w `trade-frontend` i origin/CORS w Hasurze.
|
||||||
|
|
||||||
115
doc/rpc-dlob-kanoniczna-architektura.md
Normal file
115
doc/rpc-dlob-kanoniczna-architektura.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Kanoniczna architektura Drift: własny Solana RPC + własny DLOB
|
||||||
|
|
||||||
|
Poniżej jest krótka i konkretna notatka: **co da się wyciągnąć z własnego Solana RPC** oraz **po co jest własny DLOB** w kontekście Drift (perp) i metryk pod trading/SIM.
|
||||||
|
|
||||||
|
## 1) Własny Solana RPC — co z niego wyciągniesz
|
||||||
|
|
||||||
|
Z **własnego RPC** (full node, a do backfillu najlepiej archival) możesz pobrać **wszystkie dane kontowe i ryzyko**.
|
||||||
|
|
||||||
|
### Dane pozycji i konta (RPC)
|
||||||
|
|
||||||
|
- pozycja (long/short, size, entry)
|
||||||
|
- unrealized PnL
|
||||||
|
- realized PnL (z konta użytkownika / fills)
|
||||||
|
- margin, free collateral
|
||||||
|
- liquidation price
|
||||||
|
- health / margin ratio
|
||||||
|
- funding (naliczony + historyczny)
|
||||||
|
|
||||||
|
Źródło: **konta programu Drift** (User, PerpMarket, SpotMarket). Technicznie: subskrypcje kont + (jeśli potrzebne) `getProgramAccounts`.
|
||||||
|
|
||||||
|
### Fills / transakcje / fees (RPC)
|
||||||
|
|
||||||
|
- fill price
|
||||||
|
- fee (maker/taker)
|
||||||
|
- reduce / add
|
||||||
|
- tx fee + priority fee
|
||||||
|
|
||||||
|
Źródła:
|
||||||
|
- logi transakcji,
|
||||||
|
- eventy Drift,
|
||||||
|
- historia transakcji walleta.
|
||||||
|
|
||||||
|
Uwaga: backfill 7d+ jest ciężki bez archival RPC, ale nadal wykonalny (koszt/IO/limity).
|
||||||
|
|
||||||
|
### Ceny (RPC)
|
||||||
|
|
||||||
|
- oracle price
|
||||||
|
- mark price (ze stanu rynku)
|
||||||
|
|
||||||
|
W praktyce wystarczy RPC + subskrypcje kont.
|
||||||
|
|
||||||
|
## 2) Własny DLOB — po co i co daje
|
||||||
|
|
||||||
|
**DLOB jest off-chain**, ale jest budowany z **on-chain zleceń limit**.
|
||||||
|
|
||||||
|
Co daje DLOB (i to jest kluczowe do “close now” i slippage):
|
||||||
|
- best bid / best ask (BBO)
|
||||||
|
- mid price
|
||||||
|
- spread
|
||||||
|
- realistyczny slippage
|
||||||
|
- sensowne “close now cost” (na podstawie top-of-book / L2)
|
||||||
|
|
||||||
|
Bez DLOB zwykle zostaje heurystyka na mark/oracle + założony spread/slippage.
|
||||||
|
|
||||||
|
### Jak to zrobić praktycznie
|
||||||
|
|
||||||
|
Najprostsza opcja to uruchomienie serwisu DLOB (publisher/server) z Drift SDK, który:
|
||||||
|
- subskrybuje RPC/WS,
|
||||||
|
- buduje orderbook,
|
||||||
|
- wystawia API (BBO/depth itp.),
|
||||||
|
- a worker liczy metryki (spread/depth/slippage) i zapisuje je do DB.
|
||||||
|
|
||||||
|
W tym repo mamy opis aktualnego pipeline DLOB w `doc/dlob-services.md` oraz plan “RPC + Geyser/Yellowstone” w `doc/solana-rpc-geyser-setup.md`.
|
||||||
|
|
||||||
|
## 3) Mapowanie: metryki → źródło danych
|
||||||
|
|
||||||
|
| Metryka | RPC | DLOB |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| unrealized PnL | ✅ | ❌ |
|
||||||
|
| realized / net PnL | ✅ | ❌ |
|
||||||
|
| fees / funding / tx | ✅ | ❌ |
|
||||||
|
| margin / liq / health | ✅ | ❌ |
|
||||||
|
| time in trade | ✅ | ❌ |
|
||||||
|
| best bid / ask | ❌ | ✅ |
|
||||||
|
| spread / mid | ❌ | ✅ |
|
||||||
|
| close now cost | ⚠️ heurystyka | ✅ |
|
||||||
|
| expected slippage | ⚠️ | ✅ |
|
||||||
|
|
||||||
|
## 4) 100% self-hosted (bez vendor lock‑in)
|
||||||
|
|
||||||
|
Da się zrobić w pełni self-hosted (bez Heliusa/cudzych API).
|
||||||
|
|
||||||
|
Prosty diagram:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Solana RPC (+ WS) ]
|
||||||
|
↓
|
||||||
|
[ Drift SDK / subscriptions ]
|
||||||
|
↓
|
||||||
|
[ DLOB (publisher/server) ]
|
||||||
|
↓
|
||||||
|
[ Worker (metrics TS) ]
|
||||||
|
↓
|
||||||
|
[ API / Monitor / SIM ]
|
||||||
|
↓
|
||||||
|
[ UI (tylko rysuje) ]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) Jedyny realny haczyk (operacyjnie)
|
||||||
|
|
||||||
|
- `getProgramAccounts` + websockety wymagają solidnego RPC.
|
||||||
|
- Tanie/limtowane RPC często:
|
||||||
|
- blokują/limitują GPA,
|
||||||
|
- ucinają payload,
|
||||||
|
- dropią WS.
|
||||||
|
|
||||||
|
Własny RPC = stabilność i przewidywalność na większej skali.
|
||||||
|
|
||||||
|
## 6) TL;DR
|
||||||
|
|
||||||
|
- Tak: wyciągniesz wszystko z własnego RPC + własnego DLOB.
|
||||||
|
- RPC = pozycja, PnL, ryzyko, funding.
|
||||||
|
- DLOB = bid/ask, spread, slippage, close-now.
|
||||||
|
- To pasuje idealnie pod scalping + SIM (backend liczy, UI tylko wyświetla).
|
||||||
|
|
||||||
211
doc/rpc/topol.html
Normal file
211
doc/rpc/topol.html
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Professional Drift Trading Stack (Own Solana RPC + Own DLOB)</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 20px;
|
||||||
|
max-width: 980px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
header { margin-bottom: 24px; }
|
||||||
|
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
||||||
|
.subtitle { opacity: 0.85; margin: 0; }
|
||||||
|
.card {
|
||||||
|
border: 1px solid rgba(127,127,127,0.35);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px 18px;
|
||||||
|
margin: 14px 0;
|
||||||
|
background: rgba(127,127,127,0.05);
|
||||||
|
}
|
||||||
|
h2 { font-size: 1.2rem; margin: 0 0 10px; }
|
||||||
|
h3 { font-size: 1.05rem; margin: 14px 0 8px; }
|
||||||
|
ul { margin: 8px 0 0 18px; }
|
||||||
|
li { margin: 6px 0; }
|
||||||
|
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
|
.note {
|
||||||
|
border-left: 4px solid rgba(127,127,127,0.55);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
background: rgba(127,127,127,0.07);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid rgba(127,127,127,0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Professional Drift Trading Stack</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Own Solana RPC + Own Drift DLOB (Orderbook). Main rule:
|
||||||
|
<strong>keep the RPC box lean</strong>, put “trading services” on your second VPS.
|
||||||
|
<span class="pill">Target: min 10 markets</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p>
|
||||||
|
Yes — you can build a professional Drift trading stack with your own Solana RPC + your own DLOB,
|
||||||
|
but you’ll want a few supporting services around them. The main rule:
|
||||||
|
keep the RPC box lean, put “trading services” on your second VPS.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>On the Solana RPC server (dedicated) — keep it lean</h2>
|
||||||
|
|
||||||
|
<h3>Must-have</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Solana validator/RPC node</strong><br />
|
||||||
|
The base RPC your whole stack reads from / sends transactions through.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>WireGuard</strong><br />
|
||||||
|
So RPC is reachable only privately (your second VPS + your admin).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Firewall (nftables/ufw)</strong><br />
|
||||||
|
Block RPC ports on public NIC; allow them only on WireGuard.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Time sync (chrony)</strong><br />
|
||||||
|
For stable networking, logs, and trading timestamps.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Monitoring exporters</strong>
|
||||||
|
<ul>
|
||||||
|
<li><strong>node_exporter</strong> (CPU/RAM/disk/iowait/network)</li>
|
||||||
|
<li><strong>solana-exporter</strong> (RPC/validator health via RPC)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Log + disk hygiene</strong>
|
||||||
|
<ul>
|
||||||
|
<li>logrotate/journald limits</li>
|
||||||
|
<li>NVMe health (smartmontools/nvme-cli)</li>
|
||||||
|
<li>alerts on disk filling / iowait</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Optional but “pro”</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Geyser streaming (Yellowstone gRPC plugin)</strong><br />
|
||||||
|
This gives ultra-low-latency streams of accounts/tx/slots compared to polling RPC.
|
||||||
|
Useful if you build your own real-time analytics pipeline.
|
||||||
|
<div class="note">
|
||||||
|
For Drift specifically, you can run without Geyser at the beginning,
|
||||||
|
but it’s the next step when you want “faster-than-RPC” feeds.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>On the second VPS (your trading / app box) — where “pro trading” lives</h2>
|
||||||
|
|
||||||
|
<h3>Must-have</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Drift DLOB server (self-hosted)</strong><br />
|
||||||
|
This maintains the Drift decentralized orderbook view “fresh off your RPC” and exposes
|
||||||
|
REST + WS + gRPC/polling, plus health/metrics.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>(Optional but common) Drift Gateway</strong><br />
|
||||||
|
A self-hosted API gateway to interact with Drift; handy for standardized API endpoints
|
||||||
|
around trading / market info.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Cache (Redis)</strong><br />
|
||||||
|
Cache top-of-book, funding, oracle snapshots, risk checks; protects your DLOB + RPC
|
||||||
|
from bursty bot load.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Metrics + dashboards</strong><br />
|
||||||
|
Prometheus + Grafana + Alertmanager
|
||||||
|
<div class="note">
|
||||||
|
Keep Grafana off the validator box; common ops guidance is to separate monitoring UI for safety.
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Your trading services</strong>
|
||||||
|
<ul>
|
||||||
|
<li>strategy engine(s)</li>
|
||||||
|
<li>execution service (transaction builder/sender)</li>
|
||||||
|
<li>risk service (position limits, kill-switch, circuit breakers)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Optional, depending on how “institutional” you want</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Database (Postgres/Timescale)</strong><br />
|
||||||
|
Persist fills, order events, PnL series, backtesting datasets.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Message bus (NATS/Kafka/Redis Streams)</strong><br />
|
||||||
|
Decouple ingestion (orderbook/events) from strategies/execution.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Cost model (since you asked “cost per request”)</h2>
|
||||||
|
<p>
|
||||||
|
With your own RPC, there is no per-request billing. The “cost” is:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>fixed monthly servers (your €149/m + the second VPS),</li>
|
||||||
|
<li>and capacity (CPU/RAM/NVMe/bandwidth) consumed by:
|
||||||
|
<ul>
|
||||||
|
<li>DLOB syncing from RPC,</li>
|
||||||
|
<li>number of WS subscriptions,</li>
|
||||||
|
<li>how many markets you track.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="note">
|
||||||
|
DLOB exists specifically to reduce RPC load by serving orderbook/trade views to clients
|
||||||
|
instead of every client rebuilding it from chain.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Minimal “pro” starting set (recommended)</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>RPC box:</strong> Solana RPC + WireGuard + firewall + node_exporter + solana-exporter</li>
|
||||||
|
<li><strong>App VPS:</strong> DLOB server + Redis + Prometheus/Grafana + your bot services</li>
|
||||||
|
</ul>
|
||||||
|
<p class="note">
|
||||||
|
For <strong>min 10 markets</strong>, expect the first scaling pressure to come from
|
||||||
|
continuous streaming + decoding + caching (DLOB + Redis + your strategy/execution),
|
||||||
|
and from your RPC’s WS load. Next step after the minimal set is usually:
|
||||||
|
better streaming (Geyser) or more RAM/NVMe depending on bottleneck.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer style="opacity:.75; margin-top: 22px;">
|
||||||
|
<small>Saved as HTML — you can paste this into a file like <code>drift-stack.html</code>.</small>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
284
doc/solana-rpc-geyser-setup.md
Normal file
284
doc/solana-rpc-geyser-setup.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Bare metal: Solana RPC (non‑voting) + Geyser/“Yellowstone” gRPC (Ubuntu 24.04)
|
||||||
|
|
||||||
|
Cel: postawić **jedną maszynę** jako **źródło danych on‑chain**:
|
||||||
|
- Solana `validator` w trybie **non‑voting** z **RPC + WS** (tylko prywatnie),
|
||||||
|
- **Geyser gRPC** (“Yellowstone”) jako stabilny, skalowalny feed account/tx/slot,
|
||||||
|
- serwisy tradingowe (DLOB/boty/DB/UI) działają **osobno** na VPS/k3s.
|
||||||
|
|
||||||
|
Ten dokument jest runbookiem. Nie zawiera sekretów.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Powiązane dokumenty (DLOB + metryki + koszty)
|
||||||
|
|
||||||
|
Żeby spiąć “RPC/Geyser → dane → metryki → UI”, zobacz też:
|
||||||
|
|
||||||
|
- Mapa dokumentów o RPC/DLOB/metrykach: `doc/solana-rpc.md`
|
||||||
|
- DLOB (co działa w k3s, jakie tabele, skąd dane): `doc/dlob-services.md`
|
||||||
|
- DLOB basics (L1/L2/L3, pojęcia): `doc/dlob-basics.md`
|
||||||
|
- Ingest ticków / candles / źródła danych: `doc/workflow-api-ingest.md`
|
||||||
|
- Readiness do live tradingu (w tym plan pod własny RPC + streaming): `doc/trading-readiness.md`
|
||||||
|
- Czy da się bez własnego RPC / bez RPC w ogóle (mapowanie źródeł danych): `doc/drift-data-bez-solana-rpc.md`
|
||||||
|
- Kanoniczna architektura “własny RPC + własny DLOB” (co skąd bierzemy): `doc/rpc-dlob-kanoniczna-architektura.md`
|
||||||
|
- Koszty kontraktu: API compute/monitor (backend liczy, UI tylko rysuje): `doc/contract-cost-api.md`
|
||||||
|
- Kanoniczny payload eventów bota pod koszty/PnL (żeby agregacje działały): `doc/bot-events-cost-payload.md`
|
||||||
|
|
||||||
|
## 0) Założenia
|
||||||
|
|
||||||
|
- OS: **Ubuntu 24.04**
|
||||||
|
- Sprzęt: **Ryzen 9 9950X, 192GB RAM, 2× Gen5 NVMe, 1Gbps**
|
||||||
|
- Rola: **RPC node bez voting** (brak vote account)
|
||||||
|
- Prywatny dostęp: **WireGuard** między bare metal a k3s/VPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Dlaczego RPC+WS i gRPC jednocześnie
|
||||||
|
|
||||||
|
- **RPC/WS** (HTTP + WebSocket) zostaje jako:
|
||||||
|
- wysyłka transakcji (place/cancel/close),
|
||||||
|
- odczyty ad‑hoc i fallback.
|
||||||
|
- **Geyser/Yellowstone gRPC** jest preferowany jako:
|
||||||
|
- stabilny stream updates (account/slot/tx) dla DLOB/indexerów,
|
||||||
|
- mniejsze “rwanie” niż WS przy większej skali.
|
||||||
|
|
||||||
|
W praktyce: data plane = gRPC, execution plane = RPC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Podział dysków (must‑have)
|
||||||
|
|
||||||
|
Rekomendacja (żeby uniknąć I/O contention):
|
||||||
|
- NVMe #1: ledger / accounts
|
||||||
|
- mount: `/solana/ledger`
|
||||||
|
- NVMe #2: snapshots / logs / plugin state
|
||||||
|
- mount: `/solana/snapshots`
|
||||||
|
- ewentualnie: `/var/lib/yellowstone`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Porty (proponowane)
|
||||||
|
|
||||||
|
Publicznie:
|
||||||
|
- `22/tcp` (SSH) – tylko z Twoich IP (allowlist)
|
||||||
|
|
||||||
|
Tylko po WireGuard (private):
|
||||||
|
- `8899/tcp` RPC HTTP
|
||||||
|
- `8900/tcp` RPC WS
|
||||||
|
- `10000/tcp` Geyser gRPC (Yellowstone)
|
||||||
|
|
||||||
|
Uwaga: dokładne porty Solany (gossip/TPU) są inne i zależą od flag; one zwykle muszą być publicznie osiągalne do sieci Solany, ale **RPC ma być private**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) WireGuard (skeleton)
|
||||||
|
|
||||||
|
Założenie: bare metal = `wg0 = 10.8.0.1`, k3s/VPS = `wg0 = 10.8.0.2`.
|
||||||
|
|
||||||
|
### 4.1 Bare metal: `/etc/wireguard/wg0.conf`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Interface]
|
||||||
|
Address = 10.8.0.1/24
|
||||||
|
ListenPort = 51820
|
||||||
|
PrivateKey = <BARE_METAL_PRIVATE_KEY>
|
||||||
|
|
||||||
|
# opcjonalnie: NAT dla ruchu wychodzącego
|
||||||
|
# PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||||
|
# PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <K3S_PUBLIC_KEY>
|
||||||
|
AllowedIPs = 10.8.0.2/32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 VPS/k3s: `/etc/wireguard/wg0.conf`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Interface]
|
||||||
|
Address = 10.8.0.2/24
|
||||||
|
PrivateKey = <K3S_PRIVATE_KEY>
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = <BARE_METAL_PUBLIC_KEY>
|
||||||
|
Endpoint = <BARE_METAL_PUBLIC_IP>:51820
|
||||||
|
AllowedIPs = 10.8.0.1/32
|
||||||
|
PersistentKeepalive = 25
|
||||||
|
```
|
||||||
|
|
||||||
|
Start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now wg-quick@wg0
|
||||||
|
```
|
||||||
|
|
||||||
|
Test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ping -c 2 10.8.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Firewall: zasada “RPC i gRPC tylko private”
|
||||||
|
|
||||||
|
Wariant z `ufw` (przykład, dopasuj do swojego środowiska):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw default deny incoming
|
||||||
|
sudo ufw default allow outgoing
|
||||||
|
|
||||||
|
# SSH – najlepiej allowlist Twoje IP
|
||||||
|
sudo ufw allow 22/tcp
|
||||||
|
|
||||||
|
# WireGuard
|
||||||
|
sudo ufw allow 51820/udp
|
||||||
|
|
||||||
|
# RPC/WS/gRPC tylko na interfejsie wg0 (ufw ma ograniczone wsparcie; alternatywnie nftables)
|
||||||
|
# Minimalnie: nie otwieraj 8899/8900/10000 na publicznym NIC.
|
||||||
|
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw status verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Instalacja i uruchomienie Solany (non‑voting)
|
||||||
|
|
||||||
|
### 6.1 Zasada bezpieczeństwa
|
||||||
|
- identity key i konfiguracja tylko na serwerze,
|
||||||
|
- nie commituj tego do gita,
|
||||||
|
- RPC ma być “private RPC”.
|
||||||
|
|
||||||
|
### 6.2 Flagi mogą się zmieniać
|
||||||
|
Solana bywa zmienna w detalach CLI. Zawsze weryfikuj:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
solana-validator --help | less
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Minimalny szkic uruchomienia (do uzupełnienia)
|
||||||
|
|
||||||
|
Poniżej jest “kształt” – nie traktuj jako jedyny prawdziwy set flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
solana-validator \
|
||||||
|
--identity /etc/solana/identity.json \
|
||||||
|
--ledger /solana/ledger \
|
||||||
|
--snapshots /solana/snapshots \
|
||||||
|
--rpc-port 8899 \
|
||||||
|
--rpc-bind-address 10.8.0.1 \
|
||||||
|
--private-rpc \
|
||||||
|
--ws-port 8900 \
|
||||||
|
--dynamic-port-range 8000-8020 \
|
||||||
|
--no-voting \
|
||||||
|
--entrypoint <ENTRYPOINT_1> \
|
||||||
|
--entrypoint <ENTRYPOINT_2> \
|
||||||
|
--entrypoint <ENTRYPOINT_3> \
|
||||||
|
--expected-genesis-hash <GENESIS_HASH> \
|
||||||
|
--wal-recovery-mode skip_any_corrupted_record
|
||||||
|
```
|
||||||
|
|
||||||
|
Uwagi:
|
||||||
|
- `--rpc-bind-address` ustaw na IP WireGuard (private).
|
||||||
|
- `--no-voting` = non‑voting.
|
||||||
|
- `--dynamic-port-range` i reszta portów zależą od Twojej polityki sieciowej.
|
||||||
|
|
||||||
|
### 6.4 systemd (skeleton)
|
||||||
|
|
||||||
|
Plik: `/etc/systemd/system/solana-validator.service`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Solana Validator (non-voting, private RPC)
|
||||||
|
After=network-online.target wg-quick@wg0.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=solana
|
||||||
|
Group=solana
|
||||||
|
LimitNOFILE=1048576
|
||||||
|
ExecStart=/usr/local/bin/solana-validator <ARGS...>
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStopSec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Geyser / “Yellowstone” gRPC
|
||||||
|
|
||||||
|
### 7.1 Co to jest
|
||||||
|
Geyser to plugin, który “wypycha” stream danych z runtime’u walidatora.
|
||||||
|
Yellowstone gRPC to popularny stack, który wystawia ten stream przez gRPC.
|
||||||
|
|
||||||
|
### 7.2 Model wdrożenia (recommended)
|
||||||
|
- plugin jest skonfigurowany przy starcie `solana-validator` (`--geyser-plugin-config <path>`),
|
||||||
|
- gRPC endpoint nasłuchuje na `10.8.0.1:10000` (private),
|
||||||
|
- klienci (k3s) subskrybują gRPC.
|
||||||
|
|
||||||
|
### 7.3 Konfiguracja pluginu (placeholder)
|
||||||
|
Dokładny format config zależy od wybranego pluginu/wersji.
|
||||||
|
Trzymaj config jako plik na serwerze, np. `/etc/solana/geyser-grpc.json`.
|
||||||
|
|
||||||
|
Wymagania, które chcemy mieć niezależnie od implementacji:
|
||||||
|
- bind na interfejsie WireGuard (`10.8.0.1`),
|
||||||
|
- opcjonalny auth/token dla klientów,
|
||||||
|
- limit/allowlist klientów,
|
||||||
|
- logi do journald + limitowanie.
|
||||||
|
|
||||||
|
Test (z VPS/k3s po WireGuard):
|
||||||
|
- port open: `nc -vz 10.8.0.1 10000` (albo `grpcurl` jeśli masz),
|
||||||
|
- stream slotów/health (zależy od klienta).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Integracja z naszym stackiem `trade`
|
||||||
|
|
||||||
|
### 8.1 Co zmieniamy w k3s
|
||||||
|
|
||||||
|
Aktualizujemy secreta z endpointami RPC/WS dla `dlob-publisher` i executora:
|
||||||
|
- `ENDPOINT=http://10.8.0.1:8899`
|
||||||
|
- `WS_ENDPOINT=ws://10.8.0.1:8900`
|
||||||
|
|
||||||
|
Docelowo dodamy również:
|
||||||
|
- `GEYSER_GRPC_URL=http://10.8.0.1:10000` (lub `grpc://...`) do collectorów.
|
||||||
|
|
||||||
|
### 8.2 Fallback
|
||||||
|
Zostawiamy fallback RPC endpoint (np. publiczny provider) dla:
|
||||||
|
- awarii bare-metala,
|
||||||
|
- bootstrapu,
|
||||||
|
- sanity check.
|
||||||
|
|
||||||
|
Executor ma zawsze mieć tryb degradacji:
|
||||||
|
- Vast down → `observe/off`,
|
||||||
|
- feed down → `panic` lub `off` zależnie od ryzyka.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Operacje i monitoring (must‑have)
|
||||||
|
|
||||||
|
Mierz/alertuj:
|
||||||
|
- slot lag / “behind”,
|
||||||
|
- iowait + saturacja NVMe,
|
||||||
|
- disk fill (`ledger` rośnie),
|
||||||
|
- restart loop serwisów,
|
||||||
|
- liczba klientów gRPC / błędy streamu,
|
||||||
|
- RPC latencja / error rate.
|
||||||
|
|
||||||
|
Minimalne narzędzia:
|
||||||
|
- `node_exporter` + Prometheus/Grafana,
|
||||||
|
- logrotate/journald limity,
|
||||||
|
- `smartctl`/`nvme smart-log` dla NVMe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) Gotowość do startu (checklista)
|
||||||
|
|
||||||
|
- [ ] WireGuard działa (ping wg IP).
|
||||||
|
- [ ] RPC/WS/gRPC są dostępne tylko po WG.
|
||||||
|
- [ ] `solana-validator` trzyma sync, nie robi OOM, I/O stabilne.
|
||||||
|
- [ ] Geyser gRPC stream stabilny (brak częstych reconnect).
|
||||||
|
- [ ] `dlob-publisher` działa na nowych endpointach bez “No ws data … resubscribing”.
|
||||||
20
doc/solana-rpc.md
Normal file
20
doc/solana-rpc.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Solana RPC w tym projekcie (mapa dokumentów)
|
||||||
|
|
||||||
|
Ten plik jest “spisem treści” do dokumentów o **Solana RPC/WS**, **Geyser/Yellowstone gRPC** oraz tego, jak te źródła danych zasilają **DLOB + metryki + UI**.
|
||||||
|
|
||||||
|
## Runbooki i architektura
|
||||||
|
|
||||||
|
- Bare metal RPC (non‑voting) + Geyser/Yellowstone gRPC: `doc/solana-rpc-geyser-setup.md`
|
||||||
|
- “Kanoniczna” architektura self‑hosted (RPC + DLOB → DB/Hasura → API → UI): `doc/rpc-dlob-kanoniczna-architektura.md`
|
||||||
|
- Czy da się bez własnego RPC / bez RPC w ogóle (mapowanie źródeł danych): `doc/drift-data-bez-solana-rpc.md`
|
||||||
|
|
||||||
|
## DLOB i warstwy danych
|
||||||
|
|
||||||
|
- Serwisy DLOB w k3s/VPS + przepływ danych: `doc/dlob-services.md`
|
||||||
|
- Pojęcia i metryki DLOB (L1/L2/L3, bps, slippage): `doc/dlob-basics.md`
|
||||||
|
|
||||||
|
## Metryki i koszty kontraktów (backend liczy, UI rysuje)
|
||||||
|
|
||||||
|
- API do liczenia kosztów “new contract” + monitor kontraktu: `doc/contract-cost-api.md`
|
||||||
|
- Słownik kosztów, PnL i pojęć (UI + backend): `doc/drift-costs.md`
|
||||||
|
|
||||||
111
doc/strategy-eskalacja-horyzontu.md
Normal file
111
doc/strategy-eskalacja-horyzontu.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Strategia: eskalacja horyzontu (1m → 5m → 15m → 30m → 1h) z bramkami ryzyka i kosztów
|
||||||
|
|
||||||
|
Cel: jeżeli trade nie domyka celu w krótkim oknie (np. **1m**), możemy **kontrolowanie** przejść na dłuższy horyzont (**5m/15m/30m/1h**) *bez wpadania w “przetrzymanie straty”*.
|
||||||
|
|
||||||
|
Klucz: to ma działać jako **state machine** z twardymi bramkami (gates), a nie jako “zostawię i zobaczę”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Model: “czas na realizację celu” + eskalacja okna
|
||||||
|
|
||||||
|
### Parametry (na start)
|
||||||
|
- `target_bps` albo `target_usd` (np. +3 bps, +6 bps…)
|
||||||
|
- `ttl_per_mode` (time-to-live per tryb):
|
||||||
|
- `1m`: 60–120 s
|
||||||
|
- `5m`: 5–10 min
|
||||||
|
- `15m`: 15–30 min
|
||||||
|
- `30m`: 30–60 min
|
||||||
|
- `1h`: 1–3 h
|
||||||
|
|
||||||
|
### Zasada
|
||||||
|
Jeśli w danym trybie **nie osiągniesz celu w TTL**, to:
|
||||||
|
- albo **zamykanie**,
|
||||||
|
- albo **eskalacja** do wyższego okna — *tylko* jeśli spełnione są bramki (ryzyko/koszty/struktura).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Bramki (gates) — kiedy eskalacja jest dozwolona
|
||||||
|
|
||||||
|
### Gate RISK (twarde)
|
||||||
|
Jeśli którykolwiek warunek jest niespełniony → **wyjście teraz** (close now).
|
||||||
|
|
||||||
|
Przykładowe progi:
|
||||||
|
- `health >= 0.70`
|
||||||
|
- `margin_ratio >= 0.15..0.20` (zależnie od dźwigni)
|
||||||
|
- odległość do `liq_price` >= `X%` albo >= `k * ATR`
|
||||||
|
- `max_drawdown` w oknie bieżącym nie przekracza limitu
|
||||||
|
|
||||||
|
### Gate COSTS (twarde)
|
||||||
|
- `close_now_cost_usd` nie “zjada” potencjału (np. koszty nie mogą przekroczyć `target_usd` albo `target_bps`)
|
||||||
|
- przy dłuższych trybach (30m/1h) uwzględnij wpływ funding:
|
||||||
|
- `funding_expected(next_window)` nie dominuje nad edge
|
||||||
|
|
||||||
|
### Gate STRUCTURE / EDGE (miękkie, ale zalecane)
|
||||||
|
Przykłady:
|
||||||
|
- trend/edge na wyższym oknie nie jest przeciw (np. 5m dla eskalacji 1m→5m)
|
||||||
|
- spread/slippage z DLOB nie rosną anormalnie
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Logika przejść (state machine)
|
||||||
|
|
||||||
|
Stan początkowy: `mode=1m`.
|
||||||
|
|
||||||
|
Jeżeli `ttl_expired` i `target_not_hit`:
|
||||||
|
- jeśli `risk_ok && costs_ok && structure_ok` → awans do następnego trybu,
|
||||||
|
- inaczej → `close_now`.
|
||||||
|
|
||||||
|
Przejścia:
|
||||||
|
- `1m` → `5m`
|
||||||
|
- `5m` → `15m`
|
||||||
|
- `15m` → `30m`
|
||||||
|
- `30m` → `1h`
|
||||||
|
|
||||||
|
Ważne: awans = **zmiana reżimu** (inne TTL/target/gates), a nie “przeciąganie”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Pułapka i dwa “hamulce”
|
||||||
|
|
||||||
|
Pułapka:
|
||||||
|
> “nie poszło w 1m, to poczekam 5m, jak nie to 15m…” → swing bez planu
|
||||||
|
|
||||||
|
### Hamulec A: limit eskalacji
|
||||||
|
- max 1–2 awanse na trade (np. `1m→5m→15m` i koniec)
|
||||||
|
|
||||||
|
### Hamulec B: limit straty per tryb
|
||||||
|
- jeśli w trybie 1m strata przekroczy `stop_1m` → wyjście
|
||||||
|
- nie wolno “przenosić” tej samej straty w nieskończoność do 1h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Jak to zaszyć w naszym backendzie (worker + API)
|
||||||
|
|
||||||
|
W `contract_metrics_ts` (lub w warstwie kontraktu) trzymaj:
|
||||||
|
- `mode_current`
|
||||||
|
- `mode_enter_ts`
|
||||||
|
- `ttl_s_for_mode`
|
||||||
|
- `target_bps_for_mode`
|
||||||
|
- `gates_passed`:
|
||||||
|
- `risk: boolean`
|
||||||
|
- `costs: boolean`
|
||||||
|
- `structure: boolean`
|
||||||
|
- (opcjonalnie) `escalations_used` / `escalations_remaining`
|
||||||
|
|
||||||
|
### SIM: “czy eskalacja ma sens”
|
||||||
|
Endpoint typu `POST /v1/simulate/escalate` (MVP) może zwracać:
|
||||||
|
- `expected_costs` (close now / next window)
|
||||||
|
- `risk_after` (health/margin/liq)
|
||||||
|
- `assumptions` (np. BBO+extra bps, fee tier)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) TL;DR
|
||||||
|
|
||||||
|
- Tak, eskalacja czasu ma sens **jeśli jest kontrolowana**.
|
||||||
|
- Robimy to jako **state machine** z:
|
||||||
|
- twardym `RISK gate`,
|
||||||
|
- twardym `COST gate`,
|
||||||
|
- limitem eskalacji,
|
||||||
|
- stopem per tryb.
|
||||||
|
|
||||||
111
doc/todo-before-baremetal.md
Normal file
111
doc/todo-before-baremetal.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# TODO przed zakupem bare metal (RPC+Geyser) — żeby dzień 0 poszedł gładko
|
||||||
|
|
||||||
|
Cel: zanim kupisz bare metal, dopinamy wszystko, co nie wymaga własnego RPC, żeby po zakupie:
|
||||||
|
- tylko postawić RPC+Geyser wg runbooka,
|
||||||
|
- przepiąć endpointy w k3s i zrobić rollout,
|
||||||
|
- zweryfikować stabilność feedu i gotowość do live.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status (staging / `trade.mpabi.pl`) + TODO bieżące
|
||||||
|
|
||||||
|
- [x] **Precomputed candles cache (TF: `1s..1d`, target `1024`/TF)** na backendzie (k3s) + worker liczący “ciągle”.
|
||||||
|
- [x] **DLOB slippage v2** (tabele v2 + dual-write), żeby obsłużyć “rozmiary USD” z częściami dziesiętnymi.
|
||||||
|
- [x] **Frontend (visualizer)**: dodane TF: `1s 3s 5s 15s 30s 1m 3m 5m 15m 30m 1h 4h 12h 1d` + szybkie przełączanie (abort poprzednich requestów).
|
||||||
|
- [x] **Wdrożenie na k3s**: zbudowany i wypchnięty nowy obraz `trade-frontend` + zaktualizowany `trade-deploy` (Argo rollout).
|
||||||
|
|
||||||
|
**Do zrobienia teraz (żeby „lokalny frontend” i staging działały spójnie):**
|
||||||
|
|
||||||
|
- [ ] **Sprawdzić `/graphql` (Hasura proxy) po sesji**: potwierdzić, że po `POST /auth/login` zapytania GraphQL działają i nie ma `Malformed Authorization header`.
|
||||||
|
- [ ] **Sprawdzić czasy przełączania TF w UI**: czy klik w TF tylko czyta cache i nie czeka na liczenie (ma być natychmiast).
|
||||||
|
- [ ] **Naprawić „kafelek” w headerze market** na 100% skali (overflow/ellipsis, czytelność liczb).
|
||||||
|
- [ ] **DLOB fullscreen w stack/layers**: upewnić się, że działa tak jak chart (fullscreen / exit) i że w stack mode jest czytelne.
|
||||||
|
- [ ] **Panel warstw**: dopracować UX (auto-hide + lock, DnD kolejności, suwaki opacity/brightness na warstwach) + skrócić formatki (więcej miejsca na wykresy).
|
||||||
|
- [ ] **“New contract estimate” live**: dodać toggle “auto refresh” i rysować wykresy time-series (1 px ~ 1s) tylko dla zmiennych (cena/impact/total), a stałe (fee) jako stałe wartości.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A) Decyzje i parametry (bez kodu, ale blokują implementację)
|
||||||
|
|
||||||
|
- [ ] **Docelowe porty i adresacja WireGuard**:
|
||||||
|
- WG subnet (np. `10.8.0.0/24`), IP bare metal i IP k3s/VPS
|
||||||
|
- port WG (np. `51820/udp`)
|
||||||
|
- private bind dla: RPC `8899`, WS `8900`, gRPC `10000`
|
||||||
|
- [ ] **Polityka dostępu**:
|
||||||
|
- allowlist IP do SSH
|
||||||
|
- czy gRPC wymaga auth/token dla klientów
|
||||||
|
- [ ] **Retencja danych (start)**:
|
||||||
|
- TS: 7 dni “gęsto” (np. 1–5s) + czy robimy downsample 1m na dłużej
|
||||||
|
- [ ] **Model intent**:
|
||||||
|
- potwierdzone: offset (ticks/bps) + desired-state (jest w `doc/drift-perp-contract.md`)
|
||||||
|
- [ ] **Ryzyko (hard caps)**:
|
||||||
|
- max position USD, max reprices/min, max slippage/spread, freshness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B) Dane i historia (żeby warstwy działały live+history)
|
||||||
|
|
||||||
|
- [ ] **DLOB TS tables**: `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`
|
||||||
|
- indeksy `(market_name, ts)` i retencja 7 dni
|
||||||
|
- [ ] **Archiver/collector**:
|
||||||
|
- worker, który zapisuje TS (z `*_latest` do `*_ts`), albo rozszerzenie istniejących workerów
|
||||||
|
- [ ] **Downsample (opcjonalnie, ale pro)**:
|
||||||
|
- continuous aggregates (Timescale) lub job 1m/5m
|
||||||
|
- [ ] **Hasura bootstrap**:
|
||||||
|
- track tabel TS + uprawnienia `select` (public) dla UI history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C) Kontrakty bota i audyt (must-have przed live)
|
||||||
|
|
||||||
|
- [ ] **Schema**:
|
||||||
|
- `bot_contracts` (desired-state + status)
|
||||||
|
- `bot_events` (audit log)
|
||||||
|
- mapowanie: `decision_id`, `client_order_id`, `drift_order_id`, `tx_sig`
|
||||||
|
- [ ] **Observe-only executor** (k3s):
|
||||||
|
- buduje features i zapisuje `decision` do `bot_events`, bez składania tx
|
||||||
|
- [ ] **Reconcile logic (no trade yet)**:
|
||||||
|
- start → odczyt observed state z Drift → porównanie do DB → log “diff”
|
||||||
|
- [ ] **Kill-switch w executorze**:
|
||||||
|
- env var + DB flag + safety triggers (feed stale, RPC lag)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D) Vast (GPU tylko na kilka godzin) — przygotowanie pod ephemeral training
|
||||||
|
|
||||||
|
- [ ] **Dataset export** (z k3s/DB):
|
||||||
|
- jeden plik `parquet/jsonl.zst` + `dataset_version` (hash)
|
||||||
|
- minimalny “train split / eval split”
|
||||||
|
- [ ] **Training entrypoint** (jedna komenda):
|
||||||
|
- skrypt/komenda, która: download dataset → train → eval → export
|
||||||
|
- [ ] **Artifact upload**:
|
||||||
|
- preferowane: scp na VPS/k3s albo Gitea Packages
|
||||||
|
- wersjonowanie: `model_version` + `dataset_version`
|
||||||
|
- [ ] **Predictor contract test**:
|
||||||
|
- walidator JSON schema `trade_intent`
|
||||||
|
- test: “intent TTL expired” + “gates fail” + “panic”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E) UI (warstwy + live/history, bez liczenia w JS)
|
||||||
|
|
||||||
|
- [ ] **Tryb Live/History**:
|
||||||
|
- Live: subscriptions na `*_latest`
|
||||||
|
- History: query na `*_ts` + timeframe `tf`
|
||||||
|
- [ ] **Warstwy/Panele z `doc/stats.md`**:
|
||||||
|
- mapowanie 1:1 na tabele (brak obliczeń w UI)
|
||||||
|
- [ ] **Podgląd kontraktów**:
|
||||||
|
- panel “Contracts” z `bot_contracts` + “Event timeline” z `bot_events`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F) Operacje (żeby bare metal nie stał się snowflake)
|
||||||
|
|
||||||
|
- [ ] **Sekrety i endpointy**:
|
||||||
|
- w k3s: secret `trade-dlob-rpc` / analogiczny na nowy endpoint (WG)
|
||||||
|
- fallback endpoint (np. public provider) jako opcjonalny drugi URL
|
||||||
|
- [ ] **Monitoring/alerty na k3s**:
|
||||||
|
- freshness DLOB/ticks, error rate workerów, restart loops
|
||||||
|
- [ ] **Checklist “Day 0”**:
|
||||||
|
- przejście krok po kroku wg `doc/solana-rpc-geyser-setup.md`
|
||||||
|
- smoke test: `dlob-publisher` bez reconnect storm
|
||||||
119
doc/trading-readiness.md
Normal file
119
doc/trading-readiness.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Trading readiness (staging → live) — checklista i brakujące elementy
|
||||||
|
|
||||||
|
Ten dokument odpowiada na pytanie: **czy obecny warsztat jest “już dobry do trade”** i co musi być dopięte, zanim przejdziemy z obserwacji do live tradingu.
|
||||||
|
|
||||||
|
W skrócie:
|
||||||
|
- Fundament jest dobry do **prototypowania i stagingu**.
|
||||||
|
- Do “pro trading” brakuje kilku krytycznych elementów bezpieczeństwa, audytu i historii danych.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Co już mamy (mocne strony)
|
||||||
|
|
||||||
|
- **k3s + GitOps/snapshoty**: każdy deploy to snapshot, rollback jednym ruchem (`doc/workflow.md`).
|
||||||
|
- **DLOB pipeline**: orderbook → statsy (spread/depth/slippage) pod UI/strategie (`doc/dlob-basics.md`, `doc/dlob-services.md`).
|
||||||
|
- **Ingest ticków do DB**: dane rynkowe w Postgres/Timescale + candles przez funkcję (`doc/workflow-api-ingest.md`).
|
||||||
|
- **UI/Visualizer**: warstwy i panele do obserwacji rynku (`doc/stats.md`).
|
||||||
|
- **Model plane separation**: Vast ma robić inference bez sekretów (docelowo) (`doc/drift-perp-contract.md` + `doc/vast-gpu-runbook.md`).
|
||||||
|
- **Plan pod własny RPC + streaming**: bare metal RPC + Geyser/Yellowstone gRPC (`doc/solana-rpc-geyser-setup.md`).
|
||||||
|
- **Wyjaśnienie “czy da się bez RPC”**: co możemy policzyć z DB i DLOB, a co wymaga feedu on‑chain (`doc/drift-data-bez-solana-rpc.md`).
|
||||||
|
|
||||||
|
To jest wystarczające do:
|
||||||
|
- obserwacji live,
|
||||||
|
- strojenia UI,
|
||||||
|
- budowy i testów pipeline’u danych,
|
||||||
|
- “paper trading” / dry-run w executorze.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Co jest krytycznie brakujące do live tradingu
|
||||||
|
|
||||||
|
Poniższe punkty są “must‑have” zanim bot zacznie realnie składać zlecenia na Drift.
|
||||||
|
|
||||||
|
### 2.1 Kontrakty bota + audyt (DB)
|
||||||
|
|
||||||
|
Wymagane tabele i log:
|
||||||
|
- `bot_contracts` (stan kontraktu / desired-state)
|
||||||
|
- `bot_events` (decision, order_sent, order_ack, fill, cancel, exit, error, panic)
|
||||||
|
- mapowanie: `decision_id -> client_order_id -> drift_order_id -> tx_sig`
|
||||||
|
|
||||||
|
Cel:
|
||||||
|
- da się odtworzyć “co poszło na chain”,
|
||||||
|
- UI pokazuje stan kontraktów (live + historia),
|
||||||
|
- łatwe debugowanie i strojenie.
|
||||||
|
|
||||||
|
### 2.2 Reconcile po restarcie (must-have)
|
||||||
|
|
||||||
|
Executor po starcie zawsze:
|
||||||
|
- czyta `bot_contracts` (desired),
|
||||||
|
- pobiera observed state z Drift (pozycje + open ordery),
|
||||||
|
- porównuje i wykonuje minimalne akcje korekcyjne.
|
||||||
|
|
||||||
|
Bez tego ryzykujesz:
|
||||||
|
- “ghost orders”,
|
||||||
|
- utrzymanie pozycji mimo utraty kontekstu.
|
||||||
|
|
||||||
|
### 2.3 Kill-switch + guardian poza klastrem (must-have)
|
||||||
|
|
||||||
|
Kill-switch w executorze to za mało, bo klaster/VPS może paść.
|
||||||
|
|
||||||
|
Wzorzec:
|
||||||
|
- osobny mały serwis `bot-guardian` poza głównym VPS/k3s,
|
||||||
|
- jeśli heartbeat executora jest stary → guardian robi `cancel_all` + `reduce_only close`.
|
||||||
|
|
||||||
|
### 2.4 Hard risk caps niezależne od modelu
|
||||||
|
|
||||||
|
Nawet jeśli Vast zwraca pełny `trade_intent`, executor musi egzekwować:
|
||||||
|
- `max_position_usd`, `max_leverage` (pośrednio), `max_orders_per_min`,
|
||||||
|
- `max_slippage_bps`, `max_spread_bps`, `freshness_max_ms`,
|
||||||
|
- circuit breakers (np. feed down, RPC lag, drawdown).
|
||||||
|
|
||||||
|
Model może proponować parametry, ale nie może omijać twardych limitów.
|
||||||
|
|
||||||
|
### 2.5 Historia danych (TS), retencja i downsample
|
||||||
|
|
||||||
|
`*_latest` jest świetne do live, ale do strojenia potrzebujesz TS:
|
||||||
|
- DLOB: `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts` (min. 7 dni)
|
||||||
|
- kontrakty: `bot_events_ts` / log z timestampem
|
||||||
|
|
||||||
|
Docelowo:
|
||||||
|
- trzymać gęste 7 dni,
|
||||||
|
- trzymać dłużej downsample (np. 1m/5m) pod analizy reżimów.
|
||||||
|
|
||||||
|
### 2.6 Monitoring i alerty (operacyjne)
|
||||||
|
|
||||||
|
Minimum alertów:
|
||||||
|
- feed freshness (DLOB/ticki),
|
||||||
|
- RPC slot lag / error rate,
|
||||||
|
- order reject rate,
|
||||||
|
- panic triggers,
|
||||||
|
- disk fill/iowait (RPC node).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) “Go/No-Go” do pierwszego live (small size)
|
||||||
|
|
||||||
|
**Go** jeśli:
|
||||||
|
- kontrakty i eventy są w DB,
|
||||||
|
- reconcile działa (test restartu),
|
||||||
|
- kill-switch działa (test: panic → flat),
|
||||||
|
- mamy twarde limity ryzyka,
|
||||||
|
- mamy podstawowe alerty,
|
||||||
|
- mamy 7 dni TS pod progi (albo chociaż 24h na start) i znamy percentyle spread/slippage/depth.
|
||||||
|
|
||||||
|
**No-Go** jeśli:
|
||||||
|
- nie potrafimy deterministycznie zidentyfikować orderów (brak `client_order_id` / brak logów),
|
||||||
|
- restart executora może zostawić pozycję bez kontroli,
|
||||||
|
- nie ma niezależnego guardiana.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Proponowana kolejność implementacji (minimalny path)
|
||||||
|
|
||||||
|
1) `bot_contracts` + `bot_events` + UI podgląd kontraktów (read-only).
|
||||||
|
2) “observe-only executor” (bez tx) → loguje decyzje z modelu/reguł.
|
||||||
|
3) TS history: DLOB (7 dni) + podstawowe agregacje.
|
||||||
|
4) Dry-run/paper: executor wylicza i loguje order plan (bez tx).
|
||||||
|
5) Live minimal: mały size, limit/post-only + chase, twarde caps.
|
||||||
|
6) Guardian poza klastrem.
|
||||||
|
7) Geyser/Yellowstone (jeśli WS RPC nie trzyma stabilnie na skali).
|
||||||
123
doc/vast-gpu-runbook.md
Normal file
123
doc/vast-gpu-runbook.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Vast GPU (kilka godzin) — runbook do trenowania + eksportu modelu
|
||||||
|
|
||||||
|
Cel: używać Vast (GPU) jako **krótkiej sesji treningowej** (kilka godzin), a wynik (wagi/adapter) zapisać trwałe i wersjonowane.
|
||||||
|
|
||||||
|
W tym projekcie Vast:
|
||||||
|
- **nie dostaje sekretów tradingowych**,
|
||||||
|
- nie ma dostępu do kluczy,
|
||||||
|
- zwraca tylko `trade_intent`/predykcje.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) Najważniejsza zasada przy krótkim wynajmie
|
||||||
|
|
||||||
|
Vast jest “ephemeral”. Żeby nie stracić pracy:
|
||||||
|
|
||||||
|
- wszystko musi być **reproducible** (konfig + kod + seed),
|
||||||
|
- model output musi być **mały** i łatwy do przeniesienia (preferuj LoRA/adaptery),
|
||||||
|
- checkpointy i final artifacts muszą być **uploadowane** poza maszynę (Gitea packages/S3/scp).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Co potrzebujemy mieć gotowe przed startem sesji
|
||||||
|
|
||||||
|
### 2.1 Kod i entrypoint
|
||||||
|
|
||||||
|
W repo powinien istnieć “one command training”, np.:
|
||||||
|
- `python -m train ...` albo `bash scripts/train.sh ...`
|
||||||
|
|
||||||
|
Zasada: komenda sama:
|
||||||
|
- pobiera dataset (albo czyta lokalny plik),
|
||||||
|
- trenuje,
|
||||||
|
- zapisuje `artifacts/`,
|
||||||
|
- robi upload.
|
||||||
|
|
||||||
|
### 2.2 Dataset (krótki transfer)
|
||||||
|
|
||||||
|
Dla sesji “kilka godzin” unikaj wielkich transferów.
|
||||||
|
|
||||||
|
Rekomendacja:
|
||||||
|
- zbuduj dataset jako plik (np. `jsonl`/`parquet`) i spakuj (`zstd`),
|
||||||
|
- trzymaj wersję datasetu (hash) i loguj ją do metryk.
|
||||||
|
|
||||||
|
Źródło danych (rekomendowane u nas):
|
||||||
|
- `Postgres/Timescale` w k3s → eksport do pliku (offline), potem upload.
|
||||||
|
|
||||||
|
### 2.3 Bazowy model
|
||||||
|
|
||||||
|
Masz 2 ścieżki:
|
||||||
|
- pobierasz z internetu (HF) na Vast (szybko, ale zależy od sieci),
|
||||||
|
- albo trzymasz bazę w swoim storage i ściągasz kontrolowanie.
|
||||||
|
|
||||||
|
Na start preferuj LoRA na umiarkowanym modelu i krótkiej sekwencji.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Co uruchamiamy na Vast
|
||||||
|
|
||||||
|
### 3.1 Kontener
|
||||||
|
|
||||||
|
Najprościej: jeden Docker image z zależnościami (PyTorch + libs).
|
||||||
|
Obraz powinien być **pinned** (digest) i gotowy do uruchomienia bez `pip install` w trakcie.
|
||||||
|
|
||||||
|
### 3.2 Konfiguracja treningu (config file)
|
||||||
|
|
||||||
|
Trzymaj config jako plik (np. YAML/JSON) i loguj go do artifactów:
|
||||||
|
- `model_name`, `model_version`
|
||||||
|
- `dataset_version` (hash)
|
||||||
|
- `seq_len`, `batch_size`, `grad_accum`, `lr`
|
||||||
|
- `lora_r`, `lora_alpha`, `lora_dropout` (jeśli LoRA)
|
||||||
|
- `seed`
|
||||||
|
|
||||||
|
### 3.3 Output
|
||||||
|
|
||||||
|
Preferowane outputy:
|
||||||
|
- LoRA adapter (mały): `adapter.safetensors` + config
|
||||||
|
- metryki treningu: `metrics.json`
|
||||||
|
- walidacja: `eval_report.json`
|
||||||
|
|
||||||
|
Opcjonalnie:
|
||||||
|
- export do ONNX/TensorRT jeśli planujesz inference poza GPU (zależne od modelu).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Gdzie trzymamy artefakty (żeby nie zniknęły)
|
||||||
|
|
||||||
|
Opcje (w kolejności prostoty):
|
||||||
|
|
||||||
|
1) **scp na VPS/k3s** (na start najprostsze)
|
||||||
|
2) **Gitea Packages** (jeśli chcesz wersjonować jako “package”)
|
||||||
|
3) **S3/MinIO** (najbardziej skalowalne)
|
||||||
|
|
||||||
|
Minimalne wymaganie: zawsze zapisuj `model_version` i `dataset_version` obok wag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Jak to łączy się z executor / UI
|
||||||
|
|
||||||
|
Model na Vast zwraca `trade_intent` (patrz `doc/drift-perp-contract.md`).
|
||||||
|
Executor w k3s:
|
||||||
|
- buduje features z DB,
|
||||||
|
- woła predictor,
|
||||||
|
- waliduje gates,
|
||||||
|
- loguje:
|
||||||
|
- `decision_id`, `model_version`, `features_hash`, `intent`,
|
||||||
|
- outcome (fill/exit).
|
||||||
|
|
||||||
|
Jeśli Vast jest niedostępny:
|
||||||
|
- executor przechodzi w `observe/off` (nie handluje),
|
||||||
|
- UI nadal pokazuje warstwy rynku (DLOB/ticki).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Checklist “kilka godzin” (operacyjnie)
|
||||||
|
|
||||||
|
- [ ] Vast instance: GPU + wystarczająco VRAM + szybki disk.
|
||||||
|
- [ ] Pull docker image (pinned).
|
||||||
|
- [ ] Download dataset (jedna komenda).
|
||||||
|
- [ ] Train (z logowaniem metryk co N kroków).
|
||||||
|
- [ ] Eval (krótki).
|
||||||
|
- [ ] Export adapter/weights.
|
||||||
|
- [ ] Upload artifacts (scp / packages / S3).
|
||||||
|
- [ ] Zapisz `model_version` do DB/config (k3s) przed użyciem.
|
||||||
|
|
||||||
42
doc/visualizer-candles.md
Normal file
42
doc/visualizer-candles.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Visualizer: świeczki + “brick stack” pod świecą
|
||||||
|
|
||||||
|
## Timeframe (tf)
|
||||||
|
|
||||||
|
W visualizerze `tf` to długość świecy (bucket) przekazywana do API:
|
||||||
|
|
||||||
|
- `3s`, `5s`, `15s`, `30s` — mikro‑ruchy (dużo szumu, ale świetne do obserwacji mikrostruktury)
|
||||||
|
- `1m`, `5m`, `15m`, `1h`, `4h`, `1d` — klasyczne interwały
|
||||||
|
|
||||||
|
Kiedy ma to sens:
|
||||||
|
- `3s/5s`: gdy chcesz widzieć “jak cena się buduje” w krótkich falach (np. po newsie / w dużej zmienności).
|
||||||
|
- `15s/30s`: często najlepszy kompromis między szumem a czytelnością, jeżeli patrzysz na very-short-term.
|
||||||
|
|
||||||
|
## Co pokazuje “brick stack” na dole
|
||||||
|
|
||||||
|
Pod każdą świecą rysujemy słupek złożony z “bricków” (małych segmentów) odpowiadających kolejnym krokom czasu wewnątrz świecy.
|
||||||
|
|
||||||
|
Kolory bricków:
|
||||||
|
- zielony = w tym kroku cena poszła w górę
|
||||||
|
- czerwony = w tym kroku cena poszła w dół
|
||||||
|
- niebieski = w tym kroku cena była stała (flat)
|
||||||
|
|
||||||
|
Wysokość bricków:
|
||||||
|
- zielony/czerwony: proporcjonalna do `|Δprice|` w danym kroku
|
||||||
|
- niebieski: stała (unit height)
|
||||||
|
|
||||||
|
Bricki są rozdzielone cienką czarną linią (1px), żeby było widać strukturę “krok po kroku”.
|
||||||
|
|
||||||
|
## Jakie pola musi zwracać API
|
||||||
|
|
||||||
|
Endpoint `GET /v1/chart` zwraca w każdej świecy:
|
||||||
|
|
||||||
|
- `flow`: udziały czasu `up/down/flat` w całym buckecie (0..1)
|
||||||
|
- `flowRows`: tablica kierunków per krok czasu: `-1` (down), `0` (flat), `1` (up)
|
||||||
|
- `flowMoves`: tablica “move magnitude” per krok czasu (wartości dodatnie; 0 jeśli flat)
|
||||||
|
|
||||||
|
To właśnie `flowRows` + `flowMoves` są używane do narysowania brick stacka.
|
||||||
|
|
||||||
|
## Domyślny rynek
|
||||||
|
|
||||||
|
W visualizerze domyślnie ustawiony jest `SOL-PERP`.
|
||||||
|
|
||||||
89
doc/visualizer-layers-ui.md
Normal file
89
doc/visualizer-layers-ui.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Visualizer UI: kafelki → warstwy (stack)
|
||||||
|
|
||||||
|
Ten dokument opisuje MVP dla przełączania układu UI w `apps/visualizer`:
|
||||||
|
|
||||||
|
- **Grid (kafelki / standard)**: obecny layout aplikacji.
|
||||||
|
- **Stack (warstwy / fullscreen)**: jedna aktywna warstwa jest na wierzchu (fullscreen), a kolejność warstw można ustawiać przez **DnD**.
|
||||||
|
|
||||||
|
## Zachowanie (MVP)
|
||||||
|
|
||||||
|
### Wejście / wyjście ze stack
|
||||||
|
- **Chart**: przycisk `Fullscreen` w pasku narzędzi wykresu przełącza tryb `grid` ↔ `stack` (dla warstwy `chart`).
|
||||||
|
- **DLOB**: przycisk `Fullscreen` w nagłówku DLOB przełącza tryb `grid` ↔ `stack` (dla warstwy `dlob`).
|
||||||
|
- Wyjście ze stack:
|
||||||
|
- przycisk `Back` w panelu warstw
|
||||||
|
- klawisz `Esc` **dwa razy** (w ciągu ~0.8s)
|
||||||
|
|
||||||
|
### Warstwy i DnD
|
||||||
|
- W stack pojawia się panel `Layers` jako **wysuwany drawer z boku**.
|
||||||
|
- Lista w panelu jest **od góry (top) do dołu (bottom)**:
|
||||||
|
- element na górze listy jest `active` (czyli jest “na wierzchu”).
|
||||||
|
- **DnD** na liście:
|
||||||
|
- przeciągnij element i upuść na inny — zmienisz kolejność (top/bottom).
|
||||||
|
- Kliknięcie elementu na liście przenosi go na wierzch (`active`).
|
||||||
|
|
||||||
|
### Warstwy kosztów (Costs)
|
||||||
|
- Są dwie osobne warstwy kosztów:
|
||||||
|
- `Costs (New)` — estymata nowego kontraktu (domyślnie **na samej górze**),
|
||||||
|
- `Costs (Active)` — monitoring puszczonego kontraktu (pojawia się i jest ustawiana **jeden poziom niżej** po wpisaniu `contract_id`).
|
||||||
|
- Możesz zmienić kolejność DnD (to nadpisuje domyślne ustawienie).
|
||||||
|
- Po pierwszym DnD aplikacja oznacza układ jako “manualny” i nie przestawia już automatycznie warstw przy pojawieniu się `contract_id`.
|
||||||
|
|
||||||
|
### Auto-hide + lock
|
||||||
|
- Domyślnie drawer ma **auto-hide** (chowa się po ~1s po zjechaniu kursorem z panelu).
|
||||||
|
- Przycisk `Auto/Locked`:
|
||||||
|
- `Auto` = auto-hide włączony,
|
||||||
|
- `Locked` = auto-hide wyłączony (drawer zostaje otwarty).
|
||||||
|
- Drawer otwiera się po najechaniu kursorem na wąski “hotspot” przy lewym brzegu ekranu.
|
||||||
|
|
||||||
|
### Opacity (UI)
|
||||||
|
- W drawerze są suwaki:
|
||||||
|
- `Backdrop` (przyciemnienie tła w stack)
|
||||||
|
- `Panel` (tło drawera)
|
||||||
|
- Wartości są zapisywane w localStorage (`trade.stackBackdropOpacity`, `trade.stackDrawerOpacity`).
|
||||||
|
|
||||||
|
### Opacity (warstwy)
|
||||||
|
- Każda warstwa ma swój suwak opacity (0–100%):
|
||||||
|
- `0%` = warstwa niewidoczna,
|
||||||
|
- `100%` = pełna widoczność.
|
||||||
|
- DnD nadal ustawia kolejność (z-index), a interakcje trafiają do warstwy `active` (top).
|
||||||
|
- Wartości są zapisywane w localStorage (`trade.layerOpacity`).
|
||||||
|
|
||||||
|
### Jasność (warstwy)
|
||||||
|
- Każda warstwa ma suwak `brightness` (60–180%).
|
||||||
|
- To jest filtr UI (nie wpływa na dane), przydatny gdy chcesz rozjaśnić wykres/DLOB w stack.
|
||||||
|
- Wartości są zapisywane w localStorage (`trade.layerBrightness`).
|
||||||
|
|
||||||
|
### Widoczność + lock (warstwy)
|
||||||
|
- Ikona “oka” przełącza widoczność warstwy (nie resetuje suwaka opacity).
|
||||||
|
- Ikona “kłódki” blokuje warstwę:
|
||||||
|
- nie da się jej przeciągać (DnD),
|
||||||
|
- suwak opacity jest wyłączony,
|
||||||
|
- klik w wiersz nie zmienia kolejności (nie “wypycha” na top).
|
||||||
|
- Ustawienia są zapisywane w localStorage (`trade.layerVisible`, `trade.layerLocked`).
|
||||||
|
|
||||||
|
## Implementacja (gdzie w kodzie)
|
||||||
|
- Sterowanie układem i panel `Layers`: `apps/visualizer/src/App.tsx`
|
||||||
|
- `trade.layoutMode` w localStorage: `'grid' | 'stack'`
|
||||||
|
- `trade.stackOrder` w localStorage: kolejność warstw (z-index; ostatni = top)
|
||||||
|
- `trade.stackOrderManual` w localStorage: czy użytkownik zmienił kolejność przez DnD (blokuje auto-przestawianie)
|
||||||
|
- `trade.stackPanelLocked` w localStorage: blokada auto-hide panelu warstw
|
||||||
|
- `trade.contractId` w localStorage: aktywny kontrakt do monitoringu
|
||||||
|
- Fullscreen chart przez warstwy: `apps/visualizer/src/features/chart/ChartPanel.tsx`
|
||||||
|
- `fullscreenOverride` + `onToggleFullscreenOverride` (fullscreen kontrolowany z zewnątrz, bez backdropu)
|
||||||
|
- Fullscreen DLOB: `apps/visualizer/src/features/market/DlobDashboard.tsx`
|
||||||
|
- `isFullscreen` + `onToggleFullscreen` (przycisk w nagłówku)
|
||||||
|
- Panel kosztów: `apps/visualizer/src/features/contracts/ContractCostsPanel.tsx`
|
||||||
|
|
||||||
|
## Co dalej (kolejne iteracje)
|
||||||
|
|
||||||
|
MVP nie robi jeszcze “prawdziwego” układu kafelków z:
|
||||||
|
- drag/resize okien w trybie stack,
|
||||||
|
- wyświetlaniem kilku warstw jednocześnie (np. jako nakładające się okna),
|
||||||
|
- przełączaniem grid→stack przez zoom kafelka (z animacją).
|
||||||
|
|
||||||
|
Proponowane następne kroki:
|
||||||
|
1) wydzielić `Pane` abstraction (id, title, render, hotkeys),
|
||||||
|
2) zrobić `grid` jako faktyczne kafelki (Chart, DLOB, Orderbook, TradeForm),
|
||||||
|
3) dodać “zoom” kafelka → wejście do stack,
|
||||||
|
4) dodać opcjonalnie drag/resize w stack (na start tylko z-index + focus).
|
||||||
99
doc/workflow.md
Normal file
99
doc/workflow.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Workflow pracy w projekcie `trade` (dev → staging na VPS) + snapshot/rollback
|
||||||
|
|
||||||
|
Ten plik jest “source of truth” dla sposobu pracy nad zmianami w `trade`.
|
||||||
|
Cel: **zero ręcznych zmian na VPS**, każdy deploy jest **snapshoot’em**, do którego można wrócić.
|
||||||
|
|
||||||
|
## Dla agenta / po restarcie sesji
|
||||||
|
|
||||||
|
1) Przeczytaj ten plik: `doc/workflow.md`.
|
||||||
|
2) Kontekst funkcjonalny: `README.md`, `info.md`.
|
||||||
|
3) Kontekst stacka: `doc/workflow-api-ingest.md` oraz `devops/*/README.md`.
|
||||||
|
4) Stan VPS/k3s + GitOps: `doc/migration.md` i log zmian: `doc/steps.md`.
|
||||||
|
|
||||||
|
## Zasady (must-have)
|
||||||
|
|
||||||
|
- **Nie edytujemy “na żywo” VPS** (żadnych ręcznych poprawek w kontenerach/plikach na serwerze) → tylko Git + CI + Argo.
|
||||||
|
- **Sekrety nie trafiają do gita**: `tokens/*.json` są gitignored; w dokumentacji/logach redagujemy hasła/tokeny.
|
||||||
|
- **Brak `latest`**: obrazy w deployu są przypięte do `sha-<shortsha>` albo digesta.
|
||||||
|
- **Każda zmiana = snapshot**: “wersja” to commit w repo deploy + przypięte obrazy.
|
||||||
|
|
||||||
|
## Domyślne środowisko pracy (ważne)
|
||||||
|
|
||||||
|
- **Frontend**: domyślnie pracujemy lokalnie (Vite) i łączymy się z backendem na VPS (staging) przez proxy. Deploy frontendu na VPS robimy tylko jeśli jest to wyraźnie powiedziane (“wdrażam na VPS”).
|
||||||
|
- **Backend (trade-api + ingestor)**: zmiany backendu weryfikujemy/wdrażamy na VPS (staging), bo tam działa ingestor i tam są dane. Nie traktujemy lokalnego uruchomienia backendu jako domyślnego (tylko na wyraźną prośbę do debugowania).
|
||||||
|
|
||||||
|
## Standardowy flow zmian (polecany)
|
||||||
|
|
||||||
|
1) Zmiana w kodzie lokalnie.
|
||||||
|
- Nie musisz odpalać lokalnego Dockera; na start rób szybkie walidacje (build/typecheck).
|
||||||
|
2) Commit + push (najlepiej przez PR).
|
||||||
|
3) CI:
|
||||||
|
- build → push obrazów (tag `sha-*` lub digest),
|
||||||
|
- aktualizacja `trade-deploy` (bump obrazu/rewizji).
|
||||||
|
4) Argo CD (auto-sync na staging) wdraża nowy snapshot w `trade-staging`.
|
||||||
|
5) Test na VPS:
|
||||||
|
- API: `/healthz`, `/v1/ticks`, `/v1/chart`
|
||||||
|
- UI: `trade.mpabi.pl`
|
||||||
|
- Ingest: logi `trade-ingestor` + napływ ticków do tabeli.
|
||||||
|
|
||||||
|
## Snapshoty i rollback (playbook)
|
||||||
|
|
||||||
|
### Rollback szybki (preferowany)
|
||||||
|
|
||||||
|
- Cofnij snapshot w repo deploy:
|
||||||
|
- `git revert` commita, który podbił obrazy, **albo**
|
||||||
|
- w Argo cofnij do poprzedniej rewizji (ten sam efekt).
|
||||||
|
|
||||||
|
Efekt: Argo wraca do poprzedniego “dobrego” zestawu obrazów i konfiguracji.
|
||||||
|
|
||||||
|
### Rollback bezpieczny dla “dużych” zmian (schema/ingest)
|
||||||
|
|
||||||
|
Jeśli zmiana dotyka danych/ingestu, rób ją jako nową wersję vN:
|
||||||
|
- nowa tabela: `drift_ticks_vN`
|
||||||
|
- nowa funkcja: `get_drift_candles_vN`
|
||||||
|
- osobny deploy API/UI (inne porty/sufiksy), a ingest przełączany “cutover”.
|
||||||
|
|
||||||
|
W razie problemów: robisz “cut back” vN → v1 (stare dane zostają nietknięte).
|
||||||
|
|
||||||
|
## Lokalne uruchomienie (opcjonalne, do debugowania)
|
||||||
|
|
||||||
|
Dokładna instrukcja: `doc/workflow-api-ingest.md`.
|
||||||
|
|
||||||
|
Skrót:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
docker compose -f devops/db/docker-compose.yml up -d
|
||||||
|
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init
|
||||||
|
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap
|
||||||
|
docker compose -f devops/app/docker-compose.yml up -d --build api
|
||||||
|
npm run token:api -- --scopes write --out tokens/alg.json
|
||||||
|
npm run token:api -- --scopes read --out tokens/read.json
|
||||||
|
docker compose -f devops/app/docker-compose.yml up -d --build frontend
|
||||||
|
docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend dev (Vite) z backendem na VPS (staging)
|
||||||
|
|
||||||
|
Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`.
|
||||||
|
|
||||||
|
- Vite trzyma `VITE_API_URL=/api` (default) i proxy’uje `/api/*` do VPS.
|
||||||
|
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxy’ujemy też `/whoami`, `/auth/*`, `/logout`.
|
||||||
|
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
|
||||||
|
- Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
|
||||||
|
|
||||||
|
Przykład:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/visualizer
|
||||||
|
API_PROXY_TARGET=https://trade.mpabi.pl \
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj:
|
||||||
|
`API_PROXY_BASIC_AUTH='USER:PASS'` albo `API_PROXY_BASIC_AUTH_FILE=tokens/frontend.json` (pola `username`/`password`).
|
||||||
|
|
||||||
|
## Definicja “done” dla zmiany
|
||||||
|
|
||||||
|
- Jest snapshot (commit w deploy) i można wrócić jednym ruchem.
|
||||||
|
- Staging działa (API/ingest/UI) i ma podstawowe smoke-checki.
|
||||||
|
- Sekrety nie zostały dodane do repo ani do logów/komentarzy.
|
||||||
@@ -57,15 +57,15 @@ function timingSafeEqualBuf(a, b) {
|
|||||||
|
|
||||||
function loadBasicAuth() {
|
function loadBasicAuth() {
|
||||||
const j = readJson(BASIC_AUTH_FILE);
|
const j = readJson(BASIC_AUTH_FILE);
|
||||||
const username = (j?.username || '').toString();
|
const username = (j?.username || '').toString().trim();
|
||||||
const password = (j?.password || '').toString();
|
const password = (j?.password || '').toString().trim();
|
||||||
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
|
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
|
||||||
return { username, password };
|
return { username, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadApiReadToken() {
|
function loadApiReadToken() {
|
||||||
const j = readJson(API_READ_TOKEN_FILE);
|
const j = readJson(API_READ_TOKEN_FILE);
|
||||||
const token = (j?.token || '').toString();
|
const token = (j?.token || '').toString().trim();
|
||||||
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
|
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user