35 Commits

Author SHA1 Message Date
u1
fc26e8eac9 docs(todo): add staging + visualizer next steps 2026-02-01 22:20:41 +01:00
u1
b06fe7f9a4 docs: add rpc/dlob/candles notes 2026-02-01 21:44:45 +01:00
u1
89415f6793 feat(visualizer): add layers + fast timeframe switching 2026-02-01 21:44:29 +01:00
u1
fc92392705 docs: describe candle timeframes and brick stack 2026-01-11 10:37:39 +00:00
u1
b70257fc5f docs: add DLOB services + glossary 2026-01-11 10:37:23 +00:00
u1
ca9e44a41a feat(visualizer): add DLOB depth bands panel 2026-01-10 23:02:30 +00:00
u1
c1bc6f9e2f feat(visualizer): add DLOB slippage chart component 2026-01-10 23:02:04 +00:00
u1
2a158334bf feat(visualizer): add DLOB dashboard UI 2026-01-10 23:01:36 +00:00
u1
bff6560f43 feat(visualizer): add DLOB depth bands subscription hook 2026-01-10 23:00:56 +00:00
u1
9d1ebba39d feat(visualizer): add DLOB slippage subscription hook 2026-01-10 23:00:29 +00:00
u1
965774dfbd feat(dlob): add L2 subscription hook 2026-01-10 22:56:25 +00:00
u1
fb307f0279 fix(hasura): default to /graphql proxy 2026-01-10 22:55:14 +00:00
u1
6904be4a51 feat(chart): render brick-stack flow bars 2026-01-10 22:54:22 +00:00
u1
912a78588d feat(chart): update layers panel UI 2026-01-10 22:47:22 +00:00
u1
62baa9700e feat(chart): overlay DLOB quotes + layers 2026-01-10 22:46:20 +00:00
u1
fa0ff11b5a feat(visualizer): add DLOB dashboard and SOL default 2026-01-10 22:43:49 +00:00
u1
879f45aa5c feat(chart): parse candle flow rows for brick bars 2026-01-10 22:23:51 +00:00
u1
5a9c2b0a85 feat(chart): add 5s/15s/30s timeframes 2026-01-10 22:23:15 +00:00
u1
9592d6ac16 docs(env): update visualizer defaults 2026-01-10 22:22:46 +00:00
u1
dff4d347ad feat(dlob): add stats subscription hook 2026-01-10 22:22:32 +00:00
u1
ae41f1a9de feat(hasura): add GraphQL WS subscription helper 2026-01-10 22:21:46 +00:00
u1
42e0a4d86d feat(dev): add visualizer __start helper 2026-01-10 22:20:53 +00:00
u1
a9ccc0b00e feat(visualizer): add build overlay toggle
Default is off so candles match production; enable via the new Build button when needed.
2026-01-09 03:22:31 +01:00
u1
9420c89f52 docs: update Vite staging proxy instructions 2026-01-08 06:00:23 +00:00
u1
545e1abfaa docs(workflow): update staging Vite proxy notes 2026-01-08 06:00:11 +00:00
u1
759173b5be fix(dev): enable Vite session auth proxy 2026-01-08 05:59:40 +00:00
u1
194d596284 docs(workflow): clarify local FE vs VPS backend defaults 2026-01-07 19:59:16 +00:00
u1
444f427420 docs: describe Vite dev against VPS backend 2026-01-07 19:50:44 +00:00
u1
af267ad6c9 feat(dev): support Vite proxy to VPS backend 2026-01-07 19:50:28 +00:00
u1
f3c4a999c3 docs: add workflow playbook 2026-01-07 19:49:59 +00:00
u1
1c8a6900e8 feat(chart): add candle build indicator line 2026-01-07 08:09:09 +00:00
u1
abaee44835 feat(chart): wire build indicator meta 2026-01-07 08:04:49 +00:00
u1
f57366fad2 feat(chart): pass build indicator props 2026-01-07 08:01:57 +00:00
u1
b0c7806cb6 feat(chart): expose bucketSeconds meta 2026-01-07 07:56:45 +00:00
u1
a12c86f1f8 feat(chart): layers panel + selection-first fib
- Adds Layers drawer (visible/lock/opacity) and effective opacity wiring.
- Implements selection-first overlay: click selects, drag moves selected, Ctrl-drag edits B, Space-drag always pans.
- Routes pointer events via capture and adds Fib opacity support.
- Updates .gitignore to keep secrets/local scratch out of git.
2026-01-06 23:26:51 +01:00
46 changed files with 7994 additions and 181 deletions

14
.gitignore vendored
View File

@@ -1,7 +1,15 @@
# Secrets (never commit)
tokens/*
!tokens/*.example.json
!tokens/*.example.yml
!tokens/*.example.yaml
gitea/token
node_modules/ node_modules/
dist/ dist/
.env .env
*.log *.log
tokens/*.json
tokens/*.yml # Local scratch / build output
tokens/*.yaml _tmp/
apps/visualizer/dist/

View File

@@ -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 proxyuje 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

View File

@@ -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
View 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

View File

@@ -156,6 +156,21 @@ export function IconEye(props: IconProps) {
); );
} }
export function IconLayers(props: IconProps) {
return (
<Svg title={props.title ?? 'Layers'} {...props}>
<path
d="M3.0 6.2L9.0 3.2L15.0 6.2L9.0 9.2L3.0 6.2Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path d="M3.0 9.2L9.0 12.2L15.0 9.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.85" />
<path d="M3.0 12.2L9.0 15.2L15.0 12.2" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" opacity="0.65" />
</Svg>
);
}
export function IconTrash(props: IconProps) { export function IconTrash(props: IconProps) {
return ( return (
<Svg title={props.title ?? 'Delete'} {...props}> <Svg title={props.title ?? 'Delete'} {...props}>

View File

@@ -0,0 +1,206 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import type { OverlayLayer } from './ChartPanel.types';
import { IconEye, IconLock, IconTrash } from './ChartIcons';
type Props = {
open: boolean;
layers: OverlayLayer[];
onRequestClose: () => void;
onToggleLayerVisible: (layerId: string) => void;
onToggleLayerLocked: (layerId: string) => void;
onSetLayerOpacity: (layerId: string, opacity: number) => void;
fibPresent: boolean;
fibSelected: boolean;
fibVisible: boolean;
fibLocked: boolean;
fibOpacity: number;
onSelectFib: () => void;
onToggleFibVisible: () => void;
onToggleFibLocked: () => void;
onSetFibOpacity: (opacity: number) => void;
onDeleteFib: () => void;
};
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
function opacityToPct(opacity: number): number {
return Math.round(clamp01(opacity) * 100);
}
function pctToOpacity(pct: number): number {
if (!Number.isFinite(pct)) return 1;
return clamp01(pct / 100);
}
function IconButton({
title,
active,
disabled,
onClick,
children,
}: {
title: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
className={['layersBtn', active ? 'layersBtn--active' : null].filter(Boolean).join(' ')}
title={title}
aria-label={title}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick();
}}
disabled={disabled}
>
{children}
</button>
);
}
function OpacitySlider({
value,
disabled,
onChange,
}: {
value: number;
disabled?: boolean;
onChange: (next: number) => void;
}) {
const pct = opacityToPct(value);
return (
<div className="layersOpacity">
<input
className="layersOpacity__range"
type="range"
min={0}
max={100}
value={pct}
onChange={(e) => onChange(pctToOpacity(Number(e.target.value)))}
disabled={disabled}
/>
<div className="layersOpacity__pct">{pct}%</div>
</div>
);
}
export default function ChartLayersPanel({
open,
layers,
onRequestClose,
onToggleLayerVisible,
onToggleLayerLocked,
onSetLayerOpacity,
fibPresent,
fibSelected,
fibVisible,
fibLocked,
fibOpacity,
onSelectFib,
onToggleFibVisible,
onToggleFibLocked,
onSetFibOpacity,
onDeleteFib,
}: Props) {
const drawingsLayer = useMemo(() => layers.find((l) => l.id === 'drawings'), [layers]);
return (
<>
<div
className={['chartLayersBackdrop', open ? 'chartLayersBackdrop--open' : null].filter(Boolean).join(' ')}
onClick={open ? onRequestClose : undefined}
/>
<div className={['chartLayersPanel', open ? 'chartLayersPanel--open' : null].filter(Boolean).join(' ')}>
<div className="chartLayersPanel__head">
<div className="chartLayersPanel__title">Layers</div>
<button type="button" className="chartLayersPanel__close" onClick={onRequestClose} aria-label="Close">
</button>
</div>
<div className="chartLayersTable">
<div className="chartLayersRow chartLayersRow--head">
<div className="chartLayersCell chartLayersCell--icon" title="Visible">
<IconEye />
</div>
<div className="chartLayersCell chartLayersCell--icon" title="Lock">
<IconLock />
</div>
<div className="chartLayersCell chartLayersCell--name">Name</div>
<div className="chartLayersCell chartLayersCell--opacity">Opacity</div>
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
</div>
{layers.map((layer) => (
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
<div className="chartLayersCell chartLayersCell--icon">
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
<IconEye />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--icon">
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
<IconLock />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--name">
<div className="layersName layersName--layer">
{layer.name}
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
</div>
</div>
<div className="chartLayersCell chartLayersCell--opacity">
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
</div>
<div className="chartLayersCell chartLayersCell--actions" />
</div>
))}
{drawingsLayer && fibPresent ? (
<div
className={['chartLayersRow', 'chartLayersRow--object', fibSelected ? 'chartLayersRow--selected' : null]
.filter(Boolean)
.join(' ')}
onClick={onSelectFib}
role="button"
tabIndex={0}
>
<div className="chartLayersCell chartLayersCell--icon">
<IconButton title="Toggle visible" active={fibVisible} onClick={onToggleFibVisible}>
<IconEye />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--icon">
<IconButton title="Toggle lock" active={fibLocked} onClick={onToggleFibLocked}>
<IconLock />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--name">
<div className="layersName layersName--object">Fib Retracement</div>
</div>
<div className="chartLayersCell chartLayersCell--opacity">
<OpacitySlider value={fibOpacity} onChange={onSetFibOpacity} />
</div>
<div className="chartLayersCell chartLayersCell--actions">
<IconButton title="Delete fib" onClick={onDeleteFib}>
<IconTrash />
</IconButton>
</div>
</div>
) : null}
</div>
</div>
</>
);
}

View File

@@ -1,66 +1,129 @@
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';
import ChartLayersPanel from './ChartLayersPanel';
import ChartSideToolbar from './ChartSideToolbar'; 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';
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 FibDrag = {
pointerId: number;
mode: FibDragMode;
startClientX: number;
startClientY: number;
start: FibAnchor;
origin: FibRetracement;
moved: boolean;
};
function isEditableTarget(t: EventTarget | null): boolean {
if (!(t instanceof HTMLElement)) return false;
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return t.isContentEditable;
}
export default function ChartPanel({ 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 [fibMove, setFibMove] = useState<{ start: FibAnchor; origin: FibRetracement } | null>(null); const [layers, setLayers] = useState<OverlayLayer[]>([
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
]);
const [layersOpen, setLayersOpen] = useState(false);
const [fibVisible, setFibVisible] = useState(true);
const [fibLocked, setFibLocked] = useState(false);
const [fibOpacity, setFibOpacity] = useState(1);
const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
const [priceAutoScale, setPriceAutoScale] = useState(true); const [priceAutoScale, setPriceAutoScale] = useState(true);
const chartApiRef = useRef<IChartApi | null>(null); const chartApiRef = useRef<IChartApi | null>(null);
const activeToolRef = useRef(activeTool); const activeToolRef = useRef(activeTool);
const fibStartRef = useRef<FibAnchor | null>(fibStart); const fibStartRef = useRef<FibAnchor | null>(fibStart);
const fibMoveRef = useRef<{ start: FibAnchor; origin: FibRetracement } | null>(fibMove);
const pendingMoveRef = useRef<FibAnchor | null>(null); const pendingMoveRef = useRef<FibAnchor | null>(null);
const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const spaceDownRef = useRef<boolean>(false);
const dragRef = useRef<FibDrag | null>(null);
const selectPointerRef = useRef<number | null>(null);
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
const fibRef = useRef<FibRetracement | null>(fib);
useEffect(() => { 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;
if (activeTool === 'fib-retracement') setFibMove(null);
if (activeTool !== 'fib-retracement') { if (activeTool !== 'fib-retracement') {
setFibStart(null); setFibStart(null);
setFibDraft(null); setFibDraft(null);
@@ -72,26 +135,63 @@ export default function ChartPanel({
}, [fibStart]); }, [fibStart]);
useEffect(() => { useEffect(() => {
fibMoveRef.current = fibMove; selectedOverlayIdRef.current = selectedOverlayId;
}, [fibMove]); }, [selectedOverlayId]);
useEffect(() => {
fibRef.current = fib;
}, [fib]);
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return; if (isEditableTarget(e.target)) return;
if (fibMoveRef.current) { if (e.code === 'Space') {
setFibMove(null); spaceDownRef.current = true;
e.preventDefault();
return;
}
if (e.key === 'Escape') {
if (dragRef.current) {
dragRef.current = null;
pendingDragRef.current = null;
selectPointerRef.current = null;
setFibDraft(null); setFibDraft(null);
return; return;
} }
if (activeToolRef.current !== 'fib-retracement') return; if (activeToolRef.current === 'fib-retracement') {
setFibStart(null); setFibStart(null);
setFibDraft(null); setFibDraft(null);
setActiveTool('cursor'); setActiveTool('cursor');
return;
}
if (selectedOverlayIdRef.current) setSelectedOverlayId(null);
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedOverlayIdRef.current === 'fib') {
clearFib();
}
}
}; };
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') {
spaceDownRef.current = false;
}
};
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -113,21 +213,129 @@ export default function ChartPanel({
ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 }); ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 });
} }
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
const quotesVisible = Boolean(quotesLayer?.visible);
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
const priceLines = useMemo(() => {
if (!quotesVisible) return [];
return [
{
id: 'dlob-bid',
title: 'DLOB Bid',
price: dlobQuotes?.bid ?? null,
color: `rgba(34,197,94,${quotesOpacity})`,
lineStyle: LineStyle.Dotted,
},
{
id: 'dlob-mid',
title: 'DLOB Mid',
price: dlobQuotes?.mid ?? null,
color: `rgba(230,233,239,${quotesOpacity})`,
lineStyle: LineStyle.Dashed,
},
{
id: 'dlob-ask',
title: 'DLOB Ask',
price: dlobQuotes?.ask ?? null,
color: `rgba(239,68,68,${quotesOpacity})`,
lineStyle: LineStyle.Dotted,
},
];
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]);
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
}
function clearFib() {
setFib(null);
setFibStart(null);
setFibDraft(null);
dragRef.current = null;
pendingDragRef.current = null;
selectPointerRef.current = null;
setSelectedOverlayId(null);
}
function computeFibFromDrag(drag: FibDrag, pointer: FibAnchor): FibRetracement {
if (drag.mode === 'edit-b') return { a: drag.origin.a, b: pointer };
const deltaLogical = pointer.logical - drag.start.logical;
const deltaPrice = pointer.price - drag.start.price;
return {
a: { logical: drag.origin.a.logical + deltaLogical, price: drag.origin.a.price + deltaPrice },
b: { logical: drag.origin.b.logical + deltaLogical, price: drag.origin.b.price + deltaPrice },
};
}
function scheduleFrame() {
if (rafRef.current != null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
const drag = dragRef.current;
const pendingDrag = pendingDragRef.current;
if (drag && pendingDrag) {
if (!drag.moved) {
const dx = pendingDrag.clientX - drag.startClientX;
const dy = pendingDrag.clientY - drag.startClientY;
if (dx * dx + dy * dy >= 16) drag.moved = true; // ~4px threshold
}
if (drag.moved) {
setFibDraft(computeFibFromDrag(drag, pendingDrag.anchor));
}
return;
}
const pointer = pendingMoveRef.current;
if (!pointer) return;
if (activeToolRef.current !== 'fib-retracement') return;
const start2 = fibStartRef.current;
if (!start2) return;
setFibDraft({ a: start2, b: pointer });
});
}
const drawingsLayer =
layers.find((l) => l.id === 'drawings') ?? { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 };
const fibEffectiveVisible = fibVisible && drawingsLayer.visible;
const fibEffectiveOpacity = fibOpacity * drawingsLayer.opacity;
const fibEffectiveLocked = fibLocked || drawingsLayer.locked;
const fibSelected = selectedOverlayId === 'fib';
const fibRenderable = fibEffectiveVisible ? (fibDraft ?? fib) : null;
useEffect(() => {
if (selectedOverlayId !== 'fib') return;
if (!fib) {
setSelectedOverlayId(null);
return;
}
if (!fibEffectiveVisible) setSelectedOverlayId(null);
}, [fib, fibEffectiveVisible, selectedOverlayId]);
return ( 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">
@@ -135,16 +343,13 @@ export default function ChartPanel({
timeframe={timeframe} timeframe={timeframe}
activeTool={activeTool} activeTool={activeTool}
hasFib={fib != null || fibDraft != null} hasFib={fib != null || fibDraft != null}
isLayersOpen={layersOpen}
onToolChange={setActiveTool} onToolChange={setActiveTool}
onToggleLayers={() => setLayersOpen((v) => !v)}
onZoomIn={() => zoomTime(0.8)} onZoomIn={() => zoomTime(0.8)}
onZoomOut={() => zoomTime(1.25)} onZoomOut={() => zoomTime(1.25)}
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()} onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
onClearFib={() => { onClearFib={clearFib}
setFib(null);
setFibStart(null);
setFibDraft(null);
setFibMove(null);
}}
/> />
<div className="chartCard__chart"> <div className="chartCard__chart">
<TradingChart <TradingChart
@@ -154,8 +359,13 @@ export default function ChartPanel({
ema20={indicators.ema20} ema20={indicators.ema20}
bb20={indicators.bb20} bb20={indicators.bb20}
showIndicators={showIndicators} showIndicators={showIndicators}
fib={fibDraft ?? fib} showBuild={showBuild}
fibSelected={fibMove != null} bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
priceLines={priceLines}
fib={fibRenderable}
fibOpacity={fibEffectiveOpacity}
fibSelected={fibSelected}
priceAutoScale={priceAutoScale} priceAutoScale={priceAutoScale}
onReady={({ chart }) => { onReady={({ chart }) => {
chartApiRef.current = chart; chartApiRef.current = chart;
@@ -176,50 +386,101 @@ export default function ChartPanel({
return; return;
} }
const move = fibMoveRef.current; if (p.target === 'chart') setSelectedOverlayId(null);
if (move) {
const deltaLogical = p.logical - move.start.logical;
const deltaPrice = p.price - move.start.price;
setFib({
a: { logical: move.origin.a.logical + deltaLogical, price: move.origin.a.price + deltaPrice },
b: { logical: move.origin.b.logical + deltaLogical, price: move.origin.b.price + deltaPrice },
});
setFibMove(null);
setFibDraft(null);
return;
}
if (p.target === 'fib' && fib) {
setFibMove({ start: p, origin: fib });
setFibDraft(fib);
}
}} }}
onChartCrosshairMove={(p) => { onChartCrosshairMove={(p) => {
pendingMoveRef.current = p; pendingMoveRef.current = p;
if (rafRef.current != null) return; scheduleFrame();
rafRef.current = window.requestAnimationFrame(() => { }}
rafRef.current = null; onPointerEvent={({ type, logical, price, target, event }) => {
const pointer = pendingMoveRef.current; const pointer: FibAnchor = { logical, price };
if (!pointer) return;
const move = fibMoveRef.current; if (type === 'pointerdown') {
if (move) { if (event.button !== 0) return;
const deltaLogical = pointer.logical - move.start.logical; if (spaceDownRef.current) return;
const deltaPrice = pointer.price - move.start.price; if (activeToolRef.current !== 'cursor') return;
setFibDraft({ if (target !== 'fib') return;
a: { logical: move.origin.a.logical + deltaLogical, price: move.origin.a.price + deltaPrice }, if (!fibRef.current) return;
b: { logical: move.origin.b.logical + deltaLogical, price: move.origin.b.price + deltaPrice }, if (!fibEffectiveVisible) return;
});
if (selectedOverlayIdRef.current !== 'fib') {
setSelectedOverlayId('fib');
selectPointerRef.current = event.pointerId;
return { consume: true, capturePointer: true };
}
if (fibEffectiveLocked) {
selectPointerRef.current = event.pointerId;
return { consume: true, capturePointer: true };
}
dragRef.current = {
pointerId: event.pointerId,
mode: event.ctrlKey ? 'edit-b' : 'move',
startClientX: event.clientX,
startClientY: event.clientY,
start: pointer,
origin: fibRef.current,
moved: false,
};
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
setFibDraft(fibRef.current);
return { consume: true, capturePointer: true };
}
const drag = dragRef.current;
if (drag && drag.pointerId === event.pointerId) {
if (type === 'pointermove') {
pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY };
scheduleFrame();
return { consume: true };
}
if (type === 'pointerup' || type === 'pointercancel') {
if (drag.moved) setFib(computeFibFromDrag(drag, pointer));
dragRef.current = null;
pendingDragRef.current = null;
setFibDraft(null);
return { consume: true };
}
return; return;
} }
if (activeToolRef.current !== 'fib-retracement') return; if (selectPointerRef.current != null && selectPointerRef.current === event.pointerId) {
const start2 = fibStartRef.current; if (type === 'pointermove') return { consume: true };
if (!start2) return; if (type === 'pointerup' || type === 'pointercancel') {
setFibDraft({ a: start2, b: pointer }); selectPointerRef.current = null;
}); return { consume: true };
}
}
}} }}
/> />
<ChartLayersPanel
open={layersOpen}
layers={layers}
onRequestClose={() => setLayersOpen(false)}
onToggleLayerVisible={(layerId) => {
const layer = layers.find((l) => l.id === layerId);
if (!layer) return;
updateLayer(layerId, { visible: !layer.visible });
}}
onToggleLayerLocked={(layerId) => {
const layer = layers.find((l) => l.id === layerId);
if (!layer) return;
updateLayer(layerId, { locked: !layer.locked });
}}
onSetLayerOpacity={(layerId, opacity) => updateLayer(layerId, { opacity: clamp01(opacity) })}
fibPresent={fib != null}
fibSelected={fibSelected}
fibVisible={fibVisible}
fibLocked={fibLocked}
fibOpacity={fibOpacity}
onSelectFib={() => setSelectedOverlayId('fib')}
onToggleFibVisible={() => setFibVisible((v) => !v)}
onToggleFibLocked={() => setFibLocked((v) => !v)}
onSetFibOpacity={(opacity) => setFibOpacity(clamp01(opacity))}
onDeleteFib={clearFib}
/>
</div> </div>
</div> </div>
</Card> </Card>

View File

@@ -0,0 +1,8 @@
export type OverlayLayer = {
id: string;
name: string;
visible: boolean;
locked: boolean;
opacity: number; // 0..1
};

View File

@@ -4,8 +4,8 @@ import {
IconBrush, IconBrush,
IconCrosshair, IconCrosshair,
IconCursor, IconCursor,
IconEye,
IconFib, IconFib,
IconLayers,
IconLock, IconLock,
IconPlus, IconPlus,
IconRuler, IconRuler,
@@ -24,7 +24,9 @@ type Props = {
timeframe: string; timeframe: string;
activeTool: ActiveTool; activeTool: ActiveTool;
hasFib: boolean; hasFib: boolean;
isLayersOpen: boolean;
onToolChange: (tool: ActiveTool) => void; onToolChange: (tool: ActiveTool) => void;
onToggleLayers: () => void;
onZoomIn: () => void; onZoomIn: () => void;
onZoomOut: () => void; onZoomOut: () => void;
onResetView: () => void; onResetView: () => void;
@@ -35,7 +37,9 @@ export default function ChartSideToolbar({
timeframe, timeframe,
activeTool, activeTool,
hasFib, hasFib,
isLayersOpen,
onToolChange, onToolChange,
onToggleLayers,
onZoomIn, onZoomIn,
onZoomOut, onZoomOut,
onResetView, onResetView,
@@ -195,9 +199,15 @@ export default function ChartSideToolbar({
</span> </span>
</button> </button>
<button type="button" className="chartToolBtn" title="Visibility" aria-label="Visibility" disabled> <button
type="button"
className={['chartToolBtn', isLayersOpen ? 'chartToolBtn--active' : ''].filter(Boolean).join(' ')}
title="Layers"
aria-label="Layers"
onClick={onToggleLayers}
>
<span className="chartToolBtn__icon" aria-hidden="true"> <span className="chartToolBtn__icon" aria-hidden="true">
<IconEye /> <IconLayers />
</span> </span>
</button> </button>
</div> </div>

View File

@@ -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>

View File

@@ -55,6 +55,7 @@ type State = {
series: ISeriesApi<'Candlestick', Time> | null; series: ISeriesApi<'Candlestick', Time> | null;
chart: SeriesAttachedParameter<Time>['chart'] | null; chart: SeriesAttachedParameter<Time>['chart'] | null;
selected: boolean; selected: boolean;
opacity: number;
}; };
class FibPaneRenderer implements IPrimitivePaneRenderer { class FibPaneRenderer implements IPrimitivePaneRenderer {
@@ -65,8 +66,10 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
} }
draw(target: any) { draw(target: any) {
const { fib, series, chart, selected } = this._getState(); const { fib, series, chart, selected, opacity } = this._getState();
if (!fib || !series || !chart) return; if (!fib || !series || !chart) return;
const clampedOpacity = Math.max(0, Math.min(1, opacity));
if (clampedOpacity <= 0) return;
const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any); const x1 = chart.timeScale().logicalToCoordinate(fib.a.logical as any);
const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any); const x2 = chart.timeScale().logicalToCoordinate(fib.b.logical as any);
@@ -79,6 +82,9 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
const delta = p1 - p0; const delta = p1 - p0;
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => { target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
context.save();
context.globalAlpha *= clampedOpacity;
try {
const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio)); const xStart = Math.max(0, Math.round(xLeftMedia * horizontalPixelRatio));
let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio)); let xEnd = Math.min(bitmapSize.width, Math.round(xRightMedia * horizontalPixelRatio));
if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio))); if (xEnd <= xStart) xEnd = Math.min(bitmapSize.width, xStart + Math.max(1, Math.round(1 * horizontalPixelRatio)));
@@ -157,6 +163,9 @@ class FibPaneRenderer implements IPrimitivePaneRenderer {
context.fill(); context.fill();
if (selected) context.stroke(); if (selected) context.stroke();
} }
} finally {
context.restore();
}
}); });
} }
} }
@@ -178,6 +187,7 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
private _series: ISeriesApi<'Candlestick', Time> | null = null; private _series: ISeriesApi<'Candlestick', Time> | null = null;
private _fib: FibRetracement | null = null; private _fib: FibRetracement | null = null;
private _selected = false; private _selected = false;
private _opacity = 1;
private readonly _paneView: FibPaneView; private readonly _paneView: FibPaneView;
private readonly _paneViews: readonly IPrimitivePaneView[]; private readonly _paneViews: readonly IPrimitivePaneView[];
@@ -187,6 +197,7 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
series: this._series, series: this._series,
chart: this._param?.chart ?? null, chart: this._param?.chart ?? null,
selected: this._selected, selected: this._selected,
opacity: this._opacity,
})); }));
this._paneViews = [this._paneView]; this._paneViews = [this._paneView];
} }
@@ -214,4 +225,9 @@ export class FibRetracementPrimitive implements ISeriesPrimitive<Time> {
this._selected = next; this._selected = next;
this._param?.requestUpdate(); this._param?.requestUpdate();
} }
setOpacity(next: number) {
this._opacity = Number.isFinite(next) ? next : 1;
this._param?.requestUpdate();
}
} }

View File

@@ -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,20 +30,61 @@ 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;
fibSelected?: boolean; fibSelected?: boolean;
priceAutoScale?: boolean; priceAutoScale?: boolean;
onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void; onReady?: (api: { chart: IChartApi; candles: ISeriesApi<'Candlestick', UTCTimestamp> }) => void;
onChartClick?: (p: FibAnchor & { target: 'chart' | 'fib' }) => void; onChartClick?: (p: FibAnchor & { target: 'chart' | 'fib' }) => void;
onChartCrosshairMove?: (p: FibAnchor) => void; onChartCrosshairMove?: (p: FibAnchor) => void;
onPointerEvent?: (p: {
type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel';
logical: number;
price: number;
x: number;
y: number;
target: 'chart' | 'fib';
event: PointerEvent;
}) => { consume?: boolean; capturePointer?: boolean } | void;
}; };
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;
@@ -72,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({
@@ -93,25 +492,43 @@ export default function TradingChart({
ema20, ema20,
bb20, bb20,
showIndicators, showIndicators,
showBuild,
bucketSeconds,
seriesKey,
priceLines,
fib, fib,
fibOpacity = 1,
fibSelected = false, fibSelected = false,
priceAutoScale = true, priceAutoScale = true,
onReady, onReady,
onChartClick, onChartClick,
onChartCrosshairMove, onChartCrosshairMove,
onPointerEvent,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null); const chartRef = useRef<IChartApi | null>(null);
const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null); const fibPrimitiveRef = useRef<FibRetracementPrimitive | null>(null);
const fibRef = useRef<FibRetracement | null>(fib ?? null); const fibRef = useRef<FibRetracement | null>(fib ?? null);
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 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'>;
@@ -145,14 +562,31 @@ export default function TradingChart({
onChartCrosshairMoveRef.current = onChartCrosshairMove; onChartCrosshairMoveRef.current = onChartCrosshairMove;
}, [onChartCrosshairMove]); }, [onChartCrosshairMove]);
useEffect(() => {
onPointerEventRef.current = onPointerEvent;
}, [onPointerEvent]);
useEffect(() => { useEffect(() => {
fibRef.current = fib ?? null; fibRef.current = fib ?? null;
}, [fib]); }, [fib]);
useEffect(() => {
fibOpacityRef.current = fibOpacity;
fibPrimitiveRef.current?.setOpacity(fibOpacity);
}, [fibOpacity]);
useEffect(() => { useEffect(() => {
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;
@@ -193,6 +627,7 @@ export default function TradingChart({
candleSeries.attachPrimitive(fibPrimitive); candleSeries.attachPrimitive(fibPrimitive);
fibPrimitiveRef.current = fibPrimitive; fibPrimitiveRef.current = fibPrimitive;
fibPrimitive.setFib(fib ?? null); fibPrimitive.setFib(fib ?? null);
fibPrimitive.setOpacity(fibOpacityRef.current);
const volumeSeries = chart.addSeries(HistogramSeries, { const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' }, priceFormat: { type: 'volume' },
@@ -200,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, {
@@ -224,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,
@@ -273,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);
@@ -283,6 +755,97 @@ export default function TradingChart({
chart.subscribeCrosshairMove(onCrosshairMove); chart.subscribeCrosshairMove(onCrosshairMove);
const container = containerRef.current; const container = containerRef.current;
const toAnchorFromPoint = (x: number, y: number): FibAnchor | null => {
const logical = chart.timeScale().coordinateToLogical(x);
if (logical == null) return null;
const price = candleSeries.coordinateToPrice(y);
if (price == null) return null;
return { logical: Number(logical), price: Number(price) };
};
const isOverFib = (x: number, y: number, currentFib: FibRetracement): boolean => {
const x1 = chart.timeScale().logicalToCoordinate(currentFib.a.logical as any);
const x2 = chart.timeScale().logicalToCoordinate(currentFib.b.logical as any);
if (x1 == null || x2 == null) return false;
const tol = 6;
const left = Math.min(x1, x2) - tol;
const right = Math.max(x1, x2) + tol;
const p0 = currentFib.a.price;
const delta = currentFib.b.price - p0;
const yRatioMin = 0;
const yRatioMax = 4.236;
const pMin = Math.min(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const pMax = Math.max(p0 + delta * yRatioMin, p0 + delta * yRatioMax);
const y1 = candleSeries.priceToCoordinate(pMin);
const y2 = candleSeries.priceToCoordinate(pMax);
if (y1 == null || y2 == null) return false;
const top = Math.min(y1, y2) - tol;
const bottom = Math.max(y1, y2) + tol;
return x >= left && x <= right && y >= top && y <= bottom;
};
const onOverlayPointer = (e: PointerEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const anchor = toAnchorFromPoint(x, y);
if (!anchor) return;
const currentFib = fibRef.current;
const target: 'chart' | 'fib' = currentFib && isOverFib(x, y, currentFib) ? 'fib' : 'chart';
const type =
e.type === 'pointerdown'
? ('pointerdown' as const)
: e.type === 'pointermove'
? ('pointermove' as const)
: e.type === 'pointerup'
? ('pointerup' as const)
: ('pointercancel' as const);
const decision = onPointerEventRef.current?.({
type,
logical: anchor.logical,
price: anchor.price,
x,
y,
target,
event: e,
});
if (decision?.capturePointer) {
capturedOverlayPointerRef.current = e.pointerId;
try {
containerRef.current.setPointerCapture(e.pointerId);
} catch {
// ignore
}
}
if (decision?.consume) {
e.preventDefault();
e.stopImmediatePropagation();
}
if (type === 'pointerup' || type === 'pointercancel') {
if (capturedOverlayPointerRef.current === e.pointerId) {
capturedOverlayPointerRef.current = null;
try {
containerRef.current.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
}
}
};
container?.addEventListener('pointerdown', onOverlayPointer, { capture: true });
container?.addEventListener('pointermove', onOverlayPointer, { capture: true });
container?.addEventListener('pointerup', onOverlayPointer, { capture: true });
container?.addEventListener('pointercancel', onOverlayPointer, { capture: true });
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -413,6 +976,10 @@ export default function TradingChart({
return () => { return () => {
chart.unsubscribeClick(onClick); chart.unsubscribeClick(onClick);
chart.unsubscribeCrosshairMove(onCrosshairMove); chart.unsubscribeCrosshairMove(onCrosshairMove);
container?.removeEventListener('pointerdown', onOverlayPointer, { capture: true });
container?.removeEventListener('pointermove', onOverlayPointer, { capture: true });
container?.removeEventListener('pointerup', onOverlayPointer, { capture: true });
container?.removeEventListener('pointercancel', onOverlayPointer, { capture: true });
container?.removeEventListener('wheel', onWheel, { capture: true }); container?.removeEventListener('wheel', onWheel, { capture: true });
container?.removeEventListener('pointerdown', onPointerDown, { capture: true }); container?.removeEventListener('pointerdown', onPointerDown, { capture: true });
container?.removeEventListener('pointermove', onPointerMove, { capture: true }); container?.removeEventListener('pointermove', onPointerMove, { capture: true });
@@ -423,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);
@@ -441,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 });

View File

@@ -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 ({ force }: { force: boolean }) => {
if (!force && inFlight.current) return;
// On timeframe/params change we want an immediate response — abort the older request.
if (force && abortRef.current) abortRef.current.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const reqId = requestIdRef.current + 1;
requestIdRef.current = reqId;
const fetchOnce = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true; inFlight.current = true;
setLoading(true); setLoading(true);
try { try {
const res = await fetchChart({ symbol, source, tf, limit }); const res = await fetchChart({ symbol, source, tf, limit, signal: ctrl.signal });
if (requestIdRef.current !== reqId) return; // stale response
setCandles(res.candles); setCandles(res.candles);
setIndicators(res.indicators); setIndicators(res.indicators);
setMeta(res.meta);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
setError(String(e?.message || e)); // 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 { } finally {
if (requestIdRef.current === reqId) {
setLoading(false); setLoading(false);
inFlight.current = false; inFlight.current = false;
} }
}, [symbol, source, tf, limit]); }
},
[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]
); );
} }

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

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

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

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

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

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

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

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

View File

@@ -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) },
}; };
} }

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

View File

@@ -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 {

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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 topN 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 topN 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) TopN 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 topN 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 bestprice “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 (perpoziom)** — 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
View 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 (topN) z każdej strony braliśmy do liczenia “depth”.
- `depth_bid_base` / `depth_ask_base`: suma `size` po topN levelach bid/ask (w base).
- `depth_bid_usd` / `depth_ask_usd`: suma `size_base * price` po topN 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 ś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
View 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 (minuty1h) 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ść longshort; 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).

View 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 onchain.
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 AF) 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 onchain). 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 onchain.
### 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 lockin.
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
View 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` (topN)
- 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
View 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 proxyuje 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 serverside, 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 (onchain) 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.

View 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 lockin)
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
View 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 youll 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 its 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 RPCs 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>

View File

@@ -0,0 +1,284 @@
# Bare metal: Solana RPC (nonvoting) + Geyser/“Yellowstone” gRPC (Ubuntu 24.04)
Cel: postawić **jedną maszynę** jako **źródło danych onchain**:
- Solana `validator` w trybie **nonvoting** 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 adhoc 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 (musthave)
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 (nonvoting)
### 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` = nonvoting.
- `--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 runtimeu 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 (musthave)
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
View 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 (nonvoting) + Geyser/Yellowstone gRPC: `doc/solana-rpc-geyser-setup.md`
- “Kanoniczna” architektura selfhosted (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`

View 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`: 60120 s
- `5m`: 510 min
- `15m`: 1530 min
- `30m`: 3060 min
- `1h`: 13 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 12 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.

View 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. 15s) + 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
View 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 onchain (`doc/drift-data-bez-solana-rpc.md`).
To jest wystarczające do:
- obserwacji live,
- strojenia UI,
- budowy i testów pipelineu danych,
- “paper trading” / dry-run w executorze.
---
## 2) Co jest krytycznie brakujące do live tradingu
Poniższe punkty są “musthave” 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
View 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
View 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` — mikroruchy (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`.

View 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 (0100%):
- `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` (60180%).
- 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
View 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 **snapshootem**, 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 proxyuje `/api/*` do VPS.
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxyujemy też `/whoami`, `/auth/*`, `/logout`.
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
- Na VPS `trade-frontend` proxyuje 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.

View File

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