2 Commits

Author SHA1 Message Date
u1
667df582bd chore(snapshot): normalize file permissions 2026-03-29 13:14:52 +02:00
u1
a188308013 chore(snapshot): sync frontend workspace state 2026-03-29 13:14:30 +02:00
58 changed files with 8081 additions and 5382 deletions

14
.gitignore vendored
View File

@@ -1,15 +1,7 @@
# 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
# Local scratch / build output tokens/*.yml
_tmp/ tokens/*.yaml
apps/visualizer/dist/

View File

@@ -12,21 +12,6 @@ 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

112
apps/visualizer/__r Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SSH_TUNNEL_TARGET="${SSH_TUNNEL_TARGET:-mevnode_bot}"
K8S_NAMESPACE="${K8S_NAMESPACE:-trade-staging}"
SSH_FRONTEND_LOCAL_PORT="${SSH_FRONTEND_LOCAL_PORT:-13081}"
SSH_HASURA_LOCAL_PORT="${SSH_HASURA_LOCAL_PORT:-18080}"
SSH_FRONTEND_REMOTE_HOST="${SSH_FRONTEND_REMOTE_HOST:-127.0.0.1}"
SSH_FRONTEND_REMOTE_PORT="${SSH_FRONTEND_REMOTE_PORT:-30081}"
SSH_HASURA_REMOTE_PORT="${SSH_HASURA_REMOTE_PORT:-8080}"
VITE_DEV_PORT="${VITE_DEV_PORT:-5174}"
SSH_PID=""
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
wait_for_listen() {
local port="$1"
local label="$2"
local tries="${3:-80}"
local delay="${4:-0.25}"
local i
for ((i = 0; i < tries; i += 1)); do
if ss -ltnH | awk '{print $4}' | grep -Eq "(^|[:])${port}$"; then
return 0
fi
sleep "${delay}"
done
echo "Timed out waiting for ${label} on port ${port}" >&2
return 1
}
get_cluster_ip() {
local service="$1"
ssh \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-T "${SSH_TUNNEL_TARGET}" \
"kubectl -n ${K8S_NAMESPACE} get svc ${service} -o jsonpath='{.spec.clusterIP}'"
}
cleanup() {
local code=$?
trap - EXIT INT TERM
if [[ -n "${SSH_PID}" ]] && kill -0 "${SSH_PID}" 2>/dev/null; then
kill "${SSH_PID}" 2>/dev/null || true
wait "${SSH_PID}" 2>/dev/null || true
fi
exit "${code}"
}
trap cleanup EXIT INT TERM
require_cmd ssh
require_cmd npm
require_cmd ss
HASURA_CLUSTER_IP="${HASURA_CLUSTER_IP:-$(get_cluster_ip hasura)}"
if [[ -z "${HASURA_CLUSTER_IP}" ]]; then
echo "Could not resolve ClusterIP for hasura in namespace ${K8S_NAMESPACE}" >&2
exit 1
fi
echo "SSH target: ${SSH_TUNNEL_TARGET}"
echo "Namespace: ${K8S_NAMESPACE}"
echo "hasura ClusterIP: ${HASURA_CLUSTER_IP}"
echo "Starting SSH tunnel..."
ssh \
-o StrictHostKeyChecking=accept-new \
-o ExitOnForwardFailure=yes \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=3 \
-N \
-L "${SSH_FRONTEND_LOCAL_PORT}:${SSH_FRONTEND_REMOTE_HOST}:${SSH_FRONTEND_REMOTE_PORT}" \
-L "${SSH_HASURA_LOCAL_PORT}:${HASURA_CLUSTER_IP}:${SSH_HASURA_REMOTE_PORT}" \
"${SSH_TUNNEL_TARGET}" &
SSH_PID=$!
wait_for_listen "${SSH_FRONTEND_LOCAL_PORT}" "frontend SSH tunnel"
wait_for_listen "${SSH_HASURA_LOCAL_PORT}" "Hasura SSH tunnel"
echo "Starting Vite on :${VITE_DEV_PORT}..."
cd "${SCRIPT_DIR}"
export VITE_DEV_PORT
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-ws://127.0.0.1:${SSH_HASURA_LOCAL_PORT}/v1/graphql}"
export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-http://127.0.0.1:${SSH_FRONTEND_LOCAL_PORT}}"
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-http://127.0.0.1:${SSH_HASURA_LOCAL_PORT}}"
export GRAPHQL_PROXY_PATH="${GRAPHQL_PROXY_PATH:-/v1/graphql}"
echo "Frontend auth proxy: ${FRONTEND_PROXY_TARGET}"
echo "GraphQL proxy: ${GRAPHQL_PROXY_TARGET}${GRAPHQL_PROXY_PATH}"
echo "Open: http://localhost:${VITE_DEV_PORT}/"
exec npm run dev

View File

@@ -6,17 +6,24 @@ ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "${SCRIPT_DIR}" cd "${SCRIPT_DIR}"
DEFAULT_PROXY_TARGET="${VISUALIZER_PROXY_TARGET:-${TRADE_UI_URL:-${TRADE_VPS_URL:-https://trade.mpabi.pl}}}" if [[ -n "${SSH_TUNNEL_PORT:-}" ]]; then
export API_PROXY_TARGET="${API_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}" export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-http://127.0.0.1:${SSH_TUNNEL_PORT}}"
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}" export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-http://127.0.0.1:${SSH_TUNNEL_PORT}}"
export VITE_API_URL="${VITE_API_URL:-/api}" else
export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-https://trade.mpabi.pl}"
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}"
fi
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}" export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}" 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 [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then
if [[ "${VISUALIZER_USE_HASURA_AUTH:-}" != "1" ]]; then if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then
unset VITE_HASURA_AUTH_TOKEN export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json"
unset VITE_HASURA_ADMIN_SECRET else
echo "Missing basic auth config for VPS proxy."
echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2
fi
fi fi
npm run dev npm run dev

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trade system diagram</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/diagram-main.tsx"></script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
"": { "": {
"name": "trade-visualizer", "name": "trade-visualizer",
"dependencies": { "dependencies": {
"@xyflow/react": "^12.8.5",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"chartjs-adapter-luxon": "^1.3.1", "chartjs-adapter-luxon": "^1.3.1",
"lightweight-charts": "^5.0.8", "lightweight-charts": "^5.0.8",
@@ -1110,6 +1111,49 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1121,14 +1165,14 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.27", "version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -1166,6 +1210,36 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.10.1",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
"integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
"dependencies": {
"@xyflow/system": "0.0.75",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.75",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
"integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.11", "version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
@@ -1253,6 +1327,11 @@
"luxon": ">=1.0.0" "luxon": ">=1.0.0"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1264,9 +1343,105 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1664,6 +1839,14 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -1730,6 +1913,33 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
} }
} }
} }

View File

@@ -8,6 +8,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@xyflow/react": "^12.8.5",
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"chartjs-adapter-luxon": "^1.3.1", "chartjs-adapter-luxon": "^1.3.1",
"lightweight-charts": "^5.0.8", "lightweight-charts": "^5.0.8",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import '@xyflow/react/dist/style.css';
import './diagram.css';
import { TradeSystemFlowPage } from './diagram/TradeSystemFlowPage';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<TradeSystemFlowPage />
</React.StrictMode>
);

View File

@@ -0,0 +1,943 @@
:root {
--rf-bg: #efe7db;
--rf-panel: rgba(255, 252, 246, 0.94);
--rf-card: #fffdf9;
--rf-ink: #1b1714;
--rf-muted: #625b52;
--rf-line: rgba(99, 91, 82, 0.18);
--rf-shadow: 0 24px 54px rgba(27, 23, 20, 0.12);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
}
body {
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
color: var(--rf-ink);
background:
radial-gradient(circle at top left, rgba(31, 111, 139, 0.14), transparent 26%),
radial-gradient(circle at top right, rgba(176, 79, 53, 0.14), transparent 24%),
linear-gradient(180deg, #f8f3eb 0%, var(--rf-bg) 100%);
}
.rfPage {
min-height: 100%;
padding: 16px;
}
.rfToolbar,
.rfCanvas,
.rfInspector {
background: var(--rf-panel);
border: 1px solid rgba(217, 204, 187, 0.92);
border-radius: 24px;
box-shadow: var(--rf-shadow);
backdrop-filter: blur(10px);
}
.rfToolbar {
min-height: 76px;
padding: 14px 18px;
display: grid;
grid-template-columns: minmax(260px, 340px) minmax(220px, auto) minmax(0, 1fr);
gap: 16px;
align-items: center;
}
.rfToolbar__eyebrow,
.rfInspector__eyebrow,
.rfCard__subtitle {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfToolbar__brand {
display: flex;
align-items: center;
gap: 14px;
}
.rfToolbar__mark {
width: 16px;
height: 16px;
border-radius: 5px;
background:
linear-gradient(135deg, #1f6f8b 0%, #1f6f8b 46%, #b04f35 46%, #b04f35 100%);
box-shadow: 0 0 0 4px rgba(31, 111, 139, 0.1);
}
.rfToolbar__copy {
min-width: 0;
}
.rfToolbar__title {
margin-top: 2px;
font-size: clamp(1.1rem, 1.3vw, 1.45rem);
font-weight: 800;
line-height: 1.05;
font-family: 'IBM Plex Serif', Georgia, serif;
}
.rfToolbar__summary,
.rfInspector p,
.rfCard__host,
.rfCard__bullets,
.rfSection li {
color: var(--rf-muted);
line-height: 1.5;
}
.rfInspector__metricGrid {
margin: 10px 0 4px;
}
.rfToolbar__summary {
margin-top: 3px;
font-size: 13px;
}
.rfFilters {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.rfHistoryWrap {
position: relative;
}
.rfFilter {
border: 1px solid rgba(99, 91, 82, 0.15);
border-radius: 999px;
background: #fffaf2;
color: var(--rf-ink);
font: inherit;
font-weight: 800;
padding: 9px 13px;
cursor: pointer;
transition: 140ms ease;
}
.rfFilter:hover,
.rfFilter.isActive {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08);
}
.rfFilter.isMeta {
cursor: default;
}
.rfFilter:disabled {
opacity: 0.44;
cursor: default;
transform: none;
box-shadow: none;
}
.rfHistoryPanel {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 24;
width: 340px;
max-height: 420px;
overflow: auto;
padding: 14px;
border: 1px solid rgba(99, 91, 82, 0.16);
border-radius: 18px;
background: rgba(255, 252, 246, 0.98);
box-shadow: 0 24px 48px rgba(27, 23, 20, 0.18);
}
.rfHistoryPanel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.rfHistoryPanel__eyebrow {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfHistoryPanel__title {
margin-top: 3px;
font-size: 1rem;
font-weight: 800;
}
.rfHistoryPanel__close,
.rfHistoryItem__restore {
border: 1px solid rgba(99, 91, 82, 0.14);
border-radius: 999px;
background: #fffaf2;
color: var(--rf-ink);
font: inherit;
font-size: 12px;
font-weight: 800;
padding: 8px 11px;
cursor: pointer;
}
.rfHistoryPanel__close:hover,
.rfHistoryItem__restore:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(27, 23, 20, 0.08);
}
.rfHistoryItem__restore:disabled {
opacity: 0.46;
cursor: default;
transform: none;
box-shadow: none;
}
.rfHistoryPanel__note {
margin-top: 12px;
font-size: 13px;
color: var(--rf-muted);
}
.rfHistoryPanel__note.isError {
color: #8b3d24;
}
.rfHistoryPanel__list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.rfHistoryItem {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: center;
padding: 12px;
border: 1px solid rgba(99, 91, 82, 0.12);
border-radius: 14px;
background: rgba(255, 255, 255, 0.74);
}
.rfHistoryItem.isCurrent {
border-color: rgba(31, 111, 139, 0.25);
box-shadow: inset 0 0 0 1px rgba(31, 111, 139, 0.12);
}
.rfHistoryItem__meta {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.rfHistoryItem__action {
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.rfHistoryItem__time,
.rfHistoryItem__sub {
font-size: 12px;
color: var(--rf-muted);
}
.rfStatusRail {
min-width: 0;
}
.rfStatusRail__summary {
margin-bottom: 8px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfStatusRail__list {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.rfStatusPill {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 11px;
border-radius: 999px;
border: 1px solid rgba(99, 91, 82, 0.12);
background: rgba(255, 250, 242, 0.92);
color: var(--rf-ink);
font: inherit;
cursor: pointer;
transition: 140ms ease;
}
.rfStatusPill:hover,
.rfStatusPill.isSelected {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08);
}
.rfStatusPill__name {
font-size: 12px;
font-weight: 800;
}
.rfStatusPill__state {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.rfStatusPill.is-ok .rfStatusPill__state {
color: #185f39;
}
.rfStatusPill.is-warn .rfStatusPill__state {
color: #8b3d24;
}
.rfStatusPill.is-info .rfStatusPill__state {
color: #425466;
}
.rfLayout {
margin-top: 12px;
display: grid;
grid-template-columns: minmax(0, 1.2fr) 410px;
gap: 18px;
align-items: stretch;
}
.rfCanvas {
height: calc(100vh - 126px);
min-height: 760px;
overflow: hidden;
}
.rfInspector {
padding: 22px;
max-height: calc(100vh - 126px);
overflow: auto;
}
.rfInspector h2 {
margin: 10px 0 8px;
font-size: 1.8rem;
line-height: 1.05;
font-family: 'IBM Plex Serif', Georgia, serif;
}
.rfInspector__host {
font-weight: 700;
}
.rfSection {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(99, 91, 82, 0.12);
}
.rfSection h3 {
margin: 0 0 8px;
font-size: 0.98rem;
}
.rfSection ul {
margin: 0;
padding-left: 18px;
}
.rfSection li {
margin: 6px 0;
}
.rfRouteList {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.rfRouteItem {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(99, 91, 82, 0.12);
background: rgba(255, 252, 246, 0.84);
}
.rfRouteItem.isEmpty {
color: var(--rf-muted);
font-size: 12px;
}
.rfRouteItem__head {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 13px;
}
.rfRouteItem__meta {
margin-top: 6px;
font-size: 12px;
font-weight: 700;
color: var(--rf-ink);
}
.rfRouteItem__desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
color: var(--rf-muted);
}
.rfRouteBadge {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.rfRouteBadge.is-in {
background: rgba(75, 85, 99, 0.12);
color: #3f4854;
}
.rfRouteBadge.is-out {
background: rgba(20, 114, 109, 0.14);
color: #126863;
}
.rfRouteBadge.is-path-read {
background: rgba(31, 111, 139, 0.12);
color: #1f6f8b;
}
.rfRouteBadge.is-path-live {
background: rgba(176, 79, 53, 0.12);
color: #a04a33;
}
.rfRouteBadge.is-path-write {
background: rgba(122, 98, 19, 0.14);
color: #7a6213;
}
.rfRouteBadge.is-path-control {
background: rgba(75, 85, 99, 0.12);
color: #4b5563;
}
.rfLegend {
background: rgba(255, 252, 246, 0.9);
border: 1px solid rgba(99, 91, 82, 0.12);
border-radius: 16px;
padding: 12px 14px;
box-shadow: 0 12px 28px rgba(27, 23, 20, 0.08);
}
.rfLegend__title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfLegend__row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
font-size: 13px;
color: var(--rf-ink);
}
.rfLegend__row span {
width: 12px;
height: 12px;
border-radius: 999px;
flex: 0 0 12px;
}
.rfCard {
width: 100%;
height: 100%;
border: 1px solid rgba(99, 91, 82, 0.14);
border-left: 6px solid var(--lane-color);
border-radius: 18px;
background: var(--rf-card);
box-shadow: 0 16px 32px rgba(27, 23, 20, 0.08);
padding: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
cursor: grab;
}
.rfCard.isWorkflow {
border-color: rgba(50, 130, 80, 0.72);
box-shadow:
inset 0 0 0 2px rgba(88, 175, 112, 0.22),
0 18px 34px rgba(32, 92, 52, 0.12);
}
.rfCard.isWorkflow .rfCard__subtitle {
color: #2d6b3f;
}
.rfCard.isSelected {
box-shadow: 0 18px 38px rgba(27, 23, 20, 0.16);
transform: translateY(-1px);
}
.rfCard.isDragging {
border-color: rgba(176, 48, 35, 0.78);
box-shadow:
0 0 0 3px rgba(176, 48, 35, 0.3),
0 20px 42px rgba(176, 48, 35, 0.16);
cursor: grabbing;
}
.rfCard__head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.rfDragHandle {
touch-action: none;
}
.rfCard__title {
margin-top: 4px;
font-size: 0.94rem;
font-weight: 800;
color: var(--rf-ink);
}
.rfCard__host {
margin-top: 8px;
font-size: 12px;
}
.rfCard__runtime {
margin-top: 10px;
padding: 9px 10px;
border-radius: 14px;
border: 1px solid rgba(99, 91, 82, 0.12);
background: linear-gradient(180deg, rgba(251, 247, 239, 0.96), rgba(246, 239, 227, 0.82));
}
.rfCard__runtimeLabel {
margin-bottom: 8px;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfCard__metricGrid,
.rfInspector__metricGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.rfCard__metricGrid {
margin-top: 10px;
}
.rfMetricChip {
display: grid;
gap: 4px;
padding: 8px 9px;
border-radius: 12px;
background: rgba(255, 253, 249, 0.92);
border: 1px solid rgba(99, 91, 82, 0.1);
}
.rfMetricChip span {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfMetricChip strong {
font-size: 12px;
line-height: 1.3;
color: var(--rf-ink);
}
.rfFactGrid,
.rfPanelGrid {
display: grid;
gap: 10px;
}
.rfFactGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rfFact {
padding: 11px 12px;
border-radius: 14px;
border: 1px solid rgba(99, 91, 82, 0.12);
background: rgba(255, 252, 246, 0.84);
}
.rfFact span {
display: block;
margin-bottom: 4px;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfFact strong {
font-size: 13px;
line-height: 1.4;
color: var(--rf-ink);
}
.rfPanelCard {
padding: 12px 13px;
border-radius: 16px;
border: 1px solid rgba(99, 91, 82, 0.12);
background: rgba(255, 252, 246, 0.84);
}
.rfPanelCard__title {
margin-bottom: 8px;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--rf-muted);
}
.rfPanelCard p,
.rfPanelCard li {
margin: 0;
}
.rfPanelCard ul {
margin: 0;
padding-left: 18px;
}
.rfPanelCard li + li {
margin-top: 6px;
}
.rfCard__bullets {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
overflow: hidden;
}
.rfCard__bullets li {
margin: 4px 0;
}
.rfStatus {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 5px 8px;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.rfStatus.isOk {
background: rgba(30, 107, 67, 0.11);
color: #185f39;
}
.rfStatus.isWarn {
background: rgba(176, 79, 53, 0.11);
color: #8b3d24;
}
.rfHandle {
width: 10px;
height: 10px;
background: var(--lane-color);
border: 2px solid #fff;
}
.rfHandle.isRouteHandle {
width: 12px;
height: 12px;
border: 2px solid rgba(255, 253, 249, 0.98);
box-shadow: 0 0 0 2px rgba(27, 23, 20, 0.08);
}
.rfZone {
width: 100%;
height: 100%;
border-radius: 26px;
border: 1px solid rgba(110, 101, 91, 0.14);
padding: 12px 14px;
user-select: none;
cursor: grab;
}
.rfZone__head {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-height: 64px;
width: min(100%, 320px);
padding: 2px 2px 10px;
}
.rfZone__title {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
background: rgba(255, 251, 244, 0.96);
box-shadow: 0 6px 16px rgba(27, 23, 20, 0.06);
font-size: 0.96rem;
font-weight: 900;
color: var(--rf-ink);
font-family: 'IBM Plex Serif', Georgia, serif;
}
.rfZone__subtitle {
margin-top: 6px;
max-width: 250px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--rf-muted);
line-height: 1.35;
}
.rfZone.is-host {
background:
linear-gradient(180deg, rgba(255, 253, 249, 0.92), rgba(244, 236, 225, 0.66));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.rfZone.is-namespace {
background:
linear-gradient(180deg, rgba(255, 251, 246, 0.78), rgba(236, 228, 214, 0.52));
border-style: dashed;
border-color: rgba(98, 91, 82, 0.22);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.rfZone.is-subzone {
background:
linear-gradient(180deg, rgba(255, 254, 250, 0.72), rgba(248, 242, 233, 0.46));
border-color: rgba(98, 91, 82, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
}
.rfZone.isDragging {
border-color: rgba(176, 48, 35, 0.72);
box-shadow:
inset 0 0 0 2px rgba(176, 48, 35, 0.22),
0 0 0 3px rgba(176, 48, 35, 0.14);
cursor: grabbing;
}
.rfZone.isDragging .rfZone__title {
box-shadow:
0 0 0 2px rgba(176, 48, 35, 0.18),
0 8px 18px rgba(176, 48, 35, 0.16);
}
.react-flow__renderer {
background:
linear-gradient(rgba(225, 214, 198, 0.22) 1px, transparent 1px),
linear-gradient(90deg, rgba(225, 214, 198, 0.22) 1px, transparent 1px),
linear-gradient(180deg, #fffdf8 0%, #faf4ea 100%);
background-size: 20px 20px, 20px 20px, 100% 100%;
}
.react-flow__nodes {
z-index: 10;
}
.react-flow__edges,
.react-flow__edgelabel-renderer {
z-index: 30 !important;
}
.react-flow__attribution {
display: none;
}
.react-flow__controls,
.react-flow__minimap {
border: 1px solid rgba(99, 91, 82, 0.14);
border-radius: 14px;
overflow: hidden;
box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08);
}
.react-flow__controls button {
background: rgba(255, 252, 246, 0.92);
color: var(--rf-ink);
border-bottom-color: rgba(99, 91, 82, 0.12);
}
.react-flow__edge-text {
font-weight: 700;
}
.rfResizeHandle {
width: 12px !important;
height: 12px !important;
border-radius: 999px !important;
border: 2px solid rgba(255, 253, 249, 0.96) !important;
background: rgba(31, 111, 139, 0.72) !important;
box-shadow: 0 0 0 2px rgba(27, 23, 20, 0.08);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease, transform 120ms ease, background 120ms ease;
}
.rfResizeLine {
border-color: rgba(31, 111, 139, 0.28) !important;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease, border-color 120ms ease;
}
.rfCard:hover .rfResizeHandle,
.rfCard:hover .rfResizeLine,
.rfCard.isSelected .rfResizeHandle,
.rfCard.isSelected .rfResizeLine,
.rfCard.isDragging .rfResizeHandle,
.rfCard.isDragging .rfResizeLine,
.rfZone:hover .rfResizeHandle,
.rfZone:hover .rfResizeLine,
.rfZone.isSelected .rfResizeHandle,
.rfZone.isSelected .rfResizeLine,
.rfZone.isDragging .rfResizeHandle,
.rfZone.isDragging .rfResizeLine {
opacity: 1;
pointer-events: auto;
}
.rfCard:hover .rfResizeHandle,
.rfCard.isSelected .rfResizeHandle,
.rfCard.isDragging .rfResizeHandle,
.rfZone:hover .rfResizeHandle,
.rfZone.isSelected .rfResizeHandle,
.rfZone.isDragging .rfResizeHandle {
transform: scale(1.05);
}
.rfCard:hover .rfResizeLine,
.rfCard.isSelected .rfResizeLine,
.rfCard.isDragging .rfResizeLine {
border-color: rgba(31, 111, 139, 0.42) !important;
}
.rfZone:hover .rfResizeLine,
.rfZone.isSelected .rfResizeLine,
.rfZone.isDragging .rfResizeLine {
border-color: rgba(176, 48, 35, 0.26) !important;
}
.rfZone .rfResizeHandle {
background: rgba(176, 48, 35, 0.72) !important;
}
.rfPage.isUltraWide .rfLayout {
grid-template-columns: minmax(0, 1.45fr) 430px;
}
.rfPage.isUltraWide .rfCanvas {
height: calc(100vh - 118px);
min-height: 820px;
}
code {
font-family: 'IBM Plex Mono', monospace;
background: rgba(27, 23, 20, 0.05);
padding: 2px 6px;
border-radius: 6px;
}
@media (max-width: 1180px) {
.rfPage {
padding: 14px;
}
.rfToolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.rfStatusRail__list {
justify-content: flex-start;
}
.rfLayout {
grid-template-columns: 1fr;
}
.rfCanvas {
height: 72vh;
min-height: 620px;
}
.rfCard__metricGrid,
.rfInspector__metricGrid,
.rfFactGrid {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,282 @@
import type { BotEventRow, BotObserverData, BotObserverRuntimeState } from './useBotObserver';
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(1)}K`;
return `$${v.toFixed(2)}`;
}
function formatBps(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${v.toFixed(2)} bps`;
}
function formatPct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${(v * 100).toFixed(0)}%`;
}
function formatAge(iso: string | null | undefined): string {
if (!iso) return '—';
const ts = Date.parse(iso);
if (!Number.isFinite(ts)) return '—';
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
function formatClock(iso: string | null | undefined): string {
if (!iso) return '—';
const ts = Date.parse(iso);
if (!Number.isFinite(ts)) return '—';
return new Date(ts).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function numericClass(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v) || v === 0) return '';
return v > 0 ? 'pos' : 'neg';
}
function gateTone(value: boolean | null | undefined): 'ok' | 'bad' | 'muted' {
if (value === true) return 'ok';
if (value === false) return 'bad';
return 'muted';
}
function eventSummary(event: BotEventRow): string {
const payload = event.payload || {};
if (event.type === 'decision' || event.type === 'skip') {
const decision = (payload as any).decision || {};
const side = typeof decision.side === 'string' ? decision.side.toUpperCase() : '—';
const confidence = typeof decision.confidence === 'number' ? `${Math.round(decision.confidence * 100)}%` : '—';
const target = typeof decision.target_notional_usd === 'number' ? formatUsd(decision.target_notional_usd) : '—';
const skipReason = typeof decision.skip_reason === 'string' && decision.skip_reason ? `${decision.skip_reason}` : '';
return `${side}${confidence}${target}${skipReason}`;
}
if (event.type === 'status') {
const status = (payload as any).status || {};
const bot = (payload as any).bot || {};
const mode = typeof bot.mode === 'string' ? bot.mode : '—';
const active = status.active === true ? 'active' : 'inactive';
const note = typeof status.note === 'string' && status.note ? `${status.note}` : '';
return `${mode}${active}${note}`;
}
if (event.type === 'error') {
return typeof (payload as any).error === 'string' ? (payload as any).error : 'error';
}
return JSON.stringify(payload);
}
function kpiValueClass(side: string | null | undefined): string {
if (side === 'long') return 'pos';
if (side === 'short') return 'neg';
return '';
}
function renderFeatureRow(label: string, value: string, valueClass = '') {
return (
<div className="botFeatureRow" key={label}>
<span className="botFeatureRow__label">{label}</span>
<span className={['botFeatureRow__value', valueClass].filter(Boolean).join(' ')}>{value}</span>
</div>
);
}
export default function BotObserverPanel({
marketName,
observer,
}: {
marketName: string;
observer: BotObserverData;
}) {
const { bot, resolvedBy, state, events, loading, error, warning, lastUpdatedAt } = observer;
const runtimeState: BotObserverRuntimeState | null = state?.state || null;
const lastDecision = runtimeState?.last_decision || null;
const lastFeatures = runtimeState?.last_features || null;
const lastGates = runtimeState?.last_gates || null;
const lastSnapshot = runtimeState?.last_snapshot || null;
const counters = runtimeState?.counters || null;
if (loading && !bot) {
return <div className="placeholder">Loading bot observer</div>;
}
if (error && !bot) {
return <div className="uiError">{error}</div>;
}
if (!bot) {
if (warning === 'bot_control_plane_schema_missing') {
return (
<div className="botDash botDash--empty">
<div className="botDash__emptyTitle">Bot control plane is not deployed here.</div>
<div className="muted">This Hasura environment does not expose `bot_config`, `bot_state`, or `bot_events` yet.</div>
</div>
);
}
return (
<div className="botDash botDash--empty">
<div className="botDash__emptyTitle">No bot configured for this market.</div>
<div className="muted">Set `VITE_BOT_ID` / `VITE_BOT_NAME` or create a bot for {marketName}.</div>
</div>
);
}
return (
<div className="botDash dlobDash">
<div className="dlobDash__head">
<div className="dlobDash__title">Bot Observer</div>
<div className="dlobDash__meta">
<span className="dlobDash__market">{bot.name}</span>
<span className="muted">{bot.market_name}</span>
<span className="muted">{resolvedBy ? `resolved ${resolvedBy}` : 'manual'}</span>
</div>
</div>
{error ? <div className="uiError">{error}</div> : null}
{bot.market_name !== marketName ? (
<div className="botDash__note muted">Current chart market is {marketName}, panel shows bot for {bot.market_name}.</div>
) : null}
<div className="dlobDash__statuses">
<div className="dlobStatus">
<span className="dlobStatus__label">mode</span>
<span className="dlobStatus__value">{bot.mode}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">status</span>
<span className="dlobStatus__value">{runtimeState?.status || '—'}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">heartbeat</span>
<span className="dlobStatus__value">{formatAge(state?.last_heartbeat_at)}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">updated</span>
<span className="dlobStatus__value">{lastUpdatedAt ? formatClock(lastUpdatedAt) : '—'}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">kill switch</span>
<span className={['dlobStatus__value', bot.kill_switch ? 'neg' : 'pos'].join(' ')}>
{bot.kill_switch ? 'enabled' : 'off'}
</span>
</div>
</div>
<div className="dlobDash__grid">
<div className="dlobKpi">
<div className="dlobKpi__label">Decision</div>
<div className={['dlobKpi__value', kpiValueClass(lastDecision?.side || null)].join(' ')}>
{lastDecision?.side || '—'}
</div>
<div className="dlobKpi__sub muted">{lastDecision?.skip_reason || 'current signal'}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Confidence</div>
<div className="dlobKpi__value">{formatPct(lastDecision?.confidence ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Target</div>
<div className="dlobKpi__value">{formatUsd(lastDecision?.target_notional_usd ?? null)}</div>
<div className="dlobKpi__sub muted">{lastDecision?.horizon_s != null ? `${lastDecision.horizon_s}s horizon` : '—'}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Long Score</div>
<div className={['dlobKpi__value', numericClass(lastDecision?.long_score ?? null)].join(' ')}>
{lastDecision?.long_score == null ? '—' : lastDecision.long_score.toFixed(2)}
</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Short Score</div>
<div className={['dlobKpi__value', numericClass(lastDecision?.short_score ?? null)].join(' ')}>
{lastDecision?.short_score == null ? '—' : lastDecision.short_score.toFixed(2)}
</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Loops</div>
<div className="dlobKpi__value">{counters?.loops ?? '—'}</div>
<div className="dlobKpi__sub muted">
{counters ? `${counters.decisions ?? 0} decisions / ${counters.skips ?? 0} skips` : '—'}
</div>
</div>
</div>
<div className="botDash__layout">
<div className="botPanel">
<div className="botPanel__head">
<div className="botPanel__title">Gates</div>
<div className="muted">{lastSnapshot?.data_age_ms != null ? `${lastSnapshot.data_age_ms} ms age` : '—'}</div>
</div>
<div className="botGateGrid">
{[
['fresh', lastGates?.fresh],
['spread_ok', lastGates?.spread_ok],
['slippage_ok', lastGates?.slippage_ok],
['depth_ok', lastGates?.depth_ok],
['has_candles', lastGates?.has_candles],
['all', lastGates?.all],
].map(([label, value]) => (
<div
key={label}
className={`botGate botGate--${gateTone(value as boolean | null | undefined)}`}
>
<span className="botGate__label">{label}</span>
<span className="botGate__value">{value == null ? '—' : value ? 'pass' : 'fail'}</span>
</div>
))}
</div>
<div className="botPanel__head">
<div className="botPanel__title">Feature Snapshot</div>
<div className="muted">{lastSnapshot?.query_latency_ms != null ? `${lastSnapshot.query_latency_ms} ms query` : '—'}</div>
</div>
<div className="botFeatureList">
{renderFeatureRow('spread_bps', formatBps(lastFeatures?.spread_bps ?? null))}
{renderFeatureRow('mark_vs_oracle', formatBps(lastFeatures?.mark_vs_oracle_bps ?? null), numericClass(lastFeatures?.mark_vs_oracle_bps ?? null))}
{renderFeatureRow('mom_3s', formatBps(lastFeatures?.mom_3s ?? null), numericClass(lastFeatures?.mom_3s ?? null))}
{renderFeatureRow('mom_10s', formatBps(lastFeatures?.mom_10s ?? null), numericClass(lastFeatures?.mom_10s ?? null))}
{renderFeatureRow('mom_30s', formatBps(lastFeatures?.mom_30s ?? null), numericClass(lastFeatures?.mom_30s ?? null))}
{renderFeatureRow('vol_30s', formatBps(lastFeatures?.vol_30s ?? null))}
{renderFeatureRow('depth_bid_usd', formatUsd(lastFeatures?.depth_bid_usd ?? null), 'pos')}
{renderFeatureRow('depth_ask_usd', formatUsd(lastFeatures?.depth_ask_usd ?? null), 'neg')}
{renderFeatureRow('depth_imbalance', formatPct(lastFeatures?.depth_imbalance ?? null), numericClass(lastFeatures?.depth_imbalance ?? null))}
{renderFeatureRow('buy_slippage', formatBps(lastFeatures?.buy_slippage_bps ?? null))}
{renderFeatureRow('sell_slippage', formatBps(lastFeatures?.sell_slippage_bps ?? null))}
{renderFeatureRow('last_error', state?.last_error || runtimeState?.error || '—', state?.last_error || runtimeState?.error ? 'neg' : '')}
</div>
</div>
<div className="botPanel">
<div className="botPanel__head">
<div className="botPanel__title">Event Feed</div>
<div className="muted">{events.length ? `${events.length} rows` : '—'}</div>
</div>
{events.length ? (
<div className="botEventList">
{events.map((event) => (
<div className={`botEvent botEvent--${event.type}`} key={event.id}>
<div className="botEvent__head">
<span className="botEvent__type">{event.type}</span>
<span className="muted">{formatClock(event.ts)}</span>
</div>
<div className="botEvent__summary">{eventSummary(event)}</div>
</div>
))}
</div>
) : (
<div className="botPanel__empty muted">No bot events yet.</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import type { BotObserverData, BotObserverRuntimeState } from './useBotObserver';
function formatPct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${(v * 100).toFixed(0)}%`;
}
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(1)}K`;
return `$${v.toFixed(2)}`;
}
function formatAge(iso: string | null | undefined): string {
if (!iso) return '—';
const ts = Date.parse(iso);
if (!Number.isFinite(ts)) return '—';
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h`;
}
function gateTone(value: boolean | null | undefined): 'ok' | 'bad' | 'muted' {
if (value === true) return 'ok';
if (value === false) return 'bad';
return 'muted';
}
function stateTone(value: string | null | undefined): string {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'long') return 'pos';
if (normalized === 'short' || normalized === 'panic') return 'neg';
return '';
}
function resolveMachineState(runtimeState: BotObserverRuntimeState | null, desiredMode: string, killSwitch: boolean): string {
if (killSwitch) return 'panic';
const status = String(runtimeState?.status || runtimeState?.mode || desiredMode || '')
.trim()
.toLowerCase();
if (['off', 'observe', 'paper', 'trade', 'panic'].includes(status)) return status;
return 'off';
}
export default function BotObserverSummaryCard({
marketName,
observer,
}: {
marketName: string;
observer: BotObserverData;
}) {
const { bot, state, loading, error, warning } = observer;
const runtimeState: BotObserverRuntimeState | null = state?.state || null;
const machineState = resolveMachineState(runtimeState, bot?.mode || 'off', Boolean(bot?.kill_switch));
const lastDecision = runtimeState?.last_decision || null;
const lastSnapshot = runtimeState?.last_snapshot || null;
const lastGates = runtimeState?.last_gates || null;
const counters = runtimeState?.counters || null;
if (loading && !bot) {
return <div className="botSummaryCard__empty muted">Loading bot</div>;
}
if (error && !bot) {
return <div className="uiError botSummaryCard__error">{error}</div>;
}
if (!bot) {
if (warning === 'bot_control_plane_schema_missing') {
return (
<div className="botSummaryCard__empty">
<div className="botSummaryCard__emptyTitle">Bot control plane missing</div>
<div className="muted">Current Hasura environment does not publish bot tables yet.</div>
</div>
);
}
return (
<div className="botSummaryCard__empty">
<div className="botSummaryCard__emptyTitle">No bot configured</div>
<div className="muted">No observer found for {marketName}.</div>
</div>
);
}
return (
<div className="botSummaryCard">
<div className="botSummaryCard__head">
<div>
<div className="botSummaryCard__title">Bot State Machine</div>
<div className="botSummaryCard__subtitle muted">{bot.name}</div>
</div>
<div className={['botSummaryCard__side', stateTone(lastDecision?.side)].join(' ')}>
{(lastDecision?.side || 'flat').toUpperCase()}
</div>
</div>
{error ? <div className="uiError botSummaryCard__error">{error}</div> : null}
{bot.market_name !== marketName ? (
<div className="botSummaryCard__note muted">Chart: {marketName} Bot: {bot.market_name}</div>
) : null}
<div className="botStateRail">
{['off', 'observe', 'paper', 'trade', 'panic'].map((stage) => (
<div
key={stage}
className={[
'botStateRail__node',
machineState === stage ? 'botStateRail__node--active' : '',
stage === 'panic' ? 'botStateRail__node--panic' : '',
]
.filter(Boolean)
.join(' ')}
>
{stage}
</div>
))}
</div>
<div className="botSummaryStats">
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Desired</span>
<span className="botSummaryStats__value">{bot.mode}</span>
</div>
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Runtime</span>
<span className="botSummaryStats__value">{runtimeState?.status || runtimeState?.mode || '—'}</span>
</div>
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Heartbeat</span>
<span className="botSummaryStats__value">{formatAge(state?.last_heartbeat_at)}</span>
</div>
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Data Age</span>
<span className="botSummaryStats__value">
{lastSnapshot?.data_age_ms != null ? `${lastSnapshot.data_age_ms} ms` : '—'}
</span>
</div>
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Query</span>
<span className="botSummaryStats__value">
{lastSnapshot?.query_latency_ms != null ? `${lastSnapshot.query_latency_ms} ms` : '—'}
</span>
</div>
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Confidence</span>
<span className="botSummaryStats__value">{formatPct(lastDecision?.confidence ?? null)}</span>
</div>
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Target</span>
<span className="botSummaryStats__value">{formatUsd(lastDecision?.target_notional_usd ?? null)}</span>
</div>
<div className="botSummaryStats__row">
<span className="botSummaryStats__label">Loops</span>
<span className="botSummaryStats__value">{counters?.loops ?? '—'}</span>
</div>
</div>
<div className="botGatePills">
{[
['fresh', lastGates?.fresh],
['spread', lastGates?.spread_ok],
['slippage', lastGates?.slippage_ok],
['depth', lastGates?.depth_ok],
].map(([label, value]) => (
<span key={label} className={`botGatePill botGatePill--${gateTone(value as boolean | null | undefined)}`}>
{label}
</span>
))}
</div>
{lastDecision?.skip_reason ? (
<div className="botSummaryCard__footer muted">skip: {lastDecision.skip_reason}</div>
) : runtimeState?.inactive_reason ? (
<div className="botSummaryCard__footer muted">{runtimeState.inactive_reason}</div>
) : state?.last_error ? (
<div className="botSummaryCard__footer neg">{state.last_error}</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,290 @@
import { useEffect, useRef, useState } from 'react';
import { useInterval } from '../../app/hooks/useInterval';
export type BotConfigSummary = {
id: string;
name: string;
market_name: string;
market_type: string;
mode: string;
kill_switch: boolean;
updated_at: string;
};
export type BotObserverRuntimeState = {
service?: string;
version?: string;
appVersion?: string;
observe_only?: boolean;
market_name?: string;
market_type?: string;
mode?: string;
kill_switch?: boolean;
status?: string;
strategy_type?: string;
loop_ms?: number;
last_snapshot?: {
query_latency_ms?: number | null;
data_age_ms?: number | null;
candles_count?: number | null;
stats_age_ms?: number | null;
depth_age_ms?: number | null;
buy_slippage_age_ms?: number | null;
sell_slippage_age_ms?: number | null;
} | null;
last_features?: {
mark_price?: number | null;
oracle_price?: number | null;
mid_price?: number | null;
spread_bps?: number | null;
stats_imbalance?: number | null;
depth_bid_usd?: number | null;
depth_ask_usd?: number | null;
depth_imbalance?: number | null;
buy_slippage_bps?: number | null;
sell_slippage_bps?: number | null;
mark_vs_oracle_bps?: number | null;
mom_3s?: number | null;
mom_10s?: number | null;
mom_30s?: number | null;
vol_30s?: number | null;
} | null;
last_gates?: {
fresh?: boolean;
spread_ok?: boolean;
slippage_ok?: boolean;
depth_ok?: boolean;
has_candles?: boolean;
all?: boolean;
} | null;
last_decision?: {
side?: string;
confidence?: number | null;
long_score?: number | null;
short_score?: number | null;
target_notional_usd?: number | null;
horizon_s?: number | null;
skip_reason?: string | null;
} | null;
counters?: {
loops?: number;
decisions?: number;
skips?: number;
errors?: number;
} | null;
inactive_reason?: string;
error?: string;
trade_requested_but_observer_only?: boolean;
};
export type BotStateRow = {
bot_id: string;
last_heartbeat_at: string | null;
last_action_at: string | null;
last_error: string | null;
state: BotObserverRuntimeState | null;
updated_at: string;
};
export type BotEventRow = {
id: number;
ts: string;
type: string;
payload?: Record<string, unknown> | null;
};
export type BotObserverData = {
bot: BotConfigSummary | null;
resolvedBy: string | null;
state: BotStateRow | null;
events: BotEventRow[];
loading: boolean;
error: string | null;
warning: string | null;
lastUpdatedAt: string | null;
};
function envString(name: string): string | undefined {
const value = (import.meta as any).env?.[name];
if (value == null) return undefined;
const text = String(value).trim();
return text || undefined;
}
function getApiBase(): string {
return envString('VITE_API_URL') || '/api';
}
function buildApiUrl(pathname: string): string {
const base = new URL(getApiBase(), window.location.origin);
const basePath = base.pathname && base.pathname !== '/' ? base.pathname.replace(/\/$/, '') : '';
const [pathOnly, searchOnly = ''] = String(pathname || '').split('?');
base.pathname = `${basePath}${pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`}`;
base.search = searchOnly ? `?${searchOnly}` : '';
base.hash = '';
return base.toString();
}
async function apiRequest<T>(pathname: string): Promise<T> {
const res = await fetch(buildApiUrl(pathname), {
cache: 'no-store',
credentials: 'same-origin',
});
const text = await res.text();
let json: any = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
throw new Error(`Invalid API JSON for ${pathname}`);
}
if (!res.ok) throw new Error(json?.error || `API HTTP ${res.status}`);
return json as T;
}
function preferredBotId(): string | undefined {
return envString('VITE_BOT_ID');
}
function preferredBotName(): string | undefined {
return envString('VITE_BOT_NAME');
}
function resolveBot(bots: BotConfigSummary[], marketName: string): { bot: BotConfigSummary | null; by: string | null } {
const configuredId = preferredBotId();
if (configuredId) {
const match = bots.find((entry) => entry.id === configuredId);
if (match) return { bot: match, by: 'env:id' };
}
const configuredName = preferredBotName();
if (configuredName) {
const match = bots.find((entry) => entry.name === configuredName);
if (match) return { bot: match, by: 'env:name' };
}
const match = bots.find((entry) => String(entry.market_name || '').trim().toUpperCase() === marketName.trim().toUpperCase());
if (match) return { bot: match, by: 'market' };
return { bot: null, by: null };
}
export function useBotObserver(marketName: string): BotObserverData {
const mountedRef = useRef(true);
const resolveInFlightRef = useRef(false);
const detailsInFlightRef = useRef(false);
const [bot, setBot] = useState<BotConfigSummary | null>(null);
const [resolvedBy, setResolvedBy] = useState<string | null>(null);
const [state, setState] = useState<BotStateRow | null>(null);
const [events, setEvents] = useState<BotEventRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
async function refreshBotResolution() {
if (resolveInFlightRef.current) return;
resolveInFlightRef.current = true;
try {
const json = await apiRequest<{ ok?: boolean; bots?: BotConfigSummary[]; error?: string; warning?: string }>(
'/v1/bots?limit=100'
);
const bots = Array.isArray(json?.bots) ? json.bots : [];
const resolved = resolveBot(bots, marketName);
if (!mountedRef.current) return;
setBot((current) => {
if (current?.id === resolved.bot?.id) return current;
return resolved.bot;
});
setResolvedBy(resolved.by);
if (!resolved.bot) {
setState(null);
setEvents([]);
setLoading(false);
}
setWarning(typeof json?.warning === 'string' && json.warning ? json.warning : null);
setError(null);
} catch (err) {
if (!mountedRef.current) return;
setError(String((err as Error)?.message || err));
setLoading(false);
} finally {
resolveInFlightRef.current = false;
}
}
async function refreshBotDetails(botId: string) {
if (detailsInFlightRef.current) return;
detailsInFlightRef.current = true;
try {
const [stateJson, eventsJson] = await Promise.all([
apiRequest<{ ok?: boolean; state?: BotStateRow | null; error?: string; warning?: string }>(
`/v1/bots/${botId}/state`
),
apiRequest<{ ok?: boolean; events?: BotEventRow[]; error?: string; warning?: string }>(
`/v1/bots/${botId}/events?limit=24`
),
]);
if (!mountedRef.current) return;
setState(stateJson?.state || null);
setEvents(Array.isArray(eventsJson?.events) ? eventsJson.events : []);
setLastUpdatedAt(new Date().toISOString());
setWarning(
(typeof stateJson?.warning === 'string' && stateJson.warning) ||
(typeof eventsJson?.warning === 'string' && eventsJson.warning) ||
null
);
setError(null);
setLoading(false);
} catch (err) {
if (!mountedRef.current) return;
setError(String((err as Error)?.message || err));
setLoading(false);
} finally {
detailsInFlightRef.current = false;
}
}
useEffect(() => {
setLoading(true);
setBot(null);
setResolvedBy(null);
setState(null);
setEvents([]);
setError(null);
setWarning(null);
setLastUpdatedAt(null);
void refreshBotResolution();
}, [marketName]);
useEffect(() => {
if (!bot?.id) return;
void refreshBotDetails(bot.id);
}, [bot?.id]);
useInterval(() => {
void refreshBotResolution();
}, 5000);
useInterval(() => {
if (!bot?.id) return;
void refreshBotDetails(bot.id);
}, bot?.id ? 1000 : null);
return {
bot,
resolvedBy,
state,
events,
loading,
error,
warning,
lastUpdatedAt,
};
}

View File

@@ -1,19 +1,21 @@
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 type { TickerItem } from '../tickerbar/TickerBar';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import ChartLayersPanel from './ChartLayersPanel'; 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 { LineStyle, type IChartApi } from 'lightweight-charts'; import { LineStyle, type IChartApi, type ISeriesApi, type UTCTimestamp } from 'lightweight-charts';
import type { OverlayLayer } from './ChartPanel.types'; import type { OverlayLayer } from './ChartPanel.types';
type Props = { type Props = {
candles: Candle[]; candles: Candle[];
indicators: ChartIndicators; indicators: ChartIndicators;
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null; dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
tickerItems: TickerItem[];
theme: 'day' | 'night';
timeframe: string; timeframe: string;
bucketSeconds: number; bucketSeconds: number;
seriesKey: string; seriesKey: string;
@@ -22,10 +24,10 @@ type Props = {
onToggleIndicators: () => void; onToggleIndicators: () => void;
showBuild: boolean; showBuild: boolean;
onToggleBuild: () => void; onToggleBuild: () => void;
onResetViewReady?: ((handler: (() => void) | null) => void) | undefined;
seriesLabel: string; seriesLabel: string;
fullscreenOverride?: boolean; hasMoreHistory?: boolean;
onToggleFullscreenOverride?: () => void; onRequestHistoryBefore?: (beforeTime: number) => Promise<void> | void;
fullscreenStyle?: CSSProperties;
}; };
type FibDragMode = 'move' | 'edit-b'; type FibDragMode = 'move' | 'edit-b';
@@ -51,6 +53,8 @@ export default function ChartPanel({
candles, candles,
indicators, indicators,
dlobQuotes, dlobQuotes,
tickerItems,
theme,
timeframe, timeframe,
bucketSeconds, bucketSeconds,
seriesKey, seriesKey,
@@ -59,17 +63,12 @@ export default function ChartPanel({
onToggleIndicators, onToggleIndicators,
showBuild, showBuild,
onToggleBuild, onToggleBuild,
onResetViewReady,
seriesLabel, seriesLabel,
fullscreenOverride, hasMoreHistory = false,
onToggleFullscreenOverride, onRequestHistoryBefore,
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);
@@ -86,6 +85,7 @@ export default function ChartPanel({
const [priceAutoScale, setPriceAutoScale] = useState(true); const [priceAutoScale, setPriceAutoScale] = useState(true);
const chartApiRef = useRef<IChartApi | null>(null); const chartApiRef = useRef<IChartApi | null>(null);
const candleSeriesRef = useRef<ISeriesApi<'Candlestick', UTCTimestamp> | null>(null);
const activeToolRef = useRef(activeTool); const activeToolRef = useRef(activeTool);
const fibStartRef = useRef<FibAnchor | null>(fibStart); const fibStartRef = useRef<FibAnchor | null>(fibStart);
const pendingMoveRef = useRef<FibAnchor | null>(null); const pendingMoveRef = useRef<FibAnchor | null>(null);
@@ -96,31 +96,25 @@ export default function ChartPanel({
const selectPointerRef = useRef<number | null>(null); const selectPointerRef = useRef<number | null>(null);
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId); const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
const fibRef = useRef<FibRetracement | null>(fib); const fibRef = useRef<FibRetracement | null>(fib);
const resetChartViewRef = useRef<() => void>(() => {});
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);
}, [isExternalFullscreen, isFullscreen]); }, [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');
}, [isExternalFullscreen, isFullscreen]); }, [isFullscreen]);
const cardClassName = useMemo(() => { const cardClassName = useMemo(() => {
return ['chartCard', effectiveFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' '); return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
}, [effectiveFullscreen]); }, [isFullscreen]);
const cardStyle = useMemo(() => {
if (!effectiveFullscreen) return undefined;
return fullscreenStyle;
}, [effectiveFullscreen, fullscreenStyle]);
useEffect(() => { useEffect(() => {
activeToolRef.current = activeTool; activeToolRef.current = activeTool;
@@ -146,6 +140,12 @@ export default function ChartPanel({
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) return; if (isEditableTarget(e.target)) return;
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'r') {
e.preventDefault();
resetChartViewRef.current();
return;
}
if (e.code === 'Space') { if (e.code === 'Space') {
spaceDownRef.current = true; spaceDownRef.current = true;
e.preventDefault(); e.preventDefault();
@@ -213,6 +213,17 @@ 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 resetChartView() {
const chart = chartApiRef.current;
if (!chart) return;
chart.timeScale().resetTimeScale();
candleSeriesRef.current?.priceScale().setAutoScale(true);
setPriceAutoScale(true);
}
resetChartViewRef.current = resetChartView;
function clamp01(v: number): number { function clamp01(v: number): number {
if (!Number.isFinite(v)) return 1; if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v)); return Math.max(0, Math.min(1, v));
@@ -247,7 +258,7 @@ export default function ChartPanel({
lineStyle: LineStyle.Dotted, lineStyle: LineStyle.Dotted,
}, },
]; ];
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]); }, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible, theme]);
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) { function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l))); setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
@@ -319,12 +330,19 @@ export default function ChartPanel({
if (!fibEffectiveVisible) setSelectedOverlayId(null); if (!fibEffectiveVisible) setSelectedOverlayId(null);
}, [fib, fibEffectiveVisible, selectedOverlayId]); }, [fib, fibEffectiveVisible, selectedOverlayId]);
useEffect(() => {
if (!onResetViewReady) return;
onResetViewReady(() => resetChartViewRef.current());
return () => onResetViewReady(null);
}, [onResetViewReady]);
return ( return (
<> <>
{!isExternalFullscreen && isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null} {isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
<Card className={cardClassName} style={cardStyle}> <Card className={cardClassName}>
<div className="chartCard__toolbar"> <div className="chartCard__toolbar">
<ChartToolbar <ChartToolbar
tickerItems={tickerItems}
timeframe={timeframe} timeframe={timeframe}
onTimeframeChange={onTimeframeChange} onTimeframeChange={onTimeframeChange}
showIndicators={showIndicators} showIndicators={showIndicators}
@@ -334,8 +352,8 @@ export default function ChartPanel({
priceAutoScale={priceAutoScale} priceAutoScale={priceAutoScale}
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)} onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
seriesLabel={seriesLabel} seriesLabel={seriesLabel}
isFullscreen={effectiveFullscreen} isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={() => setIsFullscreen((v) => !v)}
/> />
</div> </div>
<div className="chartCard__content"> <div className="chartCard__content">
@@ -348,12 +366,13 @@ export default function ChartPanel({
onToggleLayers={() => setLayersOpen((v) => !v)} 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={resetChartView}
onClearFib={clearFib} onClearFib={clearFib}
/> />
<div className="chartCard__chart"> <div className="chartCard__chart">
<TradingChart <TradingChart
candles={candles} candles={candles}
theme={theme}
oracle={indicators.oracle} oracle={indicators.oracle}
sma20={indicators.sma20} sma20={indicators.sma20}
ema20={indicators.ema20} ema20={indicators.ema20}
@@ -367,8 +386,11 @@ export default function ChartPanel({
fibOpacity={fibEffectiveOpacity} fibOpacity={fibEffectiveOpacity}
fibSelected={fibSelected} fibSelected={fibSelected}
priceAutoScale={priceAutoScale} priceAutoScale={priceAutoScale}
onReady={({ chart }) => { hasMoreHistory={hasMoreHistory}
onRequestHistoryBefore={onRequestHistoryBefore}
onReady={({ chart, candles: candleSeries }) => {
chartApiRef.current = chart; chartApiRef.current = chart;
candleSeriesRef.current = candleSeries;
}} }}
onChartClick={(p) => { onChartClick={(p) => {
if (activeTool === 'fib-retracement') { if (activeTool === 'fib-retracement') {

View File

@@ -1,6 +1,8 @@
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import TickerBar, { type TickerItem } from '../tickerbar/TickerBar';
type Props = { type Props = {
tickerItems: TickerItem[];
timeframe: string; timeframe: string;
onTimeframeChange: (tf: string) => void; onTimeframeChange: (tf: string) => void;
showIndicators: boolean; showIndicators: boolean;
@@ -14,9 +16,10 @@ type Props = {
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
}; };
const timeframes = ['1s', '3s', '5s', '15s', '30s', '1m', '3m', '5m', '15m', '30m', '1h', '4h', '12h', '1d'] as const; const timeframes = ['1s', '3s', '5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
export default function ChartToolbar({ export default function ChartToolbar({
tickerItems,
timeframe, timeframe,
onTimeframeChange, onTimeframeChange,
showIndicators, showIndicators,
@@ -32,6 +35,7 @@ export default function ChartToolbar({
return ( return (
<div className="chartToolbar"> <div className="chartToolbar">
<div className="chartToolbar__group"> <div className="chartToolbar__group">
<TickerBar items={tickerItems} className="tickerBar--toolbar" />
{timeframes.map((tf) => ( {timeframes.map((tf) => (
<Button <Button
key={tf} key={tf}

View File

@@ -0,0 +1,147 @@
import type {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
export type KeyboardCursorPoint = {
price: number;
color: string;
};
export type KeyboardCursorState = {
time: number;
price: number;
points: KeyboardCursorPoint[];
};
type State = {
cursor: KeyboardCursorState | null;
series: ISeriesApi<'Candlestick', Time> | null;
chart: SeriesAttachedParameter<Time>['chart'] | null;
lineColor: string;
};
class KeyboardCursorPaneRenderer implements IPrimitivePaneRenderer {
private readonly _getState: () => State;
constructor(getState: () => State) {
this._getState = getState;
}
draw(target: any) {
const { cursor, series, chart, lineColor } = this._getState();
if (!cursor || !series || !chart) return;
const x = chart.timeScale().timeToCoordinate(cursor.time as any);
const y = series.priceToCoordinate(cursor.price);
if (x == null || y == null) return;
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
context.save();
try {
const xPx = Math.round(x * horizontalPixelRatio);
const yPx = Math.round(y * verticalPixelRatio);
context.strokeStyle = lineColor;
context.lineWidth = Math.max(1, Math.round(1 * horizontalPixelRatio));
context.setLineDash([Math.max(3, Math.round(6 * horizontalPixelRatio)), Math.max(3, Math.round(6 * horizontalPixelRatio))]);
context.beginPath();
context.moveTo(xPx, 0);
context.lineTo(xPx, bitmapSize.height);
context.stroke();
context.beginPath();
context.moveTo(0, yPx);
context.lineTo(bitmapSize.width, yPx);
context.stroke();
context.setLineDash([]);
const outerR = Math.max(3, Math.round(4 * horizontalPixelRatio));
const innerR = Math.max(2, Math.round(2.5 * horizontalPixelRatio));
for (const point of cursor.points) {
if (!Number.isFinite(point.price)) continue;
const pointY = series.priceToCoordinate(point.price);
if (pointY == null) continue;
const pointYPx = Math.round(pointY * verticalPixelRatio);
context.fillStyle = lineColor;
context.beginPath();
context.arc(xPx, pointYPx, outerR, 0, Math.PI * 2);
context.fill();
context.fillStyle = point.color;
context.beginPath();
context.arc(xPx, pointYPx, innerR, 0, Math.PI * 2);
context.fill();
}
} finally {
context.restore();
}
});
}
}
class KeyboardCursorPaneView implements IPrimitivePaneView {
private readonly _renderer: KeyboardCursorPaneRenderer;
constructor(getState: () => State) {
this._renderer = new KeyboardCursorPaneRenderer(getState);
}
zOrder() {
return 'top';
}
renderer() {
return this._renderer;
}
}
export class KeyboardCursorPrimitive implements ISeriesPrimitive<Time> {
private _param: SeriesAttachedParameter<Time> | null = null;
private _series: ISeriesApi<'Candlestick', Time> | null = null;
private _cursor: KeyboardCursorState | null = null;
private _lineColor = 'rgba(96,165,250,0.92)';
private readonly _paneView: KeyboardCursorPaneView;
private readonly _paneViews: readonly IPrimitivePaneView[];
constructor() {
this._paneView = new KeyboardCursorPaneView(() => ({
cursor: this._cursor,
series: this._series,
chart: this._param?.chart ?? null,
lineColor: this._lineColor,
}));
this._paneViews = [this._paneView];
}
attached(param: SeriesAttachedParameter<Time>) {
this._param = param;
this._series = param.series as ISeriesApi<'Candlestick', Time>;
}
detached() {
this._param = null;
this._series = null;
}
paneViews() {
return this._paneViews;
}
setCursor(next: KeyboardCursorState | null) {
this._cursor = next;
this._param?.requestUpdate();
}
setLineColor(next: string) {
this._lineColor = String(next || '').trim() || 'rgba(96,165,250,0.92)';
this._param?.requestUpdate();
}
}

View File

@@ -22,9 +22,11 @@ import {
} from 'lightweight-charts'; } from 'lightweight-charts';
import type { Candle, SeriesPoint } from '../../lib/api'; import type { Candle, SeriesPoint } from '../../lib/api';
import { FibRetracementPrimitive, type FibAnchor, type FibRetracement } from './FibRetracementPrimitive'; import { FibRetracementPrimitive, type FibAnchor, type FibRetracement } from './FibRetracementPrimitive';
import { KeyboardCursorPrimitive, type KeyboardCursorPoint, type KeyboardCursorState } from './KeyboardCursorPrimitive';
type Props = { type Props = {
candles: Candle[]; candles: Candle[];
theme: 'day' | 'night';
oracle?: SeriesPoint[]; oracle?: SeriesPoint[];
sma20?: SeriesPoint[]; sma20?: SeriesPoint[];
ema20?: SeriesPoint[]; ema20?: SeriesPoint[];
@@ -46,6 +48,8 @@ type Props = {
fibOpacity?: number; fibOpacity?: number;
fibSelected?: boolean; fibSelected?: boolean;
priceAutoScale?: boolean; priceAutoScale?: boolean;
hasMoreHistory?: boolean;
onRequestHistoryBefore?: (beforeTime: number) => Promise<void> | void;
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;
@@ -69,6 +73,32 @@ const BUILD_FLAT_COLOR = '#60a5fa';
const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)'; const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)';
const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)'; const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)';
const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)'; const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)';
const ORACLE_CURSOR_POINT = 'rgba(251,146,60,0.95)';
const SMA_CURSOR_POINT = 'rgba(248,113,113,0.95)';
const EMA_CURSOR_POINT = 'rgba(52,211,153,0.95)';
const BB_UPPER_CURSOR_POINT = 'rgba(250,204,21,0.85)';
const BB_LOWER_CURSOR_POINT = 'rgba(163,163,163,0.85)';
const BB_MID_CURSOR_POINT = 'rgba(250,204,21,0.65)';
function chartPalette(theme: 'day' | 'night') {
if (theme === 'day') {
return {
textColor: '#112036',
gridColor: 'rgba(17,32,54,0.08)',
crosshairColor: 'rgba(71,85,105,0.28)',
scaleBorder: 'rgba(17,32,54,0.12)',
volumeColor: 'rgba(17,32,54,0.16)',
};
}
return {
textColor: '#e6e9ef',
gridColor: 'rgba(255,255,255,0.06)',
crosshairColor: 'rgba(226,232,240,0.22)',
scaleBorder: 'rgba(255,255,255,0.08)',
volumeColor: 'rgba(255,255,255,0.15)',
};
}
function toTime(t: number): UTCTimestamp { function toTime(t: number): UTCTimestamp {
return t as UTCTimestamp; return t as UTCTimestamp;
@@ -128,9 +158,7 @@ function toVolumeData(candles: Candle[]): HistogramData[] {
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] { function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
if (!points?.length) return []; if (!points?.length) return [];
return points.map((p) => return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }
);
} }
function colorForDelta(delta: number): string { function colorForDelta(delta: number): string {
@@ -487,6 +515,7 @@ class BuildSlicesPrimitive implements ISeriesPrimitive<Time> {
export default function TradingChart({ export default function TradingChart({
candles, candles,
theme,
oracle, oracle,
sma20, sma20,
ema20, ema20,
@@ -500,6 +529,8 @@ export default function TradingChart({
fibOpacity = 1, fibOpacity = 1,
fibSelected = false, fibSelected = false,
priceAutoScale = true, priceAutoScale = true,
hasMoreHistory = false,
onRequestHistoryBefore,
onReady, onReady,
onChartClick, onChartClick,
onChartCrosshairMove, onChartCrosshairMove,
@@ -517,14 +548,24 @@ export default function TradingChart({
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick); const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove); const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent); const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
const onRequestHistoryBeforeRef = useRef<Props['onRequestHistoryBefore']>(onRequestHistoryBefore);
const capturedOverlayPointerRef = useRef<number | null>(null); const capturedOverlayPointerRef = useRef<number | null>(null);
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null); const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
const keyboardCursorPrimitiveRef = useRef<KeyboardCursorPrimitive | null>(null);
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map()); const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
const buildKeyRef = useRef<string | null>(null); const buildKeyRef = useRef<string | null>(null);
const lastBuildCandleStartRef = useRef<number | null>(null); const lastBuildCandleStartRef = useRef<number | null>(null);
const hoverCandleTimeRef = useRef<number | null>(null); const hoverCandleTimeRef = useRef<number | null>(null);
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null); const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
const [keyboardCursor, setKeyboardCursor] = useState<KeyboardCursorState | null>(null);
const priceLinesRef = useRef<Map<string, any>>(new Map()); const priceLinesRef = useRef<Map<string, any>>(new Map());
const candlesRef = useRef<Candle[]>(candles);
const hasMoreHistoryRef = useRef<boolean>(hasMoreHistory);
const requestedHistoryBeforeRef = useRef<number | null>(null);
const prevSeriesKeyRef = useRef<string | null>(null);
const prevFirstTimeRef = useRef<number | null>(null);
const prevCandlesLenRef = useRef<number>(0);
const keyboardCursorRef = useRef<KeyboardCursorState | null>(keyboardCursor);
const seriesRef = useRef<{ const seriesRef = useRef<{
candles?: ISeriesApi<'Candlestick'>; candles?: ISeriesApi<'Candlestick'>;
volume?: ISeriesApi<'Histogram'>; volume?: ISeriesApi<'Histogram'>;
@@ -545,6 +586,7 @@ export default function TradingChart({
const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]); const bbUpper = useMemo(() => toLineSeries(bb20?.upper), [bb20?.upper]);
const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]); const bbLower = useMemo(() => toLineSeries(bb20?.lower), [bb20?.lower]);
const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]); const bbMid = useMemo(() => toLineSeries(bb20?.mid), [bb20?.mid]);
const palette = useMemo(() => chartPalette(theme), [theme]);
const priceFormat = useMemo(() => { const priceFormat = useMemo(() => {
const sample = samplePriceFromCandles(candles); const sample = samplePriceFromCandles(candles);
return sample == null ? { type: 'price' as const, precision: 2, minMove: 0.01 } : priceFormatForSample(sample); return sample == null ? { type: 'price' as const, precision: 2, minMove: 0.01 } : priceFormatForSample(sample);
@@ -566,6 +608,10 @@ export default function TradingChart({
onPointerEventRef.current = onPointerEvent; onPointerEventRef.current = onPointerEvent;
}, [onPointerEvent]); }, [onPointerEvent]);
useEffect(() => {
onRequestHistoryBeforeRef.current = onRequestHistoryBefore;
}, [onRequestHistoryBefore]);
useEffect(() => { useEffect(() => {
fibRef.current = fib ?? null; fibRef.current = fib ?? null;
}, [fib]); }, [fib]);
@@ -579,6 +625,93 @@ export default function TradingChart({
priceAutoScaleRef.current = priceAutoScale; priceAutoScaleRef.current = priceAutoScale;
}, [priceAutoScale]); }, [priceAutoScale]);
useEffect(() => {
candlesRef.current = candles;
}, [candles]);
useEffect(() => {
hasMoreHistoryRef.current = hasMoreHistory;
}, [hasMoreHistory]);
useEffect(() => {
keyboardCursorRef.current = keyboardCursor;
}, [keyboardCursor]);
function setHoverTime(next: number | null) {
if (hoverCandleTimeRef.current !== next) {
hoverCandleTimeRef.current = next;
setHoverCandleTime(next);
}
}
function findCandleIndexByTime(items: Candle[], time: number | null): number {
if (time == null || !items.length) return -1;
let lo = 0;
let hi = items.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const midTime = items[mid]!.time;
if (midTime === time) return mid;
if (midTime < time) lo = mid + 1;
else hi = mid - 1;
}
return Math.max(0, Math.min(items.length - 1, lo));
}
function ensureLogicalIndexVisible(index: number, alignRight = false) {
const chart = chartRef.current;
if (!chart) return;
const range = chart.timeScale().getVisibleLogicalRange();
if (!range) return;
const from = Number(range.from);
const to = Number(range.to);
const span = Math.max(5, to - from);
const leftPad = Math.max(2, Math.floor(span * 0.12));
const rightPad = Math.max(2, Math.floor(span * 0.12));
if (alignRight) {
chart.timeScale().setVisibleLogicalRange({
from: index - span + rightPad,
to: index + rightPad,
});
return;
}
if (index < from + leftPad) {
chart.timeScale().setVisibleLogicalRange({
from: index - leftPad,
to: index - leftPad + span,
});
return;
}
if (index > to - rightPad) {
chart.timeScale().setVisibleLogicalRange({
from: index + rightPad - span,
to: index + rightPad,
});
}
}
function activateKeyboardCursor(index: number, options?: { alignRight?: boolean; scrollToRealTime?: boolean }) {
const chart = chartRef.current;
const items = candlesRef.current;
if (!chart || !items.length) return;
const safeIndex = Math.max(0, Math.min(items.length - 1, Math.round(index)));
const candle = items[safeIndex]!;
if (options?.scrollToRealTime) chart.timeScale().scrollToRealTime();
ensureLogicalIndexVisible(safeIndex, Boolean(options?.alignRight));
setKeyboardCursor({ time: candle.time, price: candle.close, points: [] });
onChartCrosshairMoveRef.current?.({ logical: safeIndex, price: candle.close });
}
function clearKeyboardCursor() {
setKeyboardCursor(null);
}
useEffect(() => { useEffect(() => {
showBuildRef.current = showBuild; showBuildRef.current = showBuild;
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) { if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
@@ -594,21 +727,21 @@ export default function TradingChart({
const chart = createChart(containerRef.current, { const chart = createChart(containerRef.current, {
layout: { layout: {
background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' }, background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' },
textColor: '#e6e9ef', textColor: palette.textColor,
fontFamily: fontFamily:
'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif', 'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif',
}, },
grid: { grid: {
vertLines: { color: 'rgba(255,255,255,0.06)' }, vertLines: { color: palette.gridColor },
horzLines: { color: 'rgba(255,255,255,0.06)' }, horzLines: { color: palette.gridColor },
}, },
crosshair: { crosshair: {
mode: CrosshairMode.Normal, mode: CrosshairMode.Normal,
vertLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed }, vertLine: { color: palette.crosshairColor, style: LineStyle.Dashed },
horzLine: { color: 'rgba(255,255,255,0.18)', style: LineStyle.Dashed }, horzLine: { color: palette.crosshairColor, style: LineStyle.Dashed },
}, },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)' }, rightPriceScale: { borderColor: palette.scaleBorder },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false }, timeScale: { borderColor: palette.scaleBorder, timeVisible: true, secondsVisible: false },
handleScale: { mouseWheel: true, pinch: true }, handleScale: { mouseWheel: true, pinch: true },
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true }, handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
}); });
@@ -622,6 +755,10 @@ export default function TradingChart({
wickDownColor: '#ef4444', wickDownColor: '#ef4444',
priceFormat, priceFormat,
}); });
const keyboardCursorPrimitive = new KeyboardCursorPrimitive();
candleSeries.attachPrimitive(keyboardCursorPrimitive);
keyboardCursorPrimitiveRef.current = keyboardCursorPrimitive;
keyboardCursorPrimitive.setLineColor(palette.crosshairColor);
const fibPrimitive = new FibRetracementPrimitive(); const fibPrimitive = new FibRetracementPrimitive();
candleSeries.attachPrimitive(fibPrimitive); candleSeries.attachPrimitive(fibPrimitive);
@@ -632,7 +769,7 @@ export default function TradingChart({
const volumeSeries = chart.addSeries(HistogramSeries, { const volumeSeries = chart.addSeries(HistogramSeries, {
priceFormat: { type: 'volume' }, priceFormat: { type: 'volume' },
priceScaleId: '', priceScaleId: '',
color: 'rgba(255,255,255,0.15)', color: palette.volumeColor,
}); });
volumeSeries.priceScale().applyOptions({ volumeSeries.priceScale().applyOptions({
scaleMargins: { top: 0.88, bottom: 0 }, scaleMargins: { top: 0.88, bottom: 0 },
@@ -696,6 +833,12 @@ export default function TradingChart({
if (logical == null) return; if (logical == null) return;
const price = candleSeries.coordinateToPrice(param.point.y); const price = candleSeries.coordinateToPrice(param.point.y);
if (price == null) return; if (price == null) return;
const clickedTime = typeof param?.time === 'number' ? Number(param.time) : null;
const clickedIndex =
clickedTime != null && Number.isFinite(clickedTime)
? findCandleIndexByTime(candlesRef.current, clickedTime)
: Math.max(0, Math.min(candlesRef.current.length - 1, Math.round(Number(logical))));
const clickedCandle = candlesRef.current[clickedIndex] ?? null;
const currentFib = fibRef.current; const currentFib = fibRef.current;
let target: 'chart' | 'fib' = 'chart'; let target: 'chart' | 'fib' = 'chart';
@@ -724,6 +867,11 @@ export default function TradingChart({
} }
} }
if (clickedCandle) {
const chart = chartRef.current;
if (chart) ensureLogicalIndexVisible(clickedIndex);
setKeyboardCursor({ time: clickedCandle.time, price: Number(price), points: [] });
}
onChartClickRef.current?.({ logical: Number(logical), price: Number(price), target }); onChartClickRef.current?.({ logical: Number(logical), price: Number(price), target });
}; };
chart.subscribeClick(onClick); chart.subscribeClick(onClick);
@@ -731,20 +879,14 @@ export default function TradingChart({
const onCrosshairMove = (param: any) => { const onCrosshairMove = (param: any) => {
if (!param?.point) { if (!param?.point) {
if (showBuildRef.current && hoverCandleTimeRef.current != null) { if (showBuildRef.current && hoverCandleTimeRef.current != null) {
hoverCandleTimeRef.current = null; setHoverTime(null);
setHoverCandleTime(null);
} }
return; return;
} }
if (showBuildRef.current) {
const t = typeof param?.time === 'number' ? Number(param.time) : null; const t = typeof param?.time === 'number' ? Number(param.time) : null;
const next = t != null && Number.isFinite(t) ? t : null; const next = t != null && Number.isFinite(t) ? t : null;
if (hoverCandleTimeRef.current !== next) { setHoverTime(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;
@@ -754,7 +896,27 @@ export default function TradingChart({
}; };
chart.subscribeCrosshairMove(onCrosshairMove); chart.subscribeCrosshairMove(onCrosshairMove);
const onVisibleLogicalRangeChange = (range: { from: number; to: number } | null) => {
if (!range) return;
if (!hasMoreHistoryRef.current) return;
const currentCandles = candlesRef.current;
const oldestTime = currentCandles[0]?.time;
if (!oldestTime) return;
const threshold = Math.min(40, Math.max(8, Math.floor(currentCandles.length * 0.1)));
if (Number(range.from) > threshold) {
requestedHistoryBeforeRef.current = null;
return;
}
if (requestedHistoryBeforeRef.current === oldestTime) return;
requestedHistoryBeforeRef.current = oldestTime;
void onRequestHistoryBeforeRef.current?.(oldestTime);
};
chart.timeScale().subscribeVisibleLogicalRangeChange(onVisibleLogicalRangeChange);
const container = containerRef.current; const container = containerRef.current;
const focusContainer = () => containerRef.current?.focus({ preventScroll: true });
const toAnchorFromPoint = (x: number, y: number): FibAnchor | null => { const toAnchorFromPoint = (x: number, y: number): FibAnchor | null => {
const logical = chart.timeScale().coordinateToLogical(x); const logical = chart.timeScale().coordinateToLogical(x);
if (logical == null) return null; if (logical == null) return null;
@@ -789,6 +951,7 @@ export default function TradingChart({
const onOverlayPointer = (e: PointerEvent) => { const onOverlayPointer = (e: PointerEvent) => {
if (!containerRef.current) return; if (!containerRef.current) return;
focusContainer();
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; const y = e.clientY - rect.top;
@@ -847,6 +1010,25 @@ export default function TradingChart({
container?.addEventListener('pointercancel', onOverlayPointer, { capture: true }); container?.addEventListener('pointercancel', onOverlayPointer, { capture: true });
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (e.shiftKey && !e.ctrlKey) {
const range = chart.timeScale().getVisibleLogicalRange();
if (!range) return;
const rawDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (!Number.isFinite(rawDelta) || rawDelta === 0) return;
e.preventDefault();
e.stopPropagation();
const span = Math.max(5, Number(range.to) - Number(range.from));
const shift = (rawDelta / 120) * Math.max(1, span * 0.12);
chart.timeScale().setVisibleLogicalRange({
from: Number(range.from) + shift,
to: Number(range.to) + shift,
});
return;
}
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
if (!containerRef.current) return; if (!containerRef.current) return;
e.preventDefault(); e.preventDefault();
@@ -891,6 +1073,7 @@ export default function TradingChart({
let pan: { pointerId: number; startPrice: number; startRange: { from: number; to: number } } | null = null; let pan: { pointerId: number; startPrice: number; startRange: { from: number; to: number } } | null = null;
const onPointerDown = (e: PointerEvent) => { const onPointerDown = (e: PointerEvent) => {
focusContainer();
if (e.button !== 0) return; if (e.button !== 0) return;
if (!e.ctrlKey) return; if (!e.ctrlKey) return;
if (priceAutoScaleRef.current) return; if (priceAutoScaleRef.current) return;
@@ -966,6 +1149,56 @@ export default function TradingChart({
container?.addEventListener('pointerup', stopPan, { capture: true }); container?.addEventListener('pointerup', stopPan, { capture: true });
container?.addEventListener('pointercancel', stopPan, { capture: true }); container?.addEventListener('pointercancel', stopPan, { capture: true });
const onKeyDown = (e: KeyboardEvent) => {
if (!candlesRef.current.length) return;
if (e.ctrlKey || e.metaKey) return;
const items = candlesRef.current;
const lastIndex = items.length - 1;
const hoveredIndex = findCandleIndexByTime(items, keyboardCursorRef.current?.time ?? hoverCandleTimeRef.current);
const baseIndex = hoveredIndex >= 0 ? hoveredIndex : lastIndex;
const step = e.shiftKey ? 10 : 1;
if (e.altKey) {
if (!e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
activateKeyboardCursor(lastIndex, { alignRight: true, scrollToRealTime: true });
}
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
activateKeyboardCursor(baseIndex - step);
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
activateKeyboardCursor(baseIndex + step);
return;
}
if (e.key === 'Home') {
e.preventDefault();
activateKeyboardCursor(0);
return;
}
if (e.key === 'End') {
e.preventDefault();
activateKeyboardCursor(lastIndex, { alignRight: true, scrollToRealTime: true });
return;
}
if (e.key === 'Escape' && keyboardCursorRef.current != null) {
e.preventDefault();
clearKeyboardCursor();
}
};
container?.addEventListener('keydown', onKeyDown);
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const { width, height } = containerRef.current.getBoundingClientRect(); const { width, height } = containerRef.current.getBoundingClientRect();
@@ -976,6 +1209,7 @@ export default function TradingChart({
return () => { return () => {
chart.unsubscribeClick(onClick); chart.unsubscribeClick(onClick);
chart.unsubscribeCrosshairMove(onCrosshairMove); chart.unsubscribeCrosshairMove(onCrosshairMove);
chart.timeScale().unsubscribeVisibleLogicalRangeChange(onVisibleLogicalRangeChange);
container?.removeEventListener('pointerdown', onOverlayPointer, { capture: true }); container?.removeEventListener('pointerdown', onOverlayPointer, { capture: true });
container?.removeEventListener('pointermove', onOverlayPointer, { capture: true }); container?.removeEventListener('pointermove', onOverlayPointer, { capture: true });
container?.removeEventListener('pointerup', onOverlayPointer, { capture: true }); container?.removeEventListener('pointerup', onOverlayPointer, { capture: true });
@@ -985,6 +1219,7 @@ export default function TradingChart({
container?.removeEventListener('pointermove', onPointerMove, { capture: true }); container?.removeEventListener('pointermove', onPointerMove, { capture: true });
container?.removeEventListener('pointerup', stopPan, { capture: true }); container?.removeEventListener('pointerup', stopPan, { capture: true });
container?.removeEventListener('pointercancel', stopPan, { capture: true }); container?.removeEventListener('pointercancel', stopPan, { capture: true });
container?.removeEventListener('keydown', onKeyDown);
ro.disconnect(); ro.disconnect();
if (fibPrimitiveRef.current) { if (fibPrimitiveRef.current) {
candleSeries.detachPrimitive(fibPrimitiveRef.current); candleSeries.detachPrimitive(fibPrimitiveRef.current);
@@ -994,6 +1229,10 @@ export default function TradingChart({
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current); volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
buildSlicesPrimitiveRef.current = null; buildSlicesPrimitiveRef.current = null;
} }
if (keyboardCursorPrimitiveRef.current) {
candleSeries.detachPrimitive(keyboardCursorPrimitiveRef.current);
keyboardCursorPrimitiveRef.current = null;
}
const lines = priceLinesRef.current; const lines = priceLinesRef.current;
for (const line of Array.from(lines.values())) { for (const line of Array.from(lines.values())) {
try { try {
@@ -1009,6 +1248,38 @@ export default function TradingChart({
}; };
}, []); }, []);
useEffect(() => {
keyboardCursorPrimitiveRef.current?.setLineColor(palette.crosshairColor);
}, [palette.crosshairColor]);
useEffect(() => {
const chart = chartRef.current;
const volumeSeries = seriesRef.current.volume;
if (!chart) return;
chart.applyOptions({
layout: {
background: { type: ColorType.Solid, color: 'rgba(0,0,0,0)' },
textColor: palette.textColor,
fontFamily:
'system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif',
},
grid: {
vertLines: { color: palette.gridColor },
horzLines: { color: palette.gridColor },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: palette.crosshairColor, style: LineStyle.Dashed },
horzLine: { color: palette.crosshairColor, style: LineStyle.Dashed },
},
rightPriceScale: { borderColor: palette.scaleBorder },
timeScale: { borderColor: palette.scaleBorder, timeVisible: true, secondsVisible: false },
});
volumeSeries?.applyOptions({ color: palette.volumeColor });
}, [palette]);
useEffect(() => { useEffect(() => {
const candlesSeries = seriesRef.current.candles; const candlesSeries = seriesRef.current.candles;
if (!candlesSeries) return; if (!candlesSeries) return;
@@ -1054,6 +1325,7 @@ export default function TradingChart({
// ignore // ignore
} }
} }
}, [priceLines]); }, [priceLines]);
useEffect(() => { useEffect(() => {
@@ -1130,8 +1402,8 @@ export default function TradingChart({
buildPrimitive?.setEnabled(showBuild); buildPrimitive?.setEnabled(showBuild);
if (showBuild) { if (showBuild) {
const hoverTime = hoverCandleTime; const activeCursorTime = keyboardCursor?.time ?? hoverCandleTime;
const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime); const hoverCandle = activeCursorTime == null ? null : candles.find((c) => c.time === activeCursorTime);
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : []; const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
if (hoverData.length) { if (hoverData.length) {
@@ -1166,8 +1438,107 @@ export default function TradingChart({
bucketSeconds, bucketSeconds,
seriesKey, seriesKey,
hoverCandleTime, hoverCandleTime,
keyboardCursor,
]); ]);
useEffect(() => {
const chart = chartRef.current;
const nextFirstTime = candles[0]?.time ?? null;
const nextLen = candles.length;
const prevFirstTime = prevFirstTimeRef.current;
const prevLen = prevCandlesLenRef.current;
if (chart && prevFirstTime != null && nextFirstTime != null && nextFirstTime < prevFirstTime && nextLen > prevLen) {
const range = chart.timeScale().getVisibleLogicalRange();
if (range) {
const delta = nextLen - prevLen;
chart.timeScale().setVisibleLogicalRange({
from: Number(range.from) + delta,
to: Number(range.to) + delta,
});
}
}
prevFirstTimeRef.current = nextFirstTime;
prevCandlesLenRef.current = nextLen;
}, [candles]);
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
if (!candles.length) {
prevSeriesKeyRef.current = seriesKey;
clearKeyboardCursor();
return;
}
if (prevSeriesKeyRef.current === seriesKey) return;
prevSeriesKeyRef.current = seriesKey;
requestedHistoryBeforeRef.current = null;
clearKeyboardCursor();
setHoverTime(null);
chart.timeScale().fitContent();
if (priceAutoScaleRef.current) {
seriesRef.current.candles?.priceScale().setAutoScale(true);
}
}, [candles.length, seriesKey]);
useEffect(() => {
if (!keyboardCursor) return;
const items = candlesRef.current;
if (!items.length) {
clearKeyboardCursor();
return;
}
const index = findCandleIndexByTime(items, keyboardCursor.time);
if (index < 0) {
activateKeyboardCursor(items.length - 1, { alignRight: true });
return;
}
ensureLogicalIndexVisible(index);
}, [candles, keyboardCursor]);
useEffect(() => {
const primitive = keyboardCursorPrimitiveRef.current;
if (!primitive) return;
if (!keyboardCursor) {
primitive.setCursor(null);
return;
}
const candle = candles.find((item) => item.time === keyboardCursor.time) ?? null;
if (!candle) {
primitive.setCursor(null);
return;
}
const candlePointColor = candle.close >= candle.open ? '#22c55e' : '#ef4444';
const points: KeyboardCursorPoint[] = [{ price: candle.close, color: candlePointColor }];
const oracleValue = oracle.find((item) => item.time === candle.time)?.value ?? null;
if (oracleValue != null) points.push({ price: oracleValue, color: ORACLE_CURSOR_POINT });
if (showIndicators) {
const smaValue = sma20?.find((item) => item.time === candle.time)?.value ?? null;
const emaValue = ema20?.find((item) => item.time === candle.time)?.value ?? null;
const bbUpperValue = bb20?.upper.find((item) => item.time === candle.time)?.value ?? null;
const bbLowerValue = bb20?.lower.find((item) => item.time === candle.time)?.value ?? null;
const bbMidValue = bb20?.mid.find((item) => item.time === candle.time)?.value ?? null;
if (smaValue != null) points.push({ price: smaValue, color: SMA_CURSOR_POINT });
if (emaValue != null) points.push({ price: emaValue, color: EMA_CURSOR_POINT });
if (bbUpperValue != null) points.push({ price: bbUpperValue, color: BB_UPPER_CURSOR_POINT });
if (bbLowerValue != null) points.push({ price: bbLowerValue, color: BB_LOWER_CURSOR_POINT });
if (bbMidValue != null) points.push({ price: bbMidValue, color: BB_MID_CURSOR_POINT });
}
primitive.setCursor({
time: candle.time,
price: keyboardCursor.price,
points,
});
}, [bb20, candles, ema20, keyboardCursor, oracle, showIndicators, sma20]);
useEffect(() => { useEffect(() => {
const s = seriesRef.current; const s = seriesRef.current;
if (!s.candles) return; if (!s.candles) return;
@@ -1215,5 +1586,5 @@ export default function TradingChart({
ps.setVisibleRange({ from, to }); ps.setVisibleRange({ from, to });
}, [priceAutoScale]); }, [priceAutoScale]);
return <div className="tradingChart" ref={containerRef} />; return <div className="tradingChart" ref={containerRef} tabIndex={0} aria-label="Trading chart" />;
} }

View File

@@ -1,7 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Candle, ChartIndicators } from '../../lib/api'; import {
import { fetchChart } from '../../lib/api'; applyLatestRowToCandles,
import { useInterval } from '../../app/hooks/useInterval'; buildChartIndicators,
fetchChart,
getChartLiveTable,
mergeCandles,
type Candle,
type ChartIndicators,
} from '../../lib/api';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
type Params = { type Params = {
symbol: string; symbol: string;
@@ -15,72 +22,196 @@ type Result = {
candles: Candle[]; candles: Candle[];
indicators: ChartIndicators; indicators: ChartIndicators;
meta: { tf: string; bucketSeconds: number } | null; meta: { tf: string; bucketSeconds: number } | null;
dataKey: string | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
hasMoreHistory: boolean;
refresh: () => Promise<void>; refresh: () => Promise<void>;
requestHistoryBefore: (beforeTime: number) => Promise<void>;
}; };
type HasuraLatestRow = {
updated_at?: string | null;
oracle_price?: unknown;
mark_price?: unknown;
mid_price?: unknown;
source?: string | null;
};
type SubscriptionData = Record<string, HasuraLatestRow[] | undefined>;
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 [meta, setMeta] = useState<{ tf: string; bucketSeconds: number } | null>(null);
const [dataKey, setDataKey] = useState<string | 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 [hasMoreHistory, setHasMoreHistory] = useState(true);
const abortRef = useRef<AbortController | null>(null);
const candlesRef = useRef<Candle[]>([]);
const controllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
const loadingOlderRef = useRef(false);
const requestedBeforeRef = useRef<number | null>(null);
const fetchOnce = useCallback( const normalizedSource = useMemo(() => {
async ({ force }: { force: boolean }) => { const raw = String(source || '').trim();
if (!force && inFlight.current) return; return raw || undefined;
}, [source]);
const queryKey = useMemo(() => JSON.stringify([symbol, normalizedSource ?? '', tf, limit]), [symbol, normalizedSource, tf, limit]);
// On timeframe/params change we want an immediate response — abort the older request. useEffect(() => {
if (force && abortRef.current) abortRef.current.abort(); candlesRef.current = candles;
}, [candles]);
const ctrl = new AbortController(); const replaceState = useCallback(
abortRef.current = ctrl; (nextCandles: Candle[], nextMeta: { tf: string; bucketSeconds: number } | null, nextDataKey: string) => {
candlesRef.current = nextCandles;
setCandles(nextCandles);
setIndicators(buildChartIndicators(nextCandles));
setMeta(nextMeta);
setDataKey(nextDataKey);
},
[]
);
const reqId = requestIdRef.current + 1; const fetchInitial = useCallback(async () => {
requestIdRef.current = reqId; controllerRef.current?.abort();
const controller = new AbortController();
inFlight.current = true; const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
controllerRef.current = controller;
requestedBeforeRef.current = null;
loadingOlderRef.current = false;
setLoading(true); setLoading(true);
setError(null);
setHasMoreHistory(true);
try { try {
const res = await fetchChart({ symbol, source, tf, limit, signal: ctrl.signal }); const res = await fetchChart({
if (requestIdRef.current !== reqId) return; // stale response symbol,
setCandles(res.candles); source: normalizedSource,
setIndicators(res.indicators); tf,
setMeta(res.meta); limit,
setError(null); signal: controller.signal,
});
if (controller.signal.aborted) return;
if (requestId !== requestIdRef.current) return;
replaceState(res.candles, res.meta, queryKey);
setHasMoreHistory(res.candles.length > 0);
} catch (e: any) { } catch (e: any) {
// Aborts are expected during fast tf switching. if (controller.signal.aborted) return;
const name = String(e?.name || ''); if (requestId !== requestIdRef.current) return;
const msg = String(e?.message || e); setError(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) { if (requestId !== requestIdRef.current) return;
if (controllerRef.current === controller) controllerRef.current = null;
setLoading(false); setLoading(false);
inFlight.current = false;
} }
}, [limit, normalizedSource, queryKey, replaceState, symbol, tf]);
const requestHistoryBefore = useCallback(
async (beforeTime: number) => {
const currentMeta = meta;
if (!currentMeta) return;
if (loadingOlderRef.current || !hasMoreHistory) return;
if (!Number.isFinite(beforeTime)) return;
const cursor = Math.floor(beforeTime);
if (requestedBeforeRef.current === cursor) return;
requestedBeforeRef.current = cursor;
loadingOlderRef.current = true;
try {
const res = await fetchChart({
symbol,
source: normalizedSource,
tf,
limit,
beforeTime: cursor,
});
const older = res.candles;
if (!older.length) {
setHasMoreHistory(false);
return;
}
const merged = mergeCandles(older, candlesRef.current);
replaceState(merged, currentMeta, queryKey);
} catch (e: any) {
requestedBeforeRef.current = null;
setError(String(e?.message || e));
} finally {
loadingOlderRef.current = false;
} }
}, },
[symbol, source, tf, limit] [hasMoreHistory, limit, meta, normalizedSource, queryKey, replaceState, symbol, tf]
); );
useEffect(() => { useEffect(() => {
void fetchOnce({ force: true }); void fetchInitial();
return () => { }, [fetchInitial]);
abortRef.current?.abort();
};
}, [fetchOnce]);
useInterval(() => void fetchOnce({ force: false }), pollMs); useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
useEffect(() => {
if (!symbol || !meta) return;
const table = getChartLiveTable(symbol);
const sourceClause = normalizedSource ? 'source: {_eq: $source}' : '';
const query = `
subscription ChartLive($market: String!${normalizedSource ? ', $source: String!' : ''}) {
${table}(
where: {
market_name: {_eq: $market}
is_indicative: {_eq: false}
${sourceClause}
}
limit: 1
) {
updated_at
oracle_price
mark_price
mid_price
source
}
}
`;
const sub = subscribeGraphqlWs<SubscriptionData>({
query,
variables: {
market: symbol,
...(normalizedSource ? { source: normalizedSource } : {}),
},
pollMs,
onError: (nextError) => setError(nextError),
onData: (data) => {
const row = data?.[table]?.[0];
if (!row) return;
const nextCandles = applyLatestRowToCandles(candlesRef.current, row, meta.bucketSeconds);
if (nextCandles === candlesRef.current) return;
replaceState(nextCandles, meta, queryKey);
},
});
return () => sub.unsubscribe();
}, [meta, normalizedSource, pollMs, queryKey, replaceState, symbol]);
return useMemo( return useMemo(
() => ({ candles, indicators, meta, loading, error, refresh: () => fetchOnce({ force: true }) }), () => ({
[candles, indicators, meta, loading, error, fetchOnce] candles,
indicators,
meta,
dataKey,
loading,
error,
hasMoreHistory,
refresh: fetchInitial,
requestHistoryBefore,
}),
[candles, dataKey, error, fetchInitial, hasMoreHistory, indicators, loading, meta, requestHistoryBefore]
); );
} }

View File

@@ -1,858 +0,0 @@
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

@@ -4,7 +4,6 @@ import type { DlobDepthBandRow } from './useDlobDepthBands';
import type { DlobSlippageRow } from './useDlobSlippage'; import type { DlobSlippageRow } from './useDlobSlippage';
import DlobDepthBandsPanel from './DlobDepthBandsPanel'; import DlobDepthBandsPanel from './DlobDepthBandsPanel';
import DlobSlippageChart from './DlobSlippageChart'; import DlobSlippageChart from './DlobSlippageChart';
import Button from '../../ui/Button';
function formatUsd(v: number | null | undefined): string { function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'; if (v == null || !Number.isFinite(v)) return '—';
@@ -40,8 +39,6 @@ export default function DlobDashboard({
slippageRows, slippageRows,
slippageConnected, slippageConnected,
slippageError, slippageError,
isFullscreen,
onToggleFullscreen,
}: { }: {
market: string; market: string;
stats: DlobStats | null; stats: DlobStats | null;
@@ -53,8 +50,6 @@ export default function DlobDashboard({
slippageRows: DlobSlippageRow[]; slippageRows: DlobSlippageRow[];
slippageConnected: boolean; slippageConnected: boolean;
slippageError: string | null; slippageError: string | null;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
}) { }) {
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null; const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
@@ -65,16 +60,6 @@ export default function DlobDashboard({
<div className="dlobDash__meta"> <div className="dlobDash__meta">
<span className="dlobDash__market">{market}</span> <span className="dlobDash__market">{market}</span>
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</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> </div>
@@ -149,3 +134,4 @@ export default function DlobDashboard({
</div> </div>
); );
} }

View File

@@ -109,3 +109,4 @@ export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] })
return <canvas className="dlobSlippageChart" ref={canvasRef} />; return <canvas className="dlobSlippageChart" ref={canvasRef} />;
} }

View File

@@ -0,0 +1,244 @@
import type { DlobL2, OrderbookRow } from './useDlobL2';
export const DEFAULT_DLOB_DEPTH_BANDS_BPS = [5, 10, 20, 50, 100, 200] as const;
export const DEFAULT_DLOB_SLIPPAGE_SIZES_USD = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 50000] as const;
export type ComputedDlobDepthBandRow = {
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;
};
export type ComputedDlobSlippageRow = {
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;
};
type OrderbookSide = 'buy' | 'sell';
function levelUsd(level: OrderbookRow): number {
if (Number.isFinite(level.sizeUsd)) return level.sizeUsd;
if (Number.isFinite(level.sizeBase) && Number.isFinite(level.price)) return level.sizeBase * level.price;
return 0;
}
function canonicalBids(l2: DlobL2): OrderbookRow[] {
return (Array.isArray(l2.bids) ? l2.bids : [])
.slice()
.sort((a, b) => b.price - a.price);
}
function canonicalAsks(l2: DlobL2): OrderbookRow[] {
return (Array.isArray(l2.asks) ? l2.asks.slice().reverse() : [])
.slice()
.sort((a, b) => a.price - b.price);
}
function resolveBestBid(l2: DlobL2, bids: OrderbookRow[]): number | null {
if (l2.bestBid != null && Number.isFinite(l2.bestBid)) return l2.bestBid;
const price = bids[0]?.price;
return Number.isFinite(price) ? price : null;
}
function resolveBestAsk(l2: DlobL2, asks: OrderbookRow[]): number | null {
if (l2.bestAsk != null && Number.isFinite(l2.bestAsk)) return l2.bestAsk;
const price = asks[0]?.price;
return Number.isFinite(price) ? price : null;
}
function resolveMidPrice(l2: DlobL2, bestBid: number | null, bestAsk: number | null): number | null {
if (l2.mid != null && Number.isFinite(l2.mid)) return l2.mid;
if (bestBid != null && bestAsk != null) return (bestBid + bestAsk) / 2;
return bestBid ?? bestAsk ?? null;
}
function computeBandDepth(
bids: OrderbookRow[],
asks: OrderbookRow[],
midPrice: number | null,
bandBps: number
): Pick<ComputedDlobDepthBandRow, 'bidUsd' | 'askUsd' | 'bidBase' | 'askBase' | 'imbalance'> {
if (midPrice == null || !Number.isFinite(midPrice) || !(midPrice > 0)) {
return {
bidUsd: null,
askUsd: null,
bidBase: null,
askBase: null,
imbalance: null,
};
}
const minBidPrice = midPrice * (1 - bandBps / 10_000);
const maxAskPrice = midPrice * (1 + bandBps / 10_000);
let bidBase = 0;
let askBase = 0;
let bidUsd = 0;
let askUsd = 0;
for (const level of bids) {
if (!Number.isFinite(level.price) || !Number.isFinite(level.sizeBase)) continue;
if (level.price < minBidPrice) break;
bidBase += level.sizeBase;
bidUsd += levelUsd(level);
}
for (const level of asks) {
if (!Number.isFinite(level.price) || !Number.isFinite(level.sizeBase)) continue;
if (level.price > maxAskPrice) break;
askBase += level.sizeBase;
askUsd += levelUsd(level);
}
const denom = bidUsd + askUsd;
return {
bidUsd,
askUsd,
bidBase,
askBase,
imbalance: denom > 0 ? (bidUsd - askUsd) / denom : null,
};
}
function simulateFill(levels: OrderbookRow[], sizeUsd: number) {
let remainingUsd = sizeUsd;
let filledUsd = 0;
let filledBase = 0;
let worstPrice: number | null = null;
let levelsConsumed = 0;
for (const level of levels) {
const availableUsd = levelUsd(level);
if (!(availableUsd > 0) || !Number.isFinite(level.price) || !(level.price > 0)) continue;
if (remainingUsd <= 0) break;
levelsConsumed += 1;
worstPrice = level.price;
const takeUsd = Math.min(remainingUsd, availableUsd);
filledUsd += takeUsd;
filledBase += takeUsd / level.price;
remainingUsd -= takeUsd;
}
return {
filledUsd,
filledBase,
worstPrice,
levelsConsumed,
vwapPrice: filledBase > 0 ? filledUsd / filledBase : null,
fillPct: sizeUsd > 0 ? (filledUsd / sizeUsd) * 100 : null,
};
}
function impactBps(side: OrderbookSide, midPrice: number | null, vwapPrice: number | null): number | null {
if (midPrice == null || vwapPrice == null) return null;
if (!(midPrice > 0) || !Number.isFinite(vwapPrice)) return null;
if (side === 'buy') return ((vwapPrice / midPrice) - 1) * 10_000;
return (1 - vwapPrice / midPrice) * 10_000;
}
export function computeDepthBandsFromDlobL2(
l2: DlobL2 | null | undefined,
bandList: readonly number[] = DEFAULT_DLOB_DEPTH_BANDS_BPS
): ComputedDlobDepthBandRow[] {
if (!l2?.marketName) return [];
const bids = canonicalBids(l2);
const asks = canonicalAsks(l2);
const bestBid = resolveBestBid(l2, bids);
const bestAsk = resolveBestAsk(l2, asks);
const midPrice = resolveMidPrice(l2, bestBid, bestAsk);
const updatedAt = l2.updatedAt ?? null;
return bandList
.map((bandBps) => Number(bandBps))
.filter((bandBps, idx, arr) => Number.isFinite(bandBps) && bandBps > 0 && arr.indexOf(bandBps) === idx)
.sort((a, b) => a - b)
.map((bandBps) => ({
marketName: l2.marketName,
bandBps,
midPrice,
bestBid,
bestAsk,
updatedAt,
...computeBandDepth(bids, asks, midPrice, bandBps),
}));
}
export function computeSlippageFromDlobL2(
l2: DlobL2 | null | undefined,
sizeList: readonly number[] = DEFAULT_DLOB_SLIPPAGE_SIZES_USD
): ComputedDlobSlippageRow[] {
if (!l2?.marketName) return [];
const bids = canonicalBids(l2);
const asks = canonicalAsks(l2);
const bestBid = resolveBestBid(l2, bids);
const bestAsk = resolveBestAsk(l2, asks);
const midPrice = resolveMidPrice(l2, bestBid, bestAsk);
const updatedAt = l2.updatedAt ?? null;
if (midPrice == null || !Number.isFinite(midPrice) || !(midPrice > 0)) return [];
const sizes = sizeList
.map((sizeUsd) => Number(sizeUsd))
.filter((sizeUsd, idx, arr) => Number.isFinite(sizeUsd) && sizeUsd > 0 && arr.indexOf(sizeUsd) === idx)
.sort((a, b) => a - b);
const rows: ComputedDlobSlippageRow[] = [];
for (const sizeUsd of sizes) {
const buyFill = simulateFill(asks, sizeUsd);
rows.push({
marketName: l2.marketName,
side: 'buy',
sizeUsd,
midPrice,
vwapPrice: buyFill.vwapPrice,
worstPrice: buyFill.worstPrice,
filledUsd: buyFill.filledUsd,
filledBase: buyFill.filledBase,
impactBps: impactBps('buy', midPrice, buyFill.vwapPrice),
levelsConsumed: buyFill.levelsConsumed,
fillPct: buyFill.fillPct,
updatedAt,
});
const sellFill = simulateFill(bids, sizeUsd);
rows.push({
marketName: l2.marketName,
side: 'sell',
sizeUsd,
midPrice,
vwapPrice: sellFill.vwapPrice,
worstPrice: sellFill.worstPrice,
filledUsd: sellFill.filledUsd,
filledBase: sellFill.filledBase,
impactBps: impactBps('sell', midPrice, sellFill.vwapPrice),
levelsConsumed: sellFill.levelsConsumed,
fillPct: sellFill.fillPct,
updatedAt,
});
}
return rows;
}

View File

@@ -0,0 +1,32 @@
const HOT_MARKETS = new Set(['SOL-PERP', 'JTO-PERP', 'ADA-PERP']);
export type OrderbookTable = 'dlob_hot_snapshot_latest' | 'dlob_all_derived_latest';
export type LatestDlobTable = 'dlob_hot_derived_latest' | 'dlob_all_derived_latest';
export function normalizeMarketName(marketName: string): string {
return String(marketName || '').trim().toUpperCase();
}
export function isHotMarket(marketName: string): boolean {
return HOT_MARKETS.has(normalizeMarketName(marketName));
}
export function getOrderbookTables(marketName: string): OrderbookTable[] {
return isHotMarket(marketName) ? ['dlob_hot_snapshot_latest', 'dlob_all_derived_latest'] : ['dlob_all_derived_latest'];
}
export function getOrderbookTable(marketName: string): OrderbookTable {
return getOrderbookTables(marketName)[0];
}
export function getLatestDlobTables(marketName: string): LatestDlobTable[] {
return isHotMarket(marketName) ? ['dlob_hot_derived_latest', 'dlob_all_derived_latest'] : ['dlob_all_derived_latest'];
}
export function getLatestDlobTable(marketName: string): LatestDlobTable {
return getLatestDlobTables(marketName)[0];
}
export function getChartSeriesTable(marketName: string): 'dlob_hot_derived_ts' | 'dlob_all_derived_ts' {
return isHotMarket(marketName) ? 'dlob_hot_derived_ts' : 'dlob_all_derived_ts';
}

View File

@@ -1,133 +1,19 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs'; import { computeDepthBandsFromDlobL2, type ComputedDlobDepthBandRow } from './dlobDerivedMetrics';
import { useDlobL2 } from './useDlobL2';
export type DlobDepthBandRow = { const COMPUTED_DLOB_LEVELS = 512;
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 { export type DlobDepthBandRow = ComputedDlobDepthBandRow;
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( export function useDlobDepthBands(
marketName: string marketName: string
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } { ): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
const [rows, setRows] = useState<DlobDepthBandRow[]>([]); const { l2, connected, error } = useDlobL2(marketName, {
const [connected, setConnected] = useState(false); levels: COMPUTED_DLOB_LEVELS,
const [error, setError] = useState<string | null>(null); grouping: 'raw',
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(); const rows = useMemo(() => computeDepthBandsFromDlobL2(l2), [l2]);
}, [normalizedMarket]);
return { rows, connected, error }; return { rows, connected, error };
} }

View File

@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs'; import { subscribeGraphqlWs } from '../../lib/graphqlWs';
import { getOrderbookTable, isHotMarket } from './dlobSource';
const ORDERBOOK_POLL_MS = 400;
export type OrderbookRow = { export type OrderbookRow = {
price: number; price: number;
@@ -19,12 +22,11 @@ export type DlobL2 = {
updatedAt: string | null; updatedAt: string | null;
}; };
function envNumber(name: string): number | undefined { type ParsedLevel = {
const raw = (import.meta as any).env?.[name]; price: number;
if (raw == null) return undefined; sizeBase: number;
const n = Number(raw); sizeUsd: number;
return Number.isFinite(n) ? n : undefined; };
}
function toNum(v: unknown): number | null { function toNum(v: unknown): number | null {
if (v == null) return null; if (v == null) return null;
@@ -49,38 +51,64 @@ function normalizeJson(v: unknown): unknown {
} }
} }
function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number): Array<{ price: number; size: number }> { const PRICE_PRECISION = 1_000_000;
const BASE_PRECISION = 1_000_000_000;
function toScaledNum(v: unknown, precision: number): number | null {
const n = toNum(v);
if (n == null) return null;
return n / precision;
}
function parseNormalizedLevels(raw: unknown): ParsedLevel[] {
const v = normalizeJson(raw); const v = normalizeJson(raw);
if (!Array.isArray(v)) return []; if (!Array.isArray(v)) return [];
const out: Array<{ price: number; size: number }> = []; const out: ParsedLevel[] = [];
for (const item of v) { for (const item of v) {
const priceInt = toNum((item as any)?.price); const price = toNum((item as any)?.price);
const sizeInt = toNum((item as any)?.size); const sizeBase = toNum((item as any)?.sizeBase ?? (item as any)?.size);
if (priceInt == null || sizeInt == null) continue; if (price == null || sizeBase == null) continue;
if (!Number.isFinite(price) || !Number.isFinite(sizeBase)) continue;
const price = priceInt / pricePrecision; out.push({ price, sizeBase, sizeUsd: sizeBase * price });
const size = sizeInt / basePrecision;
if (!Number.isFinite(price) || !Number.isFinite(size)) continue;
out.push({ price, size });
} }
return out; return out;
} }
function withTotals(levels: Array<{ price: number; sizeBase: number }>): OrderbookRow[] { function parseRawHotLevels(raw: unknown): ParsedLevel[] {
const v = normalizeJson(raw);
if (!Array.isArray(v)) return [];
const out: ParsedLevel[] = [];
for (const item of v) {
const price = toScaledNum((item as any)?.price, PRICE_PRECISION);
const sizeBase = toScaledNum((item as any)?.size, BASE_PRECISION);
if (price == null || sizeBase == null) continue;
if (!Number.isFinite(price) || !Number.isFinite(sizeBase)) continue;
out.push({ price, sizeBase, sizeUsd: sizeBase * price });
}
return out;
}
function limitHotWindow(levels: ParsedLevel[], visibleLevels: number): ParsedLevel[] {
const cap = Math.max(visibleLevels * 4, 24);
return levels.slice(0, cap);
}
function withTotals(levels: ParsedLevel[]): OrderbookRow[] {
let totalBase = 0; let totalBase = 0;
let totalUsd = 0; let totalUsd = 0;
return levels.map((l) => { return levels.map((l) => {
const sizeUsd = l.sizeBase * l.price;
totalBase += l.sizeBase; totalBase += l.sizeBase;
totalUsd += sizeUsd; totalUsd += l.sizeUsd;
return { return {
price: l.price, price: l.price,
sizeBase: l.sizeBase, sizeBase: l.sizeBase,
sizeUsd, sizeUsd: l.sizeUsd,
totalBase, totalBase,
totalUsd, totalUsd,
}; };
@@ -89,18 +117,28 @@ function withTotals(levels: Array<{ price: number; sizeBase: number }>): Orderbo
type HasuraDlobL2Row = { type HasuraDlobL2Row = {
market_name: string; market_name: string;
bids?: unknown; bids_norm?: unknown;
asks?: unknown; asks_norm?: unknown;
best_bid_price?: unknown;
best_ask_price?: unknown;
mid_price?: unknown;
updated_at?: string | null; updated_at?: string | null;
}; };
type SubscriptionData = { type HasuraHotSnapshotRow = {
dlob_l2_latest: HasuraDlobL2Row[]; market_name: string;
bids?: unknown;
asks?: unknown;
best_bid_price_raw?: unknown;
best_ask_price_raw?: unknown;
updated_at?: string | null;
}; };
type SubscriptionData = Record<string, Array<HasuraDlobL2Row | HasuraHotSnapshotRow> | undefined>;
export function useDlobL2( export function useDlobL2(
marketName: string, marketName: string,
opts?: { levels?: number } opts?: { levels?: number; grouping?: string }
): { l2: DlobL2 | null; connected: boolean; error: string | null } { ): { l2: DlobL2 | null; connected: boolean; error: string | null } {
const [l2, setL2] = useState<DlobL2 | null>(null); const [l2, setL2] = useState<DlobL2 | null>(null);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
@@ -108,9 +146,54 @@ export function useDlobL2(
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]); const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]); const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]);
const grouping = useMemo(() => String(opts?.grouping || 'drift').trim().toLowerCase(), [opts?.grouping]);
const pricePrecision = useMemo(() => envNumber('VITE_DLOB_PRICE_PRECISION') ?? 1_000_000, []); function resolveDriftGroupingStep(referencePrice: number | null): number {
const basePrecision = useMemo(() => envNumber('VITE_DLOB_BASE_PRECISION') ?? 1_000_000_000, []); const px = referencePrice == null || !Number.isFinite(referencePrice) ? 0 : Math.abs(referencePrice);
if (px >= 1000) return 0.1;
if (px >= 100) return 0.01;
if (px >= 10) return 0.001;
if (px >= 1) return 0.0001;
if (px >= 0.1) return 0.00001;
return 0.000001;
}
function resolveGroupingStep(mode: string, referencePrice: number | null): number | null {
if (!mode || mode === 'raw' || mode === 'none' || mode === 'off') return null;
if (mode === 'drift') return resolveDriftGroupingStep(referencePrice);
const parsed = Number(mode);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function groupLevelsByStep(
side: 'bid' | 'ask',
input: ParsedLevel[],
step: number | null
): ParsedLevel[] {
if (step == null || !Number.isFinite(step) || step <= 0) return input;
const stepMicros = Math.max(1, Math.round(step * PRICE_PRECISION));
const grouped = new Map<number, { sizeBase: number; sizeUsd: number }>();
for (const level of input) {
const priceMicros = Math.round(level.price * PRICE_PRECISION);
const bucketMicros =
side === 'bid'
? Math.floor(priceMicros / stepMicros) * stepMicros
: Math.ceil(priceMicros / stepMicros) * stepMicros;
const existing = grouped.get(bucketMicros) || { sizeBase: 0, sizeUsd: 0 };
existing.sizeBase += level.sizeBase;
existing.sizeUsd += level.sizeUsd;
grouped.set(bucketMicros, existing);
}
return Array.from(grouped.entries())
.map(([bucketMicros, totals]) => ({
price: bucketMicros / PRICE_PRECISION,
sizeBase: totals.sizeBase,
sizeUsd: totals.sizeUsd,
}))
.sort((a, b) => (side === 'bid' ? b.price - a.price : a.price - b.price));
}
useEffect(() => { useEffect(() => {
if (!normalizedMarket) { if (!normalizedMarket) {
@@ -121,13 +204,21 @@ export function useDlobL2(
} }
setError(null); setError(null);
const table = getOrderbookTable(normalizedMarket);
const hotMarket = isHotMarket(normalizedMarket);
const query = ` const query = `
subscription DlobL2($market: String!) { subscription DlobL2($market: String!) {
dlob_l2_latest(where: {market_name: {_eq: $market}}, limit: 1) { ${table}(
where: {
market_name: {_eq: $market}
is_indicative: {_eq: false}
${hotMarket ? 'snapshot_kind: {_eq: "orderbook_l2"}' : ''}
}
limit: 1
) {
market_name market_name
bids ${hotMarket ? 'bids asks best_bid_price_raw best_ask_price_raw' : 'bids_norm asks_norm best_bid_price best_ask_price mid_price'}
asks
updated_at updated_at
} }
} }
@@ -136,32 +227,63 @@ export function useDlobL2(
const sub = subscribeGraphqlWs<SubscriptionData>({ const sub = subscribeGraphqlWs<SubscriptionData>({
query, query,
variables: { market: normalizedMarket }, variables: { market: normalizedMarket },
pollMs: ORDERBOOK_POLL_MS,
onStatus: ({ connected }) => setConnected(connected), onStatus: ({ connected }) => setConnected(connected),
onError: (e) => setError(e), onError: (e) => setError(e),
onData: (data) => { onData: (data) => {
const row = data?.dlob_l2_latest?.[0]; const row = data?.[table]?.[0] as HasuraDlobL2Row | HasuraHotSnapshotRow | undefined;
if (!row?.market_name) return; if (!row?.market_name) {
setL2(null);
return;
}
const bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision) const bestBidRaw = hotMarket
.slice() ? toScaledNum((row as HasuraHotSnapshotRow).best_bid_price_raw, PRICE_PRECISION)
.sort((a, b) => b.price - a.price) : toNum((row as HasuraDlobL2Row).best_bid_price);
.slice(0, levels) const bestAskRaw = hotMarket
.map((l) => ({ price: l.price, sizeBase: l.size })); ? toScaledNum((row as HasuraHotSnapshotRow).best_ask_price_raw, PRICE_PRECISION)
: toNum((row as HasuraDlobL2Row).best_ask_price);
const referencePrice =
bestBidRaw != null && bestAskRaw != null ? (bestBidRaw + bestAskRaw) / 2 : bestBidRaw ?? bestAskRaw ?? null;
const groupingStep = resolveGroupingStep(grouping, referencePrice);
const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision) const bidsSource = hotMarket
? limitHotWindow(parseRawHotLevels((row as HasuraHotSnapshotRow).bids), levels)
: parseNormalizedLevels((row as HasuraDlobL2Row).bids_norm);
const asksSource = hotMarket
? limitHotWindow(parseRawHotLevels((row as HasuraHotSnapshotRow).asks), levels)
: parseNormalizedLevels((row as HasuraDlobL2Row).asks_norm);
const bidsSorted = groupLevelsByStep(
'bid',
bidsSource,
groupingStep
)
.slice() .slice()
.sort((a, b) => a.price - b.price)
.slice(0, levels) .slice(0, levels)
.map((l) => ({ price: l.price, sizeBase: l.size })); .map((l) => ({ price: l.price, sizeBase: l.sizeBase, sizeUsd: l.sizeUsd }));
const asksSorted = groupLevelsByStep(
'ask',
asksSource,
groupingStep
)
.slice()
.slice(0, levels)
.map((l) => ({ price: l.price, sizeBase: l.sizeBase, sizeUsd: l.sizeUsd }));
// We compute totals from best -> worse. // We compute totals from best -> worse.
// For UI we display asks with best ask closest to mid (at the bottom), so we reverse. // For UI we display asks with best ask closest to mid (at the bottom), so we reverse.
const bids = withTotals(bidsSorted); const bids = withTotals(bidsSorted);
const asks = withTotals(asksSorted).slice().reverse(); const asks = withTotals(asksSorted).slice().reverse();
const bestBid = bidsSorted.length ? bidsSorted[0].price : null; const bestBid = bestBidRaw ?? (bidsSorted.length ? bidsSorted[0].price : null);
const bestAsk = asksSorted.length ? asksSorted[0].price : null; const bestAsk = bestAskRaw ?? (asksSorted.length ? asksSorted[0].price : null);
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null; const mid = hotMarket
? bestBid != null && bestAsk != null
? (bestBid + bestAsk) / 2
: null
: toNum((row as HasuraDlobL2Row).mid_price) ?? (bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null);
setL2({ setL2({
marketName: row.market_name, marketName: row.market_name,
@@ -176,7 +298,7 @@ export function useDlobL2(
}); });
return () => sub.unsubscribe(); return () => sub.unsubscribe();
}, [normalizedMarket, levels, pricePrecision, basePrecision]); }, [grouping, normalizedMarket, levels]);
return { l2, connected, error }; return { l2, connected, error };
} }

View File

@@ -1,148 +1,17 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs'; import { computeSlippageFromDlobL2, type ComputedDlobSlippageRow } from './dlobDerivedMetrics';
import { useDlobL2 } from './useDlobL2';
export type DlobSlippageRow = { const COMPUTED_DLOB_LEVELS = 512;
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 { export type DlobSlippageRow = ComputedDlobSlippageRow;
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 } { export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
const [rows, setRows] = useState<DlobSlippageRow[]>([]); const { l2, connected, error } = useDlobL2(marketName, {
const [connected, setConnected] = useState(false); levels: COMPUTED_DLOB_LEVELS,
const [error, setError] = useState<string | null>(null); grouping: 'raw',
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(); const rows = useMemo(() => computeSlippageFromDlobL2(l2), [l2]);
}, [normalizedMarket]);
return { rows, connected, error }; return { rows, connected, error };
} }

View File

@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs'; import { subscribeGraphqlWs } from '../../lib/graphqlWs';
import { getLatestDlobTable } from './dlobSource';
const ORDERBOOK_STATS_POLL_MS = 400;
export type DlobStats = { export type DlobStats = {
marketName: string; marketName: string;
@@ -32,24 +35,23 @@ function toNum(v: unknown): number | null {
type HasuraDlobStatsRow = { type HasuraDlobStatsRow = {
market_name: string; market_name: string;
mark_price?: string | null; mark_price?: unknown;
oracle_price?: string | null; oracle_price?: unknown;
best_bid_price?: string | null; best_bid_price?: unknown;
best_ask_price?: string | null; best_ask_price?: unknown;
mid_price?: string | null; mid_price?: unknown;
spread_abs?: string | null; spread_quote?: unknown;
spread_bps?: string | null; spread_bps?: unknown;
depth_bid_base?: string | null; depth_bid_base?: unknown;
depth_ask_base?: string | null; depth_ask_base?: unknown;
depth_bid_usd?: string | null; depth_bid_quote?: unknown;
depth_ask_usd?: string | null; depth_ask_quote?: unknown;
imbalance?: string | null; imbalance?: unknown;
updated_at?: string | null; updated_at?: string | null;
is_indicative?: boolean | null;
}; };
type SubscriptionData = { type SubscriptionData = Record<string, HasuraDlobStatsRow[] | undefined>;
dlob_stats_latest: HasuraDlobStatsRow[];
};
export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } { export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } {
const [stats, setStats] = useState<DlobStats | null>(null); const [stats, setStats] = useState<DlobStats | null>(null);
@@ -67,24 +69,29 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
} }
setError(null); setError(null);
const table = getLatestDlobTable(normalizedMarket);
const query = ` const query = `
subscription DlobStats($market: String!) { subscription DlobStats($market: String!) {
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) { ${table}(
where: {market_name: {_eq: $market}, is_indicative: {_eq: false}}
limit: 1
) {
market_name market_name
mark_price mark_price
oracle_price oracle_price
best_bid_price best_bid_price
best_ask_price best_ask_price
mid_price mid_price
spread_abs spread_quote
spread_bps spread_bps
depth_bid_base depth_bid_base
depth_ask_base depth_ask_base
depth_bid_usd depth_bid_quote
depth_ask_usd depth_ask_quote
imbalance imbalance
updated_at updated_at
is_indicative
} }
} }
`; `;
@@ -92,11 +99,15 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
const sub = subscribeGraphqlWs<SubscriptionData>({ const sub = subscribeGraphqlWs<SubscriptionData>({
query, query,
variables: { market: normalizedMarket }, variables: { market: normalizedMarket },
pollMs: ORDERBOOK_STATS_POLL_MS,
onStatus: ({ connected }) => setConnected(connected), onStatus: ({ connected }) => setConnected(connected),
onError: (e) => setError(e), onError: (e) => setError(e),
onData: (data) => { onData: (data) => {
const row = data?.dlob_stats_latest?.[0]; const row = data?.[table]?.[0];
if (!row?.market_name) return; if (!row?.market_name) {
setStats(null);
return;
}
setStats({ setStats({
marketName: row.market_name, marketName: row.market_name,
markPrice: toNum(row.mark_price), markPrice: toNum(row.mark_price),
@@ -104,12 +115,12 @@ export function useDlobStats(marketName: string): { stats: DlobStats | null; con
bestBid: toNum(row.best_bid_price), bestBid: toNum(row.best_bid_price),
bestAsk: toNum(row.best_ask_price), bestAsk: toNum(row.best_ask_price),
mid: toNum(row.mid_price), mid: toNum(row.mid_price),
spreadAbs: toNum(row.spread_abs), spreadAbs: toNum(row.spread_quote),
spreadBps: toNum(row.spread_bps), spreadBps: toNum(row.spread_bps),
depthBidBase: toNum(row.depth_bid_base), depthBidBase: toNum(row.depth_bid_base),
depthAskBase: toNum(row.depth_ask_base), depthAskBase: toNum(row.depth_ask_base),
depthBidUsd: toNum(row.depth_bid_usd), depthBidUsd: toNum(row.depth_bid_quote),
depthAskUsd: toNum(row.depth_ask_usd), depthAskUsd: toNum(row.depth_ask_quote),
imbalance: toNum(row.imbalance), imbalance: toNum(row.imbalance),
updatedAt: row.updated_at ?? null, updatedAt: row.updated_at ?? null,
}); });

View File

@@ -9,6 +9,7 @@ export type TickerItem = {
type Props = { type Props = {
items: TickerItem[]; items: TickerItem[];
className?: string;
}; };
function formatPct(v: number): string { function formatPct(v: number): string {
@@ -16,9 +17,9 @@ function formatPct(v: number): string {
return `${s}${v.toFixed(2)}%`; return `${s}${v.toFixed(2)}%`;
} }
export default function TickerBar({ items }: Props) { export default function TickerBar({ items, className }: Props) {
return ( return (
<div className="tickerBar"> <div className={['tickerBar', className].filter(Boolean).join(' ')}>
{items.map((t) => ( {items.map((t) => (
<div key={t.key} className={['ticker', t.active ? 'ticker--active' : ''].filter(Boolean).join(' ')}> <div key={t.key} className={['ticker', t.active ? 'ticker--active' : ''].filter(Boolean).join(' ')}>
<span className="ticker__label">{t.label}</span> <span className="ticker__label">{t.label}</span>
@@ -28,4 +29,3 @@ export default function TickerBar({ items }: Props) {
</div> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
type Props = { type Props = {
header?: ReactNode; header?: ReactNode;
@@ -9,10 +10,57 @@ type Props = {
}; };
export default function AppShell({ header, top, main, sidebar, rightbar }: Props) { export default function AppShell({ header, top, main, sidebar, rightbar }: Props) {
const headerRef = useRef<HTMLDivElement | null>(null);
const topRef = useRef<HTMLDivElement | null>(null);
const [headerHeight, setHeaderHeight] = useState(0);
const [topHeight, setTopHeight] = useState(0);
useEffect(() => {
const observers: ResizeObserver[] = [];
const observeHeight = (
element: HTMLDivElement | null,
setHeight: (height: number) => void
) => {
if (!element) {
setHeight(0);
return;
}
const update = () => setHeight(Math.ceil(element.getBoundingClientRect().height));
update();
const observer = new ResizeObserver(update);
observer.observe(element);
observers.push(observer);
};
observeHeight(headerRef.current, setHeaderHeight);
observeHeight(topRef.current, setTopHeight);
return () => {
for (const observer of observers) observer.disconnect();
};
}, [header, top]);
const shellStyle = {
['--shell-header-height' as any]: `${headerHeight}px`,
['--shell-top-height' as any]: `${topHeight}px`,
['--shell-top-offset' as any]: `${headerHeight + topHeight}px`,
} as CSSProperties;
return ( return (
<div className="shell"> <div className="shell" style={shellStyle}>
{header ? <div className="shellHeader">{header}</div> : null} {header ? (
{top ? <div className="shellTop">{top}</div> : null} <div className="shellHeader" ref={headerRef}>
{header}
</div>
) : null}
{top ? (
<div className="shellTop" ref={topRef}>
{top}
</div>
) : null}
<div className="shellBody"> <div className="shellBody">
<div className="shellMain">{main}</div> <div className="shellMain">{main}</div>
{sidebar ? <div className="shellSidebar">{sidebar}</div> : null} {sidebar ? <div className="shellSidebar">{sidebar}</div> : null}

View File

@@ -1,13 +1,62 @@
import { useEffect, useMemo, useState } from 'react';
import Button from '../ui/Button'; import Button from '../ui/Button';
type Props = { type Props = {
user: string; user: string;
theme: 'day' | 'night';
onResetView?: (() => void) | null;
onToggleTheme: () => void;
onLogout: () => void; onLogout: () => void;
}; };
export default function AuthStatus({ user, onLogout }: Props) { export default function AuthStatus({ user, theme, onResetView, onToggleTheme, onLogout }: Props) {
const formatter = useMemo(
() =>
new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
[]
);
const [nowLabel, setNowLabel] = useState(() => formatter.format(new Date()));
useEffect(() => {
const tick = () => setNowLabel(formatter.format(new Date()));
tick();
const timer = window.setInterval(tick, 1000);
return () => window.clearInterval(timer);
}, [formatter]);
return ( return (
<div className="authStatus"> <div className="authStatus">
<Button
size="sm"
variant="ghost"
type="button"
title="Reset View (Alt+R)"
aria-keyshortcuts="Alt+R"
onClick={onResetView ?? undefined}
disabled={!onResetView}
>
Reset View
</Button>
<button
className={`themeSwitch themeSwitch--${theme}`}
type="button"
role="switch"
aria-checked={theme === 'night'}
aria-label={theme === 'day' ? 'Switch to Night theme' : 'Switch to Day theme'}
title={theme === 'day' ? 'Switch to Night theme' : 'Switch to Day theme'}
onClick={onToggleTheme}
>
<span className="themeSwitch__label themeSwitch__label--day">Day</span>
<span className="themeSwitch__label themeSwitch__label--night">Night</span>
<span className="themeSwitch__thumb" aria-hidden="true" />
</button>
<div className="authStatus__clock" aria-label="Bieżący czas">
{nowLabel}
</div>
<div className="authStatus__user" aria-label="Zalogowany użytkownik"> <div className="authStatus__user" aria-label="Zalogowany użytkownik">
<div className="authStatus__userLabel">Zalogowany</div> <div className="authStatus__userLabel">Zalogowany</div>
<div className="authStatus__userName">{user}</div> <div className="authStatus__userName">{user}</div>

View File

@@ -11,6 +11,21 @@ type LoginResponse = {
error?: string; error?: string;
}; };
type WhoamiResponse = {
ok?: boolean;
user?: string | null;
};
async function readSessionUser(): Promise<string | null> {
const res = await fetch('/whoami', {
cache: 'no-store',
credentials: 'same-origin',
});
const json = (await res.json().catch(() => null)) as WhoamiResponse | null;
const user = typeof json?.user === 'string' ? json.user.trim() : '';
return user || null;
}
export default function LoginScreen({ onLoggedIn }: Props) { export default function LoginScreen({ onLoggedIn }: Props) {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -26,17 +41,18 @@ export default function LoginScreen({ onLoggedIn }: Props) {
const res = await fetch('/auth/login', { const res = await fetch('/auth/login', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
const json = (await res.json().catch(() => null)) as LoginResponse | null; const json = (await res.json().catch(() => null)) as LoginResponse | null;
const ok = Boolean(res.ok && json?.ok); const ok = Boolean(res.ok && json?.ok);
if (!ok) throw new Error(json?.error || 'invalid_credentials'); if (!ok) throw new Error(json?.error || 'invalid_credentials');
const u = typeof json?.user === 'string' ? json.user.trim() : ''; const u = await readSessionUser();
if (!u) throw new Error('bad_response'); if (!u) throw new Error('bad_response');
setPassword(''); setPassword('');
onLoggedIn(u); onLoggedIn(u);
} catch { } catch {
setError('Nieprawidłowy login lub hasło.'); setError('Logowanie nie utworzyło sesji. Sprawdź dane albo konfigurację dev proxy.');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -87,4 +103,3 @@ export default function LoginScreen({ onLoggedIn }: Props) {
</div> </div>
); );
} }

View File

@@ -24,7 +24,10 @@ export default function TopNav({ active = 'trade', onSelect, rightSlot }: Props)
<div className="topNav__left"> <div className="topNav__left">
<div className="topNav__brand" aria-label="Trade"> <div className="topNav__brand" aria-label="Trade">
<div className="topNav__brandMark" aria-hidden="true" /> <div className="topNav__brandMark" aria-hidden="true" />
<div className="topNav__brandText">
<div className="topNav__brandName">Trade</div> <div className="topNav__brandName">Trade</div>
<div className="topNav__brandMeta">by mpabi</div>
</div>
</div> </div>
<nav className="topNav__menu" aria-label="Primary"> <nav className="topNav__menu" aria-label="Primary">
{navItems.map((it) => ( {navItems.map((it) => (

View File

@@ -1,3 +1,6 @@
import { getChartSeriesTable, getLatestDlobTable } from '../features/market/dlobSource';
import { bollingerBands, ema, macd, rsi, sma } from './indicators';
export type Candle = { export type Candle = {
time: number; // unix seconds time: number; // unix seconds
open: number; open: number;
@@ -25,21 +28,289 @@ export type ChartIndicators = {
macd?: { macd: SeriesPoint[]; signal: SeriesPoint[] }; macd?: { macd: SeriesPoint[]; signal: SeriesPoint[] };
}; };
export type ChartResponse = { type GraphqlError = { message: string };
ok: boolean; type HasuraDerivedTsRow = {
symbol?: string; event_ts: string;
source?: string | null; oracle_price?: number | string | null;
tf?: string; mark_price?: number | string | null;
bucketSeconds?: number; mid_price?: number | string | null;
candles?: Candle[]; };
indicators?: ChartIndicators; type HasuraLatestDlobRow = {
error?: string; updated_at?: string | null;
oracle_price?: number | string | null;
mark_price?: number | string | null;
mid_price?: number | string | null;
}; };
function getApiBaseUrl(): string { function readEnv(name: string): string | undefined {
const v = (import.meta as any).env?.VITE_API_URL; const viteValue = (import.meta as any).env?.[name];
if (v) return String(v); if (viteValue != null && String(viteValue).trim()) return String(viteValue);
return '/api'; const nodeValue = (globalThis as any)?.process?.env?.[name];
if (nodeValue != null && String(nodeValue).trim()) return String(nodeValue);
return undefined;
}
function getHasuraUrl(): string {
const v = readEnv('VITE_HASURA_URL');
return v ? String(v) : '/graphql';
}
function getHasuraAuthHeaders(): Record<string, string> | undefined {
const token = readEnv('VITE_HASURA_AUTH_TOKEN');
if (token) return { authorization: `Bearer ${String(token)}` };
const secret = readEnv('VITE_HASURA_ADMIN_SECRET');
if (secret) return { 'x-hasura-admin-secret': String(secret) };
return undefined;
}
function parseTimeframeToSeconds(tf: string): number {
const raw = String(tf || '').trim();
const match = raw.match(/^(\d+)([smhdSMHD])$/);
if (!match) return 60;
const value = Number(match[1]);
const unit = match[2].toLowerCase();
if (!Number.isFinite(value) || value <= 0) return 60;
if (unit === 's') return value;
if (unit === 'm') return value * 60;
if (unit === 'h') return value * 3600;
if (unit === 'd') return value * 86400;
return 60;
}
function toSeries(times: number[], values: Array<number | null>) {
return times.map((time, i) => ({ time, value: values[i] ?? null }));
}
function buildIndicators(candles: Candle[]): ChartIndicators {
const times = candles.map((c) => c.time);
const closes = candles.map((c) => c.close);
const oracleValues = candles.map((c) => (c.oracle == null ? null : c.oracle));
const sma20 = sma(closes, 20);
const ema20 = ema(closes, 20);
const bb20 = bollingerBands(closes, 20, 2);
const rsi14 = rsi(closes, 14);
const macdOut = macd(closes, 12, 26, 9);
return {
oracle: toSeries(times, oracleValues),
sma20: toSeries(times, sma20),
ema20: toSeries(times, ema20),
bb20: {
upper: toSeries(times, bb20.upper),
lower: toSeries(times, bb20.lower),
mid: toSeries(times, bb20.mid),
},
rsi14: toSeries(times, rsi14),
macd: {
macd: toSeries(times, macdOut.macd),
signal: toSeries(times, macdOut.signal),
},
};
}
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 aggregateRowsToCandles(
rows: HasuraDerivedTsRow[],
bucketSeconds: number,
options?: { fillGaps?: boolean; limit?: number }
): Candle[] {
const buckets = new Map<number, Candle>();
const sortedRows = rows.slice().sort((a, b) => Date.parse(a.event_ts) - Date.parse(b.event_ts));
for (const row of sortedRows) {
const tsSec = Math.floor(Date.parse(row.event_ts) / 1000);
if (!Number.isFinite(tsSec)) continue;
const bucket = tsSec - (tsSec % bucketSeconds);
const oracle = toNum(row.oracle_price);
const close = toNum(row.mark_price) ?? toNum(row.mid_price) ?? oracle;
if (close == null) continue;
const existing = buckets.get(bucket);
if (!existing) {
buckets.set(bucket, {
time: bucket,
open: close,
high: close,
low: close,
close,
volume: 1,
oracle,
});
continue;
}
existing.high = Math.max(existing.high, close);
existing.low = Math.min(existing.low, close);
existing.close = close;
existing.volume = (existing.volume || 0) + 1;
existing.oracle = oracle ?? existing.oracle ?? null;
}
const sorted = Array.from(buckets.values()).sort((a, b) => a.time - b.time);
if (!sorted.length) return [];
if (!options?.fillGaps) {
return options?.limit ? sorted.slice(-options.limit) : sorted;
}
const tail = options?.limit ? sorted.slice(-options.limit) : sorted;
const candles: Candle[] = [];
const firstBucket = tail[0]!.time;
const lastBucket = tail[tail.length - 1]!.time;
const byTime = new Map(tail.map((c) => [c.time, c]));
let prev = tail[0]!;
for (let bucket = firstBucket; bucket <= lastBucket; bucket += bucketSeconds) {
const current = byTime.get(bucket);
if (current) {
candles.push(current);
prev = current;
continue;
}
candles.push({
time: bucket,
open: prev.close,
high: prev.close,
low: prev.close,
close: prev.close,
volume: 0,
oracle: prev.oracle ?? null,
});
}
return candles;
}
export function mergeCandles(prev: Candle[], next: Candle[]): Candle[] {
const map = new Map<number, Candle>();
for (const candle of prev) map.set(candle.time, candle);
for (const candle of next) map.set(candle.time, candle);
return Array.from(map.values()).sort((a, b) => a.time - b.time);
}
export function applyLatestRowToCandles(
candles: Candle[],
row: HasuraLatestDlobRow,
bucketSeconds: number
): Candle[] {
const tsSec = Math.floor(Date.parse(String(row.updated_at || '')) / 1000);
if (!Number.isFinite(tsSec)) return candles;
const bucket = tsSec - (tsSec % bucketSeconds);
const oracle = toNum(row.oracle_price);
const close = toNum(row.mark_price) ?? toNum(row.mid_price) ?? oracle;
if (close == null) return candles;
const next = candles.slice();
const last = next[next.length - 1];
if (!last || bucket > last.time) {
next.push({
time: bucket,
open: last?.close ?? close,
high: close,
low: close,
close,
volume: 1,
oracle,
});
return next;
}
if (bucket < last.time) return candles;
last.high = Math.max(last.high, close);
last.low = Math.min(last.low, close);
last.close = close;
last.volume = (last.volume || 0) + 1;
last.oracle = oracle ?? last.oracle ?? null;
return next;
}
export function buildChartIndicators(candles: Candle[]): ChartIndicators {
return buildIndicators(candles);
}
export function getChartLiveTable(marketName: string) {
return getLatestDlobTable(marketName);
}
async function fetchChartFromHasura(params: {
symbol: string;
source?: string;
tf: string;
limit: number;
beforeTime?: number;
signal?: AbortSignal;
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
const bucketSeconds = parseTimeframeToSeconds(params.tf);
const table = getChartSeriesTable(params.symbol);
const maxPoints = Math.min(200_000, Math.max(5_000, params.limit * Math.max(1, bucketSeconds) * 3));
const sourceFilter = params.source?.trim();
const beforeTime = Number.isFinite(params.beforeTime) ? Number(params.beforeTime) : null;
const beforeIso = beforeTime == null ? null : new Date(beforeTime * 1000).toISOString();
const query = `
query ChartSeries($symbol: String!, $limit: Int!${sourceFilter ? ', $source: String!' : ''}${beforeIso ? ', $before: timestamptz!' : ''}) {
${table}(
limit: $limit
order_by: {event_ts: desc}
where: {
market_name: {_eq: $symbol}
is_indicative: {_eq: false}
${sourceFilter ? 'source: {_eq: $source}' : ''}
${beforeIso ? 'event_ts: {_lt: $before}' : ''}
}
) {
event_ts
oracle_price
mark_price
mid_price
}
}
`;
const res = await fetch(getHasuraUrl(), {
method: 'POST',
credentials: 'same-origin',
signal: params.signal,
headers: {
'content-type': 'application/json',
...(getHasuraAuthHeaders() || {}),
},
body: JSON.stringify({
query,
variables: {
symbol: params.symbol,
limit: maxPoints,
...(sourceFilter ? { source: sourceFilter } : {}),
...(beforeIso ? { before: beforeIso } : {}),
},
}),
});
const text = await res.text();
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
const json = JSON.parse(text) as { data?: Record<string, HasuraDerivedTsRow[]>; errors?: GraphqlError[] };
if (Array.isArray(json.errors) && json.errors.length) {
throw new Error(json.errors.map((e) => e.message).join(' | '));
}
const rows = json.data?.[table] || [];
const candles = aggregateRowsToCandles(rows, bucketSeconds, { fillGaps: beforeIso == null, limit: params.limit });
return {
candles,
indicators: buildIndicators(candles),
meta: { tf: params.tf, bucketSeconds },
};
} }
export async function fetchChart(params: { export async function fetchChart(params: {
@@ -47,51 +318,8 @@ export async function fetchChart(params: {
source?: string; source?: string;
tf: string; tf: string;
limit: number; limit: number;
beforeTime?: number;
signal?: AbortSignal; 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(); return await fetchChartFromHasura(params);
const u = new URL(base, window.location.origin);
u.pathname = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/$/, '') + '/v1/chart' : '/v1/chart';
u.searchParams.set('symbol', params.symbol);
u.searchParams.set('tf', params.tf);
u.searchParams.set('limit', String(params.limit));
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
const res = await fetch(u.toString(), { signal: params.signal });
const text = await res.text();
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
const json = JSON.parse(text) as ChartResponse;
if (!json.ok) throw new Error(json.error || 'API: error');
return {
candles: (json.candles || []).map((c) => ({
...c,
time: Number(c.time),
open: Number(c.open),
high: Number(c.high),
low: Number(c.low),
close: Number(c.close),
volume: c.volume == null ? undefined : Number(c.volume),
oracle: c.oracle == null ? null : Number(c.oracle),
flow:
(c as any)?.flow && typeof (c as any).flow === 'object'
? {
up: Number((c as any).flow.up),
down: Number((c as any).flow.down),
flat: Number((c as any).flow.flat),
}
: undefined,
flowRows: Array.isArray((c as any)?.flowRows)
? (c as any).flowRows.map((x: any) => Number(x))
: Array.isArray((c as any)?.flow_rows)
? (c as any).flow_rows.map((x: any) => Number(x))
: undefined,
flowMoves: Array.isArray((c as any)?.flowMoves)
? (c as any).flowMoves.map((x: any) => Number(x))
: Array.isArray((c as any)?.flow_moves)
? (c as any).flow_moves.map((x: any) => Number(x))
: undefined,
})),
indicators: json.indicators || {},
meta: { tf: String(json.tf || params.tf), bucketSeconds: Number(json.bucketSeconds || 0) },
};
} }

View File

@@ -1,4 +1,5 @@
type HeadersMap = Record<string, string>; type HeadersMap = Record<string, string>;
type GraphqlError = { message: string };
type SubscribeParams<T> = { type SubscribeParams<T> = {
query: string; query: string;
@@ -6,12 +7,29 @@ type SubscribeParams<T> = {
onData: (data: T) => void; onData: (data: T) => void;
onError?: (err: string) => void; onError?: (err: string) => void;
onStatus?: (s: { connected: boolean }) => void; onStatus?: (s: { connected: boolean }) => void;
pollMs?: number;
}; };
function envString(name: string): string | undefined { function envString(name: string): string | undefined {
const v = (import.meta as any).env?.[name]; const viteValue = (import.meta as any).env?.[name];
const s = v == null ? '' : String(v).trim(); const viteString = viteValue == null ? '' : String(viteValue).trim();
return s ? s : undefined; if (viteString) return viteString;
const nodeValue = (globalThis as any)?.process?.env?.[name];
const nodeString = nodeValue == null ? '' : String(nodeValue).trim();
return nodeString || undefined;
}
function envBool(name: string): boolean {
const v = envString(name);
if (!v) return false;
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase());
}
function envInt(name: string, fallback: number): number {
const raw = envString(name);
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
} }
function resolveGraphqlHttpUrl(): string { function resolveGraphqlHttpUrl(): string {
@@ -43,30 +61,13 @@ function resolveGraphqlWsUrl(): string {
} }
function resolveAuthHeaders(): HeadersMap | undefined { function resolveAuthHeaders(): HeadersMap | undefined {
const rawToken = envString('VITE_HASURA_AUTH_TOKEN'); const token = envString('VITE_HASURA_AUTH_TOKEN');
if (rawToken) { if (token) return { authorization: `Bearer ${token}` };
const bearer = normalizeBearerToken(rawToken);
if (bearer) return { authorization: `Bearer ${bearer}` };
}
const secret = envString('VITE_HASURA_ADMIN_SECRET'); const secret = envString('VITE_HASURA_ADMIN_SECRET');
if (secret) return { 'x-hasura-admin-secret': secret }; if (secret) return { 'x-hasura-admin-secret': secret };
return undefined; 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 WsMessage =
| { type: 'connection_ack' | 'ka' | 'complete' } | { type: 'connection_ack' | 'ka' | 'complete' }
| { type: 'connection_error'; payload?: any } | { type: 'connection_error'; payload?: any }
@@ -77,13 +78,59 @@ export type SubscriptionHandle = {
unsubscribe: () => void; unsubscribe: () => void;
}; };
export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onStatus }: SubscribeParams<T>): SubscriptionHandle { async function fetchGraphqlOnce<T>(
httpUrl: string,
headers: HeadersMap | undefined,
query: string,
variables: Record<string, unknown> | undefined
): Promise<T> {
const operation = query.replace(/^\s*subscription\b/, 'query');
const res = await fetch(httpUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
...(headers || {}),
},
body: JSON.stringify({ query: operation, variables: variables ?? {} }),
});
const text = await res.text();
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
let json: { data?: T; errors?: GraphqlError[] };
try {
json = JSON.parse(text);
} catch {
throw new Error(`Hasura invalid JSON: ${text}`);
}
if (Array.isArray(json.errors) && json.errors.length) {
throw new Error(json.errors.map((e) => e.message).join(' | '));
}
if (json.data == null) throw new Error('Hasura: empty response');
return json.data;
}
export function subscribeGraphqlWs<T>({
query,
variables,
onData,
onError,
onStatus,
pollMs: pollMsOverride,
}: SubscribeParams<T>): SubscriptionHandle {
const httpUrl = resolveGraphqlHttpUrl();
const wsUrl = resolveGraphqlWsUrl(); const wsUrl = resolveGraphqlWsUrl();
const headers = resolveAuthHeaders(); const headers = resolveAuthHeaders();
const pollOnly = envBool('VITE_GRAPHQL_POLL_ONLY');
const pollMs = Math.max(50, pollMsOverride ?? envInt('VITE_GRAPHQL_POLL_MS', 1000));
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let closed = false; let closed = false;
let started = false; let started = false;
let polling = false;
let reconnectTimer: number | null = null; let reconnectTimer: number | null = null;
let pollTimer: number | null = null;
const subId = '1'; const subId = '1';
@@ -94,6 +141,30 @@ export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onSta
const setConnected = (connected: boolean) => onStatus?.({ connected }); const setConnected = (connected: boolean) => onStatus?.({ connected });
const clearReconnectTimer = () => {
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const clearPollTimer = () => {
if (pollTimer != null) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
};
const closeSocket = () => {
if (!ws) return;
try {
ws.close();
} catch {
// ignore
}
ws = null;
};
const start = () => { const start = () => {
if (!ws || started) return; if (!ws || started) return;
started = true; started = true;
@@ -106,14 +177,40 @@ export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onSta
); );
}; };
const pollOnce = async () => {
try {
const data = await fetchGraphqlOnce<T>(httpUrl, headers, query, variables);
if (closed) return;
setConnected(true);
onData(data);
} catch (e) {
if (closed) return;
setConnected(false);
emitError(e);
} finally {
if (!closed && polling) {
pollTimer = window.setTimeout(() => void pollOnce(), pollMs);
}
}
};
const startPolling = () => {
if (closed || polling) return;
polling = true;
clearReconnectTimer();
closeSocket();
started = false;
void pollOnce();
};
const connect = () => { const connect = () => {
if (closed) return; if (closed) return;
if (polling) return;
started = false; started = false;
try { try {
ws = new WebSocket(wsUrl, 'graphql-ws'); ws = new WebSocket(wsUrl, 'graphql-ws');
} catch (e) { } catch (e) {
emitError(e); startPolling();
reconnectTimer = window.setTimeout(connect, 1000);
return; return;
} }
@@ -138,14 +235,14 @@ export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onSta
} }
if (msg.type === 'connection_error') { if (msg.type === 'connection_error') {
emitError(msg.payload || 'connection_error'); startPolling();
return; return;
} }
if (msg.type === 'ka' || msg.type === 'complete') return; if (msg.type === 'ka' || msg.type === 'complete') return;
if (msg.type === 'error') { if (msg.type === 'error') {
emitError(msg.payload || 'subscription_error'); startPolling();
return; return;
} }
@@ -161,25 +258,29 @@ export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onSta
ws.onerror = () => { ws.onerror = () => {
setConnected(false); setConnected(false);
startPolling();
}; };
ws.onclose = () => { ws.onclose = () => {
setConnected(false); setConnected(false);
if (closed) return; if (closed) return;
reconnectTimer = window.setTimeout(connect, 1000); startPolling();
}; };
}; };
if (pollOnly) {
startPolling();
} else {
connect(); connect();
}
return { return {
unsubscribe: () => { unsubscribe: () => {
closed = true; closed = true;
polling = false;
setConnected(false); setConnected(false);
if (reconnectTimer != null) { clearReconnectTimer();
window.clearTimeout(reconnectTimer); clearPollTimer();
reconnectTimer = null;
}
if (!ws) return; if (!ws) return;
try { try {
ws.send(JSON.stringify({ id: subId, type: 'stop' })); ws.send(JSON.stringify({ id: subId, type: 'stop' }));
@@ -187,12 +288,7 @@ export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onSta
} catch { } catch {
// ignore // ignore
} }
try { closeSocket();
ws.close();
} catch {
// ignore
}
ws = null;
}, },
}; };
} }

View File

@@ -23,18 +23,7 @@ function getHasuraUrl(): string {
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;
const raw = v ? String(v) : ''; return v ? String(v) : undefined;
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 {
@@ -56,6 +45,7 @@ export async function hasuraRequest<T>(query: string, variables: Record<string,
const res = await fetch(getHasuraUrl(), { const res = await fetch(getHasuraUrl(), {
method: 'POST', method: 'POST',
credentials: 'same-origin',
headers, headers,
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
}); });
@@ -78,7 +68,9 @@ export async function fetchLatestTicks(symbol: string, limit: number, source?: s
u.searchParams.set('limit', String(limit)); u.searchParams.set('limit', String(limit));
if (source && source.trim()) u.searchParams.set('source', source.trim()); if (source && source.trim()) u.searchParams.set('source', source.trim());
const res = await fetch(u.toString()); const res = await fetch(u.toString(), {
credentials: 'same-origin',
});
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 { ok?: boolean; ticks?: any[]; error?: string }; const json = JSON.parse(text) as { ok?: boolean; ticks?: any[]; error?: string };

View File

@@ -0,0 +1,366 @@
export type DiagramRuntimePublisher = {
id: string;
title: string;
status: 'ok' | 'warn';
health: {
httpOk: boolean;
gauge: number | null;
};
metrics: {
transport: string;
updateIntervalMs: number;
perpMarkets: number;
spotMarkets: number;
totalMarkets: number;
latestSlot: number | null;
};
endpoints: {
health: string;
metrics: string;
};
errors: string[];
};
export type DiagramRuntimeResponse = {
ok: boolean;
version?: string;
fetchedAt?: string;
nodes?: {
publisher?: DiagramRuntimePublisher;
};
error?: string;
};
export type DiagramLayoutView = 'standard' | 'ultra';
export type DiagramLayoutState = Record<
string,
{
position?: { x: number; y: number };
size?: { width: number; height: number };
parentId?: string | null;
}
>;
export type DiagramLayoutResponse = {
ok: boolean;
view: DiagramLayoutView;
updatedAt?: string;
layout?: DiagramLayoutState;
currentRevisionId?: number | null;
revisionId?: number | null;
restoredFromRevisionId?: number | null;
error?: string;
};
export type DiagramLayoutRevision = {
id: number;
view: DiagramLayoutView;
updatedAt: string;
action: string;
entryCount: number;
};
export type DiagramLayoutHistoryResponse = {
ok: boolean;
view: DiagramLayoutView;
currentRevisionId?: number | null;
revisions?: DiagramLayoutRevision[];
error?: string;
};
type DiagramLayoutStoredRevision = DiagramLayoutRevision & {
layout: DiagramLayoutState;
};
type DiagramLayoutStoredView = {
layout: DiagramLayoutState;
updatedAt?: string;
currentRevisionId: number | null;
nextRevisionId: number;
revisions: DiagramLayoutStoredRevision[];
};
type DiagramLayoutStore = {
views: Partial<Record<DiagramLayoutView, DiagramLayoutStoredView>>;
};
type DiagramDebugEvent = {
ts: string;
view: DiagramLayoutView;
event: string;
nodeId?: string | null;
viewport?: { x?: number; y?: number; zoom?: number };
payload?: Record<string, unknown>;
};
const layoutStoreKey = 'trade-system-flow-layout-store:v1';
const debugStoreKey = 'trade-system-flow-debug-log:v1';
const maxRevisionCount = 32;
const maxDebugEventCount = 200;
function canUseStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function getIsoNow() {
return new Date().toISOString();
}
function cloneLayoutState(layout: DiagramLayoutState | undefined): DiagramLayoutState {
return JSON.parse(JSON.stringify(layout || {})) as DiagramLayoutState;
}
function emptyStoredView(): DiagramLayoutStoredView {
return {
layout: {},
updatedAt: undefined,
currentRevisionId: null,
nextRevisionId: 1,
revisions: [],
};
}
function sanitizeStoredView(value: unknown): DiagramLayoutStoredView {
const raw = value && typeof value === 'object' ? (value as Partial<DiagramLayoutStoredView>) : {};
const revisions = Array.isArray(raw.revisions)
? raw.revisions
.filter((entry): entry is DiagramLayoutStoredRevision => {
return Boolean(
entry &&
typeof entry === 'object' &&
Number.isInteger((entry as DiagramLayoutStoredRevision).id) &&
typeof (entry as DiagramLayoutStoredRevision).updatedAt === 'string'
);
})
.map((entry) => ({
id: entry.id,
view: entry.view,
updatedAt: entry.updatedAt,
action: entry.action,
entryCount: Number.isFinite(entry.entryCount) ? Math.max(0, Math.trunc(entry.entryCount)) : 0,
layout: cloneLayoutState(entry.layout),
}))
: [];
const nextRevisionId = Number.isInteger(raw.nextRevisionId)
? Math.max(1, Number(raw.nextRevisionId))
: revisions.reduce((maxId, entry) => Math.max(maxId, entry.id), 0) + 1;
return {
layout: cloneLayoutState(raw.layout),
updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : undefined,
currentRevisionId: Number.isInteger(raw.currentRevisionId) ? Number(raw.currentRevisionId) : null,
nextRevisionId,
revisions,
};
}
function readLayoutStore(): DiagramLayoutStore {
if (!canUseStorage()) return { views: {} };
try {
const raw = window.localStorage.getItem(layoutStoreKey);
if (!raw) return { views: {} };
const parsed = JSON.parse(raw) as Partial<DiagramLayoutStore>;
const views = parsed?.views && typeof parsed.views === 'object' ? parsed.views : {};
return {
views: {
standard: sanitizeStoredView(views.standard),
ultra: sanitizeStoredView(views.ultra),
},
};
} catch {
return { views: {} };
}
}
function writeLayoutStore(store: DiagramLayoutStore) {
if (!canUseStorage()) return;
window.localStorage.setItem(layoutStoreKey, JSON.stringify(store));
}
function readStoredView(view: DiagramLayoutView): DiagramLayoutStoredView {
const store = readLayoutStore();
return sanitizeStoredView(store.views[view]);
}
function writeStoredView(view: DiagramLayoutView, next: DiagramLayoutStoredView) {
const store = readLayoutStore();
store.views = {
...store.views,
[view]: sanitizeStoredView(next),
};
writeLayoutStore(store);
}
function appendRevision(
view: DiagramLayoutView,
storedView: DiagramLayoutStoredView,
layout: DiagramLayoutState,
action: string,
updatedAt: string
) {
const revisionId = Math.max(1, storedView.nextRevisionId || 1);
const layoutCopy = cloneLayoutState(layout);
const revision: DiagramLayoutStoredRevision = {
id: revisionId,
view,
updatedAt,
action: action || 'manual-save',
entryCount: Object.keys(layoutCopy).length,
layout: layoutCopy,
};
const revisions = [revision, ...storedView.revisions].slice(0, maxRevisionCount);
return {
revisionId,
next: {
layout: layoutCopy,
updatedAt,
currentRevisionId: revisionId,
nextRevisionId: revisionId + 1,
revisions,
} satisfies DiagramLayoutStoredView,
};
}
function toHistoryRevisions(revisions: DiagramLayoutStoredRevision[], limit: number) {
return revisions.slice(0, Math.max(1, limit)).map((entry) => ({
id: entry.id,
view: entry.view,
updatedAt: entry.updatedAt,
action: entry.action,
entryCount: entry.entryCount,
}));
}
function readDebugEvents(): DiagramDebugEvent[] {
if (!canUseStorage()) return [];
try {
const raw = window.localStorage.getItem(debugStoreKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as DiagramDebugEvent[]) : [];
} catch {
return [];
}
}
function writeDebugEvents(events: DiagramDebugEvent[]) {
if (!canUseStorage()) return;
window.localStorage.setItem(debugStoreKey, JSON.stringify(events.slice(-maxDebugEventCount)));
}
export async function fetchDiagramRuntime(): Promise<DiagramRuntimeResponse> {
return {
ok: true,
version: 'frontend-local',
fetchedAt: getIsoNow(),
};
}
export async function fetchDiagramLayout(view: DiagramLayoutView): Promise<DiagramLayoutResponse> {
const storedView = readStoredView(view);
return {
ok: true,
view,
updatedAt: storedView.updatedAt,
layout: cloneLayoutState(storedView.layout),
currentRevisionId: storedView.currentRevisionId,
};
}
export async function saveDiagramLayout(
view: DiagramLayoutView,
layout: DiagramLayoutState,
action?: string
): Promise<DiagramLayoutResponse> {
const storedView = readStoredView(view);
const updatedAt = getIsoNow();
const { revisionId, next } = appendRevision(view, storedView, layout, action || 'manual-save', updatedAt);
writeStoredView(view, next);
return {
ok: true,
view,
updatedAt,
layout: cloneLayoutState(next.layout),
currentRevisionId: revisionId,
revisionId,
};
}
export async function deleteDiagramLayout(view: DiagramLayoutView): Promise<DiagramLayoutResponse> {
const storedView = readStoredView(view);
const updatedAt = getIsoNow();
const { revisionId, next } = appendRevision(view, storedView, {}, 'reset', updatedAt);
writeStoredView(view, next);
return {
ok: true,
view,
updatedAt,
layout: {},
currentRevisionId: revisionId,
revisionId,
};
}
export async function fetchDiagramLayoutHistory(
view: DiagramLayoutView,
limit = 12
): Promise<DiagramLayoutHistoryResponse> {
const storedView = readStoredView(view);
return {
ok: true,
view,
currentRevisionId: storedView.currentRevisionId,
revisions: toHistoryRevisions(storedView.revisions, limit),
};
}
export async function restoreDiagramLayoutRevision(
view: DiagramLayoutView,
revisionId: number
): Promise<DiagramLayoutResponse> {
const storedView = readStoredView(view);
const revision = storedView.revisions.find((entry) => entry.id === revisionId);
if (!revision) throw new Error(`Local layout revision ${revisionId} not found`);
const updatedAt = getIsoNow();
const { revisionId: nextRevisionId, next } = appendRevision(
view,
storedView,
revision.layout,
'restore',
updatedAt
);
writeStoredView(view, next);
return {
ok: true,
view,
updatedAt,
layout: cloneLayoutState(revision.layout),
currentRevisionId: nextRevisionId,
revisionId: nextRevisionId,
restoredFromRevisionId: revision.id,
};
}
export async function postDiagramDebugEvent(body: {
view: DiagramLayoutView;
event: string;
nodeId?: string | null;
viewport?: { x?: number; y?: number; zoom?: number };
payload?: Record<string, unknown>;
}): Promise<void> {
const events = readDebugEvents();
events.push({
ts: getIsoNow(),
view: body.view,
event: body.event,
nodeId: body.nodeId,
viewport: body.viewport,
payload: body.payload,
});
writeDebugEvents(events);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
const DIR = path.dirname(fileURLToPath(import.meta.url)); const DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -14,8 +14,8 @@ function stripTrailingSlashes(p: string): string {
return out || '/'; return out || '/';
} }
function readApiReadToken(): string | undefined { function readApiReadToken(env: Record<string, string | undefined>): string | undefined {
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN; if (env.API_READ_TOKEN) return env.API_READ_TOKEN;
const p = path.join(ROOT, 'tokens', 'read.json'); const p = path.join(ROOT, 'tokens', 'read.json');
if (!fs.existsSync(p)) return undefined; if (!fs.existsSync(p)) return undefined;
try { try {
@@ -27,6 +27,22 @@ function readApiReadToken(): string | undefined {
} }
} }
function readApiAdminSecret(env: Record<string, string | undefined>): string | undefined {
if (Object.prototype.hasOwnProperty.call(env, 'API_PROXY_ADMIN_SECRET')) {
const raw = String(env.API_PROXY_ADMIN_SECRET || '').trim();
return raw || undefined;
}
const p = path.join(ROOT, 'tokens', 'api.json');
if (!fs.existsSync(p)) return undefined;
try {
const raw = fs.readFileSync(p, 'utf8');
const json = JSON.parse(raw) as { adminSecret?: string };
return json?.adminSecret ? String(json.adminSecret) : undefined;
} catch {
return undefined;
}
}
function parseBasicAuth(value: string | undefined): BasicAuth | undefined { function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return undefined; if (!raw) return undefined;
@@ -38,11 +54,11 @@ function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
return { username, password }; return { username, password };
} }
function readProxyBasicAuth(): BasicAuth | undefined { function readProxyBasicAuth(env: Record<string, string | undefined>): BasicAuth | undefined {
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH); const fromEnv = parseBasicAuth(env.API_PROXY_BASIC_AUTH);
if (fromEnv) return fromEnv; if (fromEnv) return fromEnv;
const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim(); const fileRaw = String(env.API_PROXY_BASIC_AUTH_FILE || '').trim();
if (!fileRaw) return undefined; if (!fileRaw) return undefined;
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw); const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
@@ -59,21 +75,6 @@ function readProxyBasicAuth(): BasicAuth | undefined {
} }
} }
const apiReadToken = readApiReadToken();
const proxyBasicAuth = readProxyBasicAuth();
const apiProxyTarget =
process.env.API_PROXY_TARGET ||
process.env.VISUALIZER_PROXY_TARGET ||
process.env.TRADE_UI_URL ||
process.env.TRADE_VPS_URL ||
'https://trade.mpabi.pl';
function 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 { function parseUrl(v: string): URL | undefined {
try { try {
return new URL(v); return new URL(v);
@@ -82,18 +83,6 @@ function parseUrl(v: string): URL | 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 { function inferUiProxyTarget(apiTarget: string): string | undefined {
try { try {
const u = new URL(apiTarget); const u = new URL(apiTarget);
@@ -110,18 +99,6 @@ function inferUiProxyTarget(apiTarget: string): string | 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) { function applyProxyBasicAuth(proxyReq: any) {
if (!proxyBasicAuth) return false; if (!proxyBasicAuth) return false;
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64'); const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
@@ -129,12 +106,6 @@ function applyProxyBasicAuth(proxyReq: any) {
return true; 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) { function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
const v = proxyRes?.headers?.['set-cookie']; const v = proxyRes?.headers?.['set-cookie'];
if (!v) return; if (!v) return;
@@ -147,24 +118,76 @@ function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v)); proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
} }
function readDevProxyAuthUser(env: Record<string, string | undefined>): string | undefined {
const raw = String(env.DEV_PROXY_AUTH_USER || env.VITE_DEV_AUTH_USER || '').trim();
return raw || undefined;
}
export default defineConfig(({ mode }) => {
const env = { ...process.env, ...loadEnv(mode, DIR, '') } as Record<string, string | undefined>;
const apiReadToken = readApiReadToken(env);
const apiAdminSecret = readApiAdminSecret(env);
const proxyBasicAuth = readProxyBasicAuth(env);
const devProxyAuthUser = readDevProxyAuthUser(env);
const apiProxyTargetRaw = String(env.API_PROXY_TARGET || '').trim();
const apiProxyTarget = apiProxyTargetRaw || undefined;
const apiProxyTargetUrl = apiProxyTarget ? parseUrl(apiProxyTarget) : undefined;
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
const apiProxyStripPrefixRaw = String(env.API_PROXY_STRIP_PREFIX || (apiProxyTargetEndsWithApi ? '/api' : '')).trim();
const apiProxyStripPrefix = apiProxyStripPrefixRaw ? stripTrailingSlashes(apiProxyStripPrefixRaw) : '';
const uiProxyTarget =
env.FRONTEND_PROXY_TARGET ||
env.UI_PROXY_TARGET ||
env.AUTH_PROXY_TARGET ||
inferUiProxyTarget(apiProxyTarget) ||
(apiProxyTargetUrl && apiProxyTargetPath === '/'
? stripTrailingSlashes(apiProxyTargetUrl.toString())
: undefined);
const graphqlProxyTarget = env.GRAPHQL_PROXY_TARGET || env.HASURA_PROXY_TARGET || uiProxyTarget;
const graphqlProxyPathRaw = String(env.GRAPHQL_PROXY_PATH || '').trim();
const graphqlProxyPath = graphqlProxyPathRaw ? stripTrailingSlashes(graphqlProxyPathRaw) : '';
const portRaw = env.VITE_DEV_PORT || env.DEV_PORT || '5173';
const port = Number.parseInt(String(portRaw), 10);
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 applyDevProxyAuth(proxyReq: any) {
if (!devProxyAuthUser) return false;
proxyReq.setHeader('x-trade-user', devProxyAuthUser);
return true;
}
const proxy: Record<string, any> = { const proxy: Record<string, any> = {
'/api': { };
if (apiProxyTarget) {
proxy['/api'] = {
target: apiProxyTarget, target: apiProxyTarget,
changeOrigin: true, changeOrigin: true,
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p), rewrite: (p: string) => {
if (!apiProxyStripPrefix) return p;
const rewritten = p.replace(new RegExp(`^${apiProxyStripPrefix}`), '');
return rewritten || '/';
},
configure: (p: any) => { configure: (p: any) => {
p.on('proxyReq', (proxyReq: 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); applyProxyBasicAuth(proxyReq);
applyDevProxyAuth(proxyReq);
if (apiAdminSecret) {
proxyReq.setHeader('x-admin-secret', apiAdminSecret);
return;
}
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
}); });
}, },
},
}; };
}
if (graphqlProxyTarget) { if (graphqlProxyTarget) {
for (const prefix of ['/graphql', '/graphql-ws']) { for (const prefix of ['/graphql', '/graphql-ws']) {
@@ -172,19 +195,21 @@ if (graphqlProxyTarget) {
target: graphqlProxyTarget, target: graphqlProxyTarget,
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
rewrite: (p: string) => (graphqlProxyPath ? p.replace(new RegExp(`^${prefix}`), graphqlProxyPath) : p),
configure: (p: any) => { configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => { p.on('proxyReq', (proxyReq: any) => {
applyProxyOrigin(proxyReq, graphqlProxyOrigin); applyProxyBasicAuth(proxyReq);
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq); applyDevProxyAuth(proxyReq);
}); });
p.on('proxyReqWs', (proxyReq: any) => { p.on('proxyReqWs', (proxyReq: any) => {
applyProxyOrigin(proxyReq, graphqlProxyOrigin); applyProxyBasicAuth(proxyReq);
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq); applyDevProxyAuth(proxyReq);
}); });
}, },
}; };
} }
} }
if (uiProxyTarget) { if (uiProxyTarget) {
for (const prefix of ['/whoami', '/auth', '/logout']) { for (const prefix of ['/whoami', '/auth', '/logout']) {
proxy[prefix] = { proxy[prefix] = {
@@ -192,8 +217,8 @@ if (uiProxyTarget) {
changeOrigin: true, changeOrigin: true,
configure: (p: any) => { configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => { p.on('proxyReq', (proxyReq: any) => {
applyProxyOrigin(proxyReq, uiProxyOrigin);
applyProxyBasicAuth(proxyReq); applyProxyBasicAuth(proxyReq);
applyDevProxyAuth(proxyReq);
}); });
p.on('proxyRes', (proxyRes: any) => { p.on('proxyRes', (proxyRes: any) => {
rewriteSetCookieForLocalDevHttp(proxyRes); rewriteSetCookieForLocalDevHttp(proxyRes);
@@ -203,11 +228,12 @@ if (uiProxyTarget) {
} }
} }
export default defineConfig({ return {
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: Number.isInteger(port) && port > 0 ? port : 5173,
strictPort: false, strictPort: true,
proxy, proxy,
}, },
};
}); });

View File

@@ -1,39 +0,0 @@
# 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`.

View File

@@ -1,216 +0,0 @@
# 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”.

View File

@@ -1,153 +0,0 @@
# 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`

View File

@@ -1,120 +0,0 @@
# 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

@@ -1,109 +0,0 @@
# 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`

View File

@@ -1,262 +0,0 @@
# 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.

View File

@@ -1,165 +0,0 @@
# 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

@@ -1,115 +0,0 @@
# 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).

View File

@@ -1,211 +0,0 @@
<!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

@@ -1,284 +0,0 @@
# 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”.

View File

@@ -1,20 +0,0 @@
# 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

@@ -1,111 +0,0 @@
# 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

@@ -1,111 +0,0 @@
# 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

View File

@@ -1,119 +0,0 @@
# 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).

View File

@@ -1,123 +0,0 @@
# 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.

View File

@@ -1,42 +0,0 @@
# 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

@@ -1,89 +0,0 @@
# 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).

View File

@@ -1,99 +0,0 @@
# 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

@@ -3,7 +3,9 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import https from 'node:https'; import https from 'node:https';
import net from 'node:net';
import path from 'node:path'; import path from 'node:path';
import tls from 'node:tls';
const PORT = Number.parseInt(process.env.PORT || '8081', 10); const PORT = Number.parseInt(process.env.PORT || '8081', 10);
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`); if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
@@ -14,8 +16,9 @@ const STARTED_AT = new Date().toISOString();
const STATIC_DIR = process.env.STATIC_DIR || '/srv'; const STATIC_DIR = process.env.STATIC_DIR || '/srv';
const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json'; const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json';
const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json'; const HASURA_UPSTREAM = process.env.HASURA_UPSTREAM || 'http://hasura:8080';
const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787'; const HASURA_GRAPHQL_PATH = process.env.HASURA_GRAPHQL_PATH || '/v1/graphql';
const GRAPHQL_CORS_ORIGIN = process.env.GRAPHQL_CORS_ORIGIN || process.env.CORS_ORIGIN || '*';
const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on') const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on')
.trim() .trim()
.toLowerCase(); .toLowerCase();
@@ -57,19 +60,12 @@ 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().trim(); const username = (j?.username || '').toString();
const password = (j?.password || '').toString().trim(); const password = (j?.password || '').toString();
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() {
const j = readJson(API_READ_TOKEN_FILE);
const token = (j?.token || '').toString().trim();
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
return token;
}
function send(res, status, headers, body) { function send(res, status, headers, body) {
res.statusCode = status; res.statusCode = status;
for (const [k, v] of Object.entries(headers || {})) res.setHeader(k, v); for (const [k, v] of Object.entries(headers || {})) res.setHeader(k, v);
@@ -363,28 +359,28 @@ function readBody(req, limitBytes = 1024 * 16) {
}); });
} }
function proxyApi(req, res, apiReadToken) { function withCors(res) {
const upstreamBase = new URL(API_UPSTREAM); res.setHeader('access-control-allow-origin', GRAPHQL_CORS_ORIGIN);
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
res.setHeader(
const prefix = '/api'; 'access-control-allow-headers',
const strippedPath = inUrl.pathname === prefix ? '/' : inUrl.pathname.startsWith(prefix + '/') ? inUrl.pathname.slice(prefix.length) : null; 'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id'
if (strippedPath == null) { );
send(res, 404, { 'content-type': 'text/plain; charset=utf-8' }, 'not_found');
return;
} }
function proxyGraphqlHttp(req, res) {
const upstreamBase = new URL(HASURA_UPSTREAM);
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const target = new URL(upstreamBase.toString()); const target = new URL(upstreamBase.toString());
target.pathname = strippedPath || '/'; target.pathname = HASURA_GRAPHQL_PATH;
target.search = inUrl.search; target.search = inUrl.search;
const isHttps = target.protocol === 'https:'; const isHttps = target.protocol === 'https:';
const lib = isHttps ? https : http; const lib = isHttps ? https : http;
const headers = stripHopByHopHeaders(req.headers); const headers = stripHopByHopHeaders(req.headers);
delete headers.authorization; // basic auth from client must not leak upstream
headers.host = target.host; headers.host = target.host;
headers.authorization = `Bearer ${apiReadToken}`;
const upstreamReq = lib.request( const upstreamReq = lib.request(
{ {
@@ -397,6 +393,7 @@ function proxyApi(req, res, apiReadToken) {
}, },
(upstreamRes) => { (upstreamRes) => {
const outHeaders = stripHopByHopHeaders(upstreamRes.headers); const outHeaders = stripHopByHopHeaders(upstreamRes.headers);
withCors(res);
res.writeHead(upstreamRes.statusCode || 502, outHeaders); res.writeHead(upstreamRes.statusCode || 502, outHeaders);
upstreamRes.pipe(res); upstreamRes.pipe(res);
} }
@@ -404,6 +401,7 @@ function proxyApi(req, res, apiReadToken) {
upstreamReq.on('error', (err) => { upstreamReq.on('error', (err) => {
if (!res.headersSent) { if (!res.headersSent) {
withCors(res);
send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`); send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`);
} else { } else {
res.destroy(); res.destroy();
@@ -413,6 +411,70 @@ function proxyApi(req, res, apiReadToken) {
req.pipe(upstreamReq); req.pipe(upstreamReq);
} }
function isGraphqlPath(pathname) {
return pathname === '/graphql' || pathname === '/graphql-ws';
}
function proxyGraphqlWs(req, socket, head) {
const upstreamBase = new URL(HASURA_UPSTREAM);
const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const target = new URL(upstreamBase.toString());
target.pathname = HASURA_GRAPHQL_PATH;
target.search = inUrl.search;
const port = Number(target.port || (target.protocol === 'https:' ? 443 : 80));
const host = target.hostname;
const connect =
target.protocol === 'https:'
? () => tls.connect({ host, port, servername: host })
: () => net.connect({ host, port });
const upstream = connect();
upstream.setNoDelay(true);
socket.setNoDelay(true);
// For WebSocket upgrades we must forward `connection`/`upgrade` and related headers.
const headers = { ...req.headers };
delete headers['content-length'];
delete headers['content-type'];
headers.host = target.host;
const lines = [];
lines.push(`GET ${target.pathname + target.search} HTTP/1.1`);
for (const [k, v] of Object.entries(headers)) {
if (v == null) continue;
if (Array.isArray(v)) {
for (const vv of v) lines.push(`${k}: ${vv}`);
} else {
lines.push(`${k}: ${v}`);
}
}
lines.push('', '');
upstream.write(lines.join('\r\n'));
if (head?.length) upstream.write(head);
upstream.on('error', () => {
try {
socket.destroy();
} catch {
// ignore
}
});
socket.on('error', () => {
try {
upstream.destroy();
} catch {
// ignore
}
});
upstream.pipe(socket);
socket.pipe(upstream);
}
async function handler(req, res) { async function handler(req, res) {
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) { if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
send( send(
@@ -425,6 +487,25 @@ async function handler(req, res) {
} }
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (isGraphqlPath(url.pathname)) {
if (req.method === 'OPTIONS') {
withCors(res);
res.statusCode = 204;
res.end();
return;
}
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
const user = resolveAuthenticatedUser(req);
if (!user) {
withCors(res);
unauthorized(res);
return;
}
}
withCors(res);
proxyGraphqlHttp(req, res);
return;
}
if (req.method === 'GET' && url.pathname === '/whoami') { if (req.method === 'GET' && url.pathname === '/whoami') {
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE }); sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
return; return;
@@ -500,26 +581,6 @@ async function handler(req, res) {
} }
} }
if (req.url?.startsWith('/api') && (req.url === '/api' || req.url.startsWith('/api/'))) {
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
const user = resolveAuthenticatedUser(req);
if (!user) {
unauthorized(res);
return;
}
}
let token;
try {
token = loadApiReadToken();
} catch (e) {
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
return;
}
proxyApi(req, res, token);
return;
}
serveStatic(req, res); serveStatic(req, res);
} }
@@ -532,6 +593,30 @@ const server = http.createServer((req, res) => {
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e)); send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
}); });
}); });
server.on('upgrade', (req, socket, head) => {
try {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (!isGraphqlPath(url.pathname)) {
socket.destroy();
return;
}
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
const user = resolveAuthenticatedUser(req);
if (!user) {
try {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
} catch {
// ignore
}
socket.destroy();
return;
}
}
proxyGraphqlWs(req, socket, head);
} catch {
socket.destroy();
}
});
server.listen(PORT, () => { server.listen(PORT, () => {
console.log( console.log(
JSON.stringify( JSON.stringify(
@@ -539,10 +624,9 @@ server.listen(PORT, () => {
service: 'trade-frontend', service: 'trade-frontend',
port: PORT, port: PORT,
staticDir: STATIC_DIR, staticDir: STATIC_DIR,
apiUpstream: API_UPSTREAM, hasuraUpstream: HASURA_UPSTREAM,
basicAuthFile: BASIC_AUTH_FILE, basicAuthFile: BASIC_AUTH_FILE,
basicAuthMode: BASIC_AUTH_MODE, basicAuthMode: BASIC_AUTH_MODE,
apiReadTokenFile: API_READ_TOKEN_FILE,
authUserHeader: AUTH_USER_HEADER, authUserHeader: AUTH_USER_HEADER,
authMode: AUTH_MODE, authMode: AUTH_MODE,
htpasswdFile: HTPASSWD_FILE, htpasswdFile: HTPASSWD_FILE,