Compare commits
4 Commits
feat/candl
...
feat/visua
| Author | SHA1 | Date | |
|---|---|---|---|
| a674d40453 | |||
| 5a9d61d674 | |||
| f76045a9a5 | |||
| fa79340cf5 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,11 +4,20 @@ tokens/*
|
||||
!tokens/*.example.yml
|
||||
!tokens/*.example.yaml
|
||||
|
||||
# Local secrets (never commit)
|
||||
pass/
|
||||
argo/pass
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
|
||||
# Baremetal IaC local overlays (do not commit)
|
||||
infra/baremetal-solana-rpc/ansible/inventory/hosts.ini
|
||||
infra/baremetal-solana-rpc/ansible/group_vars/solana_rpc.yml
|
||||
infra/baremetal-solana-rpc/ansible/group_vars/vault.yml
|
||||
|
||||
# Local scratch / build output
|
||||
_tmp/
|
||||
apps/visualizer/dist/
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# Default: UI reads ticks from the same-origin API proxy at `/api`.
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Fallback (optional): query Hasura directly (not recommended in browser).
|
||||
VITE_HASURA_URL=http://localhost:8080/v1/graphql
|
||||
# Optional (only if you intentionally query Hasura directly from the browser):
|
||||
# Hasura GraphQL endpoint (supports subscriptions via WS).
|
||||
# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`.
|
||||
VITE_HASURA_URL=/graphql
|
||||
# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`.
|
||||
# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws).
|
||||
# VITE_HASURA_WS_URL=/graphql-ws
|
||||
# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`):
|
||||
# VITE_HASURA_AUTH_TOKEN=YOUR_JWT
|
||||
# VITE_HASURA_ADMIN_SECRET=devsecret
|
||||
VITE_SYMBOL=PUMP-PERP
|
||||
VITE_SYMBOL=SOL-PERP
|
||||
# Optional: filter by source (leave empty for all)
|
||||
# VITE_SOURCE=drift_oracle
|
||||
VITE_POLL_MS=1000
|
||||
|
||||
24
apps/visualizer/__start
Normal file
24
apps/visualizer/__start
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
export API_PROXY_TARGET="${API_PROXY_TARGET:-https://trade.mpabi.pl}"
|
||||
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}"
|
||||
export VITE_API_URL="${VITE_API_URL:-/api}"
|
||||
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
||||
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
|
||||
|
||||
if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then
|
||||
if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then
|
||||
export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json"
|
||||
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
|
||||
|
||||
npm run dev
|
||||
12
apps/visualizer/diagram.html
Normal file
12
apps/visualizer/diagram.html
Normal 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>
|
||||
216
apps/visualizer/package-lock.json
generated
216
apps/visualizer/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "trade-visualizer",
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.8.5",
|
||||
"chart.js": "^4.4.1",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"lightweight-charts": "^5.0.8",
|
||||
@@ -1110,6 +1111,49 @@
|
||||
"@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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1121,14 +1165,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1166,6 +1210,36 @@
|
||||
"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": {
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
@@ -1253,6 +1327,11 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1264,9 +1343,105 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1664,6 +1839,14 @@
|
||||
"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": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -1730,6 +1913,33 @@
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.8.5",
|
||||
"chart.js": "^4.4.1",
|
||||
"chartjs-adapter-luxon": "^1.3.1",
|
||||
"lightweight-charts": "^5.0.8",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
||||
import AppShell from './layout/AppShell';
|
||||
@@ -11,6 +12,11 @@ import Button from './ui/Button';
|
||||
import TopNav from './layout/TopNav';
|
||||
import AuthStatus from './layout/AuthStatus';
|
||||
import LoginScreen from './layout/LoginScreen';
|
||||
import { useDlobStats } from './features/market/useDlobStats';
|
||||
import { useDlobL2 } from './features/market/useDlobL2';
|
||||
import { useDlobSlippage } from './features/market/useDlobSlippage';
|
||||
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
|
||||
import DlobDashboard from './features/market/DlobDashboard';
|
||||
|
||||
function envNumber(name: string, fallback: number): number {
|
||||
const v = (import.meta as any).env?.[name];
|
||||
@@ -26,7 +32,8 @@ function envString(name: string, fallback: string): string {
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (v >= 1000) return `$${v.toFixed(0)}`;
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||
return `$${v.toPrecision(4)}`;
|
||||
}
|
||||
@@ -36,6 +43,38 @@ function formatQty(v: number | null | undefined, decimals: number): string {
|
||||
return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
||||
}
|
||||
|
||||
function formatCompact(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(0)}K`;
|
||||
if (abs >= 1) return v.toFixed(2);
|
||||
return v.toPrecision(4);
|
||||
}
|
||||
|
||||
function clamp01(scale: number): number {
|
||||
return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0;
|
||||
}
|
||||
|
||||
function barCurve(scale01: number): number {
|
||||
// Makes small rows visible without letting a single wall dominate.
|
||||
return Math.sqrt(clamp01(scale01));
|
||||
}
|
||||
|
||||
function orderbookRowBarStyle(totalScale: number, levelScale: number): CSSProperties {
|
||||
return {
|
||||
['--ob-total-scale' as any]: barCurve(totalScale),
|
||||
['--ob-level-scale' as any]: barCurve(levelScale),
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
function liquidityStyle(bid: number, ask: number): CSSProperties {
|
||||
const max = Math.max(1e-9, bid, ask);
|
||||
const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0;
|
||||
const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0;
|
||||
return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties;
|
||||
}
|
||||
|
||||
type WhoamiResponse = {
|
||||
ok?: boolean;
|
||||
user?: string | null;
|
||||
@@ -99,18 +138,18 @@ export default function App() {
|
||||
}
|
||||
|
||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||
|
||||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP'));
|
||||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP'));
|
||||
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
||||
const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
|
||||
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
||||
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
||||
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
||||
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', false);
|
||||
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true);
|
||||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||||
'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||
'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||
>('trade.bottomTab', 'positions');
|
||||
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
||||
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
||||
@@ -120,6 +159,16 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
||||
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
||||
|
||||
useEffect(() => {
|
||||
if (symbol === 'BONK-PERP') {
|
||||
setSymbol('1MBONK-PERP');
|
||||
return;
|
||||
}
|
||||
if (!markets.includes(symbol)) {
|
||||
setSymbol('SOL-PERP');
|
||||
}
|
||||
}, [markets, setSymbol, symbol]);
|
||||
|
||||
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
||||
symbol,
|
||||
source: source.trim() ? source : undefined,
|
||||
@@ -128,43 +177,98 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
pollMs,
|
||||
});
|
||||
|
||||
const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol);
|
||||
const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { levels: 10 });
|
||||
const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol);
|
||||
const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol);
|
||||
|
||||
const latest = candles.length ? candles[candles.length - 1] : null;
|
||||
const first = candles.length ? candles[0] : null;
|
||||
const changePct =
|
||||
first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null;
|
||||
|
||||
const orderbook = useMemo(() => {
|
||||
if (!latest) return { asks: [], bids: [], mid: null as number | null };
|
||||
if (dlobL2) {
|
||||
return {
|
||||
asks: dlobL2.asks,
|
||||
bids: dlobL2.bids,
|
||||
mid: dlobL2.mid as number | null,
|
||||
bestBid: dlobL2.bestBid,
|
||||
bestAsk: dlobL2.bestAsk,
|
||||
};
|
||||
}
|
||||
if (!latest) return { asks: [], bids: [], mid: null as number | null, bestBid: null as number | null, bestAsk: null as number | null };
|
||||
const mid = latest.close;
|
||||
const step = Math.max(mid * 0.00018, 0.0001);
|
||||
const levels = 14;
|
||||
const levels = 10;
|
||||
|
||||
const asksRaw = Array.from({ length: levels }, (_, i) => ({
|
||||
price: mid + (i + 1) * step,
|
||||
size: 0.1 + ((i * 7) % 15) * 0.1,
|
||||
sizeBase: 0.1 + ((i * 7) % 15) * 0.1,
|
||||
}));
|
||||
const bidsRaw = Array.from({ length: levels }, (_, i) => ({
|
||||
price: mid - (i + 1) * step,
|
||||
size: 0.1 + ((i * 5) % 15) * 0.1,
|
||||
sizeBase: 0.1 + ((i * 5) % 15) * 0.1,
|
||||
}));
|
||||
|
||||
let askTotal = 0;
|
||||
let askTotalBase = 0;
|
||||
let askTotalUsd = 0;
|
||||
const asks = asksRaw
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((r) => {
|
||||
askTotal += r.size;
|
||||
return { ...r, total: askTotal };
|
||||
const sizeUsd = r.sizeBase * r.price;
|
||||
askTotalBase += r.sizeBase;
|
||||
askTotalUsd += sizeUsd;
|
||||
return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd };
|
||||
});
|
||||
|
||||
let bidTotal = 0;
|
||||
let bidTotalBase = 0;
|
||||
let bidTotalUsd = 0;
|
||||
const bids = bidsRaw.map((r) => {
|
||||
bidTotal += r.size;
|
||||
return { ...r, total: bidTotal };
|
||||
const sizeUsd = r.sizeBase * r.price;
|
||||
bidTotalBase += r.sizeBase;
|
||||
bidTotalUsd += sizeUsd;
|
||||
return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd };
|
||||
});
|
||||
|
||||
return { asks, bids, mid };
|
||||
}, [latest]);
|
||||
return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null };
|
||||
}, [dlobL2, latest]);
|
||||
|
||||
const maxAskTotal = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.asks) max = Math.max(max, (r as any).totalUsd || 0);
|
||||
return max;
|
||||
}, [orderbook.asks]);
|
||||
|
||||
const maxBidTotal = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.bids) max = Math.max(max, (r as any).totalUsd || 0);
|
||||
return max;
|
||||
}, [orderbook.bids]);
|
||||
|
||||
const maxAskSize = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||
return max;
|
||||
}, [orderbook.asks]);
|
||||
|
||||
const maxBidSize = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0);
|
||||
return max;
|
||||
}, [orderbook.bids]);
|
||||
|
||||
const liquidity = useMemo(() => {
|
||||
const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0;
|
||||
const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0;
|
||||
const bestBid = orderbook.bestBid;
|
||||
const bestAsk = orderbook.bestAsk;
|
||||
const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null;
|
||||
const spreadPct =
|
||||
spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null;
|
||||
return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct };
|
||||
}, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]);
|
||||
|
||||
const trades = useMemo(() => {
|
||||
const slice = candles.slice(-24).reverse();
|
||||
@@ -184,6 +288,24 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
return latest?.close ?? tradePrice;
|
||||
}, [latest?.close, tradeOrderType, tradePrice]);
|
||||
|
||||
const orderValueUsd = useMemo(() => {
|
||||
if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null;
|
||||
if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null;
|
||||
const v = effectiveTradePrice * tradeSize;
|
||||
return Number.isFinite(v) && v > 0 ? v : null;
|
||||
}, [effectiveTradePrice, tradeSize]);
|
||||
|
||||
const dynamicSlippage = useMemo(() => {
|
||||
if (orderValueUsd == null) return null;
|
||||
const side = tradeSide === 'short' ? 'sell' : 'buy';
|
||||
const rows = slippageRows.filter((r) => r.side === side).slice();
|
||||
rows.sort((a, b) => a.sizeUsd - b.sizeUsd);
|
||||
if (!rows.length) return null;
|
||||
const biggest = rows[rows.length - 1];
|
||||
const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest;
|
||||
return match;
|
||||
}, [orderValueUsd, slippageRows, tradeSide]);
|
||||
|
||||
const topItems = useMemo(
|
||||
() => [
|
||||
{ key: 'BTC', label: 'BTC', changePct: 1.28, active: false },
|
||||
@@ -209,12 +331,28 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
),
|
||||
},
|
||||
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
|
||||
{ key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' },
|
||||
{ key: 'oi', label: 'Open Interest', value: '—' },
|
||||
{ key: 'vol', label: '24h Volume', value: '—' },
|
||||
{ key: 'details', label: 'Market Details', value: <a href="#">View</a> },
|
||||
{ key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) },
|
||||
{ key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) },
|
||||
{
|
||||
key: 'spread',
|
||||
label: 'Spread',
|
||||
value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`,
|
||||
sub: formatUsd(dlob?.spreadAbs ?? null),
|
||||
},
|
||||
{
|
||||
key: 'dlob',
|
||||
label: 'DLOB',
|
||||
value: dlobConnected ? 'live' : '—',
|
||||
sub: dlobError ? <span className="neg">{dlobError}</span> : dlob?.updatedAt || '—',
|
||||
},
|
||||
{
|
||||
key: 'l2',
|
||||
label: 'L2',
|
||||
value: dlobL2Connected ? 'live' : '—',
|
||||
sub: dlobL2Error ? <span className="neg">{dlobL2Error}</span> : dlobL2?.updatedAt || '—',
|
||||
},
|
||||
];
|
||||
}, [latest?.close, latest?.oracle, changePct]);
|
||||
}, [latest?.close, latest?.oracle, changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error]);
|
||||
|
||||
const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []);
|
||||
const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]);
|
||||
@@ -292,11 +430,30 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
showBuild={showBuild}
|
||||
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||
seriesLabel={seriesLabel}
|
||||
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
||||
/>
|
||||
|
||||
<Card className="bottomCard">
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
id: 'dlob',
|
||||
label: 'DLOB',
|
||||
content: (
|
||||
<DlobDashboard
|
||||
market={symbol}
|
||||
stats={dlob}
|
||||
statsConnected={dlobConnected}
|
||||
statsError={dlobError}
|
||||
depthBands={depthBands}
|
||||
depthBandsConnected={depthBandsConnected}
|
||||
depthBandsError={depthBandsError}
|
||||
slippageRows={slippageRows}
|
||||
slippageConnected={slippageConnected}
|
||||
slippageError={slippageError}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
|
||||
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
|
||||
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
|
||||
@@ -323,7 +480,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
title={
|
||||
<div className="sideHead">
|
||||
<div className="sideHead__title">Orderbook</div>
|
||||
<div className="sideHead__subtitle">{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}</div>
|
||||
<div className="sideHead__subtitle">{loading ? 'loading…' : orderbook.mid != null ? formatUsd(orderbook.mid) : latest ? formatUsd(latest.close) : '—'}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -336,28 +493,63 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
<div className="orderbook">
|
||||
<div className="orderbook__header">
|
||||
<span>Price</span>
|
||||
<span className="orderbook__num">Size</span>
|
||||
<span className="orderbook__num">Total</span>
|
||||
<span className="orderbook__num">Size (USD)</span>
|
||||
<span className="orderbook__num">Total (USD)</span>
|
||||
</div>
|
||||
<div className="orderbook__rows">
|
||||
{orderbook.asks.map((r) => (
|
||||
<div key={`a-${r.price}`} className="orderbookRow orderbookRow--ask">
|
||||
<div
|
||||
key={`a-${r.price}`}
|
||||
className="orderbookRow orderbookRow--ask"
|
||||
style={orderbookRowBarStyle(
|
||||
maxAskTotal > 0 ? (r as any).totalUsd / maxAskTotal : 0,
|
||||
maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0
|
||||
)}
|
||||
>
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||||
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="orderbookMid">
|
||||
<span className="orderbookMid__price">{latest ? formatQty(latest.close, 3) : '—'}</span>
|
||||
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
|
||||
<span className="orderbookMid__label">mid</span>
|
||||
</div>
|
||||
{orderbook.bids.map((r) => (
|
||||
<div key={`b-${r.price}`} className="orderbookRow orderbookRow--bid">
|
||||
<div
|
||||
key={`b-${r.price}`}
|
||||
className="orderbookRow orderbookRow--bid"
|
||||
style={orderbookRowBarStyle(
|
||||
maxBidTotal > 0 ? (r as any).totalUsd / maxBidTotal : 0,
|
||||
maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0
|
||||
)}
|
||||
>
|
||||
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
|
||||
<span className="orderbookRow__num">{formatCompact((r as any).sizeUsd)}</span>
|
||||
<span className="orderbookRow__num">{formatCompact((r as any).totalUsd)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="orderbookMeta">
|
||||
<div className="orderbookMeta__row">
|
||||
<span className="muted">Spread</span>
|
||||
<span className="orderbookMeta__val">
|
||||
{liquidity.spreadAbs == null || liquidity.spreadPct == null
|
||||
? '—'
|
||||
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="orderbookMeta__row">
|
||||
<span className="muted">Liquidity (L1–L10)</span>
|
||||
<span className="orderbookMeta__val">
|
||||
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
|
||||
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
|
||||
<div className="liquidityBar__bid" />
|
||||
<div className="liquidityBar__ask" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -487,7 +679,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||
</div>
|
||||
<div className="tradeMeta__row">
|
||||
<span className="tradeMeta__label">Slippage (Dynamic)</span>
|
||||
<span className="tradeMeta__value">—</span>
|
||||
<span className="tradeMeta__value">
|
||||
{slippageError ? (
|
||||
<span className="neg">{slippageError}</span>
|
||||
) : dynamicSlippage?.impactBps == null ? (
|
||||
slippageConnected ? (
|
||||
'—'
|
||||
) : (
|
||||
'offline'
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
|
||||
<span className="muted">
|
||||
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
|
||||
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
|
||||
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
|
||||
: ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tradeMeta__row">
|
||||
<span className="tradeMeta__label">Margin Required</span>
|
||||
|
||||
11
apps/visualizer/src/diagram-main.tsx
Normal file
11
apps/visualizer/src/diagram-main.tsx
Normal 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>
|
||||
);
|
||||
535
apps/visualizer/src/diagram.css
Normal file
535
apps/visualizer/src/diagram.css
Normal file
@@ -0,0 +1,535 @@
|
||||
: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, 1fr) auto auto;
|
||||
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;
|
||||
}
|
||||
|
||||
.rfToolbar__summary {
|
||||
margin-top: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rfFilters {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.rfToolbar__meta {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rfToolbar__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(99, 91, 82, 0.12);
|
||||
background: rgba(255, 250, 242, 0.92);
|
||||
color: var(--rf-ink);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rfToolbar__pillIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(27, 23, 20, 0.08);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.rfLayout {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) 330px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.rfCanvas {
|
||||
height: calc(100vh - 126px);
|
||||
min-height: 760px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rfInspector {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.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__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: 248px;
|
||||
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;
|
||||
}
|
||||
|
||||
.rfCard.isSelected {
|
||||
box-shadow: 0 18px 38px rgba(27, 23, 20, 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.rfCard__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.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__bullets {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rfCard__bullets li {
|
||||
margin: 5px 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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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: 26px 26px, 26px 26px, 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;
|
||||
}
|
||||
|
||||
.rfPage.isUltraWide .rfLayout {
|
||||
grid-template-columns: minmax(0, 1.4fr) 300px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.rfToolbar__meta {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rfLayout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rfCanvas {
|
||||
height: 72vh;
|
||||
min-height: 620px;
|
||||
}
|
||||
}
|
||||
1310
apps/visualizer/src/diagram/TradeSystemFlowPage.tsx
Normal file
1310
apps/visualizer/src/diagram/TradeSystemFlowPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -142,38 +142,30 @@ export default function ChartLayersPanel({
|
||||
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
|
||||
</div>
|
||||
|
||||
{drawingsLayer ? (
|
||||
<div className="chartLayersRow chartLayersRow--layer">
|
||||
{layers.map((layer) => (
|
||||
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton
|
||||
title="Toggle visible"
|
||||
active={drawingsLayer.visible}
|
||||
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
|
||||
>
|
||||
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
|
||||
<IconEye />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--icon">
|
||||
<IconButton
|
||||
title="Toggle lock"
|
||||
active={drawingsLayer.locked}
|
||||
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
|
||||
>
|
||||
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
|
||||
<IconLock />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--name">
|
||||
<div className="layersName layersName--layer">
|
||||
{drawingsLayer.name}
|
||||
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span>
|
||||
{layer.name}
|
||||
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--opacity">
|
||||
<OpacitySlider value={drawingsLayer.opacity} onChange={(next) => onSetLayerOpacity(drawingsLayer.id, next)} />
|
||||
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
|
||||
</div>
|
||||
<div className="chartLayersCell chartLayersCell--actions" />
|
||||
</div>
|
||||
) : null}
|
||||
))}
|
||||
|
||||
{drawingsLayer && fibPresent ? (
|
||||
<div
|
||||
@@ -212,4 +204,3 @@ export default function ChartLayersPanel({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ import ChartSideToolbar from './ChartSideToolbar';
|
||||
import ChartToolbar from './ChartToolbar';
|
||||
import TradingChart from './TradingChart';
|
||||
import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive';
|
||||
import type { IChartApi } from 'lightweight-charts';
|
||||
import { LineStyle, type IChartApi } from 'lightweight-charts';
|
||||
import type { OverlayLayer } from './ChartPanel.types';
|
||||
|
||||
type Props = {
|
||||
candles: Candle[];
|
||||
indicators: ChartIndicators;
|
||||
dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null;
|
||||
timeframe: string;
|
||||
bucketSeconds: number;
|
||||
seriesKey: string;
|
||||
@@ -45,6 +46,7 @@ function isEditableTarget(t: EventTarget | null): boolean {
|
||||
export default function ChartPanel({
|
||||
candles,
|
||||
indicators,
|
||||
dlobQuotes,
|
||||
timeframe,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
@@ -61,6 +63,7 @@ export default function ChartPanel({
|
||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
|
||||
const [layers, setLayers] = useState<OverlayLayer[]>([
|
||||
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
|
||||
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
|
||||
]);
|
||||
const [layersOpen, setLayersOpen] = useState(false);
|
||||
@@ -196,6 +199,37 @@ export default function ChartPanel({
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]);
|
||||
const quotesVisible = Boolean(quotesLayer?.visible);
|
||||
const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1);
|
||||
|
||||
const priceLines = useMemo(() => {
|
||||
if (!quotesVisible) return [];
|
||||
return [
|
||||
{
|
||||
id: 'dlob-bid',
|
||||
title: 'DLOB Bid',
|
||||
price: dlobQuotes?.bid ?? null,
|
||||
color: `rgba(34,197,94,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
},
|
||||
{
|
||||
id: 'dlob-mid',
|
||||
title: 'DLOB Mid',
|
||||
price: dlobQuotes?.mid ?? null,
|
||||
color: `rgba(230,233,239,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
},
|
||||
{
|
||||
id: 'dlob-ask',
|
||||
title: 'DLOB Ask',
|
||||
price: dlobQuotes?.ask ?? null,
|
||||
color: `rgba(239,68,68,${quotesOpacity})`,
|
||||
lineStyle: LineStyle.Dotted,
|
||||
},
|
||||
];
|
||||
}, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible]);
|
||||
|
||||
function updateLayer(layerId: string, patch: Partial<OverlayLayer>) {
|
||||
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
|
||||
}
|
||||
@@ -309,6 +343,7 @@ export default function ChartPanel({
|
||||
showBuild={showBuild}
|
||||
bucketSeconds={bucketSeconds}
|
||||
seriesKey={seriesKey}
|
||||
priceLines={priceLines}
|
||||
fib={fibRenderable}
|
||||
fibOpacity={fibEffectiveOpacity}
|
||||
fibSelected={fibSelected}
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
onToggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||
const timeframes = ['3s', '5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||
|
||||
export default function ChartToolbar({
|
||||
timeframe,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CandlestickSeries,
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
HistogramSeries,
|
||||
type IPrimitivePaneRenderer,
|
||||
type IPrimitivePaneView,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type ISeriesPrimitive,
|
||||
LineStyle,
|
||||
LineSeries,
|
||||
type SeriesAttachedParameter,
|
||||
type Time,
|
||||
createChart,
|
||||
type UTCTimestamp,
|
||||
type CandlestickData,
|
||||
@@ -28,6 +33,15 @@ type Props = {
|
||||
showBuild: boolean;
|
||||
bucketSeconds: number;
|
||||
seriesKey: string;
|
||||
priceLines?: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
price: number | null;
|
||||
color: string;
|
||||
lineWidth?: number;
|
||||
lineStyle?: LineStyle;
|
||||
axisLabelVisible?: boolean;
|
||||
}>;
|
||||
fib?: FibRetracement | null;
|
||||
fibOpacity?: number;
|
||||
fibSelected?: boolean;
|
||||
@@ -49,6 +63,13 @@ type Props = {
|
||||
type LinePoint = LineData | WhitespaceData;
|
||||
type BuildSample = { t: number; v: number };
|
||||
|
||||
const BUILD_UP_COLOR = '#22c55e';
|
||||
const BUILD_DOWN_COLOR = '#ef4444';
|
||||
const BUILD_FLAT_COLOR = '#60a5fa';
|
||||
const BUILD_UP_SLICE = 'rgba(34,197,94,0.70)';
|
||||
const BUILD_DOWN_SLICE = 'rgba(239,68,68,0.70)';
|
||||
const BUILD_FLAT_SLICE = 'rgba(96,165,250,0.70)';
|
||||
|
||||
function toTime(t: number): UTCTimestamp {
|
||||
return t as UTCTimestamp;
|
||||
}
|
||||
@@ -110,6 +131,358 @@ function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
||||
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
|
||||
}
|
||||
|
||||
function colorForDelta(delta: number): string {
|
||||
if (delta > 0) return BUILD_UP_COLOR;
|
||||
if (delta < 0) return BUILD_DOWN_COLOR;
|
||||
return BUILD_FLAT_COLOR;
|
||||
}
|
||||
|
||||
function sliceColorForDelta(delta: number): string {
|
||||
if (delta > 0) return BUILD_UP_SLICE;
|
||||
if (delta < 0) return BUILD_DOWN_SLICE;
|
||||
return BUILD_FLAT_SLICE;
|
||||
}
|
||||
|
||||
type SliceDir = -1 | 0 | 1;
|
||||
|
||||
function dirForDelta(delta: number): SliceDir {
|
||||
if (delta > 0) return 1;
|
||||
if (delta < 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildDeltaSeriesForCandle(candle: Candle, bs: number, samples: BuildSample[] | undefined): LinePoint[] {
|
||||
const eps = 1e-3;
|
||||
const startT = candle.time + eps;
|
||||
const endT = candle.time + bs - eps;
|
||||
if (!(endT > startT)) return [];
|
||||
|
||||
const points: BuildSample[] = [{ t: startT, v: 0 }];
|
||||
let lastT = startT;
|
||||
for (const p of samples || []) {
|
||||
let t = p.t;
|
||||
if (t <= lastT + eps) t = lastT + eps;
|
||||
if (t >= endT) break;
|
||||
points.push({ t, v: p.v });
|
||||
lastT = t;
|
||||
}
|
||||
|
||||
const finalDelta = candle.close - candle.open;
|
||||
if (endT > lastT + eps) {
|
||||
points.push({ t: endT, v: finalDelta });
|
||||
} else if (points.length) {
|
||||
points[points.length - 1] = { ...points[points.length - 1]!, v: finalDelta };
|
||||
}
|
||||
|
||||
const out: LinePoint[] = [{ time: toTime(candle.time) } as WhitespaceData];
|
||||
out.push({ time: toTime(points[0]!.t), value: points[0]!.v } as LineData);
|
||||
|
||||
let lastLineIdx = out.length - 1;
|
||||
let lastVal = points[0]!.v;
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const v = points[i]!.v;
|
||||
const prev = out[lastLineIdx] as LineData;
|
||||
out[lastLineIdx] = { ...prev, color: colorForDelta(v - lastVal) } as LineData;
|
||||
out.push({ time: toTime(points[i]!.t), value: v } as LineData);
|
||||
lastLineIdx = out.length - 1;
|
||||
lastVal = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type BuildSlicesState = {
|
||||
enabled: boolean;
|
||||
candles: Candle[];
|
||||
bucketSeconds: number;
|
||||
samples: Map<number, BuildSample[]>;
|
||||
series: ISeriesApi<'Histogram', Time> | null;
|
||||
chart: SeriesAttachedParameter<Time>['chart'] | null;
|
||||
};
|
||||
|
||||
class BuildSlicesPaneRenderer implements IPrimitivePaneRenderer {
|
||||
private readonly _getState: () => BuildSlicesState;
|
||||
|
||||
constructor(getState: () => BuildSlicesState) {
|
||||
this._getState = getState;
|
||||
}
|
||||
|
||||
draw(target: any) {
|
||||
const { enabled, candles, bucketSeconds, samples, series, chart } = this._getState();
|
||||
if (!enabled) return;
|
||||
if (!candles.length || !series || !chart) return;
|
||||
|
||||
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||
const lastCandleTime = candles[candles.length - 1]?.time ?? null;
|
||||
|
||||
const yBase = series.priceToCoordinate(0);
|
||||
if (yBase == null) return;
|
||||
|
||||
const xs = candles.map((c) => chart.timeScale().timeToCoordinate(toTime(c.time)));
|
||||
|
||||
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }: any) => {
|
||||
const yBottomPx = Math.round(yBase * verticalPixelRatio);
|
||||
const lastIdx = xs.length - 1;
|
||||
|
||||
for (let i = 0; i < candles.length; i += 1) {
|
||||
const x = xs[i];
|
||||
if (x == null) continue;
|
||||
if (!Number.isFinite(x)) continue;
|
||||
|
||||
const c = candles[i]!;
|
||||
|
||||
const start = c.time;
|
||||
const end = start + bs;
|
||||
const isCurrent = lastCandleTime != null && c.time === lastCandleTime;
|
||||
const now = Date.now() / 1000;
|
||||
const progressT = isCurrent ? Math.min(end, Math.max(start, now)) : end;
|
||||
|
||||
let spacing = 0;
|
||||
const prevX = i > 0 ? xs[i - 1] : null;
|
||||
const nextX = i < lastIdx ? xs[i + 1] : null;
|
||||
if (prevX != null && Number.isFinite(prevX)) spacing = x - prevX;
|
||||
if (nextX != null && Number.isFinite(nextX)) {
|
||||
const s2 = nextX - x;
|
||||
spacing = spacing > 0 ? Math.min(spacing, s2) : s2;
|
||||
}
|
||||
if (!(spacing > 0)) spacing = 6;
|
||||
|
||||
const barWidthCss = Math.max(1, spacing * 0.9);
|
||||
const barWidthPx = Math.max(1, Math.round(barWidthCss * horizontalPixelRatio));
|
||||
const xCenterPx = Math.round(x * horizontalPixelRatio);
|
||||
const xLeftPx = Math.round(xCenterPx - barWidthPx / 2);
|
||||
|
||||
const volumeValue = typeof c.volume === 'number' && Number.isFinite(c.volume) ? c.volume : 0;
|
||||
if (!(volumeValue > 0)) continue;
|
||||
const yTop = series.priceToCoordinate(volumeValue);
|
||||
if (yTop == null) continue;
|
||||
const yTopPx = Math.round(yTop * verticalPixelRatio);
|
||||
if (!(yBottomPx > yTopPx)) continue;
|
||||
const barHeightPx = yBottomPx - yTopPx;
|
||||
|
||||
const x0 = Math.max(0, Math.min(bitmapSize.width, xLeftPx));
|
||||
const x1 = Math.max(0, Math.min(bitmapSize.width, xLeftPx + barWidthPx));
|
||||
const w = x1 - x0;
|
||||
if (!(w > 0)) continue;
|
||||
|
||||
// Prefer server-provided `flowRows`: brick-by-brick direction per time slice inside the candle.
|
||||
// Fallback to `flow` (3 shares) or net candle direction.
|
||||
const rowsFromApi = Array.isArray((c as any).flowRows) ? ((c as any).flowRows as any[]) : null;
|
||||
const rowDirs = rowsFromApi?.length ? rowsFromApi : null;
|
||||
|
||||
if (rowDirs) {
|
||||
const rows = Math.max(1, rowDirs.length);
|
||||
const progressRows = Math.max(0, Math.min(rows, Math.ceil(((progressT - start) / bs) * rows)));
|
||||
const movesFromApi = Array.isArray((c as any).flowMoves) ? ((c as any).flowMoves as any[]) : null;
|
||||
const rowMoves = movesFromApi && movesFromApi.length === rows ? movesFromApi : null;
|
||||
let maxMove = 0;
|
||||
if (rowMoves) {
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const v = Number(rowMoves[r]);
|
||||
if (Number.isFinite(v) && v > maxMove) maxMove = v;
|
||||
}
|
||||
}
|
||||
|
||||
const sepPx = 1; // black separator line between steps
|
||||
const sepColor = 'rgba(0,0,0,0.75)';
|
||||
|
||||
// Blue (flat) bricks have a constant pixel height (unit).
|
||||
// Up/down bricks are scaled by |Δ| in their dt.
|
||||
const minNonFlatPx = 1;
|
||||
let unitFlatPx = 2;
|
||||
|
||||
let flatCount = 0;
|
||||
let nonFlatCount = 0;
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const dirRaw = rowDirs[r];
|
||||
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||
if (dir === 0) flatCount += 1;
|
||||
else nonFlatCount += 1;
|
||||
}
|
||||
|
||||
const sepTotal = Math.max(0, progressRows - 1) * sepPx;
|
||||
if (flatCount > 0) {
|
||||
const maxUnit = Math.floor((barHeightPx - sepTotal - nonFlatCount * minNonFlatPx) / flatCount);
|
||||
unitFlatPx = Math.max(1, Math.min(unitFlatPx, maxUnit));
|
||||
} else {
|
||||
unitFlatPx = 0;
|
||||
}
|
||||
|
||||
let nonFlatAvailable = barHeightPx - sepTotal - flatCount * unitFlatPx;
|
||||
if (!Number.isFinite(nonFlatAvailable) || nonFlatAvailable < 0) nonFlatAvailable = 0;
|
||||
|
||||
const baseNonFlat = nonFlatCount * minNonFlatPx;
|
||||
let extra = nonFlatAvailable - baseNonFlat;
|
||||
if (!Number.isFinite(extra) || extra < 0) extra = 0;
|
||||
|
||||
let sumMove = 0;
|
||||
if (nonFlatCount > 0) {
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const dirRaw = rowDirs[r];
|
||||
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||
if (dir === 0) continue;
|
||||
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
||||
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
||||
sumMove += mv;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(flatCount + nonFlatCount > 0) || barHeightPx <= sepTotal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stack bricks bottom-up (earliest slices at the bottom).
|
||||
let usedExtra = 0;
|
||||
let nonFlatSeen = 0;
|
||||
let y = yBottomPx;
|
||||
for (let r = 0; r < progressRows; r += 1) {
|
||||
const dirRaw = rowDirs[r];
|
||||
const dir = dirRaw > 0 ? 1 : dirRaw < 0 ? -1 : 0;
|
||||
const isLast = r === progressRows - 1;
|
||||
|
||||
let h = 0;
|
||||
if (dir === 0) {
|
||||
h = unitFlatPx;
|
||||
} else {
|
||||
nonFlatSeen += 1;
|
||||
const mvRaw = rowMoves ? Number(rowMoves[r]) : 0;
|
||||
const mv = Number.isFinite(mvRaw) ? Math.max(0, mvRaw) : 0;
|
||||
const share = sumMove > 0 ? mv / sumMove : 1 / nonFlatCount;
|
||||
const wantExtra = Math.floor(extra * share);
|
||||
const isLastNonFlat = nonFlatSeen === nonFlatCount;
|
||||
const add = isLastNonFlat ? Math.max(0, extra - usedExtra) : Math.max(0, wantExtra);
|
||||
usedExtra += add;
|
||||
h = minNonFlatPx + add;
|
||||
}
|
||||
|
||||
if (h <= 0) continue;
|
||||
y -= h;
|
||||
context.fillStyle = sliceColorForDelta(dir);
|
||||
context.fillRect(x0, y, w, h);
|
||||
|
||||
if (!isLast) {
|
||||
y -= sepPx;
|
||||
context.fillStyle = sepColor;
|
||||
context.fillRect(x0, y, w, sepPx);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const f: any = (c as any).flow;
|
||||
let upShare = typeof f?.up === 'number' ? f.up : Number(f?.up);
|
||||
let downShare = typeof f?.down === 'number' ? f.down : Number(f?.down);
|
||||
let flatShare = typeof f?.flat === 'number' ? f.flat : Number(f?.flat);
|
||||
|
||||
if (!Number.isFinite(upShare)) upShare = 0;
|
||||
if (!Number.isFinite(downShare)) downShare = 0;
|
||||
if (!Number.isFinite(flatShare)) flatShare = 0;
|
||||
upShare = Math.max(0, upShare);
|
||||
downShare = Math.max(0, downShare);
|
||||
flatShare = Math.max(0, flatShare);
|
||||
|
||||
let sum = upShare + downShare + flatShare;
|
||||
if (!(sum > 0)) {
|
||||
const overallDir = dirForDelta(c.close - c.open);
|
||||
upShare = overallDir > 0 ? 1 : 0;
|
||||
downShare = overallDir < 0 ? 1 : 0;
|
||||
flatShare = overallDir === 0 ? 1 : 0;
|
||||
sum = 1;
|
||||
}
|
||||
|
||||
upShare /= sum;
|
||||
downShare /= sum;
|
||||
flatShare /= sum;
|
||||
|
||||
const downH = Math.floor(barHeightPx * downShare);
|
||||
const flatH = Math.floor(barHeightPx * flatShare);
|
||||
const upH = Math.max(0, barHeightPx - downH - flatH);
|
||||
|
||||
let y = yBottomPx;
|
||||
|
||||
if (downH > 0) {
|
||||
context.fillStyle = sliceColorForDelta(-1);
|
||||
context.fillRect(x0, y - downH, w, downH);
|
||||
y -= downH;
|
||||
}
|
||||
if (flatH > 0) {
|
||||
context.fillStyle = sliceColorForDelta(0);
|
||||
context.fillRect(x0, y - flatH, w, flatH);
|
||||
y -= flatH;
|
||||
}
|
||||
if (upH > 0) {
|
||||
context.fillStyle = sliceColorForDelta(1);
|
||||
context.fillRect(x0, y - upH, w, upH);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class BuildSlicesPaneView implements IPrimitivePaneView {
|
||||
private readonly _renderer: BuildSlicesPaneRenderer;
|
||||
|
||||
constructor(getState: () => BuildSlicesState) {
|
||||
this._renderer = new BuildSlicesPaneRenderer(getState);
|
||||
}
|
||||
|
||||
zOrder() {
|
||||
return 'top';
|
||||
}
|
||||
|
||||
renderer() {
|
||||
return this._renderer;
|
||||
}
|
||||
}
|
||||
|
||||
class BuildSlicesPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _param: SeriesAttachedParameter<Time> | null = null;
|
||||
private _series: ISeriesApi<'Histogram', Time> | null = null;
|
||||
private _enabled = true;
|
||||
private _candles: Candle[] = [];
|
||||
private _bucketSeconds = 0;
|
||||
private _samples: Map<number, BuildSample[]> = new Map();
|
||||
private readonly _paneView: BuildSlicesPaneView;
|
||||
private readonly _paneViews: readonly IPrimitivePaneView[];
|
||||
|
||||
constructor() {
|
||||
this._paneView = new BuildSlicesPaneView(() => ({
|
||||
enabled: this._enabled,
|
||||
candles: this._candles,
|
||||
bucketSeconds: this._bucketSeconds,
|
||||
samples: this._samples,
|
||||
series: this._series,
|
||||
chart: this._param?.chart ?? null,
|
||||
}));
|
||||
this._paneViews = [this._paneView];
|
||||
}
|
||||
|
||||
attached(param: SeriesAttachedParameter<Time>) {
|
||||
this._param = param;
|
||||
this._series = param.series as ISeriesApi<'Histogram', Time>;
|
||||
}
|
||||
|
||||
detached() {
|
||||
this._param = null;
|
||||
this._series = null;
|
||||
}
|
||||
|
||||
paneViews() {
|
||||
return this._paneViews;
|
||||
}
|
||||
|
||||
setEnabled(next: boolean) {
|
||||
this._enabled = Boolean(next);
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
|
||||
setData(next: { candles: Candle[]; bucketSeconds: number; samples: Map<number, BuildSample[]> }) {
|
||||
this._candles = Array.isArray(next.candles) ? next.candles : [];
|
||||
this._bucketSeconds = Number.isFinite(next.bucketSeconds) ? next.bucketSeconds : 0;
|
||||
this._samples = next.samples;
|
||||
this._param?.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
export default function TradingChart({
|
||||
candles,
|
||||
oracle,
|
||||
@@ -120,6 +493,7 @@ export default function TradingChart({
|
||||
showBuild,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
priceLines,
|
||||
fib,
|
||||
fibOpacity = 1,
|
||||
fibSelected = false,
|
||||
@@ -136,18 +510,23 @@ export default function TradingChart({
|
||||
const fibOpacityRef = useRef<number>(fibOpacity);
|
||||
const priceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||
const prevPriceAutoScaleRef = useRef<boolean>(priceAutoScale);
|
||||
const showBuildRef = useRef<boolean>(showBuild);
|
||||
const onReadyRef = useRef<Props['onReady']>(onReady);
|
||||
const onChartClickRef = useRef<Props['onChartClick']>(onChartClick);
|
||||
const onChartCrosshairMoveRef = useRef<Props['onChartCrosshairMove']>(onChartCrosshairMove);
|
||||
const onPointerEventRef = useRef<Props['onPointerEvent']>(onPointerEvent);
|
||||
const capturedOverlayPointerRef = useRef<number | null>(null);
|
||||
const buildSlicesPrimitiveRef = useRef<BuildSlicesPrimitive | null>(null);
|
||||
const buildSamplesRef = useRef<Map<number, BuildSample[]>>(new Map());
|
||||
const buildKeyRef = useRef<string | null>(null);
|
||||
const lastBuildCandleStartRef = useRef<number | null>(null);
|
||||
const hoverCandleTimeRef = useRef<number | null>(null);
|
||||
const [hoverCandleTime, setHoverCandleTime] = useState<number | null>(null);
|
||||
const priceLinesRef = useRef<Map<string, any>>(new Map());
|
||||
const seriesRef = useRef<{
|
||||
candles?: ISeriesApi<'Candlestick'>;
|
||||
volume?: ISeriesApi<'Histogram'>;
|
||||
build?: ISeriesApi<'Line'>;
|
||||
buildHover?: ISeriesApi<'Line'>;
|
||||
oracle?: ISeriesApi<'Line'>;
|
||||
sma20?: ISeriesApi<'Line'>;
|
||||
ema20?: ISeriesApi<'Line'>;
|
||||
@@ -198,6 +577,14 @@ export default function TradingChart({
|
||||
priceAutoScaleRef.current = priceAutoScale;
|
||||
}, [priceAutoScale]);
|
||||
|
||||
useEffect(() => {
|
||||
showBuildRef.current = showBuild;
|
||||
if (!showBuild && (hoverCandleTimeRef.current != null || hoverCandleTime != null)) {
|
||||
hoverCandleTimeRef.current = null;
|
||||
setHoverCandleTime(null);
|
||||
}
|
||||
}, [showBuild, hoverCandleTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (chartRef.current) return;
|
||||
@@ -249,8 +636,13 @@ export default function TradingChart({
|
||||
scaleMargins: { top: 0.88, bottom: 0 },
|
||||
});
|
||||
|
||||
const buildSeries = chart.addSeries(LineSeries, {
|
||||
color: '#60a5fa',
|
||||
const buildSlicesPrimitive = new BuildSlicesPrimitive();
|
||||
volumeSeries.attachPrimitive(buildSlicesPrimitive);
|
||||
buildSlicesPrimitiveRef.current = buildSlicesPrimitive;
|
||||
buildSlicesPrimitive.setEnabled(showBuildRef.current);
|
||||
|
||||
const buildHoverSeries = chart.addSeries(LineSeries, {
|
||||
color: BUILD_FLAT_COLOR,
|
||||
lineWidth: 2,
|
||||
priceFormat,
|
||||
priceScaleId: 'build',
|
||||
@@ -258,7 +650,7 @@ export default function TradingChart({
|
||||
priceLineVisible: false,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
buildSeries.priceScale().applyOptions({
|
||||
buildHoverSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.72, bottom: 0.12 },
|
||||
visible: false,
|
||||
borderVisible: false,
|
||||
@@ -285,7 +677,7 @@ export default function TradingChart({
|
||||
seriesRef.current = {
|
||||
candles: candleSeries,
|
||||
volume: volumeSeries,
|
||||
build: buildSeries,
|
||||
buildHover: buildHoverSeries,
|
||||
oracle: oracleSeries,
|
||||
sma20: smaSeries,
|
||||
ema20: emaSeries,
|
||||
@@ -335,7 +727,23 @@ export default function TradingChart({
|
||||
chart.subscribeClick(onClick);
|
||||
|
||||
const onCrosshairMove = (param: any) => {
|
||||
if (!param?.point) return;
|
||||
if (!param?.point) {
|
||||
if (showBuildRef.current && hoverCandleTimeRef.current != null) {
|
||||
hoverCandleTimeRef.current = null;
|
||||
setHoverCandleTime(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (showBuildRef.current) {
|
||||
const t = typeof param?.time === 'number' ? Number(param.time) : null;
|
||||
const next = t != null && Number.isFinite(t) ? t : null;
|
||||
if (hoverCandleTimeRef.current !== next) {
|
||||
hoverCandleTimeRef.current = next;
|
||||
setHoverCandleTime(next);
|
||||
}
|
||||
}
|
||||
|
||||
const logical = param.logical ?? chart.timeScale().coordinateToLogical(param.point.x);
|
||||
if (logical == null) return;
|
||||
const price = candleSeries.coordinateToPrice(param.point.y);
|
||||
@@ -580,15 +988,75 @@ export default function TradingChart({
|
||||
candleSeries.detachPrimitive(fibPrimitiveRef.current);
|
||||
fibPrimitiveRef.current = null;
|
||||
}
|
||||
if (buildSlicesPrimitiveRef.current) {
|
||||
volumeSeries.detachPrimitive(buildSlicesPrimitiveRef.current);
|
||||
buildSlicesPrimitiveRef.current = null;
|
||||
}
|
||||
const lines = priceLinesRef.current;
|
||||
for (const line of Array.from(lines.values())) {
|
||||
try {
|
||||
candleSeries.removePriceLine(line);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
lines.clear();
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const candlesSeries = seriesRef.current.candles;
|
||||
if (!candlesSeries) return;
|
||||
|
||||
const desired = (priceLines || []).filter((l) => l.price != null && Number.isFinite(l.price));
|
||||
const desiredIds = new Set(desired.map((l) => l.id));
|
||||
|
||||
const map = priceLinesRef.current;
|
||||
for (const [id, line] of Array.from(map.entries())) {
|
||||
if (desiredIds.has(id)) continue;
|
||||
try {
|
||||
candlesSeries.removePriceLine(line);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
map.delete(id);
|
||||
}
|
||||
|
||||
for (const spec of desired) {
|
||||
const opts: any = {
|
||||
price: spec.price,
|
||||
color: spec.color,
|
||||
title: spec.title,
|
||||
lineWidth: spec.lineWidth ?? 1,
|
||||
lineStyle: spec.lineStyle ?? LineStyle.Dotted,
|
||||
axisLabelVisible: spec.axisLabelVisible ?? true,
|
||||
};
|
||||
|
||||
const existing = map.get(spec.id);
|
||||
if (!existing) {
|
||||
try {
|
||||
const created = candlesSeries.createPriceLine(opts);
|
||||
map.set(spec.id, created);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
existing.applyOptions(opts);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [priceLines]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles || !s.volume || !s.build) return;
|
||||
if (!s.candles || !s.volume || !s.buildHover) return;
|
||||
s.candles.setData(candleData);
|
||||
s.volume.setData(volumeData);
|
||||
s.oracle?.setData(oracleData);
|
||||
@@ -598,34 +1066,18 @@ export default function TradingChart({
|
||||
s.bbLower?.setData(bbLower);
|
||||
s.bbMid?.setData(bbMid);
|
||||
|
||||
s.build.applyOptions({ visible: showBuild });
|
||||
if (!showBuild) {
|
||||
buildSamplesRef.current.clear();
|
||||
buildKeyRef.current = seriesKey;
|
||||
lastBuildCandleStartRef.current = null;
|
||||
s.build.setData([]);
|
||||
}
|
||||
|
||||
if (buildKeyRef.current !== seriesKey) {
|
||||
buildSamplesRef.current.clear();
|
||||
buildKeyRef.current = seriesKey;
|
||||
lastBuildCandleStartRef.current = null;
|
||||
}
|
||||
|
||||
if (!showBuild) {
|
||||
s.sma20?.applyOptions({ visible: showIndicators });
|
||||
s.ema20?.applyOptions({ visible: showIndicators });
|
||||
s.bbUpper?.applyOptions({ visible: showIndicators });
|
||||
s.bbLower?.applyOptions({ visible: showIndicators });
|
||||
s.bbMid?.applyOptions({ visible: showIndicators });
|
||||
return;
|
||||
}
|
||||
|
||||
const bs = resolveBucketSeconds(bucketSeconds, candles);
|
||||
const eps = 1e-3;
|
||||
const maxPointsPerCandle = 600;
|
||||
const minStep = Math.max(0.5, bs / maxPointsPerCandle);
|
||||
const map = buildSamplesRef.current;
|
||||
|
||||
if (buildKeyRef.current !== seriesKey) {
|
||||
map.clear();
|
||||
buildKeyRef.current = seriesKey;
|
||||
lastBuildCandleStartRef.current = null;
|
||||
}
|
||||
|
||||
const visibleStarts = new Set(candles.map((c) => c.time));
|
||||
for (const start of Array.from(map.keys())) {
|
||||
if (!visibleStarts.has(start)) map.delete(start);
|
||||
@@ -671,62 +1123,26 @@ export default function TradingChart({
|
||||
lastBuildCandleStartRef.current = start;
|
||||
}
|
||||
|
||||
const buildRaw: LinePoint[] = [];
|
||||
for (const c of candles) {
|
||||
const list = map.get(c.time);
|
||||
if (!list?.length) continue;
|
||||
const buildPrimitive = buildSlicesPrimitiveRef.current;
|
||||
buildPrimitive?.setData({ candles, bucketSeconds: bs, samples: map });
|
||||
buildPrimitive?.setEnabled(showBuild);
|
||||
|
||||
const startT = c.time + eps;
|
||||
const endT = c.time + bs - eps;
|
||||
if (!(endT > startT)) continue;
|
||||
if (showBuild) {
|
||||
const hoverTime = hoverCandleTime;
|
||||
const hoverCandle = hoverTime == null ? null : candles.find((c) => c.time === hoverTime);
|
||||
const hoverData = hoverCandle ? buildDeltaSeriesForCandle(hoverCandle, bs, map.get(hoverCandle.time)) : [];
|
||||
|
||||
buildRaw.push({ time: toTime(c.time) } as WhitespaceData);
|
||||
buildRaw.push({ time: toTime(startT), value: 0 } as LineData);
|
||||
|
||||
let lastT = startT;
|
||||
for (const p of list) {
|
||||
let t = p.t;
|
||||
if (t <= lastT + eps) t = lastT + eps;
|
||||
if (t >= endT) break;
|
||||
buildRaw.push({ time: toTime(t), value: p.v } as LineData);
|
||||
lastT = t;
|
||||
if (hoverData.length) {
|
||||
s.buildHover.applyOptions({ visible: true });
|
||||
s.buildHover.setData(hoverData);
|
||||
} else {
|
||||
s.buildHover.applyOptions({ visible: false });
|
||||
s.buildHover.setData([]);
|
||||
}
|
||||
|
||||
const finalDelta = c.close - c.open;
|
||||
if (endT > lastT + eps) {
|
||||
buildRaw.push({ time: toTime(endT), value: finalDelta } as LineData);
|
||||
} else if (buildRaw.length) {
|
||||
const prev = buildRaw[buildRaw.length - 1];
|
||||
if ('value' in prev) {
|
||||
buildRaw[buildRaw.length - 1] = { ...prev, value: finalDelta } as LineData;
|
||||
} else {
|
||||
s.buildHover.applyOptions({ visible: false });
|
||||
s.buildHover.setData([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildColored: LinePoint[] = [];
|
||||
let lastLineIdx: number | null = null;
|
||||
let lastLineVal: number | null = null;
|
||||
for (const p of buildRaw) {
|
||||
if (!('value' in p)) {
|
||||
buildColored.push(p);
|
||||
lastLineIdx = null;
|
||||
lastLineVal = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastLineIdx != null && lastLineVal != null) {
|
||||
const delta = p.value - lastLineVal;
|
||||
const color = delta > 0 ? '#22c55e' : delta < 0 ? '#ef4444' : '#60a5fa';
|
||||
const prev = buildColored[lastLineIdx] as LineData;
|
||||
buildColored[lastLineIdx] = { ...prev, color };
|
||||
}
|
||||
|
||||
buildColored.push({ time: p.time, value: p.value } as LineData);
|
||||
lastLineIdx = buildColored.length - 1;
|
||||
lastLineVal = p.value;
|
||||
}
|
||||
|
||||
s.build.setData(buildColored);
|
||||
|
||||
s.sma20?.applyOptions({ visible: showIndicators });
|
||||
s.ema20?.applyOptions({ visible: showIndicators });
|
||||
@@ -747,13 +1163,14 @@ export default function TradingChart({
|
||||
candles,
|
||||
bucketSeconds,
|
||||
seriesKey,
|
||||
hoverCandleTime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const s = seriesRef.current;
|
||||
if (!s.candles) return;
|
||||
s.candles.applyOptions({ priceFormat });
|
||||
s.build?.applyOptions({ priceFormat });
|
||||
s.buildHover?.applyOptions({ priceFormat });
|
||||
s.oracle?.applyOptions({ priceFormat });
|
||||
s.sma20?.applyOptions({ priceFormat });
|
||||
s.ema20?.applyOptions({ priceFormat });
|
||||
|
||||
137
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
137
apps/visualizer/src/features/market/DlobDashboard.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { DlobStats } from './useDlobStats';
|
||||
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
||||
import DlobSlippageChart from './DlobSlippageChart';
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||
return `$${v.toPrecision(4)}`;
|
||||
}
|
||||
|
||||
function formatBps(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${v.toFixed(1)} bps`;
|
||||
}
|
||||
|
||||
function formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${(v * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function statusLabel(connected: boolean, error: string | null): ReactNode {
|
||||
if (error) return <span className="neg">{error}</span>;
|
||||
return connected ? <span className="pos">live</span> : <span className="muted">offline</span>;
|
||||
}
|
||||
|
||||
export default function DlobDashboard({
|
||||
market,
|
||||
stats,
|
||||
statsConnected,
|
||||
statsError,
|
||||
depthBands,
|
||||
depthBandsConnected,
|
||||
depthBandsError,
|
||||
slippageRows,
|
||||
slippageConnected,
|
||||
slippageError,
|
||||
}: {
|
||||
market: string;
|
||||
stats: DlobStats | null;
|
||||
statsConnected: boolean;
|
||||
statsError: string | null;
|
||||
depthBands: DlobDepthBandRow[];
|
||||
depthBandsConnected: boolean;
|
||||
depthBandsError: string | null;
|
||||
slippageRows: DlobSlippageRow[];
|
||||
slippageConnected: boolean;
|
||||
slippageError: string | null;
|
||||
}) {
|
||||
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
||||
|
||||
return (
|
||||
<div className="dlobDash">
|
||||
<div className="dlobDash__head">
|
||||
<div className="dlobDash__title">DLOB</div>
|
||||
<div className="dlobDash__meta">
|
||||
<span className="dlobDash__market">{market}</span>
|
||||
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__statuses">
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">stats</span>
|
||||
<span className="dlobStatus__value">{statusLabel(statsConnected, statsError)}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">depth bands</span>
|
||||
<span className="dlobStatus__value">{statusLabel(depthBandsConnected, depthBandsError)}</span>
|
||||
</div>
|
||||
<div className="dlobStatus">
|
||||
<span className="dlobStatus__label">slippage</span>
|
||||
<span className="dlobStatus__value">{statusLabel(slippageConnected, slippageError)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__grid">
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Bid</div>
|
||||
<div className="dlobKpi__value pos">{formatUsd(stats?.bestBid ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Ask</div>
|
||||
<div className="dlobKpi__value neg">{formatUsd(stats?.bestAsk ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Mid</div>
|
||||
<div className="dlobKpi__value">{formatUsd(stats?.mid ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Spread</div>
|
||||
<div className="dlobKpi__value">{formatBps(stats?.spreadBps ?? null)}</div>
|
||||
<div className="dlobKpi__sub muted">{formatUsd(stats?.spreadAbs ?? null)}</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Depth (bid/ask)</div>
|
||||
<div className="dlobKpi__value">
|
||||
<span className="pos">{formatUsd(stats?.depthBidUsd ?? null)}</span>{' '}
|
||||
<span className="muted">/</span> <span className="neg">{formatUsd(stats?.depthAskUsd ?? null)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dlobKpi">
|
||||
<div className="dlobKpi__label">Imbalance</div>
|
||||
<div className="dlobKpi__value">{formatPct(stats?.imbalance ?? null)}</div>
|
||||
<div className="dlobKpi__sub muted">[-1..1]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__panes">
|
||||
<div className="dlobDash__pane">
|
||||
<DlobDepthBandsPanel rows={depthBands} />
|
||||
</div>
|
||||
|
||||
<div className="dlobDash__pane">
|
||||
<div className="dlobSlippage">
|
||||
<div className="dlobSlippage__head">
|
||||
<div className="dlobSlippage__title">Slippage (impact bps)</div>
|
||||
<div className="dlobSlippage__meta muted">by size (USD)</div>
|
||||
</div>
|
||||
{slippageRows.length ? (
|
||||
<div className="dlobSlippage__chartWrap">
|
||||
<DlobSlippageChart rows={slippageRows} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="dlobSlippage__empty muted">No slippage rows yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
75
apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { DlobDepthBandRow } from './useDlobDepthBands';
|
||||
|
||||
function formatUsd(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
|
||||
if (v >= 1) return `$${v.toFixed(2)}`;
|
||||
return `$${v.toPrecision(4)}`;
|
||||
}
|
||||
|
||||
function formatPct(v: number | null | undefined): string {
|
||||
if (v == null || !Number.isFinite(v)) return '—';
|
||||
return `${v.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function bandRowStyle(askScale: number, bidScale: number): CSSProperties {
|
||||
const a = Number.isFinite(askScale) && askScale > 0 ? Math.min(1, askScale) : 0;
|
||||
const b = Number.isFinite(bidScale) && bidScale > 0 ? Math.min(1, bidScale) : 0;
|
||||
return { ['--ask-scale' as any]: a, ['--bid-scale' as any]: b } as CSSProperties;
|
||||
}
|
||||
|
||||
export default function DlobDepthBandsPanel({ rows }: { rows: DlobDepthBandRow[] }) {
|
||||
const sorted = useMemo(() => rows.slice().sort((a, b) => a.bandBps - b.bandBps), [rows]);
|
||||
|
||||
const maxUsd = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const r of sorted) {
|
||||
if (r.askUsd != null && Number.isFinite(r.askUsd)) max = Math.max(max, r.askUsd);
|
||||
if (r.bidUsd != null && Number.isFinite(r.bidUsd)) max = Math.max(max, r.bidUsd);
|
||||
}
|
||||
return max;
|
||||
}, [sorted]);
|
||||
|
||||
return (
|
||||
<div className="dlobDepth">
|
||||
<div className="dlobDepth__head">
|
||||
<div className="dlobDepth__title">Depth (bands)</div>
|
||||
<div className="dlobDepth__meta">±bps around mid</div>
|
||||
</div>
|
||||
|
||||
<div className="dlobDepth__table">
|
||||
<div className="dlobDepthRow dlobDepthRow--head">
|
||||
<span>Band</span>
|
||||
<span className="dlobDepthRow__num">Ask USD</span>
|
||||
<span className="dlobDepthRow__num">Bid USD</span>
|
||||
<span className="dlobDepthRow__num">Bid %</span>
|
||||
</div>
|
||||
|
||||
{sorted.length ? (
|
||||
sorted.map((r) => (
|
||||
<div
|
||||
key={r.bandBps}
|
||||
className="dlobDepthRow"
|
||||
style={bandRowStyle(maxUsd > 0 ? (r.askUsd || 0) / maxUsd : 0, maxUsd > 0 ? (r.bidUsd || 0) / maxUsd : 0)}
|
||||
title={`band=${r.bandBps}bps bid=${r.bidUsd ?? '—'} ask=${r.askUsd ?? '—'} imbalance=${r.imbalance ?? '—'}`}
|
||||
>
|
||||
<span className="dlobDepthRow__band">{r.bandBps} bps</span>
|
||||
<span className="dlobDepthRow__num neg">{formatUsd(r.askUsd)}</span>
|
||||
<span className="dlobDepthRow__num pos">{formatUsd(r.bidUsd)}</span>
|
||||
<span className="dlobDepthRow__num muted">
|
||||
{r.imbalance == null || !Number.isFinite(r.imbalance)
|
||||
? '—'
|
||||
: formatPct(((r.imbalance + 1) / 2) * 100)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="dlobDepth__empty muted">No depth band rows yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
112
apps/visualizer/src/features/market/DlobSlippageChart.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import Chart from 'chart.js/auto';
|
||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||
|
||||
type Point = { x: number; y: number | null };
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartRef = useRef<Chart | null>(null);
|
||||
|
||||
const { buy, sell, maxImpact } = useMemo(() => {
|
||||
const buy: Point[] = [];
|
||||
const sell: Point[] = [];
|
||||
let maxImpact = 0;
|
||||
|
||||
for (const r of rows) {
|
||||
if (!Number.isFinite(r.sizeUsd) || r.sizeUsd <= 0) continue;
|
||||
const y = r.impactBps == null || !Number.isFinite(r.impactBps) ? null : r.impactBps;
|
||||
if (y != null) maxImpact = Math.max(maxImpact, y);
|
||||
if (r.side === 'buy') buy.push({ x: r.sizeUsd, y });
|
||||
if (r.side === 'sell') sell.push({ x: r.sizeUsd, y });
|
||||
}
|
||||
|
||||
buy.sort((a, b) => a.x - b.x);
|
||||
sell.sort((a, b) => a.x - b.x);
|
||||
|
||||
return { buy, sell, maxImpact };
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
if (chartRef.current) return;
|
||||
|
||||
chartRef.current = new Chart(canvasRef.current, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Buy',
|
||||
data: [],
|
||||
borderColor: 'rgba(34,197,94,0.9)',
|
||||
backgroundColor: 'rgba(34,197,94,0.15)',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: 'Sell',
|
||||
data: [],
|
||||
borderColor: 'rgba(239,68,68,0.9)',
|
||||
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4,
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
parsing: false,
|
||||
interaction: { mode: 'nearest', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#e6e9ef' } },
|
||||
tooltip: { enabled: true },
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
title: { display: true, text: 'Size (USD)', color: '#c7cbd4' },
|
||||
ticks: { color: '#c7cbd4' },
|
||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
beginAtZero: true,
|
||||
suggestedMax: Math.max(10, maxImpact * (1 + clamp01(0.15))),
|
||||
title: { display: true, text: 'Impact (bps)', color: '#c7cbd4' },
|
||||
ticks: { color: '#c7cbd4' },
|
||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [maxImpact]);
|
||||
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
chart.data.datasets[0]!.data = buy as any;
|
||||
chart.data.datasets[1]!.data = sell as any;
|
||||
chart.update('none');
|
||||
}, [buy, sell]);
|
||||
|
||||
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
|
||||
}
|
||||
|
||||
134
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
134
apps/visualizer/src/features/market/useDlobDepthBands.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type DlobDepthBandRow = {
|
||||
marketName: string;
|
||||
bandBps: number;
|
||||
midPrice: number | null;
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
bidUsd: number | null;
|
||||
askUsd: number | null;
|
||||
bidBase: number | null;
|
||||
askBase: number | null;
|
||||
imbalance: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toInt(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number.parseInt(s, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type HasuraRow = {
|
||||
market_name: string;
|
||||
band_bps: unknown;
|
||||
mid_price?: unknown;
|
||||
best_bid_price?: unknown;
|
||||
best_ask_price?: unknown;
|
||||
bid_usd?: unknown;
|
||||
ask_usd?: unknown;
|
||||
bid_base?: unknown;
|
||||
ask_base?: unknown;
|
||||
imbalance?: unknown;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
dlob_depth_bps_latest: HasuraRow[];
|
||||
};
|
||||
|
||||
export function useDlobDepthBands(
|
||||
marketName: string
|
||||
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
|
||||
const [rows, setRows] = useState<DlobDepthBandRow[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setRows([]);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const query = `
|
||||
subscription DlobDepthBands($market: String!) {
|
||||
dlob_depth_bps_latest(
|
||||
where: { market_name: { _eq: $market } }
|
||||
order_by: [{ band_bps: asc }]
|
||||
) {
|
||||
market_name
|
||||
band_bps
|
||||
mid_price
|
||||
best_bid_price
|
||||
best_ask_price
|
||||
bid_usd
|
||||
ask_usd
|
||||
bid_base
|
||||
ask_base
|
||||
imbalance
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const out: DlobDepthBandRow[] = [];
|
||||
for (const r of data?.dlob_depth_bps_latest || []) {
|
||||
if (!r?.market_name) continue;
|
||||
const bandBps = toInt(r.band_bps);
|
||||
if (bandBps == null || bandBps <= 0) continue;
|
||||
out.push({
|
||||
marketName: r.market_name,
|
||||
bandBps,
|
||||
midPrice: toNum(r.mid_price),
|
||||
bestBid: toNum(r.best_bid_price),
|
||||
bestAsk: toNum(r.best_ask_price),
|
||||
bidUsd: toNum(r.bid_usd),
|
||||
askUsd: toNum(r.ask_usd),
|
||||
bidBase: toNum(r.bid_base),
|
||||
askBase: toNum(r.ask_base),
|
||||
imbalance: toNum(r.imbalance),
|
||||
updatedAt: r.updated_at ?? null,
|
||||
});
|
||||
}
|
||||
setRows(out);
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket]);
|
||||
|
||||
return { rows, connected, error };
|
||||
}
|
||||
|
||||
182
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
182
apps/visualizer/src/features/market/useDlobL2.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type OrderbookRow = {
|
||||
price: number;
|
||||
sizeBase: number;
|
||||
sizeUsd: number;
|
||||
totalBase: number;
|
||||
totalUsd: number;
|
||||
};
|
||||
|
||||
export type DlobL2 = {
|
||||
marketName: string;
|
||||
bids: OrderbookRow[];
|
||||
asks: OrderbookRow[];
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
mid: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function envNumber(name: string): number | undefined {
|
||||
const raw = (import.meta as any).env?.[name];
|
||||
if (raw == null) return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeJson(v: unknown): unknown {
|
||||
if (typeof v !== 'string') return v;
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLevels(raw: unknown, pricePrecision: number, basePrecision: number): Array<{ price: number; size: number }> {
|
||||
const v = normalizeJson(raw);
|
||||
if (!Array.isArray(v)) return [];
|
||||
|
||||
const out: Array<{ price: number; size: number }> = [];
|
||||
for (const item of v) {
|
||||
const priceInt = toNum((item as any)?.price);
|
||||
const sizeInt = toNum((item as any)?.size);
|
||||
if (priceInt == null || sizeInt == null) continue;
|
||||
|
||||
const price = priceInt / pricePrecision;
|
||||
const size = sizeInt / basePrecision;
|
||||
if (!Number.isFinite(price) || !Number.isFinite(size)) continue;
|
||||
out.push({ price, size });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function withTotals(levels: Array<{ price: number; sizeBase: number }>): OrderbookRow[] {
|
||||
let totalBase = 0;
|
||||
let totalUsd = 0;
|
||||
|
||||
return levels.map((l) => {
|
||||
const sizeUsd = l.sizeBase * l.price;
|
||||
totalBase += l.sizeBase;
|
||||
totalUsd += sizeUsd;
|
||||
|
||||
return {
|
||||
price: l.price,
|
||||
sizeBase: l.sizeBase,
|
||||
sizeUsd,
|
||||
totalBase,
|
||||
totalUsd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type HasuraDlobL2Row = {
|
||||
market_name: string;
|
||||
bids?: unknown;
|
||||
asks?: unknown;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
dlob_l2_latest: HasuraDlobL2Row[];
|
||||
};
|
||||
|
||||
export function useDlobL2(
|
||||
marketName: string,
|
||||
opts?: { levels?: number }
|
||||
): { l2: DlobL2 | null; connected: boolean; error: string | null } {
|
||||
const [l2, setL2] = useState<DlobL2 | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
const levels = useMemo(() => Math.max(1, opts?.levels ?? 14), [opts?.levels]);
|
||||
|
||||
const pricePrecision = useMemo(() => envNumber('VITE_DLOB_PRICE_PRECISION') ?? 1_000_000, []);
|
||||
const basePrecision = useMemo(() => envNumber('VITE_DLOB_BASE_PRECISION') ?? 1_000_000_000, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setL2(null);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const query = `
|
||||
subscription DlobL2($market: String!) {
|
||||
dlob_l2_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||
market_name
|
||||
bids
|
||||
asks
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const row = data?.dlob_l2_latest?.[0];
|
||||
if (!row?.market_name) return;
|
||||
|
||||
const bidsSorted = parseLevels(row.bids, pricePrecision, basePrecision)
|
||||
.slice()
|
||||
.sort((a, b) => b.price - a.price)
|
||||
.slice(0, levels)
|
||||
.map((l) => ({ price: l.price, sizeBase: l.size }));
|
||||
|
||||
const asksSorted = parseLevels(row.asks, pricePrecision, basePrecision)
|
||||
.slice()
|
||||
.sort((a, b) => a.price - b.price)
|
||||
.slice(0, levels)
|
||||
.map((l) => ({ price: l.price, sizeBase: l.size }));
|
||||
|
||||
// We compute totals from best -> worse.
|
||||
// For UI we display asks with best ask closest to mid (at the bottom), so we reverse.
|
||||
const bids = withTotals(bidsSorted);
|
||||
const asks = withTotals(asksSorted).slice().reverse();
|
||||
|
||||
const bestBid = bidsSorted.length ? bidsSorted[0].price : null;
|
||||
const bestAsk = asksSorted.length ? asksSorted[0].price : null;
|
||||
const mid = bestBid != null && bestAsk != null ? (bestBid + bestAsk) / 2 : null;
|
||||
|
||||
setL2({
|
||||
marketName: row.market_name,
|
||||
bids,
|
||||
asks,
|
||||
bestBid,
|
||||
bestAsk,
|
||||
mid,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket, levels, pricePrecision, basePrecision]);
|
||||
|
||||
return { l2, connected, error };
|
||||
}
|
||||
138
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
138
apps/visualizer/src/features/market/useDlobSlippage.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type DlobSlippageRow = {
|
||||
marketName: string;
|
||||
side: 'buy' | 'sell';
|
||||
sizeUsd: number;
|
||||
midPrice: number | null;
|
||||
vwapPrice: number | null;
|
||||
worstPrice: number | null;
|
||||
filledUsd: number | null;
|
||||
filledBase: number | null;
|
||||
impactBps: number | null;
|
||||
levelsConsumed: number | null;
|
||||
fillPct: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
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[];
|
||||
};
|
||||
|
||||
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
||||
const [rows, setRows] = useState<DlobSlippageRow[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setRows([]);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const query = `
|
||||
subscription DlobSlippage($market: String!) {
|
||||
dlob_slippage_latest(
|
||||
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[] = [];
|
||||
for (const r of data?.dlob_slippage_latest || []) {
|
||||
if (!r?.market_name) continue;
|
||||
const side = String(r.side || '').trim();
|
||||
if (side !== 'buy' && side !== 'sell') continue;
|
||||
const sizeUsd = toInt(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: toInt(r.levels_consumed),
|
||||
fillPct: toNum(r.fill_pct),
|
||||
updatedAt: r.updated_at ?? null,
|
||||
});
|
||||
}
|
||||
setRows(out);
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket]);
|
||||
|
||||
return { rows, connected, error };
|
||||
}
|
||||
|
||||
123
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
123
apps/visualizer/src/features/market/useDlobStats.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
|
||||
|
||||
export type DlobStats = {
|
||||
marketName: string;
|
||||
markPrice: number | null;
|
||||
oraclePrice: number | null;
|
||||
bestBid: number | null;
|
||||
bestAsk: number | null;
|
||||
mid: number | null;
|
||||
spreadAbs: number | null;
|
||||
spreadBps: number | null;
|
||||
depthBidBase: number | null;
|
||||
depthAskBase: number | null;
|
||||
depthBidUsd: number | null;
|
||||
depthAskUsd: number | null;
|
||||
imbalance: number | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
function toNum(v: unknown): number | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type HasuraDlobStatsRow = {
|
||||
market_name: string;
|
||||
mark_price?: string | null;
|
||||
oracle_price?: string | null;
|
||||
best_bid_price?: string | null;
|
||||
best_ask_price?: string | null;
|
||||
mid_price?: string | null;
|
||||
spread_abs?: string | null;
|
||||
spread_bps?: string | null;
|
||||
depth_bid_base?: string | null;
|
||||
depth_ask_base?: string | null;
|
||||
depth_bid_usd?: string | null;
|
||||
depth_ask_usd?: string | null;
|
||||
imbalance?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
type SubscriptionData = {
|
||||
dlob_stats_latest: HasuraDlobStatsRow[];
|
||||
};
|
||||
|
||||
export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } {
|
||||
const [stats, setStats] = useState<DlobStats | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedMarket) {
|
||||
setStats(null);
|
||||
setError(null);
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const query = `
|
||||
subscription DlobStats($market: String!) {
|
||||
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||
market_name
|
||||
mark_price
|
||||
oracle_price
|
||||
best_bid_price
|
||||
best_ask_price
|
||||
mid_price
|
||||
spread_abs
|
||||
spread_bps
|
||||
depth_bid_base
|
||||
depth_ask_base
|
||||
depth_bid_usd
|
||||
depth_ask_usd
|
||||
imbalance
|
||||
updated_at
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sub = subscribeGraphqlWs<SubscriptionData>({
|
||||
query,
|
||||
variables: { market: normalizedMarket },
|
||||
onStatus: ({ connected }) => setConnected(connected),
|
||||
onError: (e) => setError(e),
|
||||
onData: (data) => {
|
||||
const row = data?.dlob_stats_latest?.[0];
|
||||
if (!row?.market_name) return;
|
||||
setStats({
|
||||
marketName: row.market_name,
|
||||
markPrice: toNum(row.mark_price),
|
||||
oraclePrice: toNum(row.oracle_price),
|
||||
bestBid: toNum(row.best_bid_price),
|
||||
bestAsk: toNum(row.best_ask_price),
|
||||
mid: toNum(row.mid_price),
|
||||
spreadAbs: toNum(row.spread_abs),
|
||||
spreadBps: toNum(row.spread_bps),
|
||||
depthBidBase: toNum(row.depth_bid_base),
|
||||
depthAskBase: toNum(row.depth_ask_base),
|
||||
depthBidUsd: toNum(row.depth_bid_usd),
|
||||
depthAskUsd: toNum(row.depth_ask_usd),
|
||||
imbalance: toNum(row.imbalance),
|
||||
updatedAt: row.updated_at ?? null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, [normalizedMarket]);
|
||||
|
||||
return { stats, connected, error };
|
||||
}
|
||||
@@ -6,6 +6,9 @@ export type Candle = {
|
||||
close: number;
|
||||
volume?: number;
|
||||
oracle?: number | null;
|
||||
flow?: { up: number; down: number; flat: number };
|
||||
flowRows?: number[];
|
||||
flowMoves?: number[];
|
||||
};
|
||||
|
||||
export type SeriesPoint = {
|
||||
@@ -68,9 +71,26 @@ export async function fetchChart(params: {
|
||||
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) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
181
apps/visualizer/src/lib/graphqlWs.ts
Normal file
181
apps/visualizer/src/lib/graphqlWs.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
type HeadersMap = Record<string, string>;
|
||||
|
||||
type SubscribeParams<T> = {
|
||||
query: string;
|
||||
variables?: Record<string, unknown>;
|
||||
onData: (data: T) => void;
|
||||
onError?: (err: string) => void;
|
||||
onStatus?: (s: { connected: boolean }) => void;
|
||||
};
|
||||
|
||||
function envString(name: string): string | undefined {
|
||||
const v = (import.meta as any).env?.[name];
|
||||
const s = v == null ? '' : String(v).trim();
|
||||
return s ? s : undefined;
|
||||
}
|
||||
|
||||
function resolveGraphqlHttpUrl(): string {
|
||||
return envString('VITE_HASURA_URL') || '/graphql';
|
||||
}
|
||||
|
||||
function resolveGraphqlWsUrl(): string {
|
||||
const explicit = envString('VITE_HASURA_WS_URL');
|
||||
if (explicit) {
|
||||
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
|
||||
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
|
||||
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
|
||||
return `${proto}//${host}${path}`;
|
||||
}
|
||||
|
||||
const httpUrl = resolveGraphqlHttpUrl();
|
||||
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
|
||||
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
|
||||
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`;
|
||||
return `${proto}//${host}${path}`;
|
||||
}
|
||||
|
||||
function resolveAuthHeaders(): HeadersMap | undefined {
|
||||
const token = envString('VITE_HASURA_AUTH_TOKEN');
|
||||
if (token) return { authorization: `Bearer ${token}` };
|
||||
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
||||
if (secret) return { 'x-hasura-admin-secret': secret };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type WsMessage =
|
||||
| { type: 'connection_ack' | 'ka' | 'complete' }
|
||||
| { type: 'connection_error'; payload?: any }
|
||||
| { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } }
|
||||
| { type: 'error'; id: string; payload?: any };
|
||||
|
||||
export type SubscriptionHandle = {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
|
||||
export function subscribeGraphqlWs<T>({ query, variables, onData, onError, onStatus }: SubscribeParams<T>): SubscriptionHandle {
|
||||
const wsUrl = resolveGraphqlWsUrl();
|
||||
const headers = resolveAuthHeaders();
|
||||
let ws: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let started = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
|
||||
const subId = '1';
|
||||
|
||||
const emitError = (e: unknown) => {
|
||||
const msg = typeof e === 'string' ? e : String((e as any)?.message || e);
|
||||
onError?.(msg);
|
||||
};
|
||||
|
||||
const setConnected = (connected: boolean) => onStatus?.({ connected });
|
||||
|
||||
const start = () => {
|
||||
if (!ws || started) return;
|
||||
started = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: subId,
|
||||
type: 'start',
|
||||
payload: { query, variables: variables ?? {} },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
started = false;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl, 'graphql-ws');
|
||||
} catch (e) {
|
||||
emitError(e);
|
||||
reconnectTimer = window.setTimeout(connect, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
const payload = headers ? { headers } : {};
|
||||
ws?.send(JSON.stringify({ type: 'connection_init', payload }));
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: WsMessage;
|
||||
try {
|
||||
msg = JSON.parse(String(ev.data));
|
||||
} catch (e) {
|
||||
emitError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'connection_ack') {
|
||||
start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'connection_error') {
|
||||
emitError(msg.payload || 'connection_error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'ka' || msg.type === 'complete') return;
|
||||
|
||||
if (msg.type === 'error') {
|
||||
emitError(msg.payload || 'subscription_error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'data') {
|
||||
const errors = msg.payload?.errors;
|
||||
if (Array.isArray(errors) && errors.length) {
|
||||
emitError(errors.map((e) => e.message).join(' | '));
|
||||
return;
|
||||
}
|
||||
if (msg.payload?.data != null) onData(msg.payload.data as T);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
if (closed) return;
|
||||
reconnectTimer = window.setTimeout(connect, 1000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
closed = true;
|
||||
setConnected(false);
|
||||
if (reconnectTimer != null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (!ws) return;
|
||||
try {
|
||||
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
|
||||
ws.send(JSON.stringify({ type: 'connection_terminate' }));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
ws = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -18,7 +18,7 @@ function getApiUrl(): string | undefined {
|
||||
}
|
||||
|
||||
function getHasuraUrl(): string {
|
||||
return (import.meta as any).env?.VITE_HASURA_URL || 'http://localhost:8080/v1/graphql';
|
||||
return (import.meta as any).env?.VITE_HASURA_URL || '/graphql';
|
||||
}
|
||||
|
||||
function getAuthToken(): string | undefined {
|
||||
|
||||
@@ -46,6 +46,10 @@ a:hover {
|
||||
color: var(--neg);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shell {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
@@ -981,6 +985,226 @@ body.chartFullscreen {
|
||||
padding: 10px 2px;
|
||||
}
|
||||
|
||||
.dlobDash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDash__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobDash__title {
|
||||
font-weight: 950;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.dlobDash__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobDash__market {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dlobDash__statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobStatus {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.dlobStatus__label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dlobDash__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobKpi {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.dlobKpi__label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dlobKpi__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.dlobKpi__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dlobDash__panes {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDash__pane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDepth {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobDepth__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobDepth__title {
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobDepth__meta {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dlobDepth__table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dlobDepthRow {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1fr 1fr 0.7fr;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dlobDepthRow > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dlobDepthRow::before,
|
||||
.dlobDepthRow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dlobDepthRow::before {
|
||||
transform: scaleX(var(--ask-scale, 0));
|
||||
transform-origin: left center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(239, 68, 68, 0.18) 0%,
|
||||
rgba(239, 68, 68, 0.06) 60%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dlobDepthRow::after {
|
||||
transform: scaleX(var(--bid-scale, 0));
|
||||
transform-origin: right center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 197, 94, 0) 0%,
|
||||
rgba(34, 197, 94, 0.06) 40%,
|
||||
rgba(34, 197, 94, 0.18) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dlobDepthRow--head {
|
||||
padding: 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dlobDepthRow--head::before,
|
||||
.dlobDepthRow--head::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dlobDepthRow__num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dlobDepth__empty {
|
||||
padding: 8px 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobSlippage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dlobSlippage__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dlobSlippage__title {
|
||||
font-weight: 900;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dlobSlippage__chartWrap {
|
||||
height: 220px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.dlobSlippageChart {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.dlobSlippage__empty {
|
||||
padding: 8px 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bottomCard .uiCard__body {
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
@@ -1046,6 +1270,78 @@ body.chartFullscreen {
|
||||
padding: 2px 2px;
|
||||
border-radius: 8px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.orderbookRow > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.orderbookRow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.35;
|
||||
transform: scaleX(var(--ob-total-scale, 0));
|
||||
transition: transform 220ms ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.orderbookRow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
transform: scaleX(var(--ob-level-scale, 0));
|
||||
transition: transform 220ms ease-out;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.orderbookRow--ask::before {
|
||||
transform-origin: left center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(239, 68, 68, 0.24) 0%,
|
||||
rgba(239, 68, 68, 0.09) 60%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.orderbookRow--bid::before {
|
||||
transform-origin: right center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 197, 94, 0) 0%,
|
||||
rgba(34, 197, 94, 0.09) 40%,
|
||||
rgba(34, 197, 94, 0.24) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.orderbookRow--ask::after {
|
||||
transform-origin: left center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(239, 68, 68, 0.36) 0%,
|
||||
rgba(239, 68, 68, 0.12) 55%,
|
||||
rgba(239, 68, 68, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.orderbookRow--bid::after {
|
||||
transform-origin: right center;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 197, 94, 0) 0%,
|
||||
rgba(34, 197, 94, 0.12) 45%,
|
||||
rgba(34, 197, 94, 0.36) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.orderbookRow__num {
|
||||
@@ -1081,6 +1377,69 @@ body.chartFullscreen {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.orderbookMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 2px 2px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.orderbookMeta__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.orderbookMeta__val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.liquidityBar {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.liquidityBar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
transform: translateX(-0.5px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.liquidityBar__bid,
|
||||
.liquidityBar__ask {
|
||||
height: 100%;
|
||||
transform: scaleX(0);
|
||||
transform-origin: center;
|
||||
transition: transform 180ms ease-out;
|
||||
}
|
||||
|
||||
.liquidityBar__bid {
|
||||
background: linear-gradient(90deg, rgba(34, 197, 94, 0.0) 0%, rgba(34, 197, 94, 0.35) 55%, rgba(34, 197, 94, 0.85) 100%);
|
||||
transform-origin: right center;
|
||||
transform: scaleX(var(--liq-bid, 0));
|
||||
}
|
||||
|
||||
.liquidityBar__ask {
|
||||
background: linear-gradient(90deg, rgba(239, 68, 68, 0.85) 0%, rgba(239, 68, 68, 0.35) 45%, rgba(239, 68, 68, 0.0) 100%);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(var(--liq-ask, 0));
|
||||
}
|
||||
|
||||
.trades {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -14,8 +14,8 @@ function stripTrailingSlashes(p: string): string {
|
||||
return out || '/';
|
||||
}
|
||||
|
||||
function readApiReadToken(): string | undefined {
|
||||
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
||||
function readApiReadToken(env: Record<string, string | undefined>): string | undefined {
|
||||
if (env.API_READ_TOKEN) return env.API_READ_TOKEN;
|
||||
const p = path.join(ROOT, 'tokens', 'read.json');
|
||||
if (!fs.existsSync(p)) return undefined;
|
||||
try {
|
||||
@@ -38,11 +38,11 @@ function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
function readProxyBasicAuth(): BasicAuth | undefined {
|
||||
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH);
|
||||
function readProxyBasicAuth(env: Record<string, string | undefined>): BasicAuth | undefined {
|
||||
const fromEnv = parseBasicAuth(env.API_PROXY_BASIC_AUTH);
|
||||
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;
|
||||
|
||||
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
|
||||
@@ -59,10 +59,6 @@ function readProxyBasicAuth(): BasicAuth | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const apiReadToken = readApiReadToken();
|
||||
const proxyBasicAuth = readProxyBasicAuth();
|
||||
const apiProxyTarget = process.env.API_PROXY_TARGET || 'http://localhost:8787';
|
||||
|
||||
function parseUrl(v: string): URL | undefined {
|
||||
try {
|
||||
return new URL(v);
|
||||
@@ -71,10 +67,6 @@ function parseUrl(v: string): URL | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
||||
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
||||
|
||||
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
||||
try {
|
||||
const u = new URL(apiTarget);
|
||||
@@ -91,13 +83,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);
|
||||
|
||||
function applyProxyBasicAuth(proxyReq: any) {
|
||||
if (!proxyBasicAuth) return false;
|
||||
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||
@@ -117,6 +102,33 @@ function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
||||
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = { ...process.env, ...loadEnv(mode, DIR, '') } as Record<string, string | undefined>;
|
||||
const apiReadToken = readApiReadToken(env);
|
||||
const proxyBasicAuth = readProxyBasicAuth(env);
|
||||
const apiProxyTarget = env.API_PROXY_TARGET || 'http://localhost:8787';
|
||||
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
||||
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
||||
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
||||
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 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;
|
||||
}
|
||||
|
||||
const proxy: Record<string, any> = {
|
||||
'/api': {
|
||||
target: apiProxyTarget,
|
||||
@@ -131,6 +143,24 @@ const proxy: Record<string, any> = {
|
||||
},
|
||||
};
|
||||
|
||||
if (graphqlProxyTarget) {
|
||||
for (const prefix of ['/graphql', '/graphql-ws']) {
|
||||
proxy[prefix] = {
|
||||
target: graphqlProxyTarget,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
configure: (p: any) => {
|
||||
p.on('proxyReq', (proxyReq: any) => {
|
||||
applyProxyBasicAuth(proxyReq);
|
||||
});
|
||||
p.on('proxyReqWs', (proxyReq: any) => {
|
||||
applyProxyBasicAuth(proxyReq);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (uiProxyTarget) {
|
||||
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||
proxy[prefix] = {
|
||||
@@ -148,11 +178,12 @@ if (uiProxyTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
port: Number.isInteger(port) && port > 0 ? port : 5173,
|
||||
strictPort: true,
|
||||
proxy,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
344
doc/data-ingest-strategy.md
Normal file
344
doc/data-ingest-strategy.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Data ingest strategy
|
||||
|
||||
## Cel
|
||||
|
||||
Zachowac niski latency dla logiki tradingowej i read-side, ale rownoczesnie miec trwala persystencje danych do UI, replayu, debugowania i analityki.
|
||||
|
||||
Nie robimy modelu `DB-first` jako jedynej sciezki przetwarzania.
|
||||
|
||||
## Rekomendowany model
|
||||
|
||||
Stosujemy `dual-path`:
|
||||
|
||||
1. `hot path`
|
||||
`Yellowstone gRPC / Agave RPC -> consumer -> Redis / internal state -> strategy / read-side`
|
||||
|
||||
2. `durable path`
|
||||
`ten sam consumer -> Postgres -> UI / Hasura / replay / debug`
|
||||
|
||||
Opcjonalnie pozniej:
|
||||
|
||||
3. `history / analytics path`
|
||||
`consumer lub batch exporter -> ClickHouse`
|
||||
|
||||
## Dlaczego nie DB-first
|
||||
|
||||
Model:
|
||||
|
||||
`gRPC / RPC -> DB -> dopiero potem przetwarzanie`
|
||||
|
||||
nie jest dobry jako glowna sciezka, bo:
|
||||
|
||||
- dodaje latency do kazdego eventu
|
||||
- uzaleznia ingest od kondycji bazy
|
||||
- pogarsza odpornosc calego pipeline
|
||||
- zwieksza write amplification przy duzym wolumenie zdarzen
|
||||
- utrudnia low-latency strategie i read-side
|
||||
|
||||
DB ma byc miejscem persystencji i read modelu, nie bramka przed logika runtime.
|
||||
|
||||
## Rola Yellowstone gRPC
|
||||
|
||||
Yellowstone ma byc glownym zrodlem `live feed`:
|
||||
|
||||
- account updates
|
||||
- tx updates
|
||||
- slot updates
|
||||
- program updates
|
||||
|
||||
Sciezka:
|
||||
|
||||
`Yellowstone -> consumer -> hot state`
|
||||
|
||||
oraz rownolegle:
|
||||
|
||||
`Yellowstone -> normalization / persistence -> Postgres`
|
||||
|
||||
Yellowstone nie powinien byc uzalezniony od tego, czy Postgres aktualnie pisze szybko.
|
||||
|
||||
## Rola Agave RPC
|
||||
|
||||
RPC nie jest naturalnym feedem eventowym.
|
||||
|
||||
RPC powinno sluzyc glownie do:
|
||||
|
||||
- point reads
|
||||
- bootstrapu klienta
|
||||
- recovery
|
||||
- getAccountInfo / getMultipleAccounts
|
||||
- getHealth / getSlot
|
||||
- write path dla tx, jesli nie idziemy przez TPU/Jito
|
||||
|
||||
RPC nie powinno byc traktowane jak glowny event stream do zrzucania wszystkiego do DB.
|
||||
|
||||
## Rola Redis
|
||||
|
||||
Redis powinien byc `hot state layer`:
|
||||
|
||||
- szybki stan DLOB
|
||||
- cache
|
||||
- fanout wewnetrzny
|
||||
- ewentualnie stream / queue dla workerow
|
||||
|
||||
To jest warstwa pod:
|
||||
|
||||
- strategy engine
|
||||
- orderbook read-side
|
||||
- szybkie projekcje
|
||||
|
||||
Nie trzymamy tam historii jako source of truth.
|
||||
|
||||
## Rola Postgres
|
||||
|
||||
Postgres powinien byc `durable read model`:
|
||||
|
||||
- dane dla UI
|
||||
- dane dla Hasury
|
||||
- znormalizowane ticki
|
||||
- pochodne tabele DLOB
|
||||
- stan aplikacji
|
||||
- konfiguracja
|
||||
- historia operacyjna
|
||||
|
||||
Postgres jest dobry do:
|
||||
|
||||
- frontend
|
||||
- dashboard
|
||||
- replay lekkiego zakresu
|
||||
- audyt aplikacyjny
|
||||
|
||||
## Kiedy ClickHouse
|
||||
|
||||
ClickHouse dodajemy dopiero, gdy:
|
||||
|
||||
- wolumen raw feedu bedzie duzy
|
||||
- potrzebna bedzie ciezsza analityka historyczna
|
||||
- bedziemy chcieli trzymac dluzsza historie eventow i tx
|
||||
|
||||
Na obecnym etapie nie jest konieczny do uruchomienia runtime.
|
||||
|
||||
## Docelowy przeplyw
|
||||
|
||||
```text
|
||||
mevnode_sol
|
||||
Agave RPC
|
||||
Yellowstone gRPC
|
||||
|
|
||||
v
|
||||
mevnode_bot
|
||||
consumer
|
||||
|\
|
||||
| +--> Redis -> DLOB/read-side -> strategy/workers
|
||||
|
|
||||
+----> Postgres -> Hasura/API -> frontend
|
||||
```
|
||||
|
||||
## Zasada architektoniczna
|
||||
|
||||
- `gRPC / RPC` dostarcza dane do runtime
|
||||
- `Redis` utrzymuje szybki stan operacyjny
|
||||
- `Postgres` utrzymuje trwaly stan i read model
|
||||
- `frontend` i `Hasura` czytaja z Postgresa
|
||||
- `strategy` i `workers` nie czekaja na zapis do DB, aby przetwarzac dane
|
||||
|
||||
## Rekomendacja dla projektu
|
||||
|
||||
Na teraz:
|
||||
|
||||
1. `Yellowstone -> consumer -> Redis`
|
||||
2. `consumer -> Postgres`
|
||||
3. `Hasura / API / UI <- Postgres`
|
||||
4. `strategy / read-side <- Redis`
|
||||
5. `RPC` zostawic do bootstrapu, point reads i write path
|
||||
|
||||
To daje najlepszy kompromis:
|
||||
|
||||
- niski latency
|
||||
- odpornosc runtime
|
||||
- sensowna persystencja
|
||||
- dobra baza pod UI i replay
|
||||
|
||||
## Ocena obecnego ukladu na k3s
|
||||
|
||||
Obecny runtime na `mevnode_bot / trade-staging` jest tylko czesciowo zgodny z docelowym modelem.
|
||||
|
||||
### Co jest zgodne
|
||||
|
||||
- `postgres` jest postawiony jako `StatefulSet`, wiec nadaje sie na trwały storage
|
||||
- `dlob-redis` jest osobna lekka usluga i pasuje do roli cache / hot state
|
||||
- `hasura` i `trade-api` siedza nad `Postgresem`, co jest poprawne dla durable read modelu
|
||||
- `frontend` korzysta z `API/Hasury`, a nie bezposrednio z `Redis`
|
||||
|
||||
### Co nie jest zgodne
|
||||
|
||||
- `dlob-publisher` i `dlob-server` nie sa zdrowe, wiec hot path nie jest operacyjnie domkniety
|
||||
- `dlob-publisher` jest podpiety do `gRPC`, ale nie jest glownym i pewnym writerem do `Postgresa`
|
||||
- `trade-ingestor` nie ingestuje danych bezposrednio z chain source, tylko polluje dane pochodne z `Hasury`
|
||||
- persistent path jest wiec dzisiaj czesciowo odwrocony:
|
||||
- `derived view -> ingest -> Postgres`
|
||||
- zamiast:
|
||||
- `chain source -> normalize -> Postgres`
|
||||
|
||||
## Plan zmian
|
||||
|
||||
### Cel koncowy
|
||||
|
||||
Chcemy dojsc do ukladu:
|
||||
|
||||
```text
|
||||
Yellowstone gRPC / Agave RPC
|
||||
|
|
||||
v
|
||||
consumer
|
||||
|\
|
||||
| +--> Redis -> hot state / workers / strategy
|
||||
|
|
||||
+----> Postgres -> Hasura / API / frontend / replay
|
||||
```
|
||||
|
||||
### Etap 1. Naprawa hot path
|
||||
|
||||
Najpierw trzeba przywrocic dzialanie:
|
||||
|
||||
- `dlob-publisher`
|
||||
- `dlob-server`
|
||||
|
||||
Bez zdrowego publishera nie ma sensu budowac dalszej warstwy `Redis = hot state`.
|
||||
|
||||
Zakres:
|
||||
|
||||
- ustabilizowac `Agave RPC` i `Yellowstone`
|
||||
- doprowadzic `dlob-publisher` do `Ready`
|
||||
- potwierdzic, ze `Redis` dostaje aktualny stan
|
||||
- doprowadzic `dlob-server` do `Ready`
|
||||
|
||||
### Etap 2. Uczynic publisher glownym consumerem danych chain
|
||||
|
||||
Obecnie `trade-ingestor` korzysta z danych pochodnych z `Hasury`.
|
||||
|
||||
To trzeba zmienic tak, aby glownym miejscem wejscia danych byl komponent konsumujacy:
|
||||
|
||||
- `Yellowstone gRPC`
|
||||
- `Agave RPC`
|
||||
|
||||
Rekomendacja:
|
||||
|
||||
- rozszerzyc `dlob-publisher`
|
||||
- albo wydzielic nowy `market-consumer`
|
||||
|
||||
ale w obu przypadkach komponent ma robic:
|
||||
|
||||
- odczyt live z `Yellowstone`
|
||||
- wymagane point reads z `RPC`
|
||||
- normalizacje danych
|
||||
- jednoczesny zapis do `Redis` i `Postgresa`
|
||||
|
||||
### Etap 3. Ustalic twardy podzial rol Redis / Postgres
|
||||
|
||||
#### Redis
|
||||
|
||||
Do `Redis` trafia tylko to, co potrzebne jako szybki stan runtime:
|
||||
|
||||
- latest DLOB state
|
||||
- latest L2
|
||||
- quick cache dla workerow
|
||||
- fanout do read-side i strategii
|
||||
- dane z TTL lub dane odtwarzalne
|
||||
|
||||
`Redis` nie jest source of truth.
|
||||
|
||||
#### Postgres
|
||||
|
||||
Do `Postgresa` trafia trwaly model danych:
|
||||
|
||||
- znormalizowane ticki
|
||||
- znormalizowane snapshoty rynku
|
||||
- pochodne tabele pod `Hasure`
|
||||
- historia operacyjna
|
||||
- stan aplikacji
|
||||
|
||||
`Postgres` jest source of truth dla aplikacji i UI.
|
||||
|
||||
### Etap 4. Przebudowac worker path
|
||||
|
||||
Docelowy model dla workerow:
|
||||
|
||||
- `Redis in`
|
||||
- `Postgres out`
|
||||
|
||||
To znaczy:
|
||||
|
||||
- workery czytaja szybki stan z `Redis`
|
||||
- licza projekcje
|
||||
- zapisują wynik do `Postgresa`
|
||||
|
||||
Dotyczy to szczegolnie:
|
||||
|
||||
- `dlob-worker`
|
||||
- `dlob-depth-worker`
|
||||
- `dlob-slippage-worker`
|
||||
|
||||
### Etap 5. Wygasic obecny odwrocony ingest
|
||||
|
||||
Obecny `trade-ingestor` zapisuje dane do `Postgresa` na podstawie danych juz pochodnych z `Hasury`.
|
||||
|
||||
To nie powinno byc glowna sciezka ingestu.
|
||||
|
||||
Mozliwe role po zmianie:
|
||||
|
||||
- backfill
|
||||
- sanity check
|
||||
- fallback
|
||||
- testowy importer
|
||||
|
||||
Ale nie:
|
||||
|
||||
- glowny chain ingest
|
||||
|
||||
### Etap 6. Utrwalic read model pod UI
|
||||
|
||||
Po zmianach:
|
||||
|
||||
- `Hasura` czyta tylko z `Postgresa`
|
||||
- `trade-api` czyta tylko z `Postgresa`
|
||||
- `frontend` czyta przez `API/Hasure`
|
||||
|
||||
Frontend nie powinien zalezec od `Redis`.
|
||||
|
||||
### Etap 7. Zostawic RPC w waskiej roli
|
||||
|
||||
`Agave RPC` zostawiamy do:
|
||||
|
||||
- bootstrapu
|
||||
- point reads
|
||||
- recovery
|
||||
- write path dla tx
|
||||
|
||||
Nie robimy z `RPC` glownej magistrali trwałego ingestu.
|
||||
|
||||
## Plan wdrozenia krok po kroku
|
||||
|
||||
1. Naprawic `dlob-publisher` i `dlob-server`.
|
||||
2. Potwierdzic, ze `Redis` jest zasilany poprawnym hot state.
|
||||
3. Rozszerzyc publisher albo dodac nowy consumer tak, aby pisal rownolegle do:
|
||||
- `Redis`
|
||||
- `Postgresa`
|
||||
4. Zdefiniowac schemat tabel normalized data w `Postgresie`.
|
||||
5. Przepiac workery na model:
|
||||
- read z `Redis`
|
||||
- write do `Postgresa`
|
||||
6. Zostawic `Hasure` i `trade-api` jako warstwe odczytowa nad `Postgresem`.
|
||||
7. Zdegradowac `trade-ingestor` z roli glownego ingestu do roli pomocniczej albo go usunac z glownej sciezki.
|
||||
|
||||
## Rekomendacja praktyczna
|
||||
|
||||
Najmniejsza sensowna zmiana to nie budowac od razu nowego systemu od zera, tylko:
|
||||
|
||||
1. naprawic obecny `dlob-publisher`
|
||||
2. dodac mu zapis do `Postgresa`
|
||||
3. stopniowo wyprowadzac `trade-ingestor` z glownego path
|
||||
|
||||
To jest najmniej ryzykowna droga do modelu:
|
||||
|
||||
- `Redis = cache / hot state`
|
||||
- `Postgres = persistent normalized store`
|
||||
153
doc/dlob-services.md
Normal file
153
doc/dlob-services.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Serwisy DLOB na VPS (k3s / `trade-staging`)
|
||||
|
||||
Ten dokument opisuje rolę serwisów “DLOB” uruchomionych w namespace `trade-staging` oraz ich przepływ danych.
|
||||
|
||||
## Czy `dlob-worker` pracuje na VPS?
|
||||
|
||||
Tak — wszystkie serwisy wymienione niżej działają **na VPS** jako Deploymenty w klastrze k3s, w namespace `trade-staging`.
|
||||
|
||||
## Czy na VPS jest GraphQL/WS dla stats i orderbook?
|
||||
|
||||
Tak — **GraphQL wystawia Hasura** (na VPS w k3s), a nie `dlob-server`.
|
||||
|
||||
- Dane L2 i liczone statsy są zapisane do Postgresa jako tabele `dlob_*_latest` i są dostępne przez Hasurę jako GraphQL (query + subscriptions).
|
||||
- Z zewnątrz korzystamy przez frontend (proxy) pod:
|
||||
- HTTP: `https://trade.rv32i.pl/graphql`
|
||||
- WS: `wss://trade.rv32i.pl/graphql` (subskrypcje, protokół `graphql-ws`)
|
||||
|
||||
`dlob-server` wystawia **REST** (np. `/l2`, `/l3`) w klastrze; to jest źródło danych dla workerów albo do debugowania.
|
||||
|
||||
## TL;DR: kto co robi
|
||||
|
||||
### `dlob-worker`
|
||||
- **Rola:** kolektor L2 + wyliczenie “basic stats”.
|
||||
- **Wejście:** HTTP L2 z `DLOB_HTTP_URL` (u nas obecnie `https://dlob.drift.trade`, ale można przełączyć na `http://dlob-server:6969`).
|
||||
- **Wyjście:** upsert do Hasury (Postgres) tabel:
|
||||
- `dlob_l2_latest` (raw snapshot L2, JSON leveli)
|
||||
- `dlob_stats_latest` (pochodne: best bid/ask, mid, spread, depth, imbalance, itp.)
|
||||
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 500 ms).
|
||||
|
||||
### `dlob-slippage-worker`
|
||||
- **Rola:** symulacja slippage vs rozmiar zlecenia na podstawie L2.
|
||||
- **Wejście:** czyta z Hasury `dlob_l2_latest` (dla listy rynków).
|
||||
- **Wyjście:** upsert do Hasury tabeli `dlob_slippage_latest` (m.in. `impact_bps`, `vwap_price`, `worst_price`, `fill_pct`).
|
||||
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 1000 ms); rozmiary w `DLOB_SLIPPAGE_SIZES_USD`.
|
||||
|
||||
### `dlob-depth-worker`
|
||||
- **Rola:** metryki “głębokości” w pasmach ±bps wokół mid.
|
||||
- **Wejście:** czyta z Hasury `dlob_l2_latest`.
|
||||
- **Wyjście:** upsert do Hasury tabeli `dlob_depth_bps_latest` (per `(market_name, band_bps)`).
|
||||
- **Częstotliwość:** `DLOB_POLL_MS` (u nas 1000 ms); pasma w `DLOB_DEPTH_BPS_BANDS`.
|
||||
|
||||
### `dlob-publisher`
|
||||
- **Rola:** utrzymuje “żywy” DLOB na podstawie subskrypcji on-chain i publikuje snapshoty do Redis.
|
||||
- **Wejście:** Solana RPC/WS (`ENDPOINT`, `WS_ENDPOINT` z secreta `trade-dlob-rpc`), Drift SDK; konfiguracja rynków np. `PERP_MARKETS_TO_LOAD`.
|
||||
- **Wyjście:** zapis/publish do `dlob-redis` (cache / pubsub / streamy), z którego korzysta serwer HTTP (i ewentualnie WS manager).
|
||||
|
||||
### `dlob-server`
|
||||
- **Rola:** HTTP API do danych DLOB (np. `/l2`, `/l3`) serwowane z cache Redis.
|
||||
- **Wejście:** `dlob-redis` + slot subscriber (do oceny “świeżości” danych).
|
||||
- **Wyjście:** endpoint HTTP w klastrze (Service `dlob-server:6969`), który może być źródłem dla `dlob-worker` (gdy `DLOB_HTTP_URL=http://dlob-server:6969`).
|
||||
|
||||
### `dlob-redis`
|
||||
- **Rola:** Redis (u nas single-node “cluster mode”) jako **cache i kanał komunikacji** między `dlob-publisher` a `dlob-server`.
|
||||
- **Uwagi:** to “klej” między komponentami publish/serve; bez niego publisher i server nie współpracują.
|
||||
|
||||
## Jak to się spina (przepływ danych)
|
||||
|
||||
1) `dlob-publisher` (on-chain) → publikuje snapshoty do `dlob-redis`.
|
||||
2) `dlob-server` → serwuje `/l2` i `/l3` z `dlob-redis` (HTTP w klastrze).
|
||||
3) `dlob-worker` → pobiera L2 (obecnie z `https://dlob.drift.trade`; opcjonalnie z `dlob-server`) i zapisuje “latest” do Hasury/DB.
|
||||
4) `dlob-slippage-worker` + `dlob-depth-worker` → liczą agregaty z `dlob_l2_latest` i zapisują do Hasury/DB (pod UI).
|
||||
|
||||
## Co to jest L1 / L2 / L3 (orderbook)
|
||||
|
||||
- `L1` (top-of-book): tylko najlepszy bid i najlepszy ask (czasem też spread).
|
||||
- `L2` (Level 2): **zagregowane poziomy cenowe** po stronie bid/ask — lista leveli `{ price, size }`, gdzie `size` to suma wolumenu na danej cenie (to jest typowy “orderbook UI” i baza pod spread/depth/imbalance).
|
||||
- `L3` (Level 3): **niezagregowane, pojedyncze zlecenia** (każde osobno, zwykle z dodatkowymi polami/identyfikatorami). Większy wolumen danych; przydatne do “pro” analiz i debugowania mikrostruktury.
|
||||
|
||||
W tym stacku:
|
||||
- `dlob-server` udostępnia REST endpointy `/l2` i `/l3`.
|
||||
- Hasura/DB trzyma “latest” snapshot L2 w `dlob_l2_latest` oraz metryki w `dlob_stats_latest` / `dlob_depth_bps_latest` / `dlob_slippage_latest`.
|
||||
|
||||
## Słownik pojęć (bid/ask/spread i metryki)
|
||||
|
||||
### Podstawy orderbooka
|
||||
|
||||
- **Bid**: zlecenia kupna (chęć kupna). W orderbooku “bid side”.
|
||||
- **Ask**: zlecenia sprzedaży (chęć sprzedaży). W orderbooku “ask side”.
|
||||
- **Best bid / best ask**: najlepsza (najwyższa) cena kupna i najlepsza (najniższa) cena sprzedaży na topie księgi (L1).
|
||||
- **Spread**: różnica pomiędzy `best_ask` a `best_bid`. Im mniejszy spread, tym “taniej” wejść/wyjść (mniej kosztów natychmiastowej realizacji).
|
||||
- **Mid price**: cena “po środku”: `(best_bid + best_ask) / 2`. Używana jako punkt odniesienia do bps i slippage.
|
||||
- **Level**: pojedynczy poziom cenowy w L2 (np. `price=100.00`, `size=12.3`).
|
||||
- **Size**: ilość/płynność na poziomie (zwykle w jednostkach “base asset”).
|
||||
- **Base / Quote**:
|
||||
- `base` = instrument bazowy (np. SOL),
|
||||
- `quote` = waluta wyceny (często USD).
|
||||
|
||||
## Kolory w UI (visualizer)
|
||||
|
||||
- `bid` / “buy side” = zielony (`.pos`, `#22c55e`)
|
||||
- `ask` / “sell side” = czerwony (`.neg`, `#ef4444`)
|
||||
- “flat” / brak zmiany = niebieski (`#60a5fa`) — używany m.in. w “brick stack” pod świecami
|
||||
|
||||
### Jednostki i skróty
|
||||
|
||||
- **bps (basis points)**: 1 bps = 0.01% = `0.0001`. Np. 25 bps = 0.25%.
|
||||
- **USD**: u nas wiele wartości jest przeliczanych do USD (np. `size_base * price`).
|
||||
|
||||
### Metryki “stats” (np. `dlob_stats_latest`)
|
||||
|
||||
- `spread_abs` (USD): `best_ask - best_bid`.
|
||||
- `spread_bps` (bps): `(spread_abs / mid_price) * 10_000`.
|
||||
- `depth_levels`: ile leveli (top‑N) z każdej strony braliśmy do liczenia “depth”.
|
||||
- `depth_bid_base` / `depth_ask_base`: suma `size` po top‑N levelach bid/ask (w base).
|
||||
- `depth_bid_usd` / `depth_ask_usd`: suma `size_base * price` po top‑N levelach (w USD).
|
||||
- `imbalance` ([-1..1]): miara asymetrii płynności:
|
||||
- `(depth_bid_usd - depth_ask_usd) / (depth_bid_usd + depth_ask_usd)`
|
||||
- >0 = relatywnie więcej płynności po bid, <0 = po ask.
|
||||
- `oracle_price`: cena z oracla (np. Pyth) jako punkt odniesienia.
|
||||
- `mark_price`: “mark” z rynku/perp (cena referencyjna dla rozliczeń); różni się od oracle/top-of-book.
|
||||
|
||||
### Metryki “depth bands” (np. `dlob_depth_bps_latest`)
|
||||
|
||||
- `band_bps`: szerokość pasma wokół `mid_price` (np. 5/10/20/50/100/200 bps).
|
||||
- `bid_usd` / `ask_usd`: płynność po danej stronie, ale **tylko z poziomów mieszczących się w oknie ±`band_bps`** wokół mid.
|
||||
- `imbalance`: jak wyżej, ale liczony per band.
|
||||
|
||||
### Metryki “slippage” (np. `dlob_slippage_latest`)
|
||||
|
||||
To jest symulacja “gdybym teraz zrobił market order o rozmiarze X” na podstawie L2.
|
||||
|
||||
- `size_usd`: docelowy rozmiar zlecenia w USD.
|
||||
- `vwap_price`: średnia cena realizacji (Volume Weighted Average Price) dla symulowanego fill.
|
||||
- `impact_bps`: koszt/odchylenie względem `mid_price` wyrażone w bps (zwykle na bazie `vwap` vs `mid`).
|
||||
- `worst_price`: najgorsza cena dotknięta podczas “zjadania” kolejnych leveli.
|
||||
- `filled_usd` / `filled_base`: ile realnie udało się wypełnić (może być < docelowego, jeśli brakuje płynności).
|
||||
- `fill_pct`: procent wypełnienia (100% = pełny fill).
|
||||
- `levels_consumed`: ile leveli zostało “zjedzonych” podczas fill.
|
||||
|
||||
### Metadane czasu (“świeżość”)
|
||||
|
||||
- `ts`: timestamp źródła (czas snapshotu).
|
||||
- `slot`: slot Solany, z którego pochodzi snapshot (monotoniczny “numer czasu” chaina).
|
||||
- `updated_at`: kiedy nasz worker zapisał/odświeżył rekord w DB (do oceny, czy dane są świeże).
|
||||
|
||||
## Szybka diagnostyka na VPS
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy | grep -E 'dlob-(worker|slippage-worker|depth-worker|publisher|server|redis)'
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-worker --tail=80
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-publisher --tail=80
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/dlob-server --tail=80
|
||||
```
|
||||
|
||||
## Ważna uwaga (źródło L2 w `dlob-worker`)
|
||||
|
||||
Jeśli chcesz, żeby `dlob-worker` polegał na **naszym** stacku (własny RPC + `dlob-publisher` + `dlob-server`), ustaw:
|
||||
|
||||
- `DLOB_HTTP_URL=http://dlob-server:6969`
|
||||
|
||||
Aktualnie w `trade-staging` jest ustawione:
|
||||
|
||||
- `DLOB_HTTP_URL=https://dlob.drift.trade`
|
||||
150
doc/gitea-k3s-rv32i.md
Normal file
150
doc/gitea-k3s-rv32i.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Gitea na k3s (Traefik + cert-manager + Let’s Encrypt) dla `rv32i.pl`
|
||||
|
||||
Ten dokument instaluje Gitea w k3s:
|
||||
- Ingress: Traefik
|
||||
- TLS: cert-manager + Let’s Encrypt (`ClusterIssuer` = `letsencrypt-prod`)
|
||||
- Host: `rv32i.pl`
|
||||
- Secret TLS: `rv32i-pl-tls`
|
||||
|
||||
## Ważne (hasła/sekrety)
|
||||
|
||||
Nie wstawiam i nie zalecam trzymania haseł w plikach w repo (`doc/`), bo to zwykle kończy się wyciekiem (git history, backupy, screeny).
|
||||
|
||||
Zamiast tego:
|
||||
- trzymamy hasła w **Kubernetes Secret** (w klastrze),
|
||||
- a w dokumentacji zostawiamy **placeholderey** i komendy, które proszą o hasło interaktywnie.
|
||||
|
||||
Jeśli mimo wszystko chcesz „na sztywno” wpisać hasła do pliku, podaj je jawnie w wiadomości — ale to jest zła praktyka.
|
||||
|
||||
## 0) Wymagania
|
||||
|
||||
1) `cert-manager` działa, a `ClusterIssuer` jest gotowy:
|
||||
```bash
|
||||
sudo k3s kubectl get clusterissuer letsencrypt-prod
|
||||
```
|
||||
|
||||
2) DNS wskazuje na VPS:
|
||||
```bash
|
||||
dig +short rv32i.pl A
|
||||
```
|
||||
Oczekiwane: `77.90.8.171`
|
||||
|
||||
3) Porty 80/443 są otwarte z internetu (firewall/ACL u providera).
|
||||
|
||||
## 1) Instalacja Helm (jeśli nie masz)
|
||||
|
||||
```bash
|
||||
helm version || true
|
||||
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | sudo bash
|
||||
helm version
|
||||
```
|
||||
|
||||
## 2) Namespace
|
||||
|
||||
```bash
|
||||
sudo k3s kubectl create namespace gitea
|
||||
```
|
||||
|
||||
## 3) Secret z kontem admina (bez wpisywania hasła do historii)
|
||||
|
||||
Ten krok tworzy w klastrze sekret `gitea-admin` z loginem/hasłem/email admina.
|
||||
|
||||
```bash
|
||||
read -rp "Gitea admin username: " GITEA_ADMIN_USER
|
||||
read -rsp "Gitea admin password: " GITEA_ADMIN_PASS; echo
|
||||
read -rp "Gitea admin email: " GITEA_ADMIN_EMAIL
|
||||
|
||||
sudo k3s kubectl -n gitea create secret generic gitea-admin \
|
||||
--from-literal=username="$GITEA_ADMIN_USER" \
|
||||
--from-literal=password="$GITEA_ADMIN_PASS" \
|
||||
--from-literal=email="$GITEA_ADMIN_EMAIL"
|
||||
```
|
||||
|
||||
## 4) Helm values (Ingress + TLS + Postgres w klastrze)
|
||||
|
||||
Utwórz plik `gitea-values.yaml` na VPS (nie musi być w repo):
|
||||
|
||||
```bash
|
||||
cat <<'YAML' > gitea-values.yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: traefik
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
hosts:
|
||||
- host: rv32i.pl
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls:
|
||||
- secretName: rv32i-pl-tls
|
||||
hosts:
|
||||
- rv32i.pl
|
||||
|
||||
gitea:
|
||||
admin:
|
||||
existingSecret: gitea-admin
|
||||
config:
|
||||
server:
|
||||
DOMAIN: rv32i.pl
|
||||
ROOT_URL: https://rv32i.pl/
|
||||
PROTOCOL: http
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: local-path
|
||||
size: 10Gi
|
||||
|
||||
postgresql:
|
||||
enabled: true
|
||||
primary:
|
||||
persistence:
|
||||
enabled: true
|
||||
storageClass: local-path
|
||||
size: 10Gi
|
||||
YAML
|
||||
```
|
||||
|
||||
## 5) Instalacja Gitea
|
||||
|
||||
```bash
|
||||
helm repo add gitea-charts https://dl.gitea.com/charts/
|
||||
helm repo update
|
||||
|
||||
helm upgrade --install gitea gitea-charts/gitea \
|
||||
-n gitea \
|
||||
-f gitea-values.yaml
|
||||
```
|
||||
|
||||
## 6) Sprawdzenie
|
||||
|
||||
```bash
|
||||
sudo k3s kubectl -n gitea get pods -o wide
|
||||
sudo k3s kubectl -n gitea get ingress -o wide
|
||||
sudo k3s kubectl -n gitea get certificate -o wide
|
||||
sudo k3s kubectl -n gitea get order,challenge
|
||||
```
|
||||
|
||||
Wejdź na:
|
||||
- `https://rv32i.pl`
|
||||
|
||||
Jeśli `Certificate` nie robi się `Ready=True`, to najczęściej:
|
||||
- port 80 nie dochodzi z internetu (Let’s Encrypt HTTP-01),
|
||||
- Ingress nie ma `className: traefik`,
|
||||
- DNS nie wskazuje na VPS.
|
||||
|
||||
## 7) (Opcjonalnie) dostęp do haseł po czasie
|
||||
|
||||
Jeśli chcesz podejrzeć login/email z sekretu (hasło też da się odczytać, ale rób to ostrożnie):
|
||||
```bash
|
||||
sudo k3s kubectl -n gitea get secret gitea-admin -o jsonpath='{.data.username}' | base64 -d; echo
|
||||
sudo k3s kubectl -n gitea get secret gitea-admin -o jsonpath='{.data.email}' | base64 -d; echo
|
||||
```
|
||||
|
||||
## 8) Uwaga o Git przez SSH
|
||||
|
||||
Na VPS port 22 zajmuje systemowy OpenSSH. Na start korzystaj z klonowania przez HTTPS.
|
||||
Jeśli chcesz klonować przez SSH z Gitei:
|
||||
- najprościej wystawić Gitea SSH na innym porcie (np. 2222) i zrobić NodePort/LoadBalancer,
|
||||
- albo zmapować port przez Traefik TCP (wymaga dodatkowej konfiguracji).
|
||||
|
||||
167
doc/k8s-migracja.md
Normal file
167
doc/k8s-migracja.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Plan migracji na Kubernetes (mikroserwisy + CI/CD)
|
||||
|
||||
## 1) Co jest dziś w repo (stan wejściowy)
|
||||
|
||||
Ten projekt już ma podział “mikroserwisowy” w Docker Compose:
|
||||
|
||||
- **DB stack** (`devops/db/docker-compose.yml`)
|
||||
- `postgres` (TimescaleDB, port 5432)
|
||||
- `hasura` (GraphQL Engine, port 8080)
|
||||
- `pgadmin` (narzędzie dev, port 5050)
|
||||
- **App stack** (`devops/app/docker-compose.yml`)
|
||||
- `api` = **trade-api** (Node, port 8787, `devops/api/Dockerfile`)
|
||||
- `frontend` = **trade-frontend** (Node + statyczny build visualizera, port 8081, `devops/app/frontend/Dockerfile`)
|
||||
- `ingestor` = worker (Node, `devops/ingestor/Dockerfile`) – opcjonalnie, profil `ingest`
|
||||
- **One-shot bootstrap** (`devops/tools/bootstrap/docker-compose.yml`)
|
||||
- `db-init` (aplikuje SQL `devops/db/initdb/001_init.sql`)
|
||||
- `db-version` / `db-backfill` (migracje “wersji” ticków)
|
||||
- `hasura-bootstrap` (Node script `devops/db/hasura-bootstrap.mjs`: track tabel + permissions + funkcje)
|
||||
|
||||
Konfiguracja i sekrety dziś są w `tokens/*.json` (gitignored) i są montowane jako pliki do kontenerów.
|
||||
|
||||
## 2) Docelowa architektura na K8s
|
||||
|
||||
Minimalny sensowny podział na K8s (1 namespace lub 2):
|
||||
|
||||
- **timescaledb** (stanowe) → `StatefulSet` + `PVC` + `Service`
|
||||
- **hasura** → `Deployment` + `Service`
|
||||
- **trade-api** → `Deployment` + `Service`
|
||||
- **trade-frontend** → `Deployment` + `Service` + `Ingress`
|
||||
- **trade-ingestor** → `Deployment` (1 replika) albo `CronJob` (jeśli chcesz uruchamiać okresowo)
|
||||
- **bootstrap / migracje** → `Job` (odpalane ręcznie albo jako część release’u)
|
||||
|
||||
Opcjonalnie:
|
||||
- `pgadmin` tylko na dev/staging (nieprodukcyjnie).
|
||||
|
||||
## 3) Proponowane zasoby Kubernetes (mapowanie 1:1 z Compose)
|
||||
|
||||
### 3.1 timescaledb (Postgres)
|
||||
- `StatefulSet` z `volumeClaimTemplates` (dane w PVC)
|
||||
- `Service` typu `ClusterIP` (np. `timescaledb:5432`)
|
||||
- Init schematu:
|
||||
- *na pierwszy start* można nadal użyć mechanizmu `/docker-entrypoint-initdb.d` (ConfigMap z SQL),
|
||||
- *dla istniejących wolumenów* potrzebny jest osobny `Job` (odpowiednik `db-init` z compose).
|
||||
|
||||
### 3.2 Hasura
|
||||
- `Deployment` + `Service` (`hasura:8080`)
|
||||
- `Secret` na:
|
||||
- `HASURA_GRAPHQL_ADMIN_SECRET`
|
||||
- klucz JWT (`HASURA_GRAPHQL_JWT_SECRET` / `HASURA_JWT_KEY`)
|
||||
- connection string do Postgresa (albo osobno hasło)
|
||||
- Bootstrap metadanych:
|
||||
- `Job` uruchamiający `devops/db/hasura-bootstrap.mjs` (odpowiednik `hasura-bootstrap`).
|
||||
|
||||
### 3.3 trade-api
|
||||
- `Deployment` + `Service` (`trade-api:8787`)
|
||||
- `readinessProbe`/`livenessProbe`: `GET /healthz` (już istnieje)
|
||||
- Konfiguracja:
|
||||
- env: `HASURA_GRAPHQL_URL`, `TICKS_TABLE`, `CANDLES_FUNCTION`, `APP_VERSION`, `BUILD_TIMESTAMP`
|
||||
- sekret plikowy (jak dziś): `tokens/hasura.json` + `tokens/api.json` → `Secret` montowany do `/app/tokens/*`
|
||||
|
||||
### 3.4 trade-frontend
|
||||
- `Deployment` + `Service` (`trade-frontend:8081`)
|
||||
- `Ingress` wystawiający UI na zewnątrz (TLS opcjonalnie)
|
||||
- `readinessProbe`/`livenessProbe`: `GET /healthz` (już istnieje)
|
||||
- Sekrety plikowe:
|
||||
- `tokens/frontend.json` (basic auth do UI)
|
||||
- `tokens/read.json` (read token do proxy `/api/*`)
|
||||
- oba jako `Secret` montowany do `/tokens/*`
|
||||
- `API_UPSTREAM`: `http://trade-api:8787` (Service DNS)
|
||||
|
||||
### 3.5 trade-ingestor
|
||||
- `Deployment` (zwykle `replicas: 1`)
|
||||
- Sekrety:
|
||||
- RPC do Drift (np. `tokens/heliusN.json` / `rpcUrl` / `heliusApiKey`)
|
||||
- write token do API (`tokens/alg.json`)
|
||||
- Konfiguracja:
|
||||
- `MARKET_NAME`, `INTERVAL_MS`, `SOURCE`, `INGEST_API_URL=http://trade-api:8787`
|
||||
- Uwaga: worker wymaga egress do internetu (Helius RPC + Drift).
|
||||
|
||||
### 3.6 Jobs: db-init / db-version / db-backfill / hasura-bootstrap
|
||||
Bezpieczny pattern:
|
||||
- uruchamiane manualnie (kubectl) albo jako “hook” w Helm (pre/post-install)
|
||||
- odpalane w tym samym namespace co DB/Hasura (żeby DNS działał prosto)
|
||||
|
||||
## 4) Sekrety i konfiguracja (z `tokens/` → K8s)
|
||||
|
||||
Rekomendacja: rozdziel na 2 typy:
|
||||
|
||||
- **Secret** (nie commitować w git):
|
||||
- `tokens/hasura.json` (adminSecret, jwtKey)
|
||||
- `tokens/api.json` (api adminSecret)
|
||||
- `tokens/frontend.json` (basic auth)
|
||||
- `tokens/read.json` (read token)
|
||||
- `tokens/alg.json` (write token)
|
||||
- `tokens/heliusN.json` (RPC token / url)
|
||||
- **ConfigMap**:
|
||||
- wersja i parametry nie-wrażliwe: `APP_VERSION`, `BUILD_TIMESTAMP`, `TICKS_TABLE`, `CANDLES_FUNCTION`, `MARKET_NAME`, `INTERVAL_MS`
|
||||
|
||||
Jeśli chcesz GitOps bez trzymania sekretów “na piechotę”, wybierz jedno:
|
||||
- **Sealed Secrets** (zaszyfrowane sekrety w repo),
|
||||
- **External Secrets Operator** (Vault / AWS / GCP / Azure),
|
||||
- “na start” manualne `kubectl create secret ...` per środowisko.
|
||||
|
||||
## 5) Wersjonowanie (v1, v2…) i cutover bez wyłączania DB
|
||||
|
||||
W `scripts/ops/` jest workflow wersjonowania Compose:
|
||||
- nowa wersja `api+frontend(+ingestor)` działa równolegle
|
||||
- pisze do **nowej tabeli** (`drift_ticks_vN`) i używa **nowej funkcji** (`get_drift_candles_vN`)
|
||||
|
||||
Na K8s najprościej odwzorować to tak:
|
||||
- Helm release name albo suffix w nazwach zasobów: `trade-v1`, `trade-v2`, …
|
||||
- wspólne DB/Hasura pozostają bez zmian
|
||||
- `Job` “version-init”:
|
||||
- odpala SQL migracji (odpowiednik `db-version`)
|
||||
- odpala `hasura-bootstrap` z `TICKS_TABLE` i `CANDLES_FUNCTION` ustawionymi na vN
|
||||
- “cutover”:
|
||||
- startujesz `trade-ingestor` vN
|
||||
- stopujesz `trade-ingestor` v1
|
||||
- po czasie robisz `db-backfill` (opcjonalnie) i sprzątasz stare zasoby
|
||||
|
||||
## 6) CI/CD “na gita” (build → deploy po pushu)
|
||||
|
||||
### Opcja A (polecana): GitOps (Argo CD / Flux)
|
||||
1) CI (GitHub Actions / GitLab CI) buduje obrazy:
|
||||
- `trade-api`
|
||||
- `trade-frontend`
|
||||
- `trade-ingestor`
|
||||
2) CI publikuje je do registry (np. GHCR)
|
||||
3) CD (ArgoCD/Flux) automatycznie synchronizuje manifesty/Helm z repo i robi rollout
|
||||
|
||||
Tagowanie obrazów:
|
||||
- `:sha-<shortsha>` dla każdego commita
|
||||
- opcjonalnie `:vN` dla release’ów
|
||||
|
||||
Aktualizacja tagów:
|
||||
- ArgoCD Image Updater / Flux Image Automation **albo**
|
||||
- CI robi commit do `k8s/` (np. podmienia tag w `values.yaml`)
|
||||
|
||||
### Opcja B (prostsza na start): “kubectl apply” z CI
|
||||
1) CI buduje i pushuje obrazy
|
||||
2) CI wykonuje `helm upgrade --install` albo `kubectl apply -k ...`
|
||||
3) Dostęp do klastra przez sekret w repo (kubeconfig / token)
|
||||
|
||||
## 7) Proponowana sekwencja migracji (checklista)
|
||||
|
||||
1) **Decyzje**: gdzie stoi klaster (EKS/GKE/AKS/k3s), jakie registry, jaki Ingress, jak trzymamy sekrety.
|
||||
2) **K8s “base”**: namespace, storage class, ingress controller, certy (jeśli TLS).
|
||||
3) **DB**: wdroż Timescale (StatefulSet + PVC), odpal `db-init` job.
|
||||
4) **Hasura**: wdroż Hasurę, odpal `hasura-bootstrap` job.
|
||||
5) **API**: wdroż `trade-api`, sprawdź `/healthz`.
|
||||
6) **Tokeny**: wygeneruj read/write tokeny (obecnym mechanizmem API) i wgraj je jako Secrets.
|
||||
7) **Frontend**: wdroż `trade-frontend` + Ingress, sprawdź `/healthz` i UI.
|
||||
8) **Ingestor**: wdroż `trade-ingestor` (1 replika), potwierdź że ticki wpadają.
|
||||
9) **CI/CD**: dodaj workflow build+push i deploy (GitOps albo kubectl).
|
||||
10) **Staging → Prod**: rollout na staging, potem prod.
|
||||
|
||||
## 8) Pytania, które domykają plan
|
||||
|
||||
1) Jaki “git”: **GitHub czy GitLab**?
|
||||
2) Gdzie ma stać K8s: cloud (EKS/GKE/AKS) czy on-prem/k3s?
|
||||
3) DB w klastrze (StatefulSet) czy zewnętrzny managed Postgres/Timescale?
|
||||
4) Czy “po pushu” ma:
|
||||
- tylko robić rollout na `main`,
|
||||
- czy tworzyć **preview env per branch/PR**,
|
||||
- czy startować **nową wersję vN równolegle** (jak `scripts/ops/`)?
|
||||
5) Jaki dostęp z zewnątrz: domena + TLS, czy wystarczy port-forward / internal?
|
||||
|
||||
245
doc/mevnode-bot-architecture.md
Normal file
245
doc/mevnode-bot-architecture.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# Mevnode Bot Architecture
|
||||
|
||||
## Zasada glowna
|
||||
|
||||
`mevnode_bot` nigdy nie uruchamia warstwy Solana node:
|
||||
|
||||
- bez `agave`
|
||||
- bez `ledger`
|
||||
- bez `yellowstone server`
|
||||
- bez validator stack
|
||||
|
||||
`mevnode_sol` pozostaje jedynym hostem node-level:
|
||||
|
||||
- `agave-validator`
|
||||
- `yellowstone geyser plugin`
|
||||
- `yellowstone gRPC`
|
||||
- plugin metrics
|
||||
- lokalny RPC
|
||||
- opcjonalnie pozniej RPC proxy
|
||||
|
||||
## Schemat `mevnode_bot`
|
||||
|
||||
```text
|
||||
+--------------------------------------------------------------+
|
||||
| mevnode_bot |
|
||||
| single-node k3s only |
|
||||
| |
|
||||
| ingress/web |
|
||||
| - frontend |
|
||||
| - api |
|
||||
| |
|
||||
| trading/read |
|
||||
| - yellowstone-consumer |
|
||||
| - market ingest / parser |
|
||||
| - dlob-worker |
|
||||
| |
|
||||
| trading/core |
|
||||
| - strategy-engine |
|
||||
| - risk-manager |
|
||||
| - order-manager |
|
||||
| - tx-router |
|
||||
| |
|
||||
| tx paths |
|
||||
| - tpu client |
|
||||
| - jito client |
|
||||
| - rpc client / later rpc proxy target on mevnode_sol |
|
||||
| |
|
||||
| data |
|
||||
| - postgres |
|
||||
| - redis optional |
|
||||
| - clickhouse later optional |
|
||||
| |
|
||||
| ops |
|
||||
| - prometheus |
|
||||
| - grafana |
|
||||
| - loki optional |
|
||||
| |
|
||||
| platform |
|
||||
| - argo/flux |
|
||||
| - sealed-secrets or sops |
|
||||
| - internal registry access |
|
||||
+--------------------------------------------------------------+
|
||||
```
|
||||
|
||||
## Przeplyw danych
|
||||
|
||||
```text
|
||||
Yellowstone on mevnode_sol
|
||||
|
|
||||
v
|
||||
yellowstone-consumer
|
||||
|
|
||||
v
|
||||
parser / ingest / dlob-worker
|
||||
|
|
||||
v
|
||||
strategy-engine
|
||||
|
|
||||
v
|
||||
risk-manager / order-manager
|
||||
|
|
||||
v
|
||||
tx-router
|
||||
| | |
|
||||
v v v
|
||||
TPU Jito RPC
|
||||
```
|
||||
|
||||
## Namespace plan
|
||||
|
||||
- `web`
|
||||
- `trade`
|
||||
- `data`
|
||||
- `ops`
|
||||
- `infra`
|
||||
|
||||
## Kolejnosc wdrozenia
|
||||
|
||||
1. `web`: `frontend`, `api`
|
||||
2. `data`: `postgres`
|
||||
3. `trade`: `yellowstone-consumer`
|
||||
4. `trade`: `strategy-engine`, `tx-router`
|
||||
5. `ops`: `prometheus`, `grafana`
|
||||
|
||||
## Read i write path
|
||||
|
||||
Read path:
|
||||
|
||||
- `mevnode_bot -> wg0 -> mevnode_sol:10000`
|
||||
|
||||
Metrics path:
|
||||
|
||||
- `mevnode_bot -> wg0 -> mevnode_sol:8999`
|
||||
|
||||
Write path pozniej:
|
||||
|
||||
- `mevnode_bot -> TPU/Jito`
|
||||
- albo `mevnode_bot -> RPC proxy -> mevnode_sol:127.0.0.1:8899`
|
||||
|
||||
## Zasada architektoniczna
|
||||
|
||||
- `mevnode_sol` = source of truth dla chain access
|
||||
- `mevnode_bot` = execution and product node
|
||||
|
||||
## K3s object map
|
||||
|
||||
### Namespace `web`
|
||||
|
||||
- `Deployment` `frontend`
|
||||
- `Service` `frontend` typu `ClusterIP`
|
||||
- `Deployment` `api`
|
||||
- `Service` `api` typu `ClusterIP`
|
||||
- `Ingress` `web`
|
||||
|
||||
Przeznaczenie:
|
||||
|
||||
- `frontend` wystawia UI
|
||||
- `api` wystawia backend HTTP
|
||||
- `Ingress` publikuje tylko web entrypoint
|
||||
|
||||
### Namespace `trade`
|
||||
|
||||
- `Deployment` `yellowstone-consumer`
|
||||
- `Deployment` `dlob-worker`
|
||||
- `Deployment` `strategy-engine`
|
||||
- `Deployment` `risk-manager`
|
||||
- `Deployment` `order-manager`
|
||||
- `Deployment` `tx-router`
|
||||
- `Service` `strategy-engine` typu `ClusterIP` jesli inne komponenty maja sie do niego odwolywac
|
||||
- `Service` `tx-router` typu `ClusterIP` tylko jesli potrzebne bedzie sterowanie po HTTP/gRPC
|
||||
|
||||
Przeznaczenie:
|
||||
|
||||
- `yellowstone-consumer` czyta stream z `mevnode_sol`
|
||||
- `dlob-worker` robi ingest i przygotowanie danych
|
||||
- `strategy-engine` podejmuje decyzje
|
||||
- `risk-manager` egzekwuje limity
|
||||
- `order-manager` sklada i nadzoruje lifecycle zlecen
|
||||
- `tx-router` wybiera `TPU`, `Jito` albo `RPC`
|
||||
|
||||
Uwagi:
|
||||
|
||||
- `strategy-engine`, `order-manager` i `tx-router` mozna uruchamiac z `hostNetwork: true`, jesli bedzie potrzeba minimalizacji narzutu sieciowego
|
||||
- `yellowstone-consumer` powinien miec `Secret` z `x-token` i endpointem `10.91.0.1:10000`
|
||||
|
||||
### Namespace `data`
|
||||
|
||||
- `StatefulSet` `postgres`
|
||||
- `Service` `postgres` typu `ClusterIP`
|
||||
- `PersistentVolumeClaim` dla `postgres`
|
||||
- `StatefulSet` `redis` opcjonalnie
|
||||
- `Service` `redis` typu `ClusterIP`
|
||||
- `PersistentVolumeClaim` dla `redis` tylko jesli ma byc trwaly cache
|
||||
- `StatefulSet` `clickhouse` dopiero pozniej
|
||||
- `Service` `clickhouse` typu `ClusterIP`
|
||||
- `PersistentVolumeClaim` dla `clickhouse`
|
||||
|
||||
Przeznaczenie:
|
||||
|
||||
- `postgres` dla stanu systemu, pozycji, filli, konfiguracji i jobow
|
||||
- `redis` dla cache, kolejek, lockow i rate limitu
|
||||
- `clickhouse` dla ciezszej analityki i archiwizacji eventow
|
||||
|
||||
### Namespace `ops`
|
||||
|
||||
- `StatefulSet` `prometheus`
|
||||
- `Service` `prometheus` typu `ClusterIP`
|
||||
- `Deployment` `grafana`
|
||||
- `Service` `grafana` typu `ClusterIP`
|
||||
- `Deployment` `promtail` opcjonalnie
|
||||
- `StatefulSet` albo `Deployment` `loki` opcjonalnie
|
||||
|
||||
Przeznaczenie:
|
||||
|
||||
- `prometheus` zbiera metryki z `k3s` i z aplikacji
|
||||
- `grafana` daje dashboardy
|
||||
- `loki/promtail` sa opcjonalne do logow
|
||||
|
||||
### Namespace `infra`
|
||||
|
||||
- `Deployment` `argocd-server` albo kontrolery `flux`
|
||||
- `Deployment` `argocd-repo-server` jesli wybierzemy `Argo CD`
|
||||
- `Deployment` `sealed-secrets-controller` albo integracja `sops`
|
||||
- `Secret` / `ExternalSecret` dla tokenow i kluczy
|
||||
|
||||
Przeznaczenie:
|
||||
|
||||
- GitOps sync z Gitea
|
||||
- zarzadzanie sekretami
|
||||
- bootstrap aplikacji i manifestow
|
||||
|
||||
## Service exposure rules
|
||||
|
||||
- tylko `frontend` i `api` wychodza przez `Ingress`
|
||||
- `postgres`, `redis`, `clickhouse`, `strategy-engine`, `tx-router` zostaja wewnetrzne jako `ClusterIP`
|
||||
- bez `LoadBalancer`
|
||||
- bez publicznego wystawiania komponentow tradingowych
|
||||
- `yellowstone-consumer` nie potrzebuje publicznego `Service`
|
||||
|
||||
## Storage rules
|
||||
|
||||
- `postgres` na trwałym wolumenie
|
||||
- `clickhouse` tylko na szybkim lokalnym storage
|
||||
- `redis` bez PVC jesli ma byc tylko cache
|
||||
- aplikacje tradingowe jako stateless `Deployment`, jesli stan trzymamy w `postgres`
|
||||
|
||||
## Config and secrets
|
||||
|
||||
- `ConfigMap` dla endpointow, feature flags i runtime config
|
||||
- `Secret` dla:
|
||||
- tokenu Yellowstone
|
||||
- kluczy API
|
||||
- danych do `postgres`
|
||||
- danych do `Jito`
|
||||
- kluczy signing/delegate
|
||||
|
||||
## Kolejnosc wdrozenia w k3s
|
||||
|
||||
1. `infra`: GitOps i secrets
|
||||
2. `data`: `postgres`
|
||||
3. `web`: `frontend`, `api`, `Ingress`
|
||||
4. `trade`: `yellowstone-consumer`
|
||||
5. `trade`: `strategy-engine`, `risk-manager`, `order-manager`
|
||||
6. `trade`: `tx-router`
|
||||
7. `ops`: `prometheus`, `grafana`
|
||||
1082
doc/mevnode-bot-topology-interactive.html
Normal file
1082
doc/mevnode-bot-topology-interactive.html
Normal file
File diff suppressed because it is too large
Load Diff
424
doc/mevnode-bot-topology.html
Normal file
424
doc/mevnode-bot-topology.html
Normal file
@@ -0,0 +1,424 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>mevnode topology</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe7;
|
||||
--panel: #fffaf2;
|
||||
--ink: #1f1c17;
|
||||
--muted: #6b6358;
|
||||
--line: #d9ccb7;
|
||||
--accent: #b6412f;
|
||||
--accent-2: #0e6f74;
|
||||
--accent-3: #7c5e10;
|
||||
--shadow: 0 18px 40px rgba(31, 28, 23, 0.08);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(182, 65, 47, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(14, 111, 116, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #fbf7f0 0%, var(--bg) 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.legend,
|
||||
.node,
|
||||
.strip,
|
||||
.note {
|
||||
background: rgba(255, 250, 242, 0.88);
|
||||
border: 1px solid rgba(217, 204, 183, 0.9);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(182, 65, 47, 0.22);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 16px 0 10px;
|
||||
font-family: "IBM Plex Serif", Georgia, serif;
|
||||
font-size: clamp(2rem, 4vw, 3.8rem);
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.hero-card p,
|
||||
.legend p,
|
||||
.note p,
|
||||
.box p,
|
||||
.strip p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.legend {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 14px;
|
||||
}
|
||||
|
||||
.swatch.read { background: var(--accent-2); }
|
||||
.swatch.write { background: var(--accent); }
|
||||
.swatch.ops { background: var(--accent-3); }
|
||||
.swatch.store { background: #4a5565; }
|
||||
|
||||
.diagram {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) 120px minmax(320px, 1.25fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.node {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.node h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.35rem;
|
||||
font-family: "IBM Plex Serif", Georgia, serif;
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.node-head span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.box {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
background: #fffdf8;
|
||||
}
|
||||
|
||||
.box h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.box ul {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.box li {
|
||||
margin: 6px 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.box.read { border-left: 6px solid var(--accent-2); }
|
||||
.box.write { border-left: 6px solid var(--accent); }
|
||||
.box.ops { border-left: 6px solid var(--accent-3); }
|
||||
.box.store { border-left: 6px solid #4a5565; }
|
||||
|
||||
.bridge {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bridge-line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
rgba(14, 111, 116, 0.95) 0%,
|
||||
rgba(14, 111, 116, 0.95) 38%,
|
||||
rgba(182, 65, 47, 0.95) 38%,
|
||||
rgba(182, 65, 47, 0.95) 72%,
|
||||
rgba(124, 94, 16, 0.95) 72%,
|
||||
rgba(124, 94, 16, 0.95) 100%);
|
||||
border-radius: 999px;
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.bridge-tag {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
padding: 10px 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.strips {
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.strip {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.strip h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.strip code,
|
||||
.note code {
|
||||
font-family: "IBM Plex Mono", "Fira Code", monospace;
|
||||
font-size: 0.95em;
|
||||
background: rgba(31, 28, 23, 0.06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 24px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.hero,
|
||||
.diagram,
|
||||
.strips {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bridge {
|
||||
grid-template-columns: 1fr auto 1fr auto 1fr;
|
||||
grid-template-rows: unset;
|
||||
}
|
||||
|
||||
.bridge-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
min-height: 2px;
|
||||
}
|
||||
|
||||
.bridge-tag {
|
||||
writing-mode: horizontal-tb;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<div class="hero-card">
|
||||
<div class="eyebrow">Topology / mev stack</div>
|
||||
<h1>mevnode_sol daje chain access. mevnode_bot robi produkt, ingest i execution.</h1>
|
||||
<p>
|
||||
Ten układ rozdziela warstwę validatora od warstwy aplikacyjnej. Na <strong>sol</strong>
|
||||
trzymasz tylko Agave, Yellowstone i lokalne RPC. Na <strong>bot</strong> działa
|
||||
<strong>single-node k3s</strong> z frontendem, API, DLOB, strategiami i bazami danych.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="legend">
|
||||
<div class="legend-item"><span class="swatch read"></span> Read path: Yellowstone / live stream</div>
|
||||
<div class="legend-item"><span class="swatch write"></span> Write path: RPC / TPU / Jito</div>
|
||||
<div class="legend-item"><span class="swatch ops"></span> Ops path: monitoring, metryki, GitOps</div>
|
||||
<div class="legend-item"><span class="swatch store"></span> Data path: Postgres, Redis, analityka</div>
|
||||
<p>
|
||||
Zasada jest prosta: <strong>na bot nie trafia nigdy Agave ani ledger</strong>.
|
||||
`mevnode_bot` ma tylko klienty, logikę tradingową i interfejs użytkownika.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="diagram">
|
||||
<article class="node">
|
||||
<div class="node-head">
|
||||
<h2>mevnode_sol</h2>
|
||||
<span>bare metal / validator host</span>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="box read">
|
||||
<h3>Agave validator</h3>
|
||||
<p>Źródło stanu chaina, snapshotów i lokalnego read/write RPC.</p>
|
||||
<ul>
|
||||
<li><code>127.0.0.1:8899</code> RPC</li>
|
||||
<li><code>10.91.0.1:10000</code> Yellowstone gRPC</li>
|
||||
<li><code>10.91.0.1:8999</code> Prometheus plugin metrics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="box write">
|
||||
<h3>Write capability</h3>
|
||||
<p>Bot może później używać lokalnego RPC przez proxy lub iść bezpośrednio przez TPU/Jito.</p>
|
||||
</div>
|
||||
|
||||
<div class="box ops">
|
||||
<h3>Node-only responsibilities</h3>
|
||||
<ul>
|
||||
<li>snapshot recovery</li>
|
||||
<li>accounts index</li>
|
||||
<li>geyser / yellowstone plugin</li>
|
||||
<li>node metrics</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="bridge" aria-hidden="true">
|
||||
<div class="bridge-line"></div>
|
||||
<div class="bridge-tag">wg0 / yellowstone / rpc / metrics</div>
|
||||
<div class="bridge-line"></div>
|
||||
<div class="bridge-tag">low latency read + controlled write</div>
|
||||
<div class="bridge-line"></div>
|
||||
</div>
|
||||
|
||||
<article class="node">
|
||||
<div class="node-head">
|
||||
<h2>mevnode_bot</h2>
|
||||
<span>single-node k3s / app + execution host</span>
|
||||
</div>
|
||||
<div class="stack">
|
||||
<div class="box read">
|
||||
<h3>Read plane</h3>
|
||||
<ul>
|
||||
<li><strong>yellowstone-consumer</strong>: bierze live stream z <code>10.91.0.1:10000</code></li>
|
||||
<li><strong>trade-ingestor</strong>: transformuje i dystrybuuje dane aplikacyjne</li>
|
||||
<li><strong>dlob-publisher</strong>: dociąga stan kont przez RPC i buduje DLOB</li>
|
||||
<li><strong>dlob-server</strong>: wystawia gotowy widok dla API i UI</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="box write">
|
||||
<h3>Execution plane</h3>
|
||||
<ul>
|
||||
<li><strong>strategy-engine</strong>: decyzje tradingowe</li>
|
||||
<li><strong>risk-manager</strong>: limity i walidacja</li>
|
||||
<li><strong>order-manager</strong>: lifecycle zleceń</li>
|
||||
<li><strong>tx-router</strong>: wybiera <code>RPC</code>, <code>TPU</code> albo <code>Jito</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="box store">
|
||||
<h3>Data plane</h3>
|
||||
<ul>
|
||||
<li><strong>Postgres</strong>: trwały storage, pozycje, historia, config</li>
|
||||
<li><strong>Hasura</strong>: warstwa GraphQL nad Postgres</li>
|
||||
<li><strong>Redis</strong>: cache, pub/sub, szybki stan DLOB</li>
|
||||
<li><strong>ClickHouse</strong>: opcjonalnie później pod cięższą analitykę</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="box ops">
|
||||
<h3>Product and ops</h3>
|
||||
<ul>
|
||||
<li><strong>frontend</strong> + <strong>api</strong> + <strong>ingress</strong></li>
|
||||
<li><strong>Prometheus</strong> + <strong>Grafana</strong></li>
|
||||
<li><strong>Portainer agent</strong> dla widoczności klastra w Portainerze</li>
|
||||
<li><strong>GitOps</strong>: Argo lub Flux z Gitea</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="strips">
|
||||
<div class="strip">
|
||||
<h3>Read path</h3>
|
||||
<p>
|
||||
<code>Yellowstone gRPC</code> daje live account updates, sloty i transakcje.
|
||||
<code>Agave RPC</code> zostaje do odczytów punktowych typu
|
||||
<code>getAccountInfo</code>, <code>getMultipleAccounts</code>,
|
||||
<code>getHealth</code> i <code>getLatestBlockhash</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="strip">
|
||||
<h3>Write path</h3>
|
||||
<p>
|
||||
Wysyłka transakcji nie idzie przez Yellowstone. To robi
|
||||
<code>RPC</code>, <code>TPU</code> albo <code>Jito</code>.
|
||||
Dzięki temu read plane i write plane są od siebie operacyjnie odseparowane.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="strip">
|
||||
<h3>Deployment rule</h3>
|
||||
<p>
|
||||
Wszystko aplikacyjne i webowe ląduje na <code>mevnode_bot</code>.
|
||||
Wszystko validatorowe zostaje na <code>mevnode_sol</code>.
|
||||
To upraszcza debug, upgrade i kontrolę ryzyka.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="note">
|
||||
<p>
|
||||
Docelowy przepływ: <code>mevnode_sol -> wg0 -> yellowstone-consumer / dlob-publisher -> redis / postgres -> api -> frontend</code>.
|
||||
Osobno działa execution path: <code>strategy-engine -> tx-router -> RPC / TPU / Jito</code>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
356
doc/migration.md
Normal file
356
doc/migration.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Migracja `trade` do k3s + GitOps (pull) na Gitea + CI/CD
|
||||
|
||||
Ten dokument opisuje plan migracji obecnego stacka (Docker Compose) do k3s z CD w modelu **pull-based** (GitOps): klaster sam synchronizuje „desired state” z repo na Gitei, a CI jedynie buduje/publikuje obrazy i aktualizuje repo deploymentu.
|
||||
|
||||
## Status (VPS / k3s)
|
||||
|
||||
Na VPS jest już uruchomione (k3s single-node):
|
||||
|
||||
- Ingress: Traefik (80/443)
|
||||
- TLS: cert-manager + `ClusterIssuer/letsencrypt-prod`
|
||||
- Gitea (Ingress `https://rv32i.pl`) + registry (`https://rv32i.pl/v2/`)
|
||||
- Argo CD w namespace `argocd`
|
||||
- Gitea Actions runner w namespace `gitea-actions` (act_runner + DinD)
|
||||
- GitOps repo `trade/trade-deploy` podpięte do Argo:
|
||||
- `Application/argocd/trade-staging` (auto-sync)
|
||||
- `namespace/trade-staging`: Postgres/Timescale + Hasura + pgAdmin + Job `hasura-bootstrap`
|
||||
- `namespace/trade-staging`: `trade-api` + `trade-ingestor` (obrazy z registry `rv32i.pl`)
|
||||
- `namespace/trade-staging`: `trade-frontend` + Ingress `trade.rv32i.pl` (basic auth, TLS OK)
|
||||
|
||||
Szybka weryfikacja (VPS):
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy trade-api trade-ingestor -o wide
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging logs deploy/trade-ingestor --tail=40
|
||||
```
|
||||
|
||||
### Tokeny `trade-api` (read/write)
|
||||
|
||||
Endpointy w `trade-api` są chronione tokenami z tabeli `api_tokens`:
|
||||
- `GET /v1/ticks`, `GET /v1/chart` → scope `read`
|
||||
- `POST /v1/ingest/tick` → scope `write`
|
||||
|
||||
W `staging` tokeny są przechowywane jako K8s Secrets (bez commitowania do gita):
|
||||
- `trade-staging/Secret/trade-ingestor-tokens`: `alg.json` (write) + `heliusN.json` (RPC)
|
||||
- `trade-staging/Secret/trade-read-token`: `read.json` (read)
|
||||
- `trade-staging/Secret/trade-frontend-tokens`: `frontend.json` (basic auth) + `read.json` (proxy do API)
|
||||
|
||||
### Dostęp: Argo CD UI (bez konfliktu z lokalnym Hasurą na 8080)
|
||||
|
||||
Port-forward uruchamiasz „na żądanie” (to nie jest stały serwis). Jeśli lokalnie masz Hasurę na `8080`, użyj `8090`.
|
||||
|
||||
Na VPS:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n argocd port-forward --address 127.0.0.1 svc/argocd-server 8090:443
|
||||
```
|
||||
|
||||
Na swoim komputerze:
|
||||
|
||||
```bash
|
||||
ssh -L 8090:127.0.0.1:8090 user@rv32i.pl
|
||||
```
|
||||
|
||||
UI: `https://localhost:8090`
|
||||
|
||||
### Argo CD: username / hasło
|
||||
|
||||
- Username: `admin`
|
||||
- Hasło startowe (pobierz na VPS; nie commituj / nie wklejaj do gita):
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo
|
||||
```
|
||||
|
||||
Po pierwszym logowaniu ustaw własne hasło i (opcjonalnie) usuń `argocd-initial-admin-secret`.
|
||||
|
||||
### Runner: log (czy CI działa)
|
||||
|
||||
Na VPS:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n gitea-actions logs deploy/gitea-act-runner -c runner --tail=80
|
||||
```
|
||||
|
||||
### Portainer (Kubernetes UI): `portainer.rv32i.pl`
|
||||
|
||||
Portainer jest wdrażany przez Argo CD jako osobna aplikacja `portainer` (namespace `portainer`) z Ingressem i cert-managerem.
|
||||
|
||||
Wymagania:
|
||||
- DNS: rekord A `portainer.rv32i.pl` → `77.90.8.171`
|
||||
|
||||
Weryfikacja na VPS:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n argocd get application portainer -o wide
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n portainer get pods,svc,ingress,certificate -o wide
|
||||
```
|
||||
|
||||
Jeśli cert-manager „utknie” na HTTP-01 z powodu cache NXDOMAIN w klastrze, pomocne jest zrestartowanie CoreDNS:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n kube-system rollout restart deploy/coredns
|
||||
```
|
||||
|
||||
Jeśli Portainer pokaże ekran `timeout.html` (setup/login timed out), zrestartuj deployment i od razu dokończ inicjalizację:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n portainer rollout restart deploy/portainer
|
||||
```
|
||||
|
||||
### Frontend UI: `trade.rv32i.pl`
|
||||
|
||||
Frontend jest wdrożony w `trade-staging` i ma Ingress na `trade.rv32i.pl` (Traefik + cert-manager).
|
||||
|
||||
Wymagania:
|
||||
- DNS: rekord A `trade.rv32i.pl` → `77.90.8.171`
|
||||
|
||||
Weryfikacja na VPS:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get deploy trade-frontend -o wide
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get ingress trade-frontend -o wide
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n trade-staging get certificate trade-rv32i-pl-tls -o wide
|
||||
```
|
||||
|
||||
Status na dziś:
|
||||
- `Certificate/trade-rv32i-pl-tls` = `Ready=True` (Let’s Encrypt)
|
||||
|
||||
## 0) Stan wejściowy (co dziś jest w repo)
|
||||
|
||||
Projekt ma już logiczny podział „mikroserwisowy”:
|
||||
|
||||
- **DB stack**: `devops/db/docker-compose.yml`
|
||||
- TimescaleDB/Postgres (`postgres`, port 5432)
|
||||
- Hasura (`hasura`, port 8080)
|
||||
- pgAdmin (`pgadmin`, port 5050) – raczej tylko dev/staging
|
||||
- **App stack**: `devops/app/docker-compose.yml`
|
||||
- `trade-api` (`api`, port 8787; health: `GET /healthz`)
|
||||
- `trade-frontend` (`frontend`, port 8081; health: `GET /healthz`)
|
||||
- `trade-ingestor` (`ingestor`, profil `ingest`) – worker ingestujący ticki
|
||||
- **One-shot bootstrap**: `devops/tools/bootstrap/docker-compose.yml`
|
||||
- `db-init`, `db-version`, `db-backfill` (SQL)
|
||||
- `hasura-bootstrap` (track tabel + permissions)
|
||||
|
||||
Sekrety/konfiguracja lokalnie są dziś w `tokens/*.json` (gitignored) i są montowane jako pliki do kontenerów.
|
||||
|
||||
## 1) Cel migracji
|
||||
|
||||
- Przeniesienie usług do k3s (Kubernetes) bez zmiany logiki aplikacji.
|
||||
- Wdrożenia przez GitOps (**pull**, bez „kubectl apply” z CI).
|
||||
- Powtarzalność: jedna ścieżka build → deploy, jednoznaczne tagi/digests.
|
||||
- Minimalny downtime: możliwość uruchamiania wersji równolegle (v1/v2…), jak w `scripts/ops/*`.
|
||||
|
||||
## 2) Założenia i decyzje do podjęcia (checklista)
|
||||
|
||||
Ustal na start (zaznacz jedną opcję, resztę możesz doprecyzować później):
|
||||
|
||||
1) **CD (pull)**: Argo CD czy Flux
|
||||
- Argo CD + (opcjonalnie) Image Updater – popularny i „klikany”
|
||||
- Flux + Image Automation – bardzo „git-first”
|
||||
2) **Registry obrazów**: Gitea Registry czy osobne (Harbor / registry:2)
|
||||
3) **Sekrety w GitOps**: manualne seedy (na start) czy docelowo SOPS/SealedSecrets/ExternalSecrets
|
||||
4) **DB**: w klastrze (StatefulSet + PVC) czy zewnętrzny Postgres/Timescale
|
||||
5) **Środowiska**: `staging` + `prod`? (opcjonalnie preview per branch/PR)
|
||||
6) **Ingress/TLS**: Traefik + cert-manager + Let’s Encrypt (lub inny wariant)
|
||||
|
||||
## 3) Docelowa architektura na k3s (mapowanie 1:1)
|
||||
|
||||
Minimalny, praktyczny podział zasobów:
|
||||
|
||||
- `timescaledb` (Postgres/Timescale): `StatefulSet` + `PVC` + `Service`
|
||||
- `hasura`: `Deployment` + `Service`
|
||||
- `trade-api`: `Deployment` + `Service`
|
||||
- `trade-frontend`: `Deployment` + `Service` + `Ingress`
|
||||
- `trade-ingestor`: `Deployment` (zwykle `replicas: 1`) albo `CronJob` (jeśli ingest ma być okresowy)
|
||||
- migracje/bootstrap:
|
||||
- `Job` dla `db-init`/`db-version`/`db-backfill`
|
||||
- `Job` dla `hasura-bootstrap`
|
||||
|
||||
Ważne:
|
||||
- `trade-api` i `trade-frontend` mają gotowe endpointy `/healthz` do `readinessProbe`/`livenessProbe`.
|
||||
- `trade-ingestor` potrzebuje egress do internetu (RPC do Helius/Drift).
|
||||
|
||||
## 4) Podział repozytoriów (rekomendowane)
|
||||
|
||||
Żeby GitOps było czyste i proste:
|
||||
|
||||
1) Repo aplikacji: **`trade`** (to repo)
|
||||
- kod i Dockerfile: `devops/api/Dockerfile`, `devops/ingestor/Dockerfile`, `devops/app/frontend/Dockerfile`
|
||||
2) Repo deploymentu: **`trade-deploy`** (nowe repo na Gitei)
|
||||
- manifesty K8s (Helm chart albo Kustomize)
|
||||
- overlays per środowisko: `staging/`, `prod/`
|
||||
|
||||
Zasada: CI nie wykonuje deploya do klastra; CI tylko aktualizuje `trade-deploy`, a CD w klastrze to „pulluje”.
|
||||
|
||||
## 5) CI na Gitei (build → push)
|
||||
|
||||
Do wyboru:
|
||||
- **Gitea Actions** + `act_runner` (najbliżej GitHub Actions)
|
||||
- alternatywnie Woodpecker/Drone, jeśli już masz w infra
|
||||
|
||||
Zakres CI:
|
||||
- zbuduj i wypchnij obrazy:
|
||||
- `trade-api`
|
||||
- `trade-frontend`
|
||||
- `trade-ingestor`
|
||||
- taguj deterministycznie, np.:
|
||||
- `sha-<shortsha>` (zawsze)
|
||||
- opcjonalnie `vN` na release
|
||||
- po push obrazu: zaktualizuj `trade-deploy` (tag/digest) lub zostaw to automatyzacji obrazów (patrz niżej).
|
||||
|
||||
## 6) CD (pull) z klastra: GitOps
|
||||
|
||||
Wariant A (najprostszy operacyjnie): **CI robi commit do `trade-deploy`**
|
||||
- CI po zbudowaniu obrazu aktualizuje `values.yaml`/`kustomization.yaml` w `trade-deploy` (tag lub digest) i robi commit/push.
|
||||
- Argo CD / Flux wykrywa zmianę i robi rollout.
|
||||
|
||||
Wariant B (bardziej „autopilot”): **automatyczna aktualizacja obrazów**
|
||||
- Argo CD Image Updater albo Flux Image Automation obserwuje registry i sam aktualizuje repo `trade-deploy`.
|
||||
- CI tylko buduje/pushuje obrazy.
|
||||
|
||||
## 7) Sekrety: `tokens/*.json` → Kubernetes Secret (plikowy mount)
|
||||
|
||||
Docelowo w K8s montujesz te same pliki co w Compose:
|
||||
|
||||
- `tokens/hasura.json` → dla `trade-api` (admin do Hasury)
|
||||
- `tokens/api.json` → dla `trade-api` (admin secret do tokenów API)
|
||||
- `tokens/read.json` → dla `trade-frontend` (proxy do API)
|
||||
- `tokens/frontend.json` → dla `trade-frontend` (basic auth)
|
||||
- `tokens/alg.json` → dla `trade-ingestor` (write token)
|
||||
- `tokens/heliusN.json` (lub `rpcUrl`) → dla `trade-ingestor`
|
||||
|
||||
Nie commituj sekretów do gita. W GitOps wybierz jedną drogę:
|
||||
- start: ręczne `kubectl create secret ...` na środowisko
|
||||
- docelowo: SOPS (age) / SealedSecrets / ExternalSecrets (Vault itp.)
|
||||
|
||||
## 8) Plan wdrożenia (kolejność kroków)
|
||||
|
||||
### Etap 1: Klaster i narzędzia bazowe
|
||||
1) Namespace’y (np. `trade`, `gitea`, `argocd`/`flux-system`).
|
||||
2) StorageClass (na start `local-path`, docelowo rozważ Longhorn).
|
||||
3) Ingress controller (Traefik w k3s) + cert-manager + ClusterIssuer.
|
||||
4) Registry obrazów (Gitea Registry / Harbor / registry:2).
|
||||
5) GitOps controller (Argo CD albo Flux) podpięty do `trade-deploy`.
|
||||
|
||||
### Etap 2: DB + Hasura
|
||||
6) Deploy TimescaleDB (StatefulSet + PVC + Service).
|
||||
7) Uruchom `Job` `db-init` (idempotent) – odpowiednik `devops/tools/bootstrap:db-init`.
|
||||
8) Deploy Hasura (Deployment + Service + Secret na admin/jwt/db-url).
|
||||
9) Uruchom `Job` `hasura-bootstrap` – odpowiednik `devops/tools/bootstrap:hasura-bootstrap`.
|
||||
|
||||
### Etap 3: App stack
|
||||
10) Deploy `trade-api` (Deployment + Service, env `HASURA_GRAPHQL_URL`, `TICKS_TABLE`, `CANDLES_FUNCTION`).
|
||||
11) Sprawdź `GET /healthz` po Service DNS i (opcjonalnie) z zewnątrz.
|
||||
12) Wygeneruj i wgraj tokeny:
|
||||
- read token dla frontendu (`tokens/read.json`)
|
||||
- write token dla ingestora (`tokens/alg.json`)
|
||||
13) Deploy `trade-frontend` + Ingress (TLS opcjonalnie); ustaw `API_UPSTREAM=http://trade-api:8787`.
|
||||
14) Deploy `trade-ingestor` (`replicas: 1`); potwierdź, że ticki wpadają.
|
||||
|
||||
### Etap 4: CI/CD end-to-end
|
||||
15) Skonfiguruj runnera CI (Gitea Actions / Woodpecker).
|
||||
16) Workflow CI: build+push obrazów (i ewentualny commit do `trade-deploy`).
|
||||
17) Zasady promocji: staging → prod (np. tylko tag/release).
|
||||
|
||||
### Etap 4b: Workflow zmian (dev → staging) + snapshoty/rollback
|
||||
|
||||
Rekomendacja: nie robimy “ręcznych” zmian na VPS (żeby nie tworzyć snowflake’a). Każdy deploy ma być **snapshoot’em**, do którego można wrócić: *git commit w `trade-deploy` + pin do obrazu* (`sha-<shortsha>` albo digest; bez `latest`).
|
||||
|
||||
Standardowy flow:
|
||||
1) Zmiany robisz lokalnie (nie musisz odpalać lokalnego Dockera; na start wystarczy szybki build/typecheck).
|
||||
2) Push do gita (PR/merge).
|
||||
3) CI buduje i pushuje obrazy, a następnie aktualizuje `trade-deploy` (tag/digest + ewentualnie `BUILD_TIMESTAMP`).
|
||||
4) Argo CD (auto-sync) wdraża do `trade-staging`.
|
||||
5) Testujesz na VPS (UI/API/ingestor).
|
||||
|
||||
Rollback (szybki, preferowany):
|
||||
- cofasz zmianę w `trade-deploy` (`git revert` / powrót do poprzedniej rewizji w Argo) → Argo wraca do poprzedniego snapshoot’a.
|
||||
|
||||
Rollback (bezpieczny dla “dużych” zmian, np. ingest/schema):
|
||||
- użyj wersjonowania vN (osobna tabela/funkcja/porty) + cutover ingestora; jeśli zmiana nie siądzie, robisz cut back vN → v1 (dane w starej tabeli zostają).
|
||||
|
||||
## 9) Wersjonowanie v1/v2… (równoległe wdrożenia + cutover)
|
||||
|
||||
Repo ma już pattern wersjonowania w Compose (`scripts/ops/*` + `devops/versions/vN.env`).
|
||||
|
||||
Odwzorowanie na K8s:
|
||||
- osobne release’y Helm (np. `trade-v1`, `trade-v2`) albo osobne namespace’y
|
||||
- wspólny DB/Hasura bez zmian
|
||||
- `Job` „version-init”:
|
||||
- uruchamia SQL `db-version` (tworzy `drift_ticks_vN` i funkcje)
|
||||
- uruchamia `hasura-bootstrap` z `TICKS_TABLE`/`CANDLES_FUNCTION` ustawionymi na vN
|
||||
- cutover:
|
||||
- start `trade-ingestor` vN
|
||||
- stop `trade-ingestor` v1
|
||||
- (opcjonalnie) `db-backfill` jako osobny job
|
||||
|
||||
## 10) Definition of Done (DoD)
|
||||
|
||||
- `trade-api` i `trade-frontend` działają na k3s (proby `Ready=True`, brak crashloopów).
|
||||
- `trade-ingestor` zapisuje ticki do właściwej tabeli, a UI pokazuje wykres.
|
||||
- Deploy jest sterowany przez GitOps (zmiana w `trade-deploy` → rollout bez ręcznego `kubectl apply`).
|
||||
- Sekrety są poza gitem (manualnie lub przez wybraną metodę GitOps).
|
||||
|
||||
## 11) Referencje w repo
|
||||
|
||||
- Compose: `devops/db/docker-compose.yml`, `devops/app/docker-compose.yml`, `devops/tools/bootstrap/docker-compose.yml`
|
||||
- Workflow end-to-end: `doc/workflow-api-ingest.md`
|
||||
- Szkic migracji K8s: `doc/k8s-migracja.md`
|
||||
- Gitea na k3s (Traefik/TLS): `doc/gitea-k3s-rv32i.md`
|
||||
|
||||
## 12) Metoda „superproject” (subrepo) – plan refaktoru repozytoriów
|
||||
|
||||
Cel: utrzymać osobne repo dla usług (API/ingestor/frontend) i osobne repo GitOps (`trade-deploy`), a do tego mieć jedno repo „główne” (superproject) spinające wszystko (np. jako **git submodules**).
|
||||
|
||||
### Wybór repo głównego
|
||||
|
||||
Rekomendacja: **`trade/trade-infra` jako repo główne (superproject)**:
|
||||
- jest neutralne (infra/ops), nie miesza kodu aplikacji z manifestami GitOps,
|
||||
- pozwala trzymać jedną „zafiksowaną” wersję całego systemu (SHAs submodułów),
|
||||
- nie wymaga zmiany Argo CD (który nadal może śledzić `trade/trade-deploy`).
|
||||
|
||||
### Docelowy podział ról repo
|
||||
|
||||
- `trade/trade-api` – kod + Dockerfile dla `trade-api`
|
||||
- `trade/trade-ingestor` – kod + Dockerfile dla `trade-ingestor`
|
||||
- `trade/trade-frontend` – kod + Dockerfile dla `trade-frontend` (apps/visualizer + serwer)
|
||||
- `trade/trade-deploy` – GitOps: Kustomize/Helm dla k3s (Argo CD pull)
|
||||
- `trade/trade-doc` – dokumentacja całego projektu (`doc/`), w tym log działań
|
||||
- `trade/trade-infra` – superproject + narzędzia/ops (np. makefile, skrypty, checklisty)
|
||||
|
||||
### Proponowany układ w superprojekcie
|
||||
|
||||
Przykład (ścieżki w `trade/trade-infra`):
|
||||
|
||||
```text
|
||||
trade-infra/
|
||||
api/ -> submodule: trade-api
|
||||
ingestor/ -> submodule: trade-ingestor
|
||||
frontend/ -> submodule: trade-frontend
|
||||
deploy/ -> submodule: trade-deploy
|
||||
doc/ -> submodule: trade-doc
|
||||
```
|
||||
|
||||
### Plan wdrożenia refaktoru (po akceptacji)
|
||||
|
||||
1) Zainicjalizować `trade/trade-infra` jako superproject i dodać submodule do wszystkich subrepo.
|
||||
2) Wyciąć obecny kod do subrepo:
|
||||
- API: `services/api/*` + `devops/api/Dockerfile` → `trade-api/`
|
||||
- Frontend: `apps/visualizer/*` + `services/frontend/*` + `devops/app/frontend/*` → `trade-frontend/`
|
||||
- Ingestor: `scripts/ingest-drift-oracle.ts` (+ minimalny zestaw zależności i Dockerfile) → `trade-ingestor/`
|
||||
3) Przenieść dokumentację do `trade-doc` (w tym log: `doc/steps.md`) i zostawić w pozostałych repo linki do docs.
|
||||
4) Ujednolicić nazwy obrazów i tagowanie:
|
||||
- `rv32i.pl/trade/trade-api:<tag>`
|
||||
- `rv32i.pl/trade/trade-ingestor:<tag>`
|
||||
- `rv32i.pl/trade/trade-frontend:<tag>`
|
||||
5) CI/CD (Gitea Actions) per repo usługi:
|
||||
- build → push do registry,
|
||||
- aktualizacja `trade-deploy` (commit/PR z bumpem tagu/digesta),
|
||||
- staging auto-sync w Argo (prod manual).
|
||||
6) Walidacja end-to-end na k3s:
|
||||
- rollout `trade-staging`,
|
||||
- UI `https://trade.rv32i.pl`,
|
||||
- ingest ticków (`trade-ingestor` → `trade-api`).
|
||||
|
||||
Uwaga: lokalnie repo nie ma historii commitów, więc refaktor będzie startem „od zera” w nowych repo (initial commit per subrepo).
|
||||
158
doc/stats.md
Normal file
158
doc/stats.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# DLOB stats — definicje i wizualizacja
|
||||
|
||||
Ten dokument opisuje metryki liczone z orderbooka DLOB (Drift Limit Order Book) oraz propozycje, jak je prezentować w naszej wizualizacji (warstwami/panelami).
|
||||
|
||||
## Źródła danych (tabele)
|
||||
|
||||
### `dlob_l2_latest` (snapshot L2, “surowy”)
|
||||
|
||||
Snapshot top‑N leveli orderbooka per market.
|
||||
|
||||
- `bids` / `asks`: tablice poziomów `{ price, size }` (wartości zwykle w “skalowanych intach” wg `PRICE_PRECISION` i `BASE_PRECISION`).
|
||||
- `best_bid_price` / `best_ask_price`, `mark_price`, `oracle_price`: w praktyce do szybkiego odczytu top‑of‑book (ale najdokładniej liczyć z `bids/asks`).
|
||||
- `ts`, `slot`: czas/slot źródłowego snapshota z DLOB.
|
||||
- `updated_at`: kiedy worker zapisał snapshot do DB (do oceny “świeżości”).
|
||||
|
||||
Z tego źródła robimy: orderbook UI (paski/heat), mikro‑ceny, symulacje fill.
|
||||
|
||||
### `dlob_stats_latest` (agregat z L2 pod UI)
|
||||
|
||||
Pochodne metryki liczone na podstawie `dlob_l2_latest` (lub równoważnego L2), trzymane jako “latest” per market.
|
||||
|
||||
Metryki:
|
||||
|
||||
- `best_bid_price`, `best_ask_price` (USD): najlepszy bid/ask.
|
||||
- `mid_price` (USD): `(best_bid_price + best_ask_price) / 2`.
|
||||
- `spread_abs` (USD): `best_ask_price - best_bid_price`.
|
||||
- `spread_bps` (bps): `(spread_abs / mid_price) * 10_000` (1 bps = 0.01%).
|
||||
- `depth_levels` (liczba): ile leveli z każdej strony weszło do “depth”.
|
||||
- `depth_bid_base`, `depth_ask_base` (base asset): suma size (w jednostkach bazowych) po top‑N levelach.
|
||||
- `depth_bid_usd`, `depth_ask_usd` (USD): suma `size_base * price` po top‑N levelach.
|
||||
- `imbalance` ([-1..1]): `(depth_bid_usd - depth_ask_usd) / (depth_bid_usd + depth_ask_usd)`; >0 = relatywnie większa płynność po bid.
|
||||
- `mark_price`, `oracle_price` (USD): ceny referencyjne (mark i oracle).
|
||||
- `ts`, `slot`, `updated_at`: metadane czasu/świeżości.
|
||||
|
||||
To jest najszybsze źródło do overlay na wykresie i do KPI w headerze.
|
||||
|
||||
### `dlob_depth_bps_latest` (płynność w pasmach wokół mid)
|
||||
|
||||
Metryki głębokości, ale nie “top‑N leveli” tylko “okno odległości od ceny” w bps.
|
||||
|
||||
Klucz:
|
||||
|
||||
- `(market_name, band_bps)` np. 5/10/20/50/100/200 bps.
|
||||
|
||||
Interpretacja:
|
||||
|
||||
- Dla danego `band_bps` sumujemy płynność tylko z poziomów, które mieszczą się w oknie ±`band_bps` wokół `mid_price`.
|
||||
|
||||
Metryki:
|
||||
|
||||
- `bid_base`, `ask_base` (base asset): suma size w oknie.
|
||||
- `bid_usd`, `ask_usd` (USD): suma `size_base * price` w oknie.
|
||||
- `imbalance` ([-1..1]): jak wyżej, ale per band.
|
||||
- `mid_price`, `best_bid_price`, `best_ask_price` (USD): do kontekstu wyliczeń.
|
||||
- `ts`, `slot`, `updated_at`, `raw`: metadane/diagnostyka.
|
||||
|
||||
To jest najlepsze źródło do wykresów “jak gruby jest orderbook blisko ceny”.
|
||||
|
||||
### `dlob_slippage_latest` (symulacja slippage vs rozmiar)
|
||||
|
||||
Symulacja wykonania zlecenia rynkowego po L2.
|
||||
|
||||
Klucz:
|
||||
|
||||
- `(market_name, side, size_usd)` gdzie `side ∈ {buy,sell}` a `size_usd` to predefiniowane progi (np. 100/500/1000/…).
|
||||
|
||||
Metryki:
|
||||
|
||||
- `impact_bps` (bps): wpływ wykonania vs `mid_price` (zwykle `vwap` względem mid).
|
||||
- `vwap_price` (USD): średnia cena wykonania.
|
||||
- `worst_price` (USD): najgorszy poziom dotknięty podczas fill.
|
||||
- `filled_usd`, `filled_base`: ile realnie weszło w fill (gdy brak płynności, może być < docelowego).
|
||||
- `fill_pct` (%): 100% = pełny fill.
|
||||
- `levels_consumed`: ile leveli zostało “zjedzone”.
|
||||
- `mid_price`, `ts`, `slot`, `updated_at`, `raw`: metadane/diagnostyka.
|
||||
|
||||
To jest idealne do “Dynamic Slippage” w formularzu i do wykresu slippage‑vs‑size.
|
||||
|
||||
## Jak nanieść na wizualizację (warstwy/panele)
|
||||
|
||||
Poniżej propozycja warstw, pogrupowanych tak, żeby serie w jednej warstwie były w tej samej “domenie” (jednostki i semantyka).
|
||||
|
||||
### Warstwa 1: Cena / Quotes (oś Y = USD, overlay na głównym wykresie)
|
||||
|
||||
Źródło: `dlob_stats_latest`.
|
||||
|
||||
- Linie: `mark_price` i `oracle_price` (referencje).
|
||||
- Linie: `best_bid_price` i `best_ask_price` (top‑of‑book).
|
||||
- Opcjonalnie: `mid_price` jako linia przerywana.
|
||||
- Opcjonalnie: “spread band” (wypełnienie między bid i ask).
|
||||
|
||||
Efekt: w jednym miejscu widać gdzie jest rynek + jak szeroki jest spread.
|
||||
|
||||
### Warstwa 2: Spread (panel pod wykresem, oś Y = bps)
|
||||
|
||||
Źródło: `dlob_stats_latest`.
|
||||
|
||||
- Linia: `spread_bps`.
|
||||
- Tooltip/secondary: `spread_abs` (USD) jako dodatkowa informacja (zwykle bez drugiej osi, żeby nie mieszać skali).
|
||||
|
||||
Efekt: szybkie “koszt wejścia/wyjścia” i jego zmienność.
|
||||
|
||||
### Warstwa 3: Płynność top‑N + imbalance (panel, oś Y = USD + opcjonalnie linia imbalance)
|
||||
|
||||
Źródło: `dlob_stats_latest`.
|
||||
|
||||
- Area: `depth_bid_usd` i `depth_ask_usd` (dwie serie, zielona/czerwona).
|
||||
- Opcjonalnie: linia `imbalance` (druga oś, zakres [-1..1]) albo jako wskaźnik liczbowy.
|
||||
|
||||
Efekt: ile jest płynności “najbliżej” top‑of‑book (w definicji top‑N leveli).
|
||||
|
||||
### Warstwa 4: Płynność jako funkcja odległości (bps bands) (panel)
|
||||
|
||||
Źródło: `dlob_depth_bps_latest`.
|
||||
|
||||
Dwa czytelne warianty prezentacji:
|
||||
|
||||
1) Fan chart / multi‑line:
|
||||
- Linie `bid_usd(band_bps)` i `ask_usd(band_bps)` dla kilku bandów (np. 10/50/200).
|
||||
|
||||
2) Stacked:
|
||||
- Słupki/area pokazujące “ile dodaje kolejne pasmo” (np. 0–10, 10–20, 20–50 bps), osobno dla bid i ask.
|
||||
|
||||
Efekt: “jak szybko rośnie płynność, gdy odchodzę od mid”.
|
||||
|
||||
### Warstwa 5: Slippage vs size (osobny panel XY, nie timeline)
|
||||
|
||||
Źródło: `dlob_slippage_latest`.
|
||||
|
||||
- Oś X: `size_usd`.
|
||||
- Oś Y: `impact_bps`.
|
||||
- Dwie krzywe: `buy` i `sell`.
|
||||
- Marker: aktualny “Order Value” z formularza (punkt na krzywej).
|
||||
|
||||
Efekt: bardzo czytelna krzywa kosztu wykonania względem rozmiaru.
|
||||
|
||||
### Warstwa 6: Heat / “paski” orderbooka (widok orderbook albo overlay z ograniczeniem)
|
||||
|
||||
Źródło: `dlob_l2_latest`.
|
||||
|
||||
- Paski (zielone/czerwone) per poziom ceny, intensywność ∝ `size` (jak na Drift UI).
|
||||
- To najlepiej działa jako:
|
||||
- osobny panel “Orderbook” (snapshot), albo
|
||||
- “edge overlay” przy prawej krawędzi wykresu (bez historii).
|
||||
|
||||
Efekt: “gdzie stoją ściany” i jak się zmieniają.
|
||||
|
||||
## Uwaga o historii (“latest” vs wykres w czasie)
|
||||
|
||||
Tabele `*_latest` są świetne do live UI i subscriptions, ale **nie przechowują historii** do rysowania timeline (np. spread przez ostatnie 24h).
|
||||
|
||||
Jeśli chcemy historię:
|
||||
|
||||
- opcja A: dodać osobne tabele time‑series (np. `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`) i zasilać je workerem,
|
||||
- opcja B: rozszerzyć ingest ticków (`drift_ticks`) o dodatkowe pola/nową tabelę eventów dla metryk orderbooka.
|
||||
|
||||
Wtedy warstwy 2–5 mogą być prawdziwymi wykresami “w czasie”, a nie tylko bieżącym odczytem.
|
||||
|
||||
231
doc/steps.md
Normal file
231
doc/steps.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# steps.md
|
||||
|
||||
Log działań wykonywanych w ramach migracji `trade` → k3s + Gitea + GitOps (pull).
|
||||
Uwaga: **nie zapisuję sekretów** (hasła, tokeny, prywatne klucze) – jeśli pojawiają się w outputach narzędzi, są pomijane/redagowane.
|
||||
|
||||
## 2026-01-05
|
||||
|
||||
### Repo: inwentaryzacja i plan
|
||||
- Przejrzano strukturę repo (`apps/`, `devops/`, `services/`, `doc/`) i istniejące Compose stacki:
|
||||
- DB: `devops/db/docker-compose.yml`
|
||||
- App: `devops/app/docker-compose.yml`
|
||||
- Bootstrap (one-shot): `devops/tools/bootstrap/docker-compose.yml`
|
||||
- Sprawdzono Dockerfile:
|
||||
- API: `devops/api/Dockerfile`
|
||||
- Frontend: `devops/app/frontend/Dockerfile`
|
||||
- Ingestor: `devops/ingestor/Dockerfile`
|
||||
- Sprawdzono skrypty wersjonowania Compose (v1/v2…): `scripts/ops/*` + env: `devops/versions/v1.env`.
|
||||
- Dodano dokument planu migracji: `doc/migration.md`.
|
||||
|
||||
### Gitea: repo GitOps (MCP Gitea)
|
||||
- Utworzono repo `trade/trade-deploy` (public, `main`, auto-init).
|
||||
- Dodano szkielet Kustomize:
|
||||
- `kustomize/base/` (placeholder ConfigMap)
|
||||
- `kustomize/overlays/staging/` (`namespace: trade-staging`)
|
||||
- `kustomize/overlays/prod/` (`namespace: trade-prod`)
|
||||
- Dodano bootstrap manifesty Argo CD Application:
|
||||
- `bootstrap/argocd/application-trade-staging.yaml` (auto-sync + CreateNamespace)
|
||||
- `bootstrap/argocd/application-trade-prod.yaml` (CreateNamespace, bez auto-sync)
|
||||
- Dodano DB stack do `trade-deploy` (manifests + bootstrap):
|
||||
- Postgres/Timescale: `kustomize/base/postgres/*` + init SQL: `kustomize/base/initdb/001_init.sql`
|
||||
- Hasura: `kustomize/base/hasura/*` + job `hasura-bootstrap` (track tables/functions)
|
||||
- Staging: `kustomize/overlays/staging/pgadmin.yaml` + patch Hasury (console/dev mode)
|
||||
- Zaktualizowano `README.md` w `trade-deploy` o wymagane sekrety i port-forward.
|
||||
|
||||
### VPS: audyt stanu (MCP SSH)
|
||||
- Host: `qstack` (Debian), k3s działa; dostęp do klastra uzyskany przez `KUBECONFIG=/etc/rancher/k3s/k3s.yaml` (bez sudo).
|
||||
- Ingress: Traefik działa (LB na `77.90.8.171`, porty 80/443).
|
||||
- TLS: cert-manager działa; `ClusterIssuer/letsencrypt-prod` jest `Ready=True`.
|
||||
- Gitea jest zainstalowana w k3s (Helm release `gitea` w namespace `gitea`), Ingress na `rv32i.pl`, cert `rv32i-pl-tls` jest `Ready=True`.
|
||||
- GitOps controller (Argo CD / Flux): **nie znaleziono** (brak namespace `argocd` i `flux-system`).
|
||||
- Wdrożenia aplikacji `trade-*` w k3s: **nie znaleziono** (brak deploy/podów z nazwą `trade`).
|
||||
- Registry: endpoint `https://rv32i.pl/v2/` odpowiada (401 bez autoryzacji).
|
||||
|
||||
### Gitea: audyt stanu (MCP Gitea)
|
||||
- Zalogowany użytkownik: `u1` (admin).
|
||||
- Organizacja `trade` istnieje; są utworzone repozytoria (puste, bez commitów/branchy):
|
||||
- `trade/trade-api`
|
||||
- `trade/trade-frontend`
|
||||
- `trade/trade-ingestor`
|
||||
- `trade/trade-infra`
|
||||
- Repo `trade-deploy` (manifesty GitOps): **nie znaleziono**.
|
||||
|
||||
### VPS: Argo CD (MCP SSH)
|
||||
- Zainstalowano Argo CD przez Helm: release `argocd` w namespace `argocd` (`argo/argo-cd`).
|
||||
- Utworzono `Application` dla staging z `trade-deploy`:
|
||||
- zastosowano `bootstrap/argocd/application-trade-staging.yaml`
|
||||
- Argo utworzyło namespace `trade-staging` i zsynchronizowało placeholder ConfigMap `trade-deploy-info`.
|
||||
- Uwaga: jeśli port `8080` jest zajęty lokalnie (np. przez Hasurę), do port-forward użyj innego portu (np. `8090:443`).
|
||||
|
||||
### VPS: Gitea Actions runner (MCP SSH)
|
||||
- Wykryto wymaganie: `sudo` wymaga hasła, więc runner postawiony w k3s (bez instalacji binarki na hoście).
|
||||
- Utworzono namespace `gitea-actions` oraz zasoby dla `act_runner` (DinD sidecar + runner):
|
||||
- manifest w `trade-deploy`: `bootstrap/gitea-actions/act-runner.yaml`
|
||||
- sekret `act-runner-registration-token` utworzony w klastrze z tokena rejestracyjnego pobranego z Gitei (API admin; token nie trafia do gita)
|
||||
- Naprawiono start Dockera w sidecar:
|
||||
- początkowo próba po unix-sockecie powodowała konflikt (`docker.sock` jako katalog)
|
||||
- finalnie ustawiono `DOCKER_HOST=tcp://127.0.0.1:2375` (runner ↔ dind po localhost), co uruchomiło runnera poprawnie.
|
||||
|
||||
### Uwaga: seed sekretów (blokada narzędzia)
|
||||
- Przy próbie seedowania sekretów do `trade-staging` przez MCP SSH pojawił się trwały timeout na każde polecenie (`ssh-mcp/exec`), więc ten krok został opisany w `trade-deploy/README.md` do wykonania ręcznie na VPS.
|
||||
|
||||
### VPS: dostęp SSH kluczem (wymagane hasło)
|
||||
- Użytkownik poprosił o używanie naszego klucza prywatnego dla VPS: `k/qstack/qstack`.
|
||||
- Klucz jest zaszyfrowany hasłem (passphrase). Bez podania passphrase nie da się go użyć w trybie nieinteraktywnym (`ssh`/`ssh-keygen` zwraca błąd o niepoprawnym passphrase / brak `ssh-askpass`).
|
||||
- Żeby kontynuować automatyzację z tej sesji są 2 opcje:
|
||||
1) dostarczyć passphrase poza czatem (np. lokalnie w pliku w `tokens/`, gitignored) i używać `SSH_ASKPASS`, albo
|
||||
2) wygenerować osobny klucz bez passphrase jako “deploy key” tylko do automatyzacji i dodać jego publiczną część do `~user/.ssh/authorized_keys` na VPS.
|
||||
|
||||
### Następne kroki (do zrobienia)
|
||||
- Wybrać GitOps controller (Argo CD vs Flux) i zainstalować w k3s.
|
||||
- Utworzyć repo `trade-deploy` i dodać strukturę Helm/Kustomize (staging/prod).
|
||||
- Postawić runner CI (Gitea Actions `act_runner` lub alternatywa) i dodać pipeline build+push obrazów.
|
||||
- Dopiero potem: manifesty K8s dla `trade-api`/`trade-frontend`/`trade-ingestor` + DB/Hasura + joby migracji.
|
||||
|
||||
## 2026-01-06
|
||||
|
||||
### VPS: SSH kluczem projektu (passphrase)
|
||||
- Użyto klucza prywatnego `k/qstack/qstack` do połączenia z VPS przez `ssh` (z `SSH_ASKPASS` czytającym passphrase z `tokens/vps-ssh.json`, plik gitignored).
|
||||
- Skrypt `tokens/ssh-askpass.sh` był tworzony tymczasowo i został usunięty po użyciu (żeby nie ryzykować przypadkowego commita).
|
||||
|
||||
### Doprecyzowanie dostępu i bezpieczeństwo repo
|
||||
- Dopisano w `doc/migration.md` bieżący status VPS/k3s oraz komendy do Argo CD (port-forward na `8090`) i logów runnera.
|
||||
- Zaktualizowano `.gitignore`, żeby ignorować sekrety/klucze lokalne: `tokens/*`, `k/`, `argo/pass`, `gitea/token`.
|
||||
|
||||
### k3s: seed sekretów (staging)
|
||||
- Utworzono sekrety w namespace `trade-staging` (wartości wygenerowane losowo, nie logowane):
|
||||
- `trade-postgres` (`POSTGRES_*`)
|
||||
- `trade-hasura` (`HASURA_GRAPHQL_ADMIN_SECRET`, `HASURA_JWT_KEY`)
|
||||
- `trade-pgadmin` (`PGADMIN_DEFAULT_*`)
|
||||
|
||||
### Argo CD: deploy DB stack (staging)
|
||||
- Wymuszono refresh aplikacji Argo `trade-staging` (annotation `argocd.argoproj.io/refresh=hard`).
|
||||
- Status: `trade-staging` = `Synced` / `Healthy`.
|
||||
- Workloady w `trade-staging` działają:
|
||||
- `StatefulSet/postgres` = `postgres-0 Running`
|
||||
- `Deployment/hasura` = `Running`
|
||||
- `Deployment/pgadmin` = `Running`
|
||||
- `Job/hasura-bootstrap` = `Completed` (log: `[hasura-bootstrap] ok`)
|
||||
|
||||
### Portainer: dlaczego nie widać k3s (wyjaśnienie)
|
||||
- Portainer uruchomiony jako kontener Docker domyślnie widzi tylko środowisko Docker; k3s używa `containerd`, więc nie zobaczysz „podów” w `docker ps` ani w Portainerze jako kontenerów Dockera.
|
||||
- Żeby Portainer widział „wnętrze” k3s trzeba dodać środowisko typu `Kubernetes` w Portainer UI albo postawić Portainera bezpośrednio w klastrze (rekomendowane).
|
||||
|
||||
### Portainer (opcja A): wdrożenie w k3s + Ingress `portainer.rv32i.pl`
|
||||
- Dodano do GitOps repo `trade/trade-deploy` paczkę Kustomize: `kustomize/infra/portainer` (Namespace+RBAC+PVC+Deployment+Service+Ingress).
|
||||
- Dodano Argo CD `Application` dla Portainera: `bootstrap/argocd/application-portainer.yaml`.
|
||||
- Zastosowano `Application` na VPS i potwierdzono status: `portainer` = `Synced` / `Healthy`.
|
||||
- Ingress utworzony na host `portainer.rv32i.pl` (Traefik).
|
||||
- cert-manager utworzył `Certificate/portainer-rv32i-pl-tls`, ale ACME jest `pending` dopóki DNS `portainer.rv32i.pl` nie wskazuje na `77.90.8.171` (brak rekordu A w momencie sprawdzania).
|
||||
|
||||
### Portainer: DNS + TLS + odblokowanie „New Portainer installation timed out”
|
||||
- Potwierdzono, że rekord A `portainer.rv32i.pl` istnieje na autorytatywnych DNS (`dns*.home.pl`) i rozwiązuje się na publicznych resolverach.
|
||||
- Zrestartowano `Deployment/portainer` w namespace `portainer`, żeby odblokować ekran inicjalizacji (komunikat “timed out for security purposes”).
|
||||
- cert-manager miał „zawieszone” HTTP-01 self-check przez cache NXDOMAIN w klastrze; zrestartowano `Deployment/coredns` w `kube-system`, po czym certyfikat stał się `Ready=True` (`Certificate/portainer-rv32i-pl-tls`).
|
||||
|
||||
### Registry: token do Gitea Packages + docker login
|
||||
- Na VPS (z poziomu poda Gitei) wygenerowano token `read:package,write:package` dla użytkownika `u1`:
|
||||
- polecenie: `gitea admin user generate-access-token --username u1 --scopes "read:package,write:package" --raw`
|
||||
- token zapisany lokalnie w `tokens/gitea-registry.token` (gitignored; wartość nie jest logowana).
|
||||
- Wykonano `docker login rv32i.pl` (bez logowania tokena).
|
||||
- Zaktualizowano `.dockerignore`, żeby nie wysyłać do build context katalogów z sekretami/kluczami (`tokens/*`, `k/`, `gitea/`, `argo/`).
|
||||
|
||||
### Obrazy: build + push (trade-api, trade-ingestor)
|
||||
- Zbudowano i wypchnięto obrazy do Gitea registry:
|
||||
- `rv32i.pl/trade/trade-api:k3s-20260106013603`
|
||||
- `rv32i.pl/trade/trade-ingestor:k3s-20260106013603`
|
||||
- Tag zapisany lokalnie w `tokens/last-image-tag.txt` (gitignored).
|
||||
|
||||
### GitOps: manifesty k3s dla API + ingestor (trade-deploy)
|
||||
- Dodano do repo `trade/trade-deploy`:
|
||||
- `kustomize/base/api/deployment.yaml` + `kustomize/base/api/service.yaml`
|
||||
- `kustomize/base/ingestor/deployment.yaml`
|
||||
- aktualizacja `kustomize/base/kustomization.yaml` (dopięcie nowych zasobów).
|
||||
- Konfiguracja:
|
||||
- `trade-api` używa `HASURA_GRAPHQL_URL=http://hasura:8080/v1/graphql` i sekretów `trade-hasura` + `trade-api` (admin).
|
||||
- `trade-ingestor` startuje w trybie `INGEST_VIA=hasura` (bez tokenów API) i używa `trade-hasura` + `trade-ingestor-tokens` (RPC).
|
||||
|
||||
### k3s: seedy sekretów + weryfikacja (staging)
|
||||
- Utworzono w `trade-staging`:
|
||||
- `Secret/gitea-registry` (imagePullSecret do `rv32i.pl`)
|
||||
- `Secret/trade-api` (`API_ADMIN_SECRET`, wygenerowany losowo; nie logowany)
|
||||
- `Secret/trade-ingestor-tokens` (plik `heliusN.json`; wartość nie logowana)
|
||||
- Argo CD `Application/trade-staging` po refresh: `Synced` / `Healthy`.
|
||||
- Pody w `trade-staging` uruchomione: `trade-api` i `trade-ingestor` (`Running`).
|
||||
- Logi:
|
||||
- `trade-api` startuje i maskuje sekrety (`***`).
|
||||
- `trade-ingestor` pobiera ceny i ingestuje ticki; URL RPC jest redagowany (`api-key=***`).
|
||||
|
||||
### Ingest przez API + tokeny read/write
|
||||
- Zmieniono `trade-ingestor` na `INGEST_VIA=api` (pisze do `trade-api` zamiast bezpośrednio do Hasury); manifest w `trade/trade-deploy`: `kustomize/base/ingestor/deployment.yaml`.
|
||||
- Utworzono tokeny w `trade-api`:
|
||||
- `write` (dla ingestora) oraz `read` (dla klientów UI/odczytu)
|
||||
- tokeny zapisane jako K8s Secrets (wartości nie logowane):
|
||||
- `trade-staging/Secret/trade-ingestor-tokens`: `alg.json` (write) + `heliusN.json` (RPC)
|
||||
- `trade-staging/Secret/trade-read-token`: `read.json` (read)
|
||||
- Wymuszono rollout `Deployment/trade-ingestor` i potwierdzono w logach:
|
||||
- `ingestVia: "api"`, `writeUrl: "http://trade-api:8787/v1/ingest/tick"`, `auth: "bearer"`.
|
||||
- Potwierdzono, że `trade-api /v1/ticks` działa z tokenem `read` (bez ujawniania tokena).
|
||||
|
||||
### Frontend: deploy na k3s (staging) + Ingress `trade.rv32i.pl`
|
||||
- Zbudowano i wypchnięto obraz do Gitea registry:
|
||||
- `rv32i.pl/trade/trade-frontend:k3s-20260106013603`
|
||||
- Dodano manifesty do `trade/trade-deploy`:
|
||||
- `kustomize/base/frontend/deployment.yaml` + `kustomize/base/frontend/service.yaml`
|
||||
- staging: `kustomize/overlays/staging/frontend-ingress.yaml` (host `trade.rv32i.pl`)
|
||||
- aktualizacje `kustomize/base/kustomization.yaml` i `kustomize/overlays/staging/kustomization.yaml`.
|
||||
- Utworzono sekret `trade-staging/Secret/trade-frontend-tokens` (pliki `frontend.json` + `read.json`; wartości nie logowane).
|
||||
- Argo CD `Application/trade-staging`: `Synced` / `Healthy`; `Deployment/trade-frontend` = `Running`, `/healthz` działa.
|
||||
- TLS dla `trade.rv32i.pl` jest `pending` dopóki DNS `trade.rv32i.pl` nie wskazuje na `77.90.8.171` (w razie cache NXDOMAIN: restart `kube-system/coredns` jak wcześniej).
|
||||
|
||||
### DNS: `trade.rv32i.pl` (weryfikacja)
|
||||
- Sprawdzono rekord A `trade.rv32i.pl` na autorytatywnych DNS (`dns*.home.pl`) oraz na publicznych resolverach: **brak odpowiedzi** (rekord nie był jeszcze widoczny na NS), więc cert-manager trzyma `trade-rv32i-pl-tls` w stanie `pending` (NXDOMAIN).
|
||||
- Uwaga: jeśli w panelu DNS dodasz rekord i nadal masz `NXDOMAIN`, sprawdź czy host nie ma literówki (np. `rade` zamiast `trade`) i czy rekord jest zapisany jako A/CNAME dla właściwej nazwy `trade.rv32i.pl`.
|
||||
|
||||
### DNS/TLS: `trade.rv32i.pl` (finalizacja)
|
||||
- Użytkownik potwierdził rekord A `trade.rv32i.pl` → `77.90.8.171` na autorytatywnych DNS (`dns.home.pl`).
|
||||
- Na VPS potwierdzono:
|
||||
- `Ingress/trade-frontend` ma host `trade.rv32i.pl`.
|
||||
- `Certificate/trade-rv32i-pl-tls` = `Ready=True`.
|
||||
- `https://trade.rv32i.pl` odpowiada `HTTP 401` (basic auth) – czyli Ingress + TLS działają.
|
||||
|
||||
### Weryfikacja wdrożenia (MCP SSH)
|
||||
- `argocd/Application`: `trade-staging` i `portainer` = `Synced` / `Healthy`.
|
||||
- `trade-staging`: wszystkie workloady `Running` (Postgres/Hasura/pgAdmin/trade-api/trade-ingestor/trade-frontend).
|
||||
- `trade-ingestor` ma `INGEST_VIA=api` oraz `INGEST_API_URL=http://trade-api:8787` (ingest przez API z tokenem write).
|
||||
|
||||
### Portainer: odblokowanie ekranu `timeout.html`
|
||||
- Zrestartowano `Deployment/portainer` w namespace `portainer`, żeby ponownie pojawił się kreator inicjalizacji (admin user/password).
|
||||
|
||||
### Gitea: lista repo w organizacji `trade`
|
||||
- `trade/trade-api`
|
||||
- `trade/trade-deploy`
|
||||
- `trade/trade-doc`
|
||||
- `trade/trade-frontend`
|
||||
- `trade/trade-infra`
|
||||
- `trade/trade-ingestor`
|
||||
|
||||
## 2026-01-06
|
||||
|
||||
### Zmiana: log działań w `doc/`
|
||||
- Przeniesiono log działań z `steps.md` → `doc/steps.md` (zgodnie z nową zasadą: wszystko projektowe ląduje w `doc/`).
|
||||
|
||||
### Superproject: decyzja + plan (do akceptacji)
|
||||
- Zaproponowano `trade/trade-infra` jako repo główne (superproject) spinające subrepo (API/ingestor/frontend/deploy/doc).
|
||||
- Plan refaktoru opisany w `doc/migration.md` (sekcja „Metoda superproject”).
|
||||
- Użytkownik zaakceptował `trade/trade-infra` jako superproject; do decyzji pozostało: `submodules` vs `subtree` (rekomendacja: submodules, jeśli chcemy zachować niezależne repo + pinowanie wersji).
|
||||
|
||||
## 2026-01-10
|
||||
|
||||
### V2: GraphQL + WS (Hasura) + DLOB stats (staging)
|
||||
- `trade/trade-deploy`:
|
||||
- Podbito obraz frontendu do `gitea.mpabi.pl/trade/trade-frontend:sha-f85e6da` (UI proxy’uje Hasurę pod `/graphql` + WS pod `/graphql-ws`).
|
||||
- W Hasurze włączono `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public` (UI bez tokena; bootstrap nadaje ograniczone `select`).
|
||||
- W schemacie Postgresa dodano tabele pod statystyki DLOB: `public.dlob_l2_latest` i `public.dlob_stats_latest` (w `kustomize/base/initdb/001_init.sql`).
|
||||
- Dodano job migracji DB dla istniejących wolumenów: `kustomize/base/postgres/job-migrate.yaml` (Argo hook; uruchamia `psql -f 001_init.sql`).
|
||||
- `kustomize/base/hasura/job-bootstrap.yaml` działa jako Argo hook (re-run na sync) i trackuje tabele/permissions DLOB.
|
||||
- Dodano `dlob-worker` w k3s: `kustomize/base/dlob-worker/*` (Deployment + script jako ConfigMap); worker polluje `https://dlob.drift.trade/l2` i upsertuje do Hasury dla `PUMP/SOL/BONK/BTC/ETH` perps.
|
||||
- `apps/visualizer` (frontend na laptopie, dev mode):
|
||||
- `apps/visualizer/__start` odpala Vite z proxy do `https://trade.mpabi.pl` + ustawia `VITE_HASURA_WS_URL=/graphql-ws`.
|
||||
- `apps/visualizer/src/lib/graphqlWs.ts` wspiera względny `VITE_HASURA_WS_URL` (np. `/graphql-ws`) i normalizuje go do pełnego `ws(s)://...`.
|
||||
- `apps/visualizer/src/lib/hasura.ts` domyślnie używa `/graphql` (zgodnie z flow: dev UI → staging przez proxy).
|
||||
692
doc/trade-system-flow.html
Normal file
692
doc/trade-system-flow.html
Normal file
@@ -0,0 +1,692 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Trade system flow</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #efe7db;
|
||||
--paper: rgba(255, 251, 245, 0.94);
|
||||
--panel: #fffdf9;
|
||||
--ink: #1c1814;
|
||||
--muted: #655d53;
|
||||
--line: #d9ccb9;
|
||||
--web: #2a6b8f;
|
||||
--api: #0f6c72;
|
||||
--data: #4e5968;
|
||||
--chain: #b24a32;
|
||||
--exec: #7b6110;
|
||||
--ok: #1d6b43;
|
||||
--warn: #914017;
|
||||
--shadow: 0 18px 44px rgba(28, 24, 20, 0.10);
|
||||
--radius: 18px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(42, 107, 143, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(178, 74, 50, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #f8f3eb 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1540px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 42px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.card,
|
||||
.table-card,
|
||||
.lane,
|
||||
.note,
|
||||
.tabs {
|
||||
background: var(--paper);
|
||||
border: 1px solid rgba(217, 204, 185, 0.9);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 26px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(28, 24, 20, 0.10);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 14px 0 10px;
|
||||
font-size: clamp(2rem, 4vw, 3.9rem);
|
||||
line-height: 0.96;
|
||||
font-family: "IBM Plex Serif", Georgia, serif;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--line);
|
||||
background: #fffaf2;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: 150ms ease;
|
||||
}
|
||||
|
||||
.tab.active,
|
||||
.tab:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 22px rgba(28, 24, 20, 0.08);
|
||||
}
|
||||
|
||||
.tab[data-target="overview"].active { color: var(--web); }
|
||||
.tab[data-target="routes"].active { color: var(--api); }
|
||||
.tab[data-target="payloads"].active { color: var(--chain); }
|
||||
|
||||
.view { display: none; }
|
||||
.view.active { display: block; }
|
||||
|
||||
.lanes {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.9fr 0.95fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.lane h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.2rem;
|
||||
font-family: "IBM Plex Serif", Georgia, serif;
|
||||
}
|
||||
|
||||
.lane small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 14px;
|
||||
background: var(--panel);
|
||||
border-left: 6px solid var(--line);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card.web { border-left-color: var(--web); }
|
||||
.card.api { border-left-color: var(--api); }
|
||||
.card.data { border-left-color: var(--data); }
|
||||
.card.chain { border-left-color: var(--chain); }
|
||||
.card.exec { border-left-color: var(--exec); }
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(28, 24, 20, 0.06);
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.flow-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.flow-node {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #fffdf9;
|
||||
min-height: 118px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-node strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgba(28, 24, 20, 0.04);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.payload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 16px 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(28, 24, 20, 0.06);
|
||||
}
|
||||
|
||||
.ok { color: var(--ok); background: rgba(29, 107, 67, 0.10); }
|
||||
.warn { color: var(--warn); background: rgba(178, 74, 50, 0.10); }
|
||||
|
||||
@media (max-width: 1220px) {
|
||||
.lanes,
|
||||
.payload-grid,
|
||||
.flow-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
transform: rotate(90deg);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
table { min-width: 720px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Trade architecture / runtime map</div>
|
||||
<h1>Skąd dane trafiają, w jakiej postaci i które endpointy naprawdę wystawiamy</h1>
|
||||
<p>
|
||||
Ta strona opisuje faktyczny przepływ runtime dla waszej appki: <strong>browser -> frontend -> API/Hasura -> Postgres/Redis</strong>
|
||||
oraz zależności od <strong>Agave RPC</strong> i <strong>Yellowstone gRPC</strong>. To nie jest ogólny diagram architektury,
|
||||
tylko mapa wejść, wyjść, formatów danych i tras HTTP/WS używanych przez produkt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="tabs">
|
||||
<button class="tab active" data-target="overview">Overview</button>
|
||||
<button class="tab" data-target="routes">Routes / APIs</button>
|
||||
<button class="tab" data-target="payloads">Payloads / shapes</button>
|
||||
</section>
|
||||
|
||||
<section class="view active" id="overview">
|
||||
<div class="lanes">
|
||||
<article class="lane">
|
||||
<h2>Public web surface</h2>
|
||||
<small>To widzi użytkownik i to jest jedyny realny entrypoint produktu na zewnątrz.</small>
|
||||
<div class="stack">
|
||||
<div class="card web">
|
||||
<div class="meta">NodePort / browser entry</div>
|
||||
<h3>trade-frontend</h3>
|
||||
<p>Serwuje SPA, obsługuje logowanie, robi proxy do API i Hasury.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">NodePort 30081</span>
|
||||
<span class="chip">service 8081</span>
|
||||
<span class="chip">/api/*</span>
|
||||
<span class="chip">/graphql</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card web">
|
||||
<div class="meta">Browser calls</div>
|
||||
<h3>Frontend SPA</h3>
|
||||
<p>Wykres bierze REST JSON. DLOB live bierze GraphQL WebSocket. Auth idzie przez sesję cookie.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">GET /whoami</span>
|
||||
<span class="chip">POST /auth/login</span>
|
||||
<span class="chip">GET /api/v1/chart</span>
|
||||
<span class="chip">WS /graphql</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="lane">
|
||||
<h2>App services in k3s</h2>
|
||||
<small>Wewnętrzne usługi aplikacyjne. Tylko frontend jest dziś wystawiony na zewnątrz jako NodePort.</small>
|
||||
<div class="stack">
|
||||
<div class="card api">
|
||||
<div class="meta">ClusterIP / app logic</div>
|
||||
<h3>trade-api</h3>
|
||||
<p>REST backend. Zwraca candles, przyjmuje tick ingest, zarządza tokenami.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">ClusterIP 8787</span>
|
||||
<span class="chip">/v1/chart</span>
|
||||
<span class="chip">/v1/ticks</span>
|
||||
<span class="chip">/v1/ingest/tick</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card data">
|
||||
<div class="meta">ClusterIP / query plane</div>
|
||||
<h3>Hasura + Postgres</h3>
|
||||
<p>Frontend subskrybuje Hasurę po GraphQL WS. API czyta i zapisuje przez GraphQL do Hasury.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">Hasura 8080</span>
|
||||
<span class="chip">Postgres 5432</span>
|
||||
<span class="chip">GraphQL</span>
|
||||
<span class="chip">subscriptions</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card data">
|
||||
<div class="meta">Redis-backed read side</div>
|
||||
<h3>DLOB stack</h3>
|
||||
<p><span class="status warn">publisher 0/1</span> <span class="status warn">server 0/1</span> bo bootstrapping zależy od zdrowego Agave RPC.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">Redis 6379</span>
|
||||
<span class="chip">dlob-server 6969</span>
|
||||
<span class="chip">workers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="lane">
|
||||
<h2>Chain and execution plane</h2>
|
||||
<small>To nie jest wystawiane użytkownikowi końcowemu. To feed i write side dla botów.</small>
|
||||
<div class="stack">
|
||||
<div class="card chain">
|
||||
<div class="meta">mevnode_sol / source of truth</div>
|
||||
<h3>Agave RPC</h3>
|
||||
<p>Punktowe odczyty: <code>getAccountInfo</code>, <code>getMultipleAccounts</code>, <code>getSlot</code>, <code>getHealth</code>.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">127.0.0.1:8899</span>
|
||||
<span class="chip">RPC only</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card chain">
|
||||
<div class="meta">mevnode_sol / live feed</div>
|
||||
<h3>Yellowstone gRPC</h3>
|
||||
<p>Pushowy stream kont, slotów i transakcji po <code>wg0</code>.</p>
|
||||
<div class="chips">
|
||||
<span class="chip">10.91.0.1:10000</span>
|
||||
<span class="chip">token auth</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card exec">
|
||||
<div class="meta">future execution split</div>
|
||||
<h3>tx-router / TPU / Jito</h3>
|
||||
<p>To jest write path. Nie mylić z Yellowstone, który jest tylko read streamem.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="meta">End-to-end runtime flow</div>
|
||||
<div class="flow">
|
||||
<div class="flow-row">
|
||||
<div class="flow-node"><strong>1. Browser</strong>Użytkownik otwiera SPA i trzyma sesję cookie.</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="flow-node"><strong>2. trade-frontend</strong>Serwuje UI i robi reverse proxy do <code>/api</code> i <code>/graphql</code>.</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="flow-node"><strong>3. trade-api / Hasura</strong>REST dla chartów, GraphQL WS dla live DLOB.</div>
|
||||
</div>
|
||||
<div class="flow-row">
|
||||
<div class="flow-node"><strong>4. Postgres / Redis</strong>Trwały stan i szybki stan pośredni.</div>
|
||||
<div class="arrow">←</div>
|
||||
<div class="flow-node"><strong>5. DLOB workers</strong>Przeliczają depth, slippage, orderbook views.</div>
|
||||
<div class="arrow">←</div>
|
||||
<div class="flow-node"><strong>6. Agave / Yellowstone</strong>RPC do odczytów punktowych, gRPC do streamu live.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<p>
|
||||
Najważniejszy podział: <strong>chart path = REST JSON przez trade-api</strong>, a <strong>DLOB live = GraphQL WS przez Hasurę</strong>.
|
||||
To oznacza, że nie cały frontend jedzie jednym typem transportu. Produkt ma dwa równoległe read pathy.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="routes">
|
||||
<div class="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Surface</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Transport</th>
|
||||
<th>Input</th>
|
||||
<th>Output</th>
|
||||
<th>Where it goes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Frontend</td>
|
||||
<td><code>GET /whoami</code></td>
|
||||
<td>HTTP JSON</td>
|
||||
<td>cookie session</td>
|
||||
<td><code>{ ok, user, mode }</code></td>
|
||||
<td>handled directly by <code>trade-frontend</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Frontend</td>
|
||||
<td><code>POST /auth/login</code></td>
|
||||
<td>HTTP JSON/form</td>
|
||||
<td><code>username</code>, <code>password</code></td>
|
||||
<td><code>{ ok, user }</code> + session cookie</td>
|
||||
<td>handled directly by <code>trade-frontend</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Frontend</td>
|
||||
<td><code>POST /auth/logout</code></td>
|
||||
<td>HTTP JSON</td>
|
||||
<td>session cookie</td>
|
||||
<td><code>{ ok: true }</code></td>
|
||||
<td>handled directly by <code>trade-frontend</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Frontend proxy</td>
|
||||
<td><code>/api/*</code></td>
|
||||
<td>HTTP</td>
|
||||
<td>browser request, frontend injects read token</td>
|
||||
<td>proxied API response</td>
|
||||
<td><code>trade-api:8787</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Frontend proxy</td>
|
||||
<td><code>/graphql</code></td>
|
||||
<td>HTTP GraphQL</td>
|
||||
<td>GraphQL query/mutation</td>
|
||||
<td>GraphQL response</td>
|
||||
<td><code>hasura:8080/v1/graphql</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Frontend proxy</td>
|
||||
<td><code>WS /graphql</code></td>
|
||||
<td>GraphQL WebSocket</td>
|
||||
<td>subscription payload</td>
|
||||
<td>live subscription frames</td>
|
||||
<td><code>hasura:8080/v1/graphql</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>API</td>
|
||||
<td><code>GET /v1/chart</code></td>
|
||||
<td>HTTP JSON</td>
|
||||
<td><code>symbol</code>, <code>tf</code>, <code>limit</code>, optional <code>source</code></td>
|
||||
<td>candles + indicators + flow rows</td>
|
||||
<td>Hasura function <code>get_drift_candles</code> + table <code>drift_ticks</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>API</td>
|
||||
<td><code>POST /v1/ingest/tick</code></td>
|
||||
<td>HTTP JSON</td>
|
||||
<td>tick payload</td>
|
||||
<td><code>{ ok, id }</code></td>
|
||||
<td>writes tick through Hasura into DB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>API</td>
|
||||
<td><code>GET /v1/ticks</code></td>
|
||||
<td>HTTP JSON</td>
|
||||
<td><code>symbol</code>, optional <code>source</code>, <code>limit</code>, <code>from</code>, <code>to</code></td>
|
||||
<td>tick list</td>
|
||||
<td>reads <code>drift_ticks</code> through Hasura</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>API admin</td>
|
||||
<td><code>POST /v1/admin/tokens</code></td>
|
||||
<td>HTTP JSON</td>
|
||||
<td>name, scopes</td>
|
||||
<td>new API token</td>
|
||||
<td>stored in DB via Hasura</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>API admin</td>
|
||||
<td><code>POST /v1/admin/tokens/revoke</code></td>
|
||||
<td>HTTP JSON</td>
|
||||
<td>token id</td>
|
||||
<td>revocation status</td>
|
||||
<td>updates token row in DB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chain read</td>
|
||||
<td><code>Agave RPC</code></td>
|
||||
<td>JSON-RPC</td>
|
||||
<td>account/state requests</td>
|
||||
<td>point reads</td>
|
||||
<td><code>mevnode_sol</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Chain live</td>
|
||||
<td><code>Yellowstone gRPC</code></td>
|
||||
<td>gRPC streaming</td>
|
||||
<td>subscription config + token</td>
|
||||
<td>account/slot/tx stream</td>
|
||||
<td><code>mevnode_sol</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="payloads">
|
||||
<div class="payload-grid">
|
||||
<div class="table-card">
|
||||
<div class="meta">Chart response</div>
|
||||
<pre>{
|
||||
"ok": true,
|
||||
"symbol": "SOL-PERP",
|
||||
"tf": "1m",
|
||||
"bucketSeconds": 60,
|
||||
"candles": [
|
||||
{
|
||||
"time": 1710000000,
|
||||
"open": 132.1,
|
||||
"high": 133.2,
|
||||
"low": 131.8,
|
||||
"close": 132.9,
|
||||
"volume": 412,
|
||||
"oracle": 132.7,
|
||||
"flow": { "up": 0.46, "down": 0.41, "flat": 0.13 },
|
||||
"flowRows": [1, 1, 0, -1],
|
||||
"flowMoves": [0.2, 0.1, 0, 0.3]
|
||||
}
|
||||
],
|
||||
"indicators": {
|
||||
"sma20": [{ "time": 1710000000, "value": 131.4 }],
|
||||
"ema20": [{ "time": 1710000000, "value": 131.8 }],
|
||||
"bb20": { "upper": [], "lower": [], "mid": [] },
|
||||
"rsi14": [],
|
||||
"macd": { "macd": [], "signal": [] }
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="meta">Tick ingest body</div>
|
||||
<pre>{
|
||||
"ts": "2026-03-13T11:20:00.000Z",
|
||||
"market_index": 0,
|
||||
"symbol": "SOL-PERP",
|
||||
"oracle_price": "132.70",
|
||||
"mark_price": "132.91",
|
||||
"oracle_slot": 406125100,
|
||||
"source": "drift",
|
||||
"raw": {
|
||||
"provider": "internal"
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="meta">GraphQL DLOB subscriptions</div>
|
||||
<pre>subscription DlobStats($market: String!) {
|
||||
dlob_stats_latest(where: {market_name: {_eq: $market}}, limit: 1) {
|
||||
market_name
|
||||
mark_price
|
||||
oracle_price
|
||||
best_bid_price
|
||||
best_ask_price
|
||||
mid_price
|
||||
spread_abs
|
||||
spread_bps
|
||||
depth_bid_usd
|
||||
depth_ask_usd
|
||||
imbalance
|
||||
updated_at
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<div class="meta">L2 payload shape</div>
|
||||
<pre>{
|
||||
"market_name": "SOL-PERP",
|
||||
"bids": [
|
||||
{ "price": 132910000, "size": 2500000000 }
|
||||
],
|
||||
"asks": [
|
||||
{ "price": 132930000, "size": 1700000000 }
|
||||
],
|
||||
"updated_at": "2026-03-13T11:20:01.000Z"
|
||||
}
|
||||
|
||||
Frontend przelicza to przez:
|
||||
- pricePrecision = 1_000_000
|
||||
- basePrecision = 1_000_000_000</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<p>
|
||||
Krytyczne rozróżnienie: <strong>DLOB live nie idzie z REST API</strong>. DLOB do UI idzie przez <strong>Hasura GraphQL subscriptions</strong>,
|
||||
a chart przez <strong>REST JSON</strong> z <code>trade-api</code>. To są dwa osobne transporty, dwa osobne modele danych i dwa osobne źródła opóźnień.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tabs = [...document.querySelectorAll('.tab')];
|
||||
const views = [...document.querySelectorAll('.view')];
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const target = tab.dataset.target;
|
||||
tabs.forEach((x) => x.classList.toggle('active', x === tab));
|
||||
views.forEach((view) => view.classList.toggle('active', view.id === target));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
194
doc/workflow-api-ingest.md
Normal file
194
doc/workflow-api-ingest.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Workflow: init bazy + app stack + ingest przez API (tokeny + basic auth)
|
||||
|
||||
Docelowy podział na 3 compose’y:
|
||||
1) `devops/db/docker-compose.yml` → **Postgres/TimescaleDB + Hasura + pgAdmin** (DB stack)
|
||||
2) `devops/tools/bootstrap/docker-compose.yml` → **one-shot tools** (init SQL + Hasura metadata)
|
||||
3) `devops/app/docker-compose.yml` → **API + frontend (+ opcjonalny ingestor)** (app stack)
|
||||
|
||||
## TL;DR (local, end-to-end)
|
||||
1) `npm install`
|
||||
2) DB: `docker compose -f devops/db/docker-compose.yml up -d`
|
||||
3) Schema/metadata: `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`
|
||||
4) Tokeny: skopiuj `tokens/*.example.json` → `tokens/*.json` + ustaw RPC w `tokens/heliusN.json` (albo po prostu `cp tokens/helius.json tokens/heliusN.json`)
|
||||
5) API: `docker compose -f devops/app/docker-compose.yml up -d api` + `curl -sS http://localhost:8787/healthz`
|
||||
6) Tokeny (revocable): `npm run token:api -- --scopes write --out tokens/alg.json` + `npm run token:api -- --scopes read --out tokens/read.json`
|
||||
7) Frontend: `docker compose -f devops/app/docker-compose.yml up -d frontend`
|
||||
8) Ingestor: `docker compose -f devops/app/docker-compose.yml --profile ingest up -d`
|
||||
9) DLOB worker (Hasura stats/subscriptions): `docker compose -f devops/app/docker-compose.yml --profile dlob up -d dlob-worker`
|
||||
|
||||
Uwaga: ceny w `drift_ticks` są `NUMERIC` (Hasura zwykle zwraca/oczekuje stringów dla `numeric`); `trade-api` normalizuje je do `number` w `/v1/chart`.
|
||||
|
||||
## DLOB worker (Drift DLOB → Hasura) + GraphQL subscriptions
|
||||
|
||||
Cel: UI dostaje “global stats” (best bid/ask/spread/depth) przez **GraphQL + WS** (Hasura subscriptions), bez wystawiania sekretów do przeglądarki.
|
||||
|
||||
- Worker: `dlob-worker` pobiera snapshoty orderbooka z `https://dlob.drift.trade/l2` i upsertuje do Hasury.
|
||||
- Tabele:
|
||||
- `dlob_l2_latest` (raw L2 snapshot)
|
||||
- `dlob_stats_latest` (wyliczone statystyki)
|
||||
- UI: subskrybuje `dlob_stats_latest` po `/graphql` (proxy w `trade-frontend` obsługuje też WS upgrade).
|
||||
|
||||
Start lokalnie:
|
||||
```bash
|
||||
docker compose -f devops/app/docker-compose.yml --profile dlob up -d --build dlob-worker
|
||||
```
|
||||
|
||||
Opcjonalne env:
|
||||
- `DLOB_MARKETS=PUMP-PERP,SOL-PERP,BONK-PERP,BTC-PERP,ETH-PERP`
|
||||
- `DLOB_POLL_MS=500`
|
||||
- `DLOB_DEPTH=10`
|
||||
|
||||
Uwaga: UI nie potrzebuje sekretów Hasury, bo w `devops/db/docker-compose.yml` jest `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`, a `hasura-bootstrap` nadaje roli `public` tylko `select` na tabelach DLOB.
|
||||
|
||||
## Migracja (bez kasowania danych)
|
||||
Szybka sekwencja do migracji schematu + odpalenia stacka (bez `down -v`) jest w `r1.txt`.
|
||||
|
||||
## Wersjonowanie API/UI (v2, v3...) bez ruszania DB/Hasury
|
||||
Cel: uruchamiasz nową wersję `api+frontend(+ingestor)` równolegle, na **nowych tabelach**, na **nowych portach**, a potem robisz cutover ingestora.
|
||||
|
||||
Konwencja:
|
||||
- v1: tabela `drift_ticks`, funkcja `get_drift_candles`, porty `8787/8081`
|
||||
- vN: tabela `drift_ticks_vN`, funkcja `get_drift_candles_vN`, porty `8787+(N-1)` i `8081+(N-1)`
|
||||
- wspólne: Postgres/Hasura/pgAdmin + `api_tokens` (tokeny) zostają bez wersjonowania
|
||||
|
||||
Skrypty: `scripts/ops/` (tworzą też env w `devops/versions/vN.env`)
|
||||
- `bash scripts/ops/version-init.sh 2` migracja DB + create `drift_ticks_v2` + track w Hasurze
|
||||
- `bash scripts/ops/version-up.sh 2` start `api+frontend` v2 (host: `8788/8082`)
|
||||
- `bash scripts/ops/version-ingestor-up.sh 2` start ingestor v2 (pisze do v2 API → `drift_ticks_v2`)
|
||||
- `bash scripts/ops/version-cutover.sh 1 2` start v2 ingestor, potem stop v1 ingestor
|
||||
- `bash scripts/ops/version-backfill.sh 1 2` backfill ALL: `drift_ticks` → `drift_ticks_v2`
|
||||
- `bash scripts/ops/version-down.sh 1` usuń stare kontenery v1 (DB bez zmian)
|
||||
- `bash scripts/ops/version-check.sh 2` healthz/ticks/chart dla v2
|
||||
|
||||
## Reset (UWAGA: kasuje dane z bazy)
|
||||
Jeśli uruchomisz `docker compose -f devops/db/docker-compose.yml down -v`, Postgres/Timescale traci cały wolumen (czyli rekordy z `drift_ticks`).
|
||||
|
||||
## Dane logowania (dev)
|
||||
- Postgres: `postgres://admin:pass@localhost:5432/crypto`
|
||||
- Hasura: `http://localhost:8080` (admin secret: `tokens/hasura.json` → `adminSecret`)
|
||||
- pgAdmin: `http://localhost:5050` (login: `admin@example.com`, hasło: `admin`)
|
||||
- Frontend UI: `http://localhost:8081` (basic auth: `tokens/frontend.json` → `username`/`password`)
|
||||
|
||||
## 0) Start DB stack
|
||||
Z root repo:
|
||||
```bash
|
||||
docker compose -f devops/db/docker-compose.yml up -d
|
||||
```
|
||||
Ten krok tworzy wspólną sieć Dockera `trade-net`, przez którą app stack łączy się do Hasury.
|
||||
|
||||
## 1) Init / upgrade schematu bazy (idempotent)
|
||||
Użyj zwłaszcza, jeśli masz istniejący wolumen Postgresa:
|
||||
```bash
|
||||
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init
|
||||
```
|
||||
To jest też krok, który trzeba odpalić po zmianach w `devops/db/initdb/001_init.sql` (np. migracja `DOUBLE PRECISION` → `NUMERIC`).
|
||||
|
||||
## 2) Hasura metadata bootstrap (track tabel + permissions)
|
||||
```bash
|
||||
docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap
|
||||
```
|
||||
|
||||
## 3) Skonfiguruj API + frontend
|
||||
Utwórz lokalne pliki (gitignored) w `tokens/`:
|
||||
- `tokens/hasura.json` z `tokens/hasura.example.json` (URL + admin secret dla Hasury, używane przez `trade-api`)
|
||||
- `tokens/api.json` z `tokens/api.example.json` (sekret admina do tworzenia/revoke tokenów API)
|
||||
- `tokens/frontend.json` z `tokens/frontend.example.json` (basic auth do UI)
|
||||
- `tokens/heliusN.json` (RPC dla Drift; albo `heliusApiKey`, albo pełny `rpcUrl`)
|
||||
|
||||
Jeśli odpalasz lokalne skrypty (`npm run token:*`, `npm run ingest:*`) wykonaj też:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 4) Start API (trade-api)
|
||||
```bash
|
||||
docker compose -f devops/app/docker-compose.yml up -d api
|
||||
```
|
||||
|
||||
- API: `http://localhost:8787`
|
||||
|
||||
Healthcheck:
|
||||
```bash
|
||||
curl -sS http://localhost:8787/healthz
|
||||
```
|
||||
|
||||
## 5) Wygeneruj tokeny (revocable)
|
||||
**Write token** dla ingestora (domyślnie zapisuje do `tokens/alg.json`):
|
||||
```bash
|
||||
npm run token:api -- --name algo1 --scopes write --out tokens/alg.json
|
||||
```
|
||||
|
||||
Jeśli wcześniej uruchomiłeś `frontend` bez pliku `tokens/read.json`, Docker mógł utworzyć katalog `tokens/read.json`.
|
||||
Usuń go przed generacją tokena:
|
||||
```bash
|
||||
rm -rf tokens/read.json
|
||||
```
|
||||
|
||||
**Read token** dla frontendu (proxy wstrzykuje go serwerowo; nie trafia do JS):
|
||||
```bash
|
||||
npm run token:api -- --name reader-ui --scopes read --out tokens/read.json
|
||||
```
|
||||
|
||||
## 6) Start frontend (UI)
|
||||
```bash
|
||||
docker compose -f devops/app/docker-compose.yml up -d frontend
|
||||
```
|
||||
|
||||
- Frontend: `http://localhost:8081` (basic auth)
|
||||
|
||||
Dev mode (Vite + proxy `/api` z tokenem z `tokens/read.json`):
|
||||
```bash
|
||||
npm run visualizer:dev
|
||||
```
|
||||
- Dev UI: `http://localhost:5173`
|
||||
|
||||
## 7) Uruchom kontener ingestora, który wysyła ticki do API
|
||||
W ramach app stack (profil `ingest`):
|
||||
```bash
|
||||
docker compose -f devops/app/docker-compose.yml --profile ingest up -d
|
||||
```
|
||||
|
||||
Alternatywnie (bez Dockera) możesz odpalić ingest lokalnie:
|
||||
```bash
|
||||
npm run ingest:oracle -- --ingest-via api --market-name PUMP-PERP --interval-ms 1000 --source drift_oracle
|
||||
```
|
||||
Wymaga: `tokens/heliusN.json` (RPC) + `tokens/alg.json` (write token do API) + działającego `trade-api`.
|
||||
|
||||
Override (przykład):
|
||||
```bash
|
||||
MARKET_NAME=PUMP-PERP INTERVAL_MS=250 SOURCE=drift_oracle \
|
||||
docker compose -f devops/app/docker-compose.yml --profile ingest up -d
|
||||
```
|
||||
|
||||
## 8) Sprawdź, czy rekordy wpadają
|
||||
Przez API (wymaga read tokena):
|
||||
```bash
|
||||
curl -sS "http://localhost:8787/v1/ticks?symbol=PUMP-PERP&limit=5" \
|
||||
-H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"
|
||||
```
|
||||
|
||||
W UI: `http://localhost:8081` → wykres powinien się aktualizować.
|
||||
|
||||
Sprawdź agregacje + wskaźniki (candles + indicators z backendu):
|
||||
```bash
|
||||
curl -sS "http://localhost:8787/v1/chart?symbol=PUMP-PERP&tf=1m&limit=120" \
|
||||
-H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"
|
||||
```
|
||||
|
||||
## Checklist (copy/paste)
|
||||
- `npm install` zainstaluj zależności do `npm run token:*` / `npm run ingest:*`
|
||||
- `cp tokens/hasura.example.json tokens/hasura.json` skopiuj konfigurację Hasury
|
||||
- `cp tokens/api.example.json tokens/api.json` skopiuj konfigurację API (adminSecret)
|
||||
- `cp tokens/frontend.example.json tokens/frontend.json` skopiuj basic auth do UI
|
||||
- `cp tokens/helius.json tokens/heliusN.json` ustaw RPC dla Drift (fallback jest też `tokens/helius.json`)
|
||||
- `docker compose -f devops/db/docker-compose.yml up -d` uruchom Postgres/Timescale + Hasura + pgAdmin
|
||||
- `docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm db-init` zastosuj schemat PG (tabele/indeksy/hypertable)
|
||||
- `docker compose -f devops/tools/bootstrap/docker-compose.yml run --rm hasura-bootstrap` track tabel + permissions w Hasurze
|
||||
- `docker compose -f devops/app/docker-compose.yml up -d api` uruchom API
|
||||
- `curl -sS http://localhost:8787/healthz` sprawdź czy API działa
|
||||
- `npm run token:api -- --name algo1 --scopes write --out tokens/alg.json` wygeneruj write token dla ingestora
|
||||
- `npm run token:api -- --name reader-ui --scopes read --out tokens/read.json` wygeneruj read token dla UI/curl
|
||||
- `docker compose -f devops/app/docker-compose.yml up -d frontend` uruchom frontend
|
||||
- `docker compose -f devops/app/docker-compose.yml --profile ingest up -d` uruchom ingestora (Drift → API → DB)
|
||||
- `curl -sS "http://localhost:8787/v1/ticks?symbol=PUMP-PERP&limit=5" -H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"` sprawdź czy ticki wpadają
|
||||
- `curl -sS "http://localhost:8787/v1/chart?symbol=PUMP-PERP&tf=1m&limit=120" -H "Authorization: Bearer $(node -p 'require("./tokens/read.json").token')"` sprawdź candles + wskaźniki z backendu
|
||||
@@ -77,6 +77,7 @@ docker compose -f devops/app/docker-compose.yml --profile ingest up -d --build
|
||||
Jeśli chcesz szybko iterować nad UI bez deploya, możesz odpalić lokalny Vite i podpiąć go do backendu na VPS przez istniejący proxy `/api` na `trade.mpabi.pl`.
|
||||
|
||||
- Vite trzyma `VITE_API_URL=/api` (default) i proxy’uje `/api/*` do VPS.
|
||||
- UI ma też GraphQL (Hasura) pod `/graphql` (HTTP + WS subscriptions) – w dev proxy’ujemy `/graphql` i `/graphql-ws` do VPS, żeby subscriptions działały na `http://localhost:5173`.
|
||||
- Auth w staging jest w trybie `session` (`/auth/login`, cookie `trade_session`), więc w dev proxy’ujemy też `/whoami`, `/auth/*`, `/logout`.
|
||||
- Dev proxy usuwa `Secure` z `Set-Cookie`, żeby cookie działało na `http://localhost:5173`.
|
||||
- Na VPS `trade-frontend` proxy’uje dalej do `trade-api` i wstrzykuje read-token **server-side** (token nie trafia do przeglądarki).
|
||||
@@ -85,8 +86,7 @@ Przykład:
|
||||
|
||||
```bash
|
||||
cd apps/visualizer
|
||||
API_PROXY_TARGET=https://trade.mpabi.pl \
|
||||
npm run dev
|
||||
bash __start
|
||||
```
|
||||
|
||||
Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj:
|
||||
|
||||
@@ -3,7 +3,9 @@ import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import tls from 'node:tls';
|
||||
|
||||
const PORT = Number.parseInt(process.env.PORT || '8081', 10);
|
||||
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
|
||||
@@ -16,6 +18,9 @@ const STATIC_DIR = process.env.STATIC_DIR || '/srv';
|
||||
const BASIC_AUTH_FILE = process.env.BASIC_AUTH_FILE || '/tokens/frontend.json';
|
||||
const API_READ_TOKEN_FILE = process.env.API_READ_TOKEN_FILE || '/tokens/read.json';
|
||||
const API_UPSTREAM = process.env.API_UPSTREAM || process.env.API_URL || 'http://api:8787';
|
||||
const HASURA_UPSTREAM = process.env.HASURA_UPSTREAM || 'http://hasura:8080';
|
||||
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')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
@@ -413,6 +418,122 @@ function proxyApi(req, res, apiReadToken) {
|
||||
req.pipe(upstreamReq);
|
||||
}
|
||||
|
||||
function withCors(res) {
|
||||
res.setHeader('access-control-allow-origin', GRAPHQL_CORS_ORIGIN);
|
||||
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
||||
res.setHeader(
|
||||
'access-control-allow-headers',
|
||||
'content-type, authorization, x-hasura-admin-secret, x-hasura-role, x-hasura-user-id'
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
target.pathname = HASURA_GRAPHQL_PATH;
|
||||
target.search = inUrl.search;
|
||||
|
||||
const isHttps = target.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
|
||||
const headers = stripHopByHopHeaders(req.headers);
|
||||
headers.host = target.host;
|
||||
|
||||
const upstreamReq = lib.request(
|
||||
{
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port || (isHttps ? 443 : 80),
|
||||
method: req.method,
|
||||
path: target.pathname + target.search,
|
||||
headers,
|
||||
},
|
||||
(upstreamRes) => {
|
||||
const outHeaders = stripHopByHopHeaders(upstreamRes.headers);
|
||||
withCors(res);
|
||||
res.writeHead(upstreamRes.statusCode || 502, outHeaders);
|
||||
upstreamRes.pipe(res);
|
||||
}
|
||||
);
|
||||
|
||||
upstreamReq.on('error', (err) => {
|
||||
if (!res.headersSent) {
|
||||
withCors(res);
|
||||
send(res, 502, { 'content-type': 'text/plain; charset=utf-8' }, `bad_gateway: ${err?.message || err}`);
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
|
||||
send(
|
||||
@@ -425,6 +546,25 @@ async function handler(req, res) {
|
||||
}
|
||||
|
||||
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') {
|
||||
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
|
||||
return;
|
||||
@@ -532,6 +672,30 @@ const server = http.createServer((req, res) => {
|
||||
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, () => {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
@@ -540,6 +704,7 @@ server.listen(PORT, () => {
|
||||
port: PORT,
|
||||
staticDir: STATIC_DIR,
|
||||
apiUpstream: API_UPSTREAM,
|
||||
hasuraUpstream: HASURA_UPSTREAM,
|
||||
basicAuthFile: BASIC_AUTH_FILE,
|
||||
basicAuthMode: BASIC_AUTH_MODE,
|
||||
apiReadTokenFile: API_READ_TOKEN_FILE,
|
||||
|
||||
Reference in New Issue
Block a user