Compare commits
4 Commits
feat/candl
...
feat/visua
| Author | SHA1 | Date | |
|---|---|---|---|
| a674d40453 | |||
| 5a9d61d674 | |||
| f76045a9a5 | |||
| fa79340cf5 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -3,13 +3,21 @@ tokens/*
|
|||||||
!tokens/*.example.json
|
!tokens/*.example.json
|
||||||
!tokens/*.example.yml
|
!tokens/*.example.yml
|
||||||
!tokens/*.example.yaml
|
!tokens/*.example.yaml
|
||||||
gitea/token
|
|
||||||
|
# Local secrets (never commit)
|
||||||
|
pass/
|
||||||
|
argo/pass
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.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
|
# Local scratch / build output
|
||||||
_tmp/
|
_tmp/
|
||||||
apps/visualizer/dist/
|
apps/visualizer/dist/
|
||||||
|
|||||||
@@ -6,17 +6,19 @@ ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|||||||
|
|
||||||
cd "${SCRIPT_DIR}"
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
DEFAULT_PROXY_TARGET="${VISUALIZER_PROXY_TARGET:-${TRADE_UI_URL:-${TRADE_VPS_URL:-https://trade.mpabi.pl}}}"
|
export API_PROXY_TARGET="${API_PROXY_TARGET:-https://trade.mpabi.pl}"
|
||||||
export API_PROXY_TARGET="${API_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
|
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}"
|
||||||
export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-${DEFAULT_PROXY_TARGET}}"
|
|
||||||
export VITE_API_URL="${VITE_API_URL:-/api}"
|
export VITE_API_URL="${VITE_API_URL:-/api}"
|
||||||
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}"
|
||||||
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
|
export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}"
|
||||||
|
|
||||||
# Safety: avoid passing stale auth env vars into Hasura WS unless explicitly enabled.
|
if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then
|
||||||
if [[ "${VISUALIZER_USE_HASURA_AUTH:-}" != "1" ]]; then
|
if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then
|
||||||
unset VITE_HASURA_AUTH_TOKEN
|
export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json"
|
||||||
unset VITE_HASURA_ADMIN_SECRET
|
else
|
||||||
|
echo "Missing basic auth config for VPS proxy."
|
||||||
|
echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|||||||
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",
|
"name": "trade-visualizer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.8.5",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"chartjs-adapter-luxon": "^1.3.1",
|
"chartjs-adapter-luxon": "^1.3.1",
|
||||||
"lightweight-charts": "^5.0.8",
|
"lightweight-charts": "^5.0.8",
|
||||||
@@ -1110,6 +1111,49 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-drag": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-selection": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-transition": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-zoom": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1121,14 +1165,14 @@
|
|||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "18.3.27",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
@@ -1166,6 +1210,36 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
|
||||||
|
"integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.75",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.75",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz",
|
||||||
|
"integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.11",
|
"version": "2.9.11",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||||
@@ -1253,6 +1327,11 @@
|
|||||||
"luxon": ">=1.0.0"
|
"luxon": ">=1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1264,9 +1343,105 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1664,6 +1839,14 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -1730,6 +1913,33 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.8.5",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"chartjs-adapter-luxon": "^1.3.1",
|
"chartjs-adapter-luxon": "^1.3.1",
|
||||||
"lightweight-charts": "^5.0.8",
|
"lightweight-charts": "^5.0.8",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
import { useLocalStorageState } from './app/hooks/useLocalStorageState';
|
||||||
import AppShell from './layout/AppShell';
|
import AppShell from './layout/AppShell';
|
||||||
import ChartPanel from './features/chart/ChartPanel';
|
import ChartPanel from './features/chart/ChartPanel';
|
||||||
@@ -17,78 +17,6 @@ import { useDlobL2 } from './features/market/useDlobL2';
|
|||||||
import { useDlobSlippage } from './features/market/useDlobSlippage';
|
import { useDlobSlippage } from './features/market/useDlobSlippage';
|
||||||
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
|
import { useDlobDepthBands } from './features/market/useDlobDepthBands';
|
||||||
import DlobDashboard from './features/market/DlobDashboard';
|
import DlobDashboard from './features/market/DlobDashboard';
|
||||||
import ContractCostsPanel from './features/contracts/ContractCostsPanel';
|
|
||||||
|
|
||||||
type PaneId = 'chart' | 'dlob' | 'costsActive' | 'costsNew';
|
|
||||||
|
|
||||||
// Order matters: missing panes are appended in this order; last is default "top".
|
|
||||||
const ALL_PANES: PaneId[] = ['chart', 'dlob', 'costsActive', 'costsNew'];
|
|
||||||
|
|
||||||
function makePaneRecord<T>(factory: (id: PaneId) => T): Record<PaneId, T> {
|
|
||||||
const out: any = {};
|
|
||||||
for (const id of ALL_PANES) out[id] = factory(id);
|
|
||||||
return out as Record<PaneId, T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePaneOrder(raw: unknown): PaneId[] {
|
|
||||||
const out: PaneId[] = [];
|
|
||||||
const arr = Array.isArray(raw) ? raw : [];
|
|
||||||
for (const v of arr) {
|
|
||||||
if (v === 'chart' || v === 'dlob' || v === 'costsActive' || v === 'costsNew') {
|
|
||||||
if (!out.includes(v)) out.push(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const id of ALL_PANES) {
|
|
||||||
if (!out.includes(id)) out.push(id);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLayerOpacity(raw: unknown): Record<PaneId, number> {
|
|
||||||
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
|
|
||||||
const clamp = (v: unknown, fallback: number) => {
|
|
||||||
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
|
|
||||||
if (!Number.isFinite(n)) return fallback;
|
|
||||||
return Math.min(1, Math.max(0, n));
|
|
||||||
};
|
|
||||||
return makePaneRecord((id) => clamp((input as any)[id], 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLayerToggle(raw: unknown, fallback: boolean): Record<PaneId, boolean> {
|
|
||||||
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
|
|
||||||
const toBool = (v: unknown) => (typeof v === 'boolean' ? v : fallback);
|
|
||||||
return makePaneRecord((id) => toBool((input as any)[id]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLayerFactor(raw: unknown, fallback: number, min: number, max: number): Record<PaneId, number> {
|
|
||||||
const input = (raw && typeof raw === 'object' ? (raw as any) : {}) as Partial<Record<PaneId, unknown>>;
|
|
||||||
const clamp = (v: unknown) => {
|
|
||||||
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
|
|
||||||
if (!Number.isFinite(n)) return fallback;
|
|
||||||
return Math.min(max, Math.max(min, n));
|
|
||||||
};
|
|
||||||
return makePaneRecord((id) => clamp((input as any)[id]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function reorderList<T>(items: T[], from: T, to: T): T[] {
|
|
||||||
if (from === to) return items.slice();
|
|
||||||
const next = items.filter((x) => x !== from);
|
|
||||||
const idx = next.indexOf(to);
|
|
||||||
if (idx < 0) return next.concat(from);
|
|
||||||
next.splice(idx, 0, from);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampNumber(v: number, min: number, max: number): number {
|
|
||||||
if (!Number.isFinite(v)) return min;
|
|
||||||
return Math.min(max, Math.max(min, v));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepByWheel(e: React.WheelEvent, step: number): number {
|
|
||||||
// Wheel up => increase, wheel down => decrease
|
|
||||||
if (e.deltaY === 0) return 0;
|
|
||||||
return e.deltaY < 0 ? step : -step;
|
|
||||||
}
|
|
||||||
|
|
||||||
function envNumber(name: string, fallback: number): number {
|
function envNumber(name: string, fallback: number): number {
|
||||||
const v = (import.meta as any).env?.[name];
|
const v = (import.meta as any).env?.[name];
|
||||||
@@ -212,28 +140,17 @@ export default function App() {
|
|||||||
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
||||||
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []);
|
||||||
|
|
||||||
const normalizeTf = (raw: string): string => {
|
|
||||||
const v = String(raw || '').trim();
|
|
||||||
if (!v) return '1m';
|
|
||||||
const lower = v.toLowerCase();
|
|
||||||
// keep backwards compatibility with older saved values (e.g. "1D")
|
|
||||||
if (lower === '1d') return '1d';
|
|
||||||
return lower;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP'));
|
const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP'));
|
||||||
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', ''));
|
||||||
const [tfRaw, setTfRaw] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
|
const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m'));
|
||||||
const tf = useMemo(() => normalizeTf(tfRaw), [tfRaw]);
|
|
||||||
const setTf = (next: string) => setTfRaw(normalizeTf(next));
|
|
||||||
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000));
|
||||||
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300));
|
||||||
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true);
|
||||||
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true);
|
const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true);
|
||||||
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook');
|
||||||
const [bottomTab, setBottomTab] = useLocalStorageState<
|
const [bottomTab, setBottomTab] = useLocalStorageState<
|
||||||
'dlob' | 'costs' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
'dlob' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory'
|
||||||
>('trade.bottomTab', envString('VITE_BOTTOM_TAB', 'dlob') as any);
|
>('trade.bottomTab', 'positions');
|
||||||
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long');
|
||||||
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>(
|
||||||
'trade.form.type',
|
'trade.form.type',
|
||||||
@@ -242,213 +159,6 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
const [tradePrice, setTradePrice] = useLocalStorageState<number>('trade.form.price', 0);
|
||||||
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
|
||||||
|
|
||||||
const [layoutMode, setLayoutMode] = useLocalStorageState<'grid' | 'stack'>('trade.layoutMode', 'grid');
|
|
||||||
const [stackOrderRaw, setStackOrder] = useLocalStorageState<PaneId[]>('trade.stackOrder', ALL_PANES);
|
|
||||||
const stackOrder = useMemo(() => normalizePaneOrder(stackOrderRaw), [stackOrderRaw]);
|
|
||||||
const activePane = stackOrder[stackOrder.length - 1] ?? 'chart';
|
|
||||||
const [stackOrderManual, setStackOrderManual] = useLocalStorageState<boolean>('trade.stackOrderManual', false);
|
|
||||||
const escRef = useRef<number>(0);
|
|
||||||
const [stackPanelLocked, setStackPanelLocked] = useLocalStorageState<boolean>('trade.stackPanelLocked', false);
|
|
||||||
const [stackPanelOpen, setStackPanelOpen] = useState(true);
|
|
||||||
const stackPanelHideTimerRef = useRef<number | null>(null);
|
|
||||||
const [stackDrawerOpacity, setStackDrawerOpacity] = useLocalStorageState<number>('trade.stackDrawerOpacity', 0.92);
|
|
||||||
const [stackBackdropOpacity, setStackBackdropOpacity] = useLocalStorageState<number>('trade.stackBackdropOpacity', 0.55);
|
|
||||||
const [layerOpacityRaw, setLayerOpacity] = useLocalStorageState<Record<PaneId, number>>('trade.layerOpacity', {
|
|
||||||
chart: 1,
|
|
||||||
dlob: 1,
|
|
||||||
costsActive: 1,
|
|
||||||
costsNew: 1,
|
|
||||||
});
|
|
||||||
const layerOpacity = useMemo(() => normalizeLayerOpacity(layerOpacityRaw), [layerOpacityRaw]);
|
|
||||||
const [layerVisibleRaw, setLayerVisible] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerVisible', {
|
|
||||||
chart: true,
|
|
||||||
dlob: true,
|
|
||||||
costsActive: false,
|
|
||||||
costsNew: true,
|
|
||||||
});
|
|
||||||
const layerVisible = useMemo(() => normalizeLayerToggle(layerVisibleRaw, true), [layerVisibleRaw]);
|
|
||||||
const [layerLockedRaw, setLayerLocked] = useLocalStorageState<Record<PaneId, boolean>>('trade.layerLocked', {
|
|
||||||
chart: false,
|
|
||||||
dlob: false,
|
|
||||||
costsActive: false,
|
|
||||||
costsNew: false,
|
|
||||||
});
|
|
||||||
const layerLocked = useMemo(() => normalizeLayerToggle(layerLockedRaw, false), [layerLockedRaw]);
|
|
||||||
const [layerBrightnessRaw, setLayerBrightness] = useLocalStorageState<Record<PaneId, number>>('trade.layerBrightness', {
|
|
||||||
chart: 1,
|
|
||||||
dlob: 1,
|
|
||||||
costsActive: 1,
|
|
||||||
costsNew: 1,
|
|
||||||
});
|
|
||||||
const layerBrightness = useMemo(() => normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8), [layerBrightnessRaw]);
|
|
||||||
const [activeContractIdSeen, setActiveContractIdSeen] = useLocalStorageState<string>('trade.activeContractIdSeen', '');
|
|
||||||
const [activeContractId] = useLocalStorageState<string>('trade.contractId', '');
|
|
||||||
const hasActiveContract = Boolean(activeContractId.trim());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const normalized = normalizePaneOrder(stackOrderRaw);
|
|
||||||
if (normalized.join('|') !== (Array.isArray(stackOrderRaw) ? stackOrderRaw.join('|') : '')) {
|
|
||||||
setStackOrder(normalized);
|
|
||||||
}
|
|
||||||
}, [setStackOrder, stackOrderRaw]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const normalized = normalizeLayerOpacity(layerOpacityRaw);
|
|
||||||
const raw = layerOpacityRaw as any;
|
|
||||||
if (!raw || normalized.chart !== raw.chart || normalized.dlob !== raw.dlob) setLayerOpacity(normalized);
|
|
||||||
}, [layerOpacityRaw, setLayerOpacity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const normalized = normalizeLayerToggle(layerVisibleRaw, true);
|
|
||||||
const raw = layerVisibleRaw as any;
|
|
||||||
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
|
|
||||||
if (needsFix) setLayerVisible(normalized);
|
|
||||||
}, [layerVisibleRaw, setLayerVisible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const normalized = normalizeLayerToggle(layerLockedRaw, false);
|
|
||||||
const raw = layerLockedRaw as any;
|
|
||||||
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
|
|
||||||
if (needsFix) setLayerLocked(normalized);
|
|
||||||
}, [layerLockedRaw, setLayerLocked]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const normalized = normalizeLayerFactor(layerBrightnessRaw, 1, 0.6, 1.8);
|
|
||||||
const raw = layerBrightnessRaw as any;
|
|
||||||
const needsFix = !raw || ALL_PANES.some((k) => normalized[k] !== raw[k]);
|
|
||||||
if (needsFix) setLayerBrightness(normalized);
|
|
||||||
}, [layerBrightnessRaw, setLayerBrightness]);
|
|
||||||
|
|
||||||
// When a contract is "pushed" (contractId becomes non-empty), ensure Active layer is visible and placed
|
|
||||||
// directly below Costs(New) (unless user reorders later). We do this once per contract id.
|
|
||||||
useEffect(() => {
|
|
||||||
const id = activeContractId.trim();
|
|
||||||
if (!id) return;
|
|
||||||
if (id === activeContractIdSeen) return;
|
|
||||||
|
|
||||||
setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), costsActive: true }));
|
|
||||||
|
|
||||||
if (!stackOrderManual) {
|
|
||||||
setStackOrder((prev) => {
|
|
||||||
const normalized = normalizePaneOrder(prev);
|
|
||||||
const without = normalized.filter((p) => p !== 'costsActive');
|
|
||||||
const idxNew = without.indexOf('costsNew');
|
|
||||||
const insertAt = idxNew >= 0 ? idxNew : Math.max(0, without.length - 1);
|
|
||||||
without.splice(insertAt, 0, 'costsActive');
|
|
||||||
return without;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveContractIdSeen(id);
|
|
||||||
}, [activeContractId, activeContractIdSeen, setActiveContractIdSeen, setLayerVisible, setStackOrder, stackOrderManual]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.classList.toggle('stackMode', layoutMode === 'stack');
|
|
||||||
return () => document.body.classList.remove('stackMode');
|
|
||||||
}, [layoutMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const drawer = clampNumber(stackDrawerOpacity, 0.05, 1);
|
|
||||||
const backdrop = clampNumber(stackBackdropOpacity, 0, 0.95);
|
|
||||||
document.body.style.setProperty('--stack-drawer-opacity', String(drawer));
|
|
||||||
document.body.style.setProperty('--stack-backdrop-opacity', String(backdrop));
|
|
||||||
return () => {
|
|
||||||
document.body.style.removeProperty('--stack-drawer-opacity');
|
|
||||||
document.body.style.removeProperty('--stack-backdrop-opacity');
|
|
||||||
};
|
|
||||||
}, [stackBackdropOpacity, stackDrawerOpacity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (layoutMode !== 'stack') return;
|
|
||||||
// Default focus order in stack mode unless the user manually reordered (DnD).
|
|
||||||
if (!stackOrderManual) {
|
|
||||||
setStackOrder(() => normalizePaneOrder(ALL_PANES));
|
|
||||||
}
|
|
||||||
setStackPanelOpen(true);
|
|
||||||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
|
||||||
stackPanelHideTimerRef.current = null;
|
|
||||||
}, [layoutMode, setStackOrder, stackOrderManual]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
|
||||||
stackPanelHideTimerRef.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (layoutMode !== 'stack') return;
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key !== 'Escape') return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - escRef.current < 800) {
|
|
||||||
escRef.current = 0;
|
|
||||||
setLayoutMode('grid');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
escRef.current = now;
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
|
||||||
}, [layoutMode, setLayoutMode]);
|
|
||||||
|
|
||||||
const openStackPanel = () => {
|
|
||||||
if (layoutMode !== 'stack') return;
|
|
||||||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
|
||||||
stackPanelHideTimerRef.current = null;
|
|
||||||
setStackPanelOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleHideStackPanel = () => {
|
|
||||||
if (layoutMode !== 'stack') return;
|
|
||||||
if (stackPanelLocked) return;
|
|
||||||
if (stackPanelHideTimerRef.current != null) window.clearTimeout(stackPanelHideTimerRef.current);
|
|
||||||
stackPanelHideTimerRef.current = window.setTimeout(() => {
|
|
||||||
setStackPanelOpen(false);
|
|
||||||
stackPanelHideTimerRef.current = null;
|
|
||||||
}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const enterStack = (pane: PaneId) => {
|
|
||||||
if (pane === 'dlob') setBottomTab('dlob');
|
|
||||||
setLayoutMode('stack');
|
|
||||||
setStackOrder((prev) => {
|
|
||||||
const normalized = normalizePaneOrder(prev);
|
|
||||||
return normalized.filter((p) => p !== pane).concat(pane);
|
|
||||||
});
|
|
||||||
openStackPanel();
|
|
||||||
};
|
|
||||||
|
|
||||||
const exitStack = () => setLayoutMode('grid');
|
|
||||||
|
|
||||||
const togglePaneFullscreen = (pane: PaneId) => {
|
|
||||||
if (layoutMode === 'stack' && activePane === pane) {
|
|
||||||
exitStack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
enterStack(pane);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stackTopFirst = useMemo(() => stackOrder.slice().reverse(), [stackOrder]);
|
|
||||||
const stackZ = useMemo(() => {
|
|
||||||
const z: Record<PaneId, number> = makePaneRecord(() => 2600);
|
|
||||||
const base = 2600;
|
|
||||||
const step = 20;
|
|
||||||
for (let i = 0; i < stackOrder.length; i++) {
|
|
||||||
const id = stackOrder[i];
|
|
||||||
z[id] = base + i * step;
|
|
||||||
}
|
|
||||||
return z;
|
|
||||||
}, [stackOrder]);
|
|
||||||
|
|
||||||
const effectiveLayerOpacity = useMemo(() => {
|
|
||||||
const out: Record<PaneId, number> = makePaneRecord(() => 0);
|
|
||||||
for (const id of ALL_PANES) {
|
|
||||||
out[id] = layerVisible[id] ? clampNumber(layerOpacity[id] ?? 1, 0, 1) : 0;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}, [layerOpacity, layerVisible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (symbol === 'BONK-PERP') {
|
if (symbol === 'BONK-PERP') {
|
||||||
setSymbol('1MBONK-PERP');
|
setSymbol('1MBONK-PERP');
|
||||||
@@ -459,13 +169,6 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
}
|
}
|
||||||
}, [markets, setSymbol, symbol]);
|
}, [markets, setSymbol, symbol]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const wanted = (params.get('bottomTab') || params.get('tab') || '').trim();
|
|
||||||
const allowed = new Set(['dlob', 'costs', 'positions', 'orders', 'balances', 'orderHistory', 'positionHistory']);
|
|
||||||
if (wanted && allowed.has(wanted)) setBottomTab(wanted as any);
|
|
||||||
}, [setBottomTab]);
|
|
||||||
|
|
||||||
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
const { candles, indicators, meta, loading, error, refresh } = useChartData({
|
||||||
symbol,
|
symbol,
|
||||||
source: source.trim() ? source : undefined,
|
source: source.trim() ? source : undefined,
|
||||||
@@ -656,7 +359,6 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
const bucketSeconds = meta?.bucketSeconds ?? 60;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<AppShell
|
<AppShell
|
||||||
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
|
||||||
top={<TickerBar items={topItems} />}
|
top={<TickerBar items={topItems} />}
|
||||||
@@ -729,18 +431,6 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
onToggleBuild={() => setShowBuild((v) => !v)}
|
onToggleBuild={() => setShowBuild((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }}
|
||||||
fullscreenOverride={layoutMode === 'stack'}
|
|
||||||
onToggleFullscreenOverride={() => togglePaneFullscreen('chart')}
|
|
||||||
fullscreenStyle={
|
|
||||||
layoutMode === 'stack'
|
|
||||||
? {
|
|
||||||
zIndex: stackZ.chart,
|
|
||||||
opacity: effectiveLayerOpacity.chart,
|
|
||||||
filter: `brightness(${layerBrightness.chart})`,
|
|
||||||
pointerEvents: activePane === 'chart' ? 'auto' : 'none',
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="bottomCard">
|
<Card className="bottomCard">
|
||||||
@@ -761,16 +451,9 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
slippageRows={slippageRows}
|
slippageRows={slippageRows}
|
||||||
slippageConnected={slippageConnected}
|
slippageConnected={slippageConnected}
|
||||||
slippageError={slippageError}
|
slippageError={slippageError}
|
||||||
isFullscreen={layoutMode === 'stack' && activePane === 'dlob'}
|
|
||||||
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'costs',
|
|
||||||
label: 'Costs',
|
|
||||||
content: <ContractCostsPanel market={symbol} />,
|
|
||||||
},
|
|
||||||
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
|
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
|
||||||
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
|
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
|
||||||
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
|
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
|
||||||
@@ -1031,308 +714,5 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
|
|||||||
</Card>
|
</Card>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{layoutMode === 'stack' ? (
|
|
||||||
<>
|
|
||||||
<div className="stackDrawerHotspot" onMouseEnter={openStackPanel} aria-label="Layers hotspot" />
|
|
||||||
<Card
|
|
||||||
className={['stackDrawer', stackPanelOpen ? 'stackDrawer--open' : 'stackDrawer--closed'].join(' ')}
|
|
||||||
title="Layers"
|
|
||||||
right={
|
|
||||||
<div className="stackPanel__actions">
|
|
||||||
<span className="muted stackPanel__hint">Esc ×2</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={stackPanelLocked ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => {
|
|
||||||
setStackPanelLocked((v) => !v);
|
|
||||||
openStackPanel();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
title={stackPanelLocked ? 'Auto-hide disabled' : 'Auto-hide enabled'}
|
|
||||||
>
|
|
||||||
{stackPanelLocked ? 'Locked' : 'Auto'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setStackPanelOpen(false);
|
|
||||||
scheduleHideStackPanel();
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Hide
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="primary" onClick={exitStack} type="button">
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
onMouseEnter={openStackPanel}
|
|
||||||
onMouseLeave={scheduleHideStackPanel}
|
|
||||||
>
|
|
||||||
<div className="stackPanel__sub muted">UI Opacity</div>
|
|
||||||
<div className="stackPanel__sliders" style={{ marginBottom: 14 }}>
|
|
||||||
<label className="stackPanel__sliderRow">
|
|
||||||
<span className="stackPanel__sliderLabel muted">Backdrop</span>
|
|
||||||
<input
|
|
||||||
className="stackPanel__slider"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={95}
|
|
||||||
step={1}
|
|
||||||
value={Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}
|
|
||||||
onChange={(e) => setStackBackdropOpacity(Number(e.target.value) / 100)}
|
|
||||||
onWheel={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = Math.round(clampNumber(stackBackdropOpacity, 0, 0.95) * 100);
|
|
||||||
const step = e.shiftKey ? 5 : 1;
|
|
||||||
const next = clampNumber(cur + stepByWheel(e, step), 0, 95);
|
|
||||||
setStackBackdropOpacity(next / 100);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="stackPanel__sliderValue muted">
|
|
||||||
{Math.round(Math.min(0.95, Math.max(0, stackBackdropOpacity)) * 100)}%
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label className="stackPanel__sliderRow">
|
|
||||||
<span className="stackPanel__sliderLabel muted">Panel</span>
|
|
||||||
<input
|
|
||||||
className="stackPanel__slider"
|
|
||||||
type="range"
|
|
||||||
min={5}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}
|
|
||||||
onChange={(e) => setStackDrawerOpacity(Number(e.target.value) / 100)}
|
|
||||||
onWheel={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = Math.round(clampNumber(stackDrawerOpacity, 0.05, 1) * 100);
|
|
||||||
const step = e.shiftKey ? 5 : 1;
|
|
||||||
const next = clampNumber(cur + stepByWheel(e, step), 5, 100);
|
|
||||||
setStackDrawerOpacity(next / 100);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="stackPanel__sliderValue muted">
|
|
||||||
{Math.round(Math.min(1, Math.max(0.05, stackDrawerOpacity)) * 100)}%
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stackPanel__sub muted">
|
|
||||||
Drag to reorder, click to focus (top = active).
|
|
||||||
</div>
|
|
||||||
<div className="stackPanel__list" role="list">
|
|
||||||
{stackTopFirst.map((pane) => {
|
|
||||||
const isActive = pane === activePane;
|
|
||||||
const label =
|
|
||||||
pane === 'chart'
|
|
||||||
? 'Chart'
|
|
||||||
: pane === 'dlob'
|
|
||||||
? 'DLOB'
|
|
||||||
: pane === 'costsNew'
|
|
||||||
? 'Costs (New)'
|
|
||||||
: 'Costs (Active)';
|
|
||||||
const pct = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100);
|
|
||||||
const isVisible = layerVisible[pane];
|
|
||||||
const isLocked = layerLocked[pane];
|
|
||||||
const brightPct = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={pane}
|
|
||||||
role="listitem"
|
|
||||||
className={[
|
|
||||||
'stackPanel__item',
|
|
||||||
isActive ? 'stackPanel__item--active' : null,
|
|
||||||
isLocked ? 'stackPanel__item--locked' : null,
|
|
||||||
!isVisible ? 'stackPanel__item--hidden' : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
draggable={!isLocked}
|
|
||||||
onClick={() => {
|
|
||||||
if (isLocked) return;
|
|
||||||
enterStack(pane);
|
|
||||||
}}
|
|
||||||
onDragStart={(e) => {
|
|
||||||
if (isLocked) return;
|
|
||||||
e.dataTransfer.setData('text/plain', pane);
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
if (isLocked) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
}}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const dragged = e.dataTransfer.getData('text/plain') as PaneId;
|
|
||||||
if (!ALL_PANES.includes(dragged)) return;
|
|
||||||
if (layerLocked[dragged]) return;
|
|
||||||
if (isLocked) return;
|
|
||||||
const nextTop = reorderList(stackTopFirst, dragged, pane);
|
|
||||||
setStackOrder(nextTop.slice().reverse() as any);
|
|
||||||
setStackOrderManual(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="stackPanel__drag">⋮⋮</span>
|
|
||||||
<span className="stackPanel__label">{label}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="stackPanel__iconBtn"
|
|
||||||
title={isVisible ? 'Hide layer' : 'Show layer'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setLayerVisible((prev) => ({ ...normalizeLayerToggle(prev, true), [pane]: !isVisible }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isVisible ? '👁' : '🚫'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="stackPanel__iconBtn"
|
|
||||||
title={isLocked ? 'Unlock layer' : 'Lock layer'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setLayerLocked((prev) => ({ ...normalizeLayerToggle(prev, false), [pane]: !isLocked }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLocked ? '🔒' : '🔓'}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
className="stackPanel__layerBrightness"
|
|
||||||
type="range"
|
|
||||||
min={60}
|
|
||||||
max={180}
|
|
||||||
step={1}
|
|
||||||
value={brightPct}
|
|
||||||
title={`${label} brightness`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onWheel={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = Math.round(clampNumber(layerBrightness[pane] ?? 1, 0.6, 1.8) * 100);
|
|
||||||
const step = e.shiftKey ? 10 : 2;
|
|
||||||
const next = clampNumber(cur + stepByWheel(e, step), 60, 180);
|
|
||||||
setLayerBrightness((prev) => ({ ...normalizeLayerFactor(prev, 1, 0.6, 1.8), [pane]: next / 100 }));
|
|
||||||
}}
|
|
||||||
disabled={isLocked}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLayerBrightness((prev) => ({
|
|
||||||
...normalizeLayerFactor(prev, 1, 0.6, 1.8),
|
|
||||||
[pane]: Number(e.target.value) / 100,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="stackPanel__layerBrightnessValue muted">{brightPct}%</span>
|
|
||||||
<input
|
|
||||||
className="stackPanel__layerOpacity"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={pct}
|
|
||||||
title={`${label} opacity`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onWheel={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const cur = Math.round(clampNumber(layerOpacity[pane] ?? 1, 0, 1) * 100);
|
|
||||||
const step = e.shiftKey ? 5 : 1;
|
|
||||||
const next = clampNumber(cur + stepByWheel(e, step), 0, 100);
|
|
||||||
setLayerOpacity((prev) => ({ ...normalizeLayerOpacity(prev), [pane]: next / 100 }));
|
|
||||||
}}
|
|
||||||
disabled={isLocked}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLayerOpacity((prev) => ({
|
|
||||||
...normalizeLayerOpacity(prev),
|
|
||||||
[pane]: Number(e.target.value) / 100,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="stackPanel__layerOpacityValue muted">{pct}%</span>
|
|
||||||
{isActive ? <span className="stackPanel__badge">active</span> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{layoutMode === 'stack' ? <div className="stackBackdrop" aria-hidden="true" /> : null}
|
|
||||||
|
|
||||||
{layoutMode === 'stack' && effectiveLayerOpacity.dlob > 0 ? (
|
|
||||||
<div
|
|
||||||
className="stackLayer stackLayer--dlob"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="DLOB layer"
|
|
||||||
style={{
|
|
||||||
zIndex: stackZ.dlob,
|
|
||||||
opacity: effectiveLayerOpacity.dlob,
|
|
||||||
filter: `brightness(${layerBrightness.dlob})`,
|
|
||||||
pointerEvents: activePane === 'dlob' ? 'auto' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="stackLayer__body">
|
|
||||||
<Card className="stackLayer__card">
|
|
||||||
<DlobDashboard
|
|
||||||
market={symbol}
|
|
||||||
stats={dlob}
|
|
||||||
statsConnected={dlobConnected}
|
|
||||||
statsError={dlobError}
|
|
||||||
depthBands={depthBands}
|
|
||||||
depthBandsConnected={depthBandsConnected}
|
|
||||||
depthBandsError={depthBandsError}
|
|
||||||
slippageRows={slippageRows}
|
|
||||||
slippageConnected={slippageConnected}
|
|
||||||
slippageError={slippageError}
|
|
||||||
isFullscreen
|
|
||||||
onToggleFullscreen={() => togglePaneFullscreen('dlob')}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{layoutMode === 'stack' && effectiveLayerOpacity.costsActive > 0 && hasActiveContract ? (
|
|
||||||
<div
|
|
||||||
className="stackLayer stackLayer--costsActive"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Costs active layer"
|
|
||||||
style={{
|
|
||||||
zIndex: stackZ.costsActive,
|
|
||||||
opacity: effectiveLayerOpacity.costsActive,
|
|
||||||
filter: `brightness(${layerBrightness.costsActive})`,
|
|
||||||
pointerEvents: activePane === 'costsActive' ? 'auto' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="stackLayer__body">
|
|
||||||
<Card className="stackLayer__card">
|
|
||||||
<ContractCostsPanel market={symbol} view="active" />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{layoutMode === 'stack' && effectiveLayerOpacity.costsNew > 0 ? (
|
|
||||||
<div
|
|
||||||
className="stackLayer stackLayer--costsNew"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Costs new layer"
|
|
||||||
style={{
|
|
||||||
zIndex: stackZ.costsNew,
|
|
||||||
opacity: effectiveLayerOpacity.costsNew,
|
|
||||||
filter: `brightness(${layerBrightness.costsNew})`,
|
|
||||||
pointerEvents: activePane === 'costsNew' ? 'auto' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="stackLayer__body">
|
|
||||||
<Card className="stackLayer__card">
|
|
||||||
<ContractCostsPanel market={symbol} view="new" />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -1,4 +1,3 @@
|
|||||||
import type { CSSProperties } from 'react';
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { Candle, ChartIndicators } from '../../lib/api';
|
import type { Candle, ChartIndicators } from '../../lib/api';
|
||||||
import Card from '../../ui/Card';
|
import Card from '../../ui/Card';
|
||||||
@@ -23,9 +22,6 @@ type Props = {
|
|||||||
showBuild: boolean;
|
showBuild: boolean;
|
||||||
onToggleBuild: () => void;
|
onToggleBuild: () => void;
|
||||||
seriesLabel: string;
|
seriesLabel: string;
|
||||||
fullscreenOverride?: boolean;
|
|
||||||
onToggleFullscreenOverride?: () => void;
|
|
||||||
fullscreenStyle?: CSSProperties;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type FibDragMode = 'move' | 'edit-b';
|
type FibDragMode = 'move' | 'edit-b';
|
||||||
@@ -60,16 +56,8 @@ export default function ChartPanel({
|
|||||||
showBuild,
|
showBuild,
|
||||||
onToggleBuild,
|
onToggleBuild,
|
||||||
seriesLabel,
|
seriesLabel,
|
||||||
fullscreenOverride,
|
|
||||||
onToggleFullscreenOverride,
|
|
||||||
fullscreenStyle,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const isExternalFullscreen = typeof fullscreenOverride === 'boolean';
|
|
||||||
const effectiveFullscreen = isExternalFullscreen ? (fullscreenOverride as boolean) : isFullscreen;
|
|
||||||
const toggleFullscreen = isExternalFullscreen
|
|
||||||
? onToggleFullscreenOverride ?? (() => {})
|
|
||||||
: () => setIsFullscreen((v) => !v);
|
|
||||||
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor');
|
||||||
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
const [fibStart, setFibStart] = useState<FibAnchor | null>(null);
|
||||||
const [fib, setFib] = useState<FibRetracement | null>(null);
|
const [fib, setFib] = useState<FibRetracement | null>(null);
|
||||||
@@ -98,29 +86,22 @@ export default function ChartPanel({
|
|||||||
const fibRef = useRef<FibRetracement | null>(fib);
|
const fibRef = useRef<FibRetracement | null>(fib);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExternalFullscreen) return;
|
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setIsFullscreen(false);
|
if (e.key === 'Escape') setIsFullscreen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [isExternalFullscreen, isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isExternalFullscreen) return;
|
|
||||||
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
document.body.classList.toggle('chartFullscreen', isFullscreen);
|
||||||
return () => document.body.classList.remove('chartFullscreen');
|
return () => document.body.classList.remove('chartFullscreen');
|
||||||
}, [isExternalFullscreen, isFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
const cardClassName = useMemo(() => {
|
const cardClassName = useMemo(() => {
|
||||||
return ['chartCard', effectiveFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
return ['chartCard', isFullscreen ? 'chartCard--fullscreen' : null].filter(Boolean).join(' ');
|
||||||
}, [effectiveFullscreen]);
|
}, [isFullscreen]);
|
||||||
|
|
||||||
const cardStyle = useMemo(() => {
|
|
||||||
if (!effectiveFullscreen) return undefined;
|
|
||||||
return fullscreenStyle;
|
|
||||||
}, [effectiveFullscreen, fullscreenStyle]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeToolRef.current = activeTool;
|
activeToolRef.current = activeTool;
|
||||||
@@ -321,8 +302,8 @@ export default function ChartPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!isExternalFullscreen && isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
|
||||||
<Card className={cardClassName} style={cardStyle}>
|
<Card className={cardClassName}>
|
||||||
<div className="chartCard__toolbar">
|
<div className="chartCard__toolbar">
|
||||||
<ChartToolbar
|
<ChartToolbar
|
||||||
timeframe={timeframe}
|
timeframe={timeframe}
|
||||||
@@ -334,8 +315,8 @@ export default function ChartPanel({
|
|||||||
priceAutoScale={priceAutoScale}
|
priceAutoScale={priceAutoScale}
|
||||||
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
|
||||||
seriesLabel={seriesLabel}
|
seriesLabel={seriesLabel}
|
||||||
isFullscreen={effectiveFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={() => setIsFullscreen((v) => !v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="chartCard__content">
|
<div className="chartCard__content">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type Props = {
|
|||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeframes = ['1s', '3s', '5s', '15s', '30s', '1m', '3m', '5m', '15m', '30m', '1h', '4h', '12h', '1d'] as const;
|
const timeframes = ['3s', '5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const;
|
||||||
|
|
||||||
export default function ChartToolbar({
|
export default function ChartToolbar({
|
||||||
timeframe,
|
timeframe,
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ function toVolumeData(candles: Candle[]): HistogramData[] {
|
|||||||
|
|
||||||
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
function toLineSeries(points: SeriesPoint[] | undefined): LinePoint[] {
|
||||||
if (!points?.length) return [];
|
if (!points?.length) return [];
|
||||||
return points.map((p) =>
|
return points.map((p) => (p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }));
|
||||||
p.value == null ? ({ time: toTime(p.time) } as WhitespaceData) : { time: toTime(p.time), value: p.value }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorForDelta(delta: number): string {
|
function colorForDelta(delta: number): string {
|
||||||
|
|||||||
@@ -27,60 +27,33 @@ export function useChartData({ symbol, source, tf, limit, pollMs }: Params): Res
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inFlight = useRef(false);
|
const inFlight = useRef(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
|
||||||
const requestIdRef = useRef(0);
|
|
||||||
|
|
||||||
const fetchOnce = useCallback(
|
|
||||||
async ({ force }: { force: boolean }) => {
|
|
||||||
if (!force && inFlight.current) return;
|
|
||||||
|
|
||||||
// On timeframe/params change we want an immediate response — abort the older request.
|
|
||||||
if (force && abortRef.current) abortRef.current.abort();
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
abortRef.current = ctrl;
|
|
||||||
|
|
||||||
const reqId = requestIdRef.current + 1;
|
|
||||||
requestIdRef.current = reqId;
|
|
||||||
|
|
||||||
|
const fetchOnce = useCallback(async () => {
|
||||||
|
if (inFlight.current) return;
|
||||||
inFlight.current = true;
|
inFlight.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchChart({ symbol, source, tf, limit, signal: ctrl.signal });
|
const res = await fetchChart({ symbol, source, tf, limit });
|
||||||
if (requestIdRef.current !== reqId) return; // stale response
|
|
||||||
setCandles(res.candles);
|
setCandles(res.candles);
|
||||||
setIndicators(res.indicators);
|
setIndicators(res.indicators);
|
||||||
setMeta(res.meta);
|
setMeta(res.meta);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Aborts are expected during fast tf switching.
|
setError(String(e?.message || e));
|
||||||
const name = String(e?.name || '');
|
|
||||||
const msg = String(e?.message || e);
|
|
||||||
if (name === 'AbortError' || msg.toLowerCase().includes('abort')) return;
|
|
||||||
if (requestIdRef.current !== reqId) return;
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (requestIdRef.current === reqId) {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
inFlight.current = false;
|
inFlight.current = false;
|
||||||
}
|
}
|
||||||
}
|
}, [symbol, source, tf, limit]);
|
||||||
},
|
|
||||||
[symbol, source, tf, limit]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchOnce({ force: true });
|
void fetchOnce();
|
||||||
return () => {
|
|
||||||
abortRef.current?.abort();
|
|
||||||
};
|
|
||||||
}, [fetchOnce]);
|
}, [fetchOnce]);
|
||||||
|
|
||||||
useInterval(() => void fetchOnce({ force: false }), pollMs);
|
useInterval(() => void fetchOnce(), pollMs);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ candles, indicators, meta, loading, error, refresh: () => fetchOnce({ force: true }) }),
|
() => ({ candles, indicators, meta, loading, error, refresh: fetchOnce }),
|
||||||
[candles, indicators, meta, loading, error, fetchOnce]
|
[candles, indicators, meta, loading, error, fetchOnce]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,858 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import Chart from 'chart.js/auto';
|
|
||||||
import 'chartjs-adapter-luxon';
|
|
||||||
import { useLocalStorageState } from '../../app/hooks/useLocalStorageState';
|
|
||||||
import Button from '../../ui/Button';
|
|
||||||
|
|
||||||
type ContractMonitorResponse = {
|
|
||||||
ok?: boolean;
|
|
||||||
error?: string;
|
|
||||||
contract?: any;
|
|
||||||
eventsCount?: number;
|
|
||||||
costs?: {
|
|
||||||
tradeFeeUsd: number;
|
|
||||||
txFeeUsd: number;
|
|
||||||
slippageUsd: number;
|
|
||||||
fundingUsd: number;
|
|
||||||
realizedPnlUsd: number;
|
|
||||||
totalCostsUsd: number;
|
|
||||||
netPnlUsd: number;
|
|
||||||
txCount: number;
|
|
||||||
fillCount: number;
|
|
||||||
cancelCount: number;
|
|
||||||
modifyCount: number;
|
|
||||||
errorCount: number;
|
|
||||||
};
|
|
||||||
series?: Array<{
|
|
||||||
ts: string;
|
|
||||||
tradeFeeUsd: number;
|
|
||||||
txFeeUsd: number;
|
|
||||||
slippageUsd: number;
|
|
||||||
fundingUsd: number;
|
|
||||||
totalCostsUsd: number;
|
|
||||||
realizedPnlUsd: number;
|
|
||||||
netPnlUsd: number;
|
|
||||||
}> | null;
|
|
||||||
closeEstimate?: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CostEstimateResponse = {
|
|
||||||
ok?: boolean;
|
|
||||||
error?: string;
|
|
||||||
input?: any;
|
|
||||||
dlob?: any;
|
|
||||||
breakdown?: {
|
|
||||||
trade_fee_usd: number;
|
|
||||||
slippage_usd: number;
|
|
||||||
tx_fee_usd: number;
|
|
||||||
expected_modify_usd: number;
|
|
||||||
total_usd: number;
|
|
||||||
total_bps: number;
|
|
||||||
breakeven_bps: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatUsd(v: number | null | undefined): string {
|
|
||||||
if (v == null || !Number.isFinite(v)) return '—';
|
|
||||||
const abs = Math.abs(v);
|
|
||||||
if (abs >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
|
||||||
if (abs >= 1000) return `$${(v / 1000).toFixed(1)}K`;
|
|
||||||
return `$${v.toFixed(4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBps(v: number | null | undefined): string {
|
|
||||||
if (v == null || !Number.isFinite(v)) return '—';
|
|
||||||
return `${v.toFixed(2)} bps`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampNumber(v: number, min: number, max: number): number {
|
|
||||||
if (!Number.isFinite(v)) return min;
|
|
||||||
return Math.min(max, Math.max(min, v));
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniLineChart({
|
|
||||||
title,
|
|
||||||
points,
|
|
||||||
valueKey,
|
|
||||||
format,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
points: Array<Record<string, any>>;
|
|
||||||
valueKey: string;
|
|
||||||
format?: (v: number | null | undefined) => string;
|
|
||||||
}) {
|
|
||||||
const values = useMemo(() => {
|
|
||||||
const out: number[] = [];
|
|
||||||
for (const p of points) {
|
|
||||||
const v = Number(p?.[valueKey]);
|
|
||||||
if (Number.isFinite(v)) out.push(v);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}, [points, valueKey]);
|
|
||||||
|
|
||||||
const last = values.length ? values[values.length - 1] : null;
|
|
||||||
const min = values.length ? Math.min(...values) : 0;
|
|
||||||
const max = values.length ? Math.max(...values) : 1;
|
|
||||||
const span = max - min || 1;
|
|
||||||
const w = 360;
|
|
||||||
const h = 84;
|
|
||||||
const pad = 6;
|
|
||||||
|
|
||||||
const d = useMemo(() => {
|
|
||||||
if (!values.length) return '';
|
|
||||||
const step = values.length > 1 ? (w - pad * 2) / (values.length - 1) : 0;
|
|
||||||
let path = '';
|
|
||||||
for (let i = 0; i < values.length; i++) {
|
|
||||||
const x = pad + i * step;
|
|
||||||
const y = pad + ((max - values[i]) / span) * (h - pad * 2);
|
|
||||||
path += i === 0 ? `M ${x.toFixed(2)} ${y.toFixed(2)}` : ` L ${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}, [h, max, span, values, w]);
|
|
||||||
|
|
||||||
const cls = last != null && last >= 0 ? 'pos' : 'neg';
|
|
||||||
const fmt = format || formatUsd;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="costChart">
|
|
||||||
<div className="costChart__head">
|
|
||||||
<span className="costChart__title">{title}</span>
|
|
||||||
<span className={['costChart__value', cls].join(' ')}>{fmt(last)}</span>
|
|
||||||
</div>
|
|
||||||
{values.length ? (
|
|
||||||
<svg className="costChart__svg" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none">
|
|
||||||
<path d={d} fill="none" stroke="rgba(168, 85, 247, 0.9)" strokeWidth="2" />
|
|
||||||
<path d={`M ${pad} ${h - pad} H ${w - pad}`} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="1" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<div className="muted" style={{ fontSize: 12 }}>
|
|
||||||
No series yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EstimatePoint = {
|
|
||||||
ts: number;
|
|
||||||
tradeFeeUsd: number;
|
|
||||||
slippageUsd: number;
|
|
||||||
txFeeUsd: number;
|
|
||||||
modifyUsd: number;
|
|
||||||
totalUsd: number;
|
|
||||||
breakevenBps: number;
|
|
||||||
totalBps: number;
|
|
||||||
midPrice: number | null;
|
|
||||||
vwapPrice: number | null;
|
|
||||||
impactBps: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useWindowedEstimateSeries(points: EstimatePoint[], windowSec: number) {
|
|
||||||
const nowMs = Date.now();
|
|
||||||
const windowMs = Math.max(10, Math.min(3600, Math.floor(windowSec))) * 1000;
|
|
||||||
const startMs = nowMs - windowMs;
|
|
||||||
|
|
||||||
return useMemo(() => points.filter((p) => p.ts >= startMs && p.ts <= nowMs + 2000), [nowMs, points, startMs]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EstimateChart({
|
|
||||||
title,
|
|
||||||
points,
|
|
||||||
windowSec,
|
|
||||||
kind,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
points: EstimatePoint[];
|
|
||||||
windowSec: number;
|
|
||||||
kind: 'price' | 'costUsd' | 'costBps';
|
|
||||||
}) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const chartRef = useRef<Chart | null>(null);
|
|
||||||
const windowed = useWindowedEstimateSeries(points, windowSec);
|
|
||||||
|
|
||||||
const datasets = useMemo(() => {
|
|
||||||
const toXY = (key: keyof EstimatePoint) =>
|
|
||||||
windowed.map((p) => ({
|
|
||||||
x: p.ts,
|
|
||||||
y: (p[key] as any) == null ? null : Number(p[key] as any),
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (kind === 'price') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Mid',
|
|
||||||
data: toXY('midPrice'),
|
|
||||||
borderColor: 'rgba(96,165,250,0.95)',
|
|
||||||
backgroundColor: 'rgba(96,165,250,0.10)',
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.15,
|
|
||||||
yAxisID: 'price',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'VWAP (quote)',
|
|
||||||
data: toXY('vwapPrice'),
|
|
||||||
borderColor: 'rgba(168,85,247,0.95)',
|
|
||||||
backgroundColor: 'rgba(168,85,247,0.10)',
|
|
||||||
pointRadius: 0,
|
|
||||||
borderDash: [6, 4],
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.15,
|
|
||||||
yAxisID: 'price',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === 'costBps') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Impact (bps)',
|
|
||||||
data: toXY('impactBps'),
|
|
||||||
borderColor: 'rgba(34,197,94,0.95)',
|
|
||||||
backgroundColor: 'rgba(34,197,94,0.10)',
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.15,
|
|
||||||
yAxisID: 'bps',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Total (bps)',
|
|
||||||
data: toXY('totalBps'),
|
|
||||||
borderColor: 'rgba(239,68,68,0.95)',
|
|
||||||
backgroundColor: 'rgba(239,68,68,0.10)',
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.15,
|
|
||||||
yAxisID: 'bps',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Total',
|
|
||||||
data: toXY('totalUsd'),
|
|
||||||
borderColor: 'rgba(168,85,247,0.95)',
|
|
||||||
backgroundColor: 'rgba(168,85,247,0.10)',
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.15,
|
|
||||||
yAxisID: 'usd',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Slippage',
|
|
||||||
data: toXY('slippageUsd'),
|
|
||||||
borderColor: 'rgba(34,197,94,0.95)',
|
|
||||||
backgroundColor: 'rgba(34,197,94,0.10)',
|
|
||||||
pointRadius: 0,
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.15,
|
|
||||||
yAxisID: 'usd',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Tx',
|
|
||||||
data: toXY('txFeeUsd'),
|
|
||||||
borderColor: 'rgba(96,165,250,0.95)',
|
|
||||||
backgroundColor: 'rgba(96,165,250,0.10)',
|
|
||||||
pointRadius: 0,
|
|
||||||
borderDash: [6, 4],
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.15,
|
|
||||||
yAxisID: 'usd',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}, [kind, windowed]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current) return;
|
|
||||||
if (chartRef.current) return;
|
|
||||||
|
|
||||||
chartRef.current = new Chart(canvasRef.current, {
|
|
||||||
type: 'line',
|
|
||||||
data: { datasets: datasets as any },
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
animation: false,
|
|
||||||
parsing: false,
|
|
||||||
interaction: { mode: 'index', intersect: false },
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
labels: {
|
|
||||||
color: '#e6e9ef',
|
|
||||||
font: { size: 13, weight: '600' },
|
|
||||||
boxWidth: 10,
|
|
||||||
boxHeight: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
enabled: true,
|
|
||||||
titleFont: { size: 13, weight: '700' },
|
|
||||||
bodyFont: { size: 13, weight: '600' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: 'time',
|
|
||||||
time: { unit: 'second' },
|
|
||||||
// Time labels are already readable; keep them slightly smaller than the rest.
|
|
||||||
ticks: { color: '#c7cbd4', font: { size: 11, weight: '600' } },
|
|
||||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
|
||||||
},
|
|
||||||
price: {
|
|
||||||
type: 'linear',
|
|
||||||
position: 'right',
|
|
||||||
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
|
|
||||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
|
||||||
display: kind === 'price',
|
|
||||||
},
|
|
||||||
usd: {
|
|
||||||
type: 'linear',
|
|
||||||
position: 'right',
|
|
||||||
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
|
|
||||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
|
||||||
display: kind === 'costUsd',
|
|
||||||
},
|
|
||||||
bps: {
|
|
||||||
type: 'linear',
|
|
||||||
position: 'right',
|
|
||||||
ticks: { color: '#c7cbd4', font: { size: 13, weight: '650' } },
|
|
||||||
grid: { color: 'rgba(255,255,255,0.06)' },
|
|
||||||
display: kind === 'costBps',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
chartRef.current?.destroy();
|
|
||||||
chartRef.current = null;
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [kind]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const chart = chartRef.current;
|
|
||||||
if (!chart) return;
|
|
||||||
chart.data.datasets = datasets as any;
|
|
||||||
chart.update('none');
|
|
||||||
}, [datasets]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={['costChart', 'costChart--big', `costChart--${kind}`].join(' ')}>
|
|
||||||
<div className="costChart__head">
|
|
||||||
<span className="costChart__title">{title}</span>
|
|
||||||
<span className="muted" style={{ fontSize: 12 }}>
|
|
||||||
{windowSec}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="costChart__canvas">
|
|
||||||
<canvas ref={canvasRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
||||||
const res = await fetch(url, init);
|
|
||||||
const text = await res.text();
|
|
||||||
let json: any = null;
|
|
||||||
try {
|
|
||||||
json = text ? JSON.parse(text) : null;
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = json?.error || text || `HTTP ${res.status}`;
|
|
||||||
throw new Error(String(msg));
|
|
||||||
}
|
|
||||||
return (json ?? {}) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContractCostsPanel({
|
|
||||||
market,
|
|
||||||
view,
|
|
||||||
}: {
|
|
||||||
market: string;
|
|
||||||
view?: 'both' | 'active' | 'new';
|
|
||||||
}) {
|
|
||||||
const [contractId, setContractId] = useLocalStorageState<string>('trade.contractId', '');
|
|
||||||
const [autoPoll, setAutoPoll] = useLocalStorageState<boolean>('trade.contractMonitor.autoPoll', true);
|
|
||||||
const [pollMs, setPollMs] = useLocalStorageState<number>('trade.contractMonitor.pollMs', 1500);
|
|
||||||
|
|
||||||
const [monitor, setMonitor] = useState<ContractMonitorResponse | null>(null);
|
|
||||||
const [monitorLoading, setMonitorLoading] = useState(false);
|
|
||||||
const [monitorError, setMonitorError] = useState<string | null>(null);
|
|
||||||
const lastMonitorAtRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const normalizedContractId = useMemo(() => contractId.trim(), [contractId]);
|
|
||||||
|
|
||||||
const loadMonitor = async () => {
|
|
||||||
const id = normalizedContractId;
|
|
||||||
if (!id) return;
|
|
||||||
setMonitorLoading(true);
|
|
||||||
setMonitorError(null);
|
|
||||||
try {
|
|
||||||
const data = await fetchJson<ContractMonitorResponse>(
|
|
||||||
`/api/v1/contracts/${encodeURIComponent(id)}/monitor?eventsLimit=2000&series=1&seriesMax=600`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'cache-control': 'no-store' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setMonitor(data);
|
|
||||||
lastMonitorAtRef.current = Date.now();
|
|
||||||
} catch (e: any) {
|
|
||||||
setMonitor(null);
|
|
||||||
setMonitorError(String(e?.message || e));
|
|
||||||
} finally {
|
|
||||||
setMonitorLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoPoll) return;
|
|
||||||
if (!normalizedContractId) return;
|
|
||||||
const ms = clampNumber(pollMs, 250, 30_000);
|
|
||||||
void loadMonitor();
|
|
||||||
const t = window.setInterval(() => void loadMonitor(), ms);
|
|
||||||
return () => window.clearInterval(t);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [autoPoll, normalizedContractId, pollMs]);
|
|
||||||
|
|
||||||
const [notionalUsd, setNotionalUsd] = useLocalStorageState<number>('trade.newContract.notionalUsd', 10);
|
|
||||||
const [side, setSide] = useLocalStorageState<'long' | 'short'>('trade.newContract.side', 'long');
|
|
||||||
const [orderType, setOrderType] = useLocalStorageState<'market' | 'limit'>('trade.newContract.orderType', 'market');
|
|
||||||
const [advancedOpen, setAdvancedOpen] = useLocalStorageState<boolean>('trade.newContract.advancedOpen', false);
|
|
||||||
const [feeTakerBps, setFeeTakerBps] = useLocalStorageState<number>('trade.newContract.feeTakerBps', 5);
|
|
||||||
const [feeMakerBps, setFeeMakerBps] = useLocalStorageState<number>('trade.newContract.feeMakerBps', 0);
|
|
||||||
const [txFeeUsdEst, setTxFeeUsdEst] = useLocalStorageState<number>('trade.newContract.txFeeUsdEst', 0);
|
|
||||||
const [expectedReprices, setExpectedReprices] = useLocalStorageState<number>('trade.newContract.expectedReprices', 0);
|
|
||||||
|
|
||||||
const [estimate, setEstimate] = useState<CostEstimateResponse | null>(null);
|
|
||||||
const [estimateLoading, setEstimateLoading] = useState(false);
|
|
||||||
const [estimateError, setEstimateError] = useState<string | null>(null);
|
|
||||||
const [autoEstimate, setAutoEstimate] = useLocalStorageState<boolean>('trade.newContract.autoEstimate', true);
|
|
||||||
const [estimateWindowSec, setEstimateWindowSec] = useLocalStorageState<number>('trade.newContract.estimateWindowSec', 300);
|
|
||||||
const estimateInFlightRef = useRef(false);
|
|
||||||
const [estimateSeries, setEstimateSeries] = useState<EstimatePoint[]>([]);
|
|
||||||
|
|
||||||
const runEstimate = async () => {
|
|
||||||
setEstimateLoading(true);
|
|
||||||
setEstimateError(null);
|
|
||||||
try {
|
|
||||||
const body: any = {
|
|
||||||
market_name: market,
|
|
||||||
notional_usd: notionalUsd,
|
|
||||||
side,
|
|
||||||
order_type: orderType,
|
|
||||||
};
|
|
||||||
if (advancedOpen) {
|
|
||||||
body.fee_taker_bps = feeTakerBps;
|
|
||||||
body.fee_maker_bps = feeMakerBps;
|
|
||||||
body.tx_fee_usd_est = txFeeUsdEst;
|
|
||||||
body.expected_reprices_per_entry = expectedReprices;
|
|
||||||
}
|
|
||||||
const data = await fetchJson<CostEstimateResponse>('/api/v1/contracts/costs/estimate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
setEstimate(data);
|
|
||||||
if (data?.breakdown) {
|
|
||||||
const b = data.breakdown;
|
|
||||||
const midPriceRaw = data?.dlob?.mid_price;
|
|
||||||
const vwapPriceRaw = data?.dlob?.vwap_price;
|
|
||||||
const impactBpsRaw = data?.dlob?.impact_bps;
|
|
||||||
const midPrice = midPriceRaw == null ? null : Number(midPriceRaw);
|
|
||||||
const vwapPrice = vwapPriceRaw == null ? null : Number(vwapPriceRaw);
|
|
||||||
const impactBps = impactBpsRaw == null ? null : Number(impactBpsRaw);
|
|
||||||
setEstimateSeries((prev) => {
|
|
||||||
const next = [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
ts: Date.now(),
|
|
||||||
tradeFeeUsd: Number(b.trade_fee_usd ?? 0) || 0,
|
|
||||||
slippageUsd: Number(b.slippage_usd ?? 0) || 0,
|
|
||||||
txFeeUsd: Number(b.tx_fee_usd ?? 0) || 0,
|
|
||||||
modifyUsd: Number(b.expected_modify_usd ?? 0) || 0,
|
|
||||||
totalUsd: Number(b.total_usd ?? 0) || 0,
|
|
||||||
breakevenBps: Number(b.breakeven_bps ?? 0) || 0,
|
|
||||||
totalBps: Number(b.total_bps ?? b.breakeven_bps ?? 0) || 0,
|
|
||||||
midPrice: Number.isFinite(midPrice) ? midPrice : null,
|
|
||||||
vwapPrice: Number.isFinite(vwapPrice) ? vwapPrice : null,
|
|
||||||
impactBps: Number.isFinite(impactBps) ? impactBps : null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return next.slice(-4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
setEstimate(null);
|
|
||||||
setEstimateError(String(e?.message || e));
|
|
||||||
} finally {
|
|
||||||
setEstimateLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tickEstimate = async () => {
|
|
||||||
if (estimateInFlightRef.current) return;
|
|
||||||
estimateInFlightRef.current = true;
|
|
||||||
try {
|
|
||||||
await runEstimate();
|
|
||||||
} finally {
|
|
||||||
estimateInFlightRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoEstimate) return;
|
|
||||||
void tickEstimate();
|
|
||||||
const t = window.setInterval(() => void tickEstimate(), 1000);
|
|
||||||
return () => window.clearInterval(t);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
|
||||||
autoEstimate,
|
|
||||||
market,
|
|
||||||
notionalUsd,
|
|
||||||
side,
|
|
||||||
orderType,
|
|
||||||
advancedOpen,
|
|
||||||
feeTakerBps,
|
|
||||||
feeMakerBps,
|
|
||||||
txFeeUsdEst,
|
|
||||||
expectedReprices,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [mode, setMode] = useLocalStorageState<'both' | 'active' | 'new'>('trade.costsPanel.mode', 'both');
|
|
||||||
const effectiveMode = view || mode;
|
|
||||||
|
|
||||||
const showActive = effectiveMode === 'both' || effectiveMode === 'active';
|
|
||||||
const showNew = effectiveMode === 'both' || effectiveMode === 'new';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={['costsPanel', view ? 'costsPanel--stack' : null].filter(Boolean).join(' ')}>
|
|
||||||
{!view ? (
|
|
||||||
<div className="costsPanel__toolbar">
|
|
||||||
<span className="muted">View</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={mode === 'both' ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => setMode('both')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Both
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={mode === 'active' ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => setMode('active')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Active
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={mode === 'new' ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => setMode('new')}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
New
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={['costsPanel__grid', !showActive || !showNew ? 'costsPanel__grid--single' : null]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
{showActive ? (
|
|
||||||
<section className="costsCard">
|
|
||||||
<div className="costsCard__head">
|
|
||||||
<div className="costsCard__title">Active contract</div>
|
|
||||||
<div className="costsCard__actions">
|
|
||||||
<Button size="sm" variant={autoPoll ? 'primary' : 'ghost'} onClick={() => setAutoPoll((v) => !v)} type="button">
|
|
||||||
Auto
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => void loadMonitor()} type="button" disabled={!normalizedContractId || monitorLoading}>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="costsForm">
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">contract_id</span>
|
|
||||||
<input
|
|
||||||
className="inlineField__input"
|
|
||||||
value={contractId}
|
|
||||||
onChange={(e) => setContractId(e.target.value)}
|
|
||||||
placeholder="uuid…"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">poll ms</span>
|
|
||||||
<input
|
|
||||||
className="inlineField__input"
|
|
||||||
type="number"
|
|
||||||
min={250}
|
|
||||||
step={250}
|
|
||||||
value={pollMs}
|
|
||||||
onChange={(e) => setPollMs(Number(e.target.value))}
|
|
||||||
disabled={!autoPoll}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{monitorError ? <div className="uiError">{monitorError}</div> : null}
|
|
||||||
|
|
||||||
<div className="costsKpis">
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Fees</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(monitor?.costs?.tradeFeeUsd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Tx</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(monitor?.costs?.txFeeUsd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Slippage</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(monitor?.costs?.slippageUsd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Funding</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(monitor?.costs?.fundingUsd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Total costs</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(monitor?.costs?.totalCostsUsd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Net PnL</div>
|
|
||||||
<div className={['costKpi__value', (monitor?.costs?.netPnlUsd ?? 0) >= 0 ? 'pos' : 'neg'].join(' ')}>
|
|
||||||
{formatUsd(monitor?.costs?.netPnlUsd ?? null)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="costsMeta muted">
|
|
||||||
<div>events: {monitor?.eventsCount ?? '—'}</div>
|
|
||||||
<div>tx: {monitor?.costs?.txCount ?? '—'}</div>
|
|
||||||
<div>fills: {monitor?.costs?.fillCount ?? '—'}</div>
|
|
||||||
<div>cancel: {monitor?.costs?.cancelCount ?? '—'}</div>
|
|
||||||
<div>modify: {monitor?.costs?.modifyCount ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="costsCard__subhead">PnL / costs over time</div>
|
|
||||||
<div className="costCharts">
|
|
||||||
<MiniLineChart title="Net PnL" points={monitor?.series || []} valueKey="netPnlUsd" />
|
|
||||||
<MiniLineChart title="Total costs" points={monitor?.series || []} valueKey="totalCostsUsd" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="costsCard__subhead">Close now (DLOB quote)</div>
|
|
||||||
<div className="costsClose">
|
|
||||||
<div className="costsClose__row">
|
|
||||||
<span className="muted">buy impact</span>
|
|
||||||
<span>{formatBps(monitor?.closeEstimate?.buy?.impact_bps ?? null)}</span>
|
|
||||||
<span className="muted">vwap</span>
|
|
||||||
<span>{monitor?.closeEstimate?.buy?.vwap_price ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="costsClose__row">
|
|
||||||
<span className="muted">sell impact</span>
|
|
||||||
<span>{formatBps(monitor?.closeEstimate?.sell?.impact_bps ?? null)}</span>
|
|
||||||
<span className="muted">vwap</span>
|
|
||||||
<span>{monitor?.closeEstimate?.sell?.vwap_price ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showNew ? (
|
|
||||||
<section className="costsCard costsCard--new">
|
|
||||||
<div className="costsCard__head">
|
|
||||||
<div className="costsCard__title">New contract estimate</div>
|
|
||||||
<div className="costsCard__actions">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={autoEstimate ? 'primary' : 'ghost'}
|
|
||||||
onClick={() => setAutoEstimate((v) => !v)}
|
|
||||||
type="button"
|
|
||||||
title="Auto refresh (1s)"
|
|
||||||
>
|
|
||||||
Auto
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="primary" onClick={() => void runEstimate()} type="button" disabled={estimateLoading}>
|
|
||||||
Estimate
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant={advancedOpen ? 'primary' : 'ghost'} onClick={() => setAdvancedOpen((v) => !v)} type="button">
|
|
||||||
Advanced
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="costsNewLayout">
|
|
||||||
<div className="costsNewCharts">
|
|
||||||
<div className="costsMeta costsMeta--new">
|
|
||||||
<span className="muted">Live window</span>
|
|
||||||
<select
|
|
||||||
className="inlineField__input"
|
|
||||||
value={estimateWindowSec}
|
|
||||||
onChange={(e) => setEstimateWindowSec(Number(e.target.value))}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
title="Chart window"
|
|
||||||
>
|
|
||||||
<option value={60}>60s</option>
|
|
||||||
<option value={300}>5m</option>
|
|
||||||
<option value={900}>15m</option>
|
|
||||||
<option value={3600}>1h</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="costCharts costCharts--new">
|
|
||||||
<EstimateChart
|
|
||||||
title="Price (mid vs vwap)"
|
|
||||||
points={estimateSeries}
|
|
||||||
windowSec={estimateWindowSec}
|
|
||||||
kind="price"
|
|
||||||
/>
|
|
||||||
<EstimateChart title="Costs (bps)" points={estimateSeries} windowSec={estimateWindowSec} kind="costBps" />
|
|
||||||
<EstimateChart title="Costs (USD)" points={estimateSeries} windowSec={estimateWindowSec} kind="costUsd" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="costsNewSide">
|
|
||||||
<div className="costsForm costsForm--newSide">
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">market</span>
|
|
||||||
<input className="inlineField__input" value={market} disabled />
|
|
||||||
</label>
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">notional</span>
|
|
||||||
<input
|
|
||||||
className="inlineField__input"
|
|
||||||
type="number"
|
|
||||||
min={0.01}
|
|
||||||
step={0.01}
|
|
||||||
value={notionalUsd}
|
|
||||||
onChange={(e) => setNotionalUsd(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">side</span>
|
|
||||||
<select className="inlineField__input" value={side} onChange={(e) => setSide(e.target.value as any)}>
|
|
||||||
<option value="long">long</option>
|
|
||||||
<option value="short">short</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">order</span>
|
|
||||||
<select
|
|
||||||
className="inlineField__input"
|
|
||||||
value={orderType}
|
|
||||||
onChange={(e) => setOrderType(e.target.value as any)}
|
|
||||||
>
|
|
||||||
<option value="market">market (taker)</option>
|
|
||||||
<option value="limit">limit/post-only (maker)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{advancedOpen ? (
|
|
||||||
<div className="costsForm costsForm--newSide">
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">taker bps</span>
|
|
||||||
<input
|
|
||||||
className="inlineField__input"
|
|
||||||
type="number"
|
|
||||||
step={0.1}
|
|
||||||
value={feeTakerBps}
|
|
||||||
onChange={(e) => setFeeTakerBps(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">maker bps</span>
|
|
||||||
<input
|
|
||||||
className="inlineField__input"
|
|
||||||
type="number"
|
|
||||||
step={0.1}
|
|
||||||
value={feeMakerBps}
|
|
||||||
onChange={(e) => setFeeMakerBps(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">tx usd est</span>
|
|
||||||
<input
|
|
||||||
className="inlineField__input"
|
|
||||||
type="number"
|
|
||||||
step={0.001}
|
|
||||||
value={txFeeUsdEst}
|
|
||||||
onChange={(e) => setTxFeeUsdEst(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inlineField">
|
|
||||||
<span className="inlineField__label">reprices</span>
|
|
||||||
<input
|
|
||||||
className="inlineField__input"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={expectedReprices}
|
|
||||||
onChange={(e) => setExpectedReprices(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="muted" style={{ fontSize: 12 }}>
|
|
||||||
Defaults: backend computes fee/tx/modify estimates (use “Advanced” to override).
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{estimateError ? <div className="uiError">{estimateError}</div> : null}
|
|
||||||
|
|
||||||
<div className="costsKpis costsKpis--newSide">
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Total</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.total_usd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Breakeven</div>
|
|
||||||
<div className="costKpi__value">{formatBps(estimate?.breakdown?.breakeven_bps ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Fee</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.trade_fee_usd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Slippage</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.slippage_usd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Tx</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.tx_fee_usd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="costKpi">
|
|
||||||
<div className="costKpi__label">Modify</div>
|
|
||||||
<div className="costKpi__value">{formatUsd(estimate?.breakdown?.expected_modify_usd ?? null)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="costsCard__subhead">DLOB quote used</div>
|
|
||||||
<div className="costsClose">
|
|
||||||
<div className="costsClose__row">
|
|
||||||
<span className="muted">size</span>
|
|
||||||
<span>{estimate?.dlob?.size_usd ?? '—'}</span>
|
|
||||||
<span className="muted">impact</span>
|
|
||||||
<span>{formatBps(estimate?.dlob?.impact_bps ?? null)}</span>
|
|
||||||
<span className="muted">fill</span>
|
|
||||||
<span>{estimate?.dlob?.fill_pct == null ? '—' : `${Number(estimate.dlob.fill_pct).toFixed(0)}%`}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import type { DlobDepthBandRow } from './useDlobDepthBands';
|
|||||||
import type { DlobSlippageRow } from './useDlobSlippage';
|
import type { DlobSlippageRow } from './useDlobSlippage';
|
||||||
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
|
||||||
import DlobSlippageChart from './DlobSlippageChart';
|
import DlobSlippageChart from './DlobSlippageChart';
|
||||||
import Button from '../../ui/Button';
|
|
||||||
|
|
||||||
function formatUsd(v: number | null | undefined): string {
|
function formatUsd(v: number | null | undefined): string {
|
||||||
if (v == null || !Number.isFinite(v)) return '—';
|
if (v == null || !Number.isFinite(v)) return '—';
|
||||||
@@ -40,8 +39,6 @@ export default function DlobDashboard({
|
|||||||
slippageRows,
|
slippageRows,
|
||||||
slippageConnected,
|
slippageConnected,
|
||||||
slippageError,
|
slippageError,
|
||||||
isFullscreen,
|
|
||||||
onToggleFullscreen,
|
|
||||||
}: {
|
}: {
|
||||||
market: string;
|
market: string;
|
||||||
stats: DlobStats | null;
|
stats: DlobStats | null;
|
||||||
@@ -53,8 +50,6 @@ export default function DlobDashboard({
|
|||||||
slippageRows: DlobSlippageRow[];
|
slippageRows: DlobSlippageRow[];
|
||||||
slippageConnected: boolean;
|
slippageConnected: boolean;
|
||||||
slippageError: string | null;
|
slippageError: string | null;
|
||||||
isFullscreen?: boolean;
|
|
||||||
onToggleFullscreen?: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
|
||||||
|
|
||||||
@@ -65,16 +60,6 @@ export default function DlobDashboard({
|
|||||||
<div className="dlobDash__meta">
|
<div className="dlobDash__meta">
|
||||||
<span className="dlobDash__market">{market}</span>
|
<span className="dlobDash__market">{market}</span>
|
||||||
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
|
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
|
||||||
{onToggleFullscreen ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={isFullscreen ? 'primary' : 'ghost'}
|
|
||||||
onClick={onToggleFullscreen}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isFullscreen ? 'Exit' : 'Fullscreen'}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,3 +134,4 @@ export default function DlobDashboard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,3 +109,4 @@ export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] })
|
|||||||
|
|
||||||
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
|
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,3 +131,4 @@ export function useDlobDepthBands(
|
|||||||
|
|
||||||
return { rows, connected, error };
|
return { rows, connected, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ function toNum(v: unknown): number | null {
|
|||||||
return 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 = {
|
type HasuraRow = {
|
||||||
market_name: string;
|
market_name: string;
|
||||||
side: string;
|
side: string;
|
||||||
@@ -45,7 +57,6 @@ type HasuraRow = {
|
|||||||
|
|
||||||
type SubscriptionData = {
|
type SubscriptionData = {
|
||||||
dlob_slippage_latest: HasuraRow[];
|
dlob_slippage_latest: HasuraRow[];
|
||||||
dlob_slippage_latest_v2: HasuraRow[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
|
||||||
@@ -67,23 +78,6 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
|
|||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
subscription DlobSlippage($market: String!) {
|
subscription DlobSlippage($market: String!) {
|
||||||
dlob_slippage_latest_v2(
|
|
||||||
where: { market_name: { _eq: $market } }
|
|
||||||
order_by: [{ side: asc }, { size_usd: asc }]
|
|
||||||
) {
|
|
||||||
market_name
|
|
||||||
side
|
|
||||||
size_usd
|
|
||||||
mid_price
|
|
||||||
vwap_price
|
|
||||||
worst_price
|
|
||||||
filled_usd
|
|
||||||
filled_base
|
|
||||||
impact_bps
|
|
||||||
levels_consumed
|
|
||||||
fill_pct
|
|
||||||
updated_at
|
|
||||||
}
|
|
||||||
dlob_slippage_latest(
|
dlob_slippage_latest(
|
||||||
where: { market_name: { _eq: $market } }
|
where: { market_name: { _eq: $market } }
|
||||||
order_by: [{ side: asc }, { size_usd: asc }]
|
order_by: [{ side: asc }, { size_usd: asc }]
|
||||||
@@ -111,13 +105,11 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
|
|||||||
onError: (e) => setError(e),
|
onError: (e) => setError(e),
|
||||||
onData: (data) => {
|
onData: (data) => {
|
||||||
const out: DlobSlippageRow[] = [];
|
const out: DlobSlippageRow[] = [];
|
||||||
const v2 = data?.dlob_slippage_latest_v2 || [];
|
for (const r of data?.dlob_slippage_latest || []) {
|
||||||
const src = v2.length ? v2 : data?.dlob_slippage_latest || [];
|
|
||||||
for (const r of src) {
|
|
||||||
if (!r?.market_name) continue;
|
if (!r?.market_name) continue;
|
||||||
const side = String(r.side || '').trim();
|
const side = String(r.side || '').trim();
|
||||||
if (side !== 'buy' && side !== 'sell') continue;
|
if (side !== 'buy' && side !== 'sell') continue;
|
||||||
const sizeUsd = toNum(r.size_usd);
|
const sizeUsd = toInt(r.size_usd);
|
||||||
if (sizeUsd == null || sizeUsd <= 0) continue;
|
if (sizeUsd == null || sizeUsd <= 0) continue;
|
||||||
out.push({
|
out.push({
|
||||||
marketName: r.market_name,
|
marketName: r.market_name,
|
||||||
@@ -129,10 +121,7 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
|
|||||||
filledUsd: toNum(r.filled_usd),
|
filledUsd: toNum(r.filled_usd),
|
||||||
filledBase: toNum(r.filled_base),
|
filledBase: toNum(r.filled_base),
|
||||||
impactBps: toNum(r.impact_bps),
|
impactBps: toNum(r.impact_bps),
|
||||||
levelsConsumed: (() => {
|
levelsConsumed: toInt(r.levels_consumed),
|
||||||
const v = toNum(r.levels_consumed);
|
|
||||||
return v == null ? null : Math.trunc(v);
|
|
||||||
})(),
|
|
||||||
fillPct: toNum(r.fill_pct),
|
fillPct: toNum(r.fill_pct),
|
||||||
updatedAt: r.updated_at ?? null,
|
updatedAt: r.updated_at ?? null,
|
||||||
});
|
});
|
||||||
@@ -146,3 +135,4 @@ export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[];
|
|||||||
|
|
||||||
return { rows, connected, error };
|
return { rows, connected, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export async function fetchChart(params: {
|
|||||||
source?: string;
|
source?: string;
|
||||||
tf: string;
|
tf: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
signal?: AbortSignal;
|
|
||||||
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
}): Promise<{ candles: Candle[]; indicators: ChartIndicators; meta: { tf: string; bucketSeconds: number } }> {
|
||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
const u = new URL(base, window.location.origin);
|
const u = new URL(base, window.location.origin);
|
||||||
@@ -57,7 +56,7 @@ export async function fetchChart(params: {
|
|||||||
u.searchParams.set('limit', String(params.limit));
|
u.searchParams.set('limit', String(params.limit));
|
||||||
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
if (params.source && params.source.trim()) u.searchParams.set('source', params.source.trim());
|
||||||
|
|
||||||
const res = await fetch(u.toString(), { signal: params.signal });
|
const res = await fetch(u.toString());
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
|
||||||
const json = JSON.parse(text) as ChartResponse;
|
const json = JSON.parse(text) as ChartResponse;
|
||||||
|
|||||||
@@ -43,30 +43,13 @@ function resolveGraphqlWsUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAuthHeaders(): HeadersMap | undefined {
|
function resolveAuthHeaders(): HeadersMap | undefined {
|
||||||
const rawToken = envString('VITE_HASURA_AUTH_TOKEN');
|
const token = envString('VITE_HASURA_AUTH_TOKEN');
|
||||||
if (rawToken) {
|
if (token) return { authorization: `Bearer ${token}` };
|
||||||
const bearer = normalizeBearerToken(rawToken);
|
|
||||||
if (bearer) return { authorization: `Bearer ${bearer}` };
|
|
||||||
}
|
|
||||||
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
const secret = envString('VITE_HASURA_ADMIN_SECRET');
|
||||||
if (secret) return { 'x-hasura-admin-secret': secret };
|
if (secret) return { 'x-hasura-admin-secret': secret };
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBearerToken(raw: string): string | undefined {
|
|
||||||
const trimmed = String(raw || '').trim();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
const m = trimmed.match(/^Bearer\s+(.+)$/i);
|
|
||||||
const token = (m ? m[1] : trimmed).trim();
|
|
||||||
if (!token) return undefined;
|
|
||||||
const parts = token.split(/\s+/).filter(Boolean);
|
|
||||||
if (!parts.length) return undefined;
|
|
||||||
if (parts.length > 1) {
|
|
||||||
console.warn('VITE_HASURA_AUTH_TOKEN contains whitespace; using the first segment only.');
|
|
||||||
}
|
|
||||||
return parts[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
type WsMessage =
|
type WsMessage =
|
||||||
| { type: 'connection_ack' | 'ka' | 'complete' }
|
| { type: 'connection_ack' | 'ka' | 'complete' }
|
||||||
| { type: 'connection_error'; payload?: any }
|
| { type: 'connection_error'; payload?: any }
|
||||||
|
|||||||
@@ -23,18 +23,7 @@ function getHasuraUrl(): string {
|
|||||||
|
|
||||||
function getAuthToken(): string | undefined {
|
function getAuthToken(): string | undefined {
|
||||||
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
|
const v = (import.meta as any).env?.VITE_HASURA_AUTH_TOKEN;
|
||||||
const raw = v ? String(v) : '';
|
return v ? String(v) : undefined;
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
const m = trimmed.match(/^Bearer\s+(.+)$/i);
|
|
||||||
const token = (m ? m[1] : trimmed).trim();
|
|
||||||
if (!token) return undefined;
|
|
||||||
const parts = token.split(/\s+/).filter(Boolean);
|
|
||||||
if (!parts.length) return undefined;
|
|
||||||
if (parts.length > 1) {
|
|
||||||
console.warn('VITE_HASURA_AUTH_TOKEN contains whitespace; using the first segment only.');
|
|
||||||
}
|
|
||||||
return parts[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAdminSecret(): string | undefined {
|
function getAdminSecret(): string | undefined {
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ a:hover {
|
|||||||
color: var(--neg);
|
color: var(--neg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -411,7 +415,7 @@ a:hover {
|
|||||||
|
|
||||||
.marketHeader {
|
.marketHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,16 +484,12 @@ a:hover {
|
|||||||
|
|
||||||
.statsRow {
|
.statsRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* 7 stat tiles by default (Last/Oracle/Bid/Ask/Spread/DLOB/L2), but keep it responsive. */
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
gap: 10px;
|
||||||
column-gap: 14px;
|
|
||||||
row-gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat__label {
|
.stat__label {
|
||||||
@@ -501,18 +501,12 @@ a:hover {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat__sub {
|
.stat__sub {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartCard {
|
.chartCard {
|
||||||
@@ -1078,306 +1072,6 @@ body.chartFullscreen {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.costsPanel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsPanel--stack {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsPanel--stack .costsPanel__grid {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsPanel__toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsPanel__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsPanel__grid--single {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard {
|
|
||||||
background: rgba(0, 0, 0, 0.20);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard--new {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard--new .costsCard__subhead {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard__title {
|
|
||||||
font-weight: 950;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard__subhead {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsForm {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsForm--new {
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsForm--newSide {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsKpis {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsKpis--new {
|
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsKpis--newSide {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsNewLayout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 420px;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsNewCharts {
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsNewSide {
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costKpi {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard--new .costKpi {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard--new .costKpi__label {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsCard--new .costKpi__value {
|
|
||||||
margin-top: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costKpi__label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.costKpi__value {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-weight: 900;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsMeta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px 14px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsMeta--new {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsClose {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsClose__row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto auto auto;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costCharts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costCharts--new {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
grid-template-areas:
|
|
||||||
"price price"
|
|
||||||
"bps usd";
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart--big {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart--price {
|
|
||||||
grid-area: price;
|
|
||||||
}
|
|
||||||
.costChart--costBps {
|
|
||||||
grid-area: bps;
|
|
||||||
}
|
|
||||||
.costChart--costUsd {
|
|
||||||
grid-area: usd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__canvas {
|
|
||||||
height: 280px;
|
|
||||||
min-height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart--price .costChart__canvas {
|
|
||||||
height: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart--costBps .costChart__canvas,
|
|
||||||
.costChart--costUsd .costChart__canvas {
|
|
||||||
height: 180px;
|
|
||||||
min-height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__canvas canvas {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__title {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__value {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 84px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__scroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costChart__svg--time {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.costsPanel__grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.costCharts {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.costsForm--new {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
.costsKpis--new {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
.costCharts--new {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-areas:
|
|
||||||
"price"
|
|
||||||
"bps"
|
|
||||||
"usd";
|
|
||||||
}
|
|
||||||
.costChart--price .costChart__canvas {
|
|
||||||
height: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.costsNewLayout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dlobDepth {
|
.dlobDepth {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1974,215 +1668,6 @@ body.chartFullscreen {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.statsRow {
|
.statsRow {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body.stackMode {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackBackdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1990;
|
|
||||||
background: rgba(0, 0, 0, var(--stack-backdrop-opacity, 0.55));
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackDrawerHotspot {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 10px;
|
|
||||||
height: 96px;
|
|
||||||
z-index: 4050;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackDrawer {
|
|
||||||
position: fixed;
|
|
||||||
top: 12px;
|
|
||||||
left: 12px;
|
|
||||||
bottom: 12px;
|
|
||||||
width: min(460px, calc(100vw - 24px));
|
|
||||||
z-index: 4000;
|
|
||||||
transform: translateX(0);
|
|
||||||
transition: transform 180ms ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackDrawer--closed {
|
|
||||||
transform: translateX(calc(-100% - 20px));
|
|
||||||
opacity: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
pointer-events: none;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackDrawer.uiCard {
|
|
||||||
box-shadow: 0 30px 120px rgba(0, 0, 0, 0.55);
|
|
||||||
pointer-events: auto;
|
|
||||||
background: rgba(10, 11, 16, var(--stack-drawer-opacity, 0.92));
|
|
||||||
border-color: rgba(255, 255, 255, 0.10);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__sliders {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__sliderRow {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 68px 1fr 46px;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__slider {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__sliderValue {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__layerOpacity {
|
|
||||||
margin-left: 8px;
|
|
||||||
width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__layerOpacityValue {
|
|
||||||
width: 42px;
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__layerBrightness {
|
|
||||||
margin-left: 8px;
|
|
||||||
width: 110px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__layerBrightnessValue {
|
|
||||||
width: 46px;
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__iconBtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
|
||||||
background: rgba(0, 0, 0, 0.18);
|
|
||||||
color: rgba(230, 233, 239, 0.9);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__iconBtn:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.16);
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__item--locked {
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__item--hidden .stackPanel__label {
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__hint {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__sub {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
|
||||||
background: rgba(0, 0, 0, 0.22);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__item:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.16);
|
|
||||||
background: rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__item--active {
|
|
||||||
border-color: rgba(168, 85, 247, 0.45);
|
|
||||||
box-shadow: 0 0 0 1px rgba(168, 85, 247, 0.12), 0 18px 50px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__drag {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__label {
|
|
||||||
font-weight: 900;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackPanel__badge {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(168, 85, 247, 0.14);
|
|
||||||
border: 1px solid rgba(168, 85, 247, 0.25);
|
|
||||||
color: rgba(230, 233, 239, 0.92);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackLayer {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 2500;
|
|
||||||
background: transparent;
|
|
||||||
padding: 12px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackLayer__body {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stackLayer__card {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
const DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -14,8 +14,8 @@ function stripTrailingSlashes(p: string): string {
|
|||||||
return out || '/';
|
return out || '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function readApiReadToken(): string | undefined {
|
function readApiReadToken(env: Record<string, string | undefined>): string | undefined {
|
||||||
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
|
if (env.API_READ_TOKEN) return env.API_READ_TOKEN;
|
||||||
const p = path.join(ROOT, 'tokens', 'read.json');
|
const p = path.join(ROOT, 'tokens', 'read.json');
|
||||||
if (!fs.existsSync(p)) return undefined;
|
if (!fs.existsSync(p)) return undefined;
|
||||||
try {
|
try {
|
||||||
@@ -38,11 +38,11 @@ function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
|
|||||||
return { username, password };
|
return { username, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
function readProxyBasicAuth(): BasicAuth | undefined {
|
function readProxyBasicAuth(env: Record<string, string | undefined>): BasicAuth | undefined {
|
||||||
const fromEnv = parseBasicAuth(process.env.API_PROXY_BASIC_AUTH);
|
const fromEnv = parseBasicAuth(env.API_PROXY_BASIC_AUTH);
|
||||||
if (fromEnv) return fromEnv;
|
if (fromEnv) return fromEnv;
|
||||||
|
|
||||||
const fileRaw = String(process.env.API_PROXY_BASIC_AUTH_FILE || '').trim();
|
const fileRaw = String(env.API_PROXY_BASIC_AUTH_FILE || '').trim();
|
||||||
if (!fileRaw) return undefined;
|
if (!fileRaw) return undefined;
|
||||||
|
|
||||||
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
|
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
|
||||||
@@ -59,21 +59,6 @@ function readProxyBasicAuth(): BasicAuth | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiReadToken = readApiReadToken();
|
|
||||||
const proxyBasicAuth = readProxyBasicAuth();
|
|
||||||
const apiProxyTarget =
|
|
||||||
process.env.API_PROXY_TARGET ||
|
|
||||||
process.env.VISUALIZER_PROXY_TARGET ||
|
|
||||||
process.env.TRADE_UI_URL ||
|
|
||||||
process.env.TRADE_VPS_URL ||
|
|
||||||
'https://trade.mpabi.pl';
|
|
||||||
|
|
||||||
function isLocalHost(hostname: string | undefined): boolean {
|
|
||||||
const h = String(hostname || '').trim().toLowerCase();
|
|
||||||
if (!h) return false;
|
|
||||||
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUrl(v: string): URL | undefined {
|
function parseUrl(v: string): URL | undefined {
|
||||||
try {
|
try {
|
||||||
return new URL(v);
|
return new URL(v);
|
||||||
@@ -82,18 +67,6 @@ function parseUrl(v: string): URL | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toOrigin(u: URL | undefined): string | undefined {
|
|
||||||
if (!u) return undefined;
|
|
||||||
return `${u.protocol}//${u.host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiProxyTargetUrl = parseUrl(apiProxyTarget);
|
|
||||||
const apiProxyOrigin = toOrigin(apiProxyTargetUrl);
|
|
||||||
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
|
|
||||||
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
|
|
||||||
const apiProxyIsLocal = isLocalHost(apiProxyTargetUrl?.hostname);
|
|
||||||
const apiProxyForceBearer = process.env.API_PROXY_FORCE_BEARER === '1' || process.env.API_PROXY_USE_READ_TOKEN === '1';
|
|
||||||
|
|
||||||
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
function inferUiProxyTarget(apiTarget: string): string | undefined {
|
||||||
try {
|
try {
|
||||||
const u = new URL(apiTarget);
|
const u = new URL(apiTarget);
|
||||||
@@ -110,18 +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);
|
|
||||||
const uiProxyOrigin = toOrigin(parseUrl(uiProxyTarget || ''));
|
|
||||||
|
|
||||||
const graphqlProxyTarget = process.env.GRAPHQL_PROXY_TARGET || process.env.HASURA_PROXY_TARGET || uiProxyTarget;
|
|
||||||
const graphqlProxyOrigin = toOrigin(parseUrl(graphqlProxyTarget || ''));
|
|
||||||
const graphqlProxyBasicAuthEnabled =
|
|
||||||
process.env.GRAPHQL_PROXY_BASIC_AUTH === '1' || process.env.HASURA_PROXY_BASIC_AUTH === '1';
|
|
||||||
function applyProxyBasicAuth(proxyReq: any) {
|
function applyProxyBasicAuth(proxyReq: any) {
|
||||||
if (!proxyBasicAuth) return false;
|
if (!proxyBasicAuth) return false;
|
||||||
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
|
||||||
@@ -129,12 +90,6 @@ function applyProxyBasicAuth(proxyReq: any) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProxyOrigin(proxyReq: any, origin: string | undefined) {
|
|
||||||
if (!origin) return;
|
|
||||||
// Some upstreams (notably WS endpoints) validate Origin and may drop the connection when it doesn't match.
|
|
||||||
proxyReq.setHeader('Origin', origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
||||||
const v = proxyRes?.headers?.['set-cookie'];
|
const v = proxyRes?.headers?.['set-cookie'];
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
@@ -147,26 +102,48 @@ function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
|
|||||||
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
|
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxy: Record<string, any> = {
|
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': {
|
'/api': {
|
||||||
target: apiProxyTarget,
|
target: apiProxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
rewrite: (p: string) => (apiProxyTargetEndsWithApi ? p.replace(/^\/api/, '') : p),
|
||||||
configure: (p: any) => {
|
configure: (p: any) => {
|
||||||
p.on('proxyReq', (proxyReq: any) => {
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
applyProxyOrigin(proxyReq, apiProxyOrigin);
|
|
||||||
if (applyProxyBasicAuth(proxyReq)) return;
|
if (applyProxyBasicAuth(proxyReq)) return;
|
||||||
if ((apiProxyIsLocal || apiProxyForceBearer) && apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
|
||||||
});
|
|
||||||
p.on('proxyReqWs', (proxyReq: any) => {
|
|
||||||
applyProxyOrigin(proxyReq, apiProxyOrigin);
|
|
||||||
applyProxyBasicAuth(proxyReq);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (graphqlProxyTarget) {
|
if (graphqlProxyTarget) {
|
||||||
for (const prefix of ['/graphql', '/graphql-ws']) {
|
for (const prefix of ['/graphql', '/graphql-ws']) {
|
||||||
proxy[prefix] = {
|
proxy[prefix] = {
|
||||||
target: graphqlProxyTarget,
|
target: graphqlProxyTarget,
|
||||||
@@ -174,25 +151,23 @@ if (graphqlProxyTarget) {
|
|||||||
ws: true,
|
ws: true,
|
||||||
configure: (p: any) => {
|
configure: (p: any) => {
|
||||||
p.on('proxyReq', (proxyReq: any) => {
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
applyProxyOrigin(proxyReq, graphqlProxyOrigin);
|
applyProxyBasicAuth(proxyReq);
|
||||||
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq);
|
|
||||||
});
|
});
|
||||||
p.on('proxyReqWs', (proxyReq: any) => {
|
p.on('proxyReqWs', (proxyReq: any) => {
|
||||||
applyProxyOrigin(proxyReq, graphqlProxyOrigin);
|
applyProxyBasicAuth(proxyReq);
|
||||||
if (graphqlProxyBasicAuthEnabled) applyProxyBasicAuth(proxyReq);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uiProxyTarget) {
|
|
||||||
|
if (uiProxyTarget) {
|
||||||
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
for (const prefix of ['/whoami', '/auth', '/logout']) {
|
||||||
proxy[prefix] = {
|
proxy[prefix] = {
|
||||||
target: uiProxyTarget,
|
target: uiProxyTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
configure: (p: any) => {
|
configure: (p: any) => {
|
||||||
p.on('proxyReq', (proxyReq: any) => {
|
p.on('proxyReq', (proxyReq: any) => {
|
||||||
applyProxyOrigin(proxyReq, uiProxyOrigin);
|
|
||||||
applyProxyBasicAuth(proxyReq);
|
applyProxyBasicAuth(proxyReq);
|
||||||
});
|
});
|
||||||
p.on('proxyRes', (proxyRes: any) => {
|
p.on('proxyRes', (proxyRes: any) => {
|
||||||
@@ -201,13 +176,14 @@ if (uiProxyTarget) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: Number.isInteger(port) && port > 0 ? port : 5173,
|
||||||
strictPort: false,
|
strictPort: true,
|
||||||
proxy,
|
proxy,
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Candles cache: precompute wszystkich timeframe (1s…1d)
|
|
||||||
|
|
||||||
Cel: przełączanie `tf` w UI ma być natychmiastowe. Backend ma **ciągle liczyć** i **przechowywać** świeczki dla wszystkich timeframe:
|
|
||||||
|
|
||||||
`1s 3s 5s 15s 30s 1m 3m 5m 15m 30m 1h 4h 12h 1d`
|
|
||||||
|
|
||||||
## Jak to działa
|
|
||||||
|
|
||||||
1) Ticki (append-only) lądują w `drift_ticks`.
|
|
||||||
2) Worker `candles-cache-worker`:
|
|
||||||
- liczy świeczki dla **każdego** `bucket_seconds` bezpośrednio z `drift_ticks`,
|
|
||||||
- trzyma w DB “ostatnie N” świec (domyślnie `N=1024`) per `(symbol, source, tf)`,
|
|
||||||
- jeśli danych historycznych jest mniej (np. brak wielu dni) — zapisuje tylko to, co istnieje,
|
|
||||||
- robi backfill/warmup przy starcie i potem dopisuje “na bieżąco” w pętli.
|
|
||||||
3) API `GET /v1/chart` czyta **cache-first** z `drift_candles_cache` (fallback do on-demand funkcji, jeśli cache pusty).
|
|
||||||
|
|
||||||
## Tabela
|
|
||||||
|
|
||||||
- `drift_candles_cache` (Timescale hypertable, partycjonowanie po `bucket`)
|
|
||||||
- `bucket_seconds` = długość świecy w sekundach
|
|
||||||
- `source=''` oznacza “(any)” (brak filtra po źródle ticków)
|
|
||||||
|
|
||||||
## Worker
|
|
||||||
|
|
||||||
Plik: `services/candles-worker/candles-cache-worker.mjs`
|
|
||||||
|
|
||||||
Env:
|
|
||||||
- `CANDLES_SYMBOLS` (np. `SOL-PERP,PUMP-PERP`)
|
|
||||||
- `CANDLES_SOURCES` (np. `any,drift_oracle`)
|
|
||||||
- `CANDLES_TFS` (np. `1s,3s,5s,15s,...,1d`)
|
|
||||||
- `CANDLES_TARGET_POINTS` (default `1024`)
|
|
||||||
- `CANDLES_BACKFILL_DAYS` (opcjonalnie: wymusza minimalny warmup “co najmniej X dni”)
|
|
||||||
- `CANDLES_POLL_MS` (default `5000`)
|
|
||||||
|
|
||||||
## Dlaczego to jest szybkie
|
|
||||||
|
|
||||||
- najcięższe agregacje są robione raz i utrzymywane “na bieżąco”,
|
|
||||||
- przełączenie `tf` to tylko query po gotowych wierszach (`order_by bucket desc limit N`),
|
|
||||||
- “flow/brick stack” w `/v1/chart` jest liczone z cache “point candles” (np. `1s/3s/5s/15s/…`) bez skanowania `drift_ticks`.
|
|
||||||
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`
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# DLOB + L1…L10 — podstawy (co jest czym i gdzie to liczymy)
|
|
||||||
|
|
||||||
Ten dokument wyjaśnia pojęcia:
|
|
||||||
- **DLOB** (Drift Limit Order Book),
|
|
||||||
- **L1 / L2 / L3** oraz potoczne **L1…L10**,
|
|
||||||
- na jakich warstwach w naszym stacku powstają dane i metryki,
|
|
||||||
- gdzie “pracuje AI” (modele/strategie) vs gdzie jest execution (order placement).
|
|
||||||
|
|
||||||
## Co to jest DLOB
|
|
||||||
|
|
||||||
**DLOB** = *Decentralized Limit Order Book* w Drift.
|
|
||||||
|
|
||||||
W praktyce: to jest **księga zleceń** dla rynku (np. `SOL-PERP`):
|
|
||||||
- **bids** = zlecenia kupna (po stronie bid),
|
|
||||||
- **asks** = zlecenia sprzedaży (po stronie ask).
|
|
||||||
|
|
||||||
Księga ma wiele “poziomów” cenowych; przy każdej cenie stoi pewna ilość (size).
|
|
||||||
|
|
||||||
## L1 / L2 / L3 (format i sens)
|
|
||||||
|
|
||||||
### L1 (Top of Book)
|
|
||||||
L1 to skrót od “top of book”:
|
|
||||||
- **best bid** = najwyższa cena kupna (pierwszy poziom po stronie bid),
|
|
||||||
- **best ask** = najniższa cena sprzedaży (pierwszy poziom po stronie ask).
|
|
||||||
|
|
||||||
Z L1 najczęściej liczysz:
|
|
||||||
- **spread** = `best_ask - best_bid`,
|
|
||||||
- **mid** = `(best_bid + best_ask) / 2`.
|
|
||||||
|
|
||||||
### L2 (zagregowane poziomy)
|
|
||||||
L2 to lista poziomów (levels) po obu stronach:
|
|
||||||
- `bids: [{ price, size }, ...]` (zwykle posortowane malejąco po `price`)
|
|
||||||
- `asks: [{ price, size }, ...]` (zwykle posortowane rosnąco po `price`)
|
|
||||||
|
|
||||||
To jest najpopularniejszy “orderbook UI”: słupki/heat per poziom ceny.
|
|
||||||
|
|
||||||
### L3 (pojedyncze zlecenia)
|
|
||||||
L3 to “niezagregowane” dane: pojedyncze zlecenia (większy wolumen danych).
|
|
||||||
U nas pod UI i metryki zazwyczaj wystarcza L2.
|
|
||||||
|
|
||||||
## L1…L10 (co to znaczy w praktyce)
|
|
||||||
|
|
||||||
**L1…L10** to potoczne określenie:
|
|
||||||
> “pierwsze 10 poziomów z L2 najbliżej top of book”.
|
|
||||||
|
|
||||||
To nie jest osobny format; to po prostu wycinek L2.
|
|
||||||
|
|
||||||
W naszym stacku “ile leveli bierzemy” kontroluje:
|
|
||||||
- `DLOB_DEPTH` (np. 10 → “L1…L10”).
|
|
||||||
|
|
||||||
## Jak to działa w naszym stacku (warstwy)
|
|
||||||
|
|
||||||
Poniżej “łańcuch” od źródła do metryk:
|
|
||||||
|
|
||||||
### Warstwa A: On-chain → DLOB w pamięci (VPS/k3s)
|
|
||||||
Komponent: `dlob-publisher`.
|
|
||||||
|
|
||||||
- Łączy się do Solany przez `ENDPOINT` (HTTP RPC) i `WS_ENDPOINT` (WebSocket).
|
|
||||||
- Subskrybuje konta/zdarzenia i buduje DLOB (orderbook) w pamięci.
|
|
||||||
- Publikuje snapshoty do Redis (u nas: `dlob-redis`).
|
|
||||||
|
|
||||||
To jest najbliżej źródła i zwykle najbardziej “real-time”.
|
|
||||||
|
|
||||||
### Warstwa B: Cache + REST API (VPS/k3s)
|
|
||||||
Komponenty: `dlob-redis` + `dlob-server`.
|
|
||||||
|
|
||||||
- `dlob-redis` trzyma snapshoty/publish.
|
|
||||||
- `dlob-server` udostępnia HTTP:
|
|
||||||
- `GET /l2?marketName=SOL-PERP&depth=10` → L2 (bids/asks + best bid/ask itp.)
|
|
||||||
- `GET /l3?...` → L3 (jeśli potrzebujesz)
|
|
||||||
|
|
||||||
To jest warstwa dystrybucji danych “w klastrze”, żeby inne serwisy nie musiały gadać bezpośrednio z Solaną.
|
|
||||||
|
|
||||||
Uwaga o rynkach:
|
|
||||||
- `dlob-publisher` ładuje rynki wg `PERP_MARKETS_TO_LOAD` (indeksy) / `SPOT_MARKETS_TO_LOAD`.
|
|
||||||
- Jeśli rynek nie jest załadowany przez publisher, `dlob-server` nie rozpozna `marketName`.
|
|
||||||
|
|
||||||
### Warstwa C: Metryki w DB/Hasura (VPS/k3s)
|
|
||||||
Komponenty: `dlob-worker`, `dlob-depth-worker`, `dlob-slippage-worker`.
|
|
||||||
|
|
||||||
To są “workery pod UI/AI”, które liczą metryki i zapisują je do Postgresa (Hasura).
|
|
||||||
|
|
||||||
#### `dlob-worker` (collector + basic stats)
|
|
||||||
Wejście:
|
|
||||||
- odpytuje `dlob-server` po HTTP `/l2` (źródło L2),
|
|
||||||
- rynki: `DLOB_MARKETS`,
|
|
||||||
- głębokość (ile leveli): `DLOB_DEPTH`,
|
|
||||||
- częstotliwość: `DLOB_POLL_MS`.
|
|
||||||
|
|
||||||
Wyjście (upsert do DB):
|
|
||||||
- `dlob_l2_latest` = snapshot L2 “latest” per market,
|
|
||||||
- `dlob_stats_latest` = pochodne metryki liczone z top‑N leveli (N=`DLOB_DEPTH`), m.in.:
|
|
||||||
- `mid_price`, `spread_abs`, `spread_bps`,
|
|
||||||
- `depth_bid_*` / `depth_ask_*`,
|
|
||||||
- `imbalance`.
|
|
||||||
|
|
||||||
Czyli: jeśli pytasz “gdzie liczymy L1…L10 metryki” → tutaj (w `dlob-worker`), bo bierze top‑N leveli z L2.
|
|
||||||
|
|
||||||
#### `dlob-depth-worker` (depth w bandach bps)
|
|
||||||
Wejście:
|
|
||||||
- czyta z DB `dlob_l2_latest` (czyli już “przetworzone” L2).
|
|
||||||
|
|
||||||
Wyjście:
|
|
||||||
- `dlob_depth_bps_latest` = płynność w pasmach wokół mid (np. ±5/10/20/50/100/200 bps).
|
|
||||||
|
|
||||||
To nie jest “L1…L10”, tylko “ile płynności mieści się w oknie cenowym” wokół mid.
|
|
||||||
|
|
||||||
#### `dlob-slippage-worker` (slippage vs size)
|
|
||||||
Wejście:
|
|
||||||
- czyta z DB `dlob_l2_latest`.
|
|
||||||
|
|
||||||
Wyjście:
|
|
||||||
- `dlob_slippage_latest` = symulacja wykonania zlecenia (market) po L2 dla progów `DLOB_SLIPPAGE_SIZES_USD`.
|
|
||||||
|
|
||||||
To jest bardzo użyteczne jako feature do strategii (“ile kosztuje wejście/wyjście teraz dla X USD”).
|
|
||||||
|
|
||||||
## Gdzie “pracuje AI” (TFT itp.)
|
|
||||||
|
|
||||||
AI/strategia powinna pracować na warstwie “features”, a nie na surowych subskrypcjach Solany:
|
|
||||||
|
|
||||||
Najczęstszy zestaw wejść dla modelu:
|
|
||||||
- candles/ticki (np. `drift_ticks` + `get_drift_candles(...)`),
|
|
||||||
- bieżące statsy z DLOB:
|
|
||||||
- `dlob_stats_latest` (mid/spread/depth/imbalance),
|
|
||||||
- `dlob_depth_bps_latest` (depth w bandach),
|
|
||||||
- `dlob_slippage_latest` (slippage vs size),
|
|
||||||
- opcjonalnie pełny snapshot L2 (z `dlob_l2_latest`), jeśli model potrzebuje “kształtu” książki.
|
|
||||||
|
|
||||||
Kluczowa zasada bezpieczeństwa:
|
|
||||||
- **Model (np. na Vast)** może sugerować “desired state” (wejść/wyjść/parametry),
|
|
||||||
- **Executor na VPS** zawsze odpowiada za:
|
|
||||||
- risk checks,
|
|
||||||
- składanie/cancel/close,
|
|
||||||
- klucze prywatne i podpisywanie transakcji,
|
|
||||||
- kill switch.
|
|
||||||
|
|
||||||
## Szybki słownik (1-liner)
|
|
||||||
|
|
||||||
- **bid**: kupno, zielona strona książki
|
|
||||||
- **ask**: sprzedaż, czerwona strona książki
|
|
||||||
- **best bid / best ask (L1)**: top-of-book
|
|
||||||
- **spread**: koszt “wejścia/wyjścia natychmiast” (ask-bid)
|
|
||||||
- **mid**: punkt odniesienia między bid/ask
|
|
||||||
- **L2**: lista poziomów `{price,size}`
|
|
||||||
- **L1…L10**: top 10 poziomów z L2 (u nas kontrolowane przez `DLOB_DEPTH`)
|
|
||||||
|
|
||||||
## Jak liczymy “liquidity” i “kasa” (USD) w metrykach
|
|
||||||
|
|
||||||
W UI/DB słowo “liquidity” zwykle oznacza **depth**: “ile wolumenu stoi w orderbooku blisko ceny”.
|
|
||||||
U nas trzymamy to rozdzielnie dla bid/ask oraz w dwóch wariantach:
|
|
||||||
|
|
||||||
### A) Top‑N leveli (np. L1…L10) — `dlob_stats_latest`
|
|
||||||
Liczone w `dlob-worker` na podstawie L2 z `/l2`:
|
|
||||||
|
|
||||||
- Bierzemy pierwsze `N = DLOB_DEPTH` leveli z `bids` i `asks`.
|
|
||||||
- Każdy level ma:
|
|
||||||
- `price = price_int / PRICE_PRECISION`
|
|
||||||
- `size_base = size_int / BASE_PRECISION`
|
|
||||||
- “kasa” (notional) na tym levelu: `size_usd = size_base * price`
|
|
||||||
- Sumujemy po levelach:
|
|
||||||
- `depth_bid_base = Σ size_base` (po stronie bid),
|
|
||||||
- `depth_bid_usd = Σ (size_base * price)` (po stronie bid),
|
|
||||||
- analogicznie `depth_ask_base`, `depth_ask_usd` (po stronie ask).
|
|
||||||
|
|
||||||
To odpowiada intuicji “ile jest płynności na L1…LN”.
|
|
||||||
|
|
||||||
### B) Okno cenowe w bps od mid — `dlob_depth_bps_latest`
|
|
||||||
Liczone w `dlob-depth-worker` na podstawie `dlob_l2_latest`:
|
|
||||||
|
|
||||||
- Dla pasma `band_bps` wyznaczamy:
|
|
||||||
- `minBidPrice = mid * (1 - band_bps/10_000)`
|
|
||||||
- `maxAskPrice = mid * (1 + band_bps/10_000)`
|
|
||||||
- Sumujemy wszystkie levele, które mieszczą się w tym oknie:
|
|
||||||
- bids: `price >= minBidPrice`
|
|
||||||
- asks: `price <= maxAskPrice`
|
|
||||||
- Liczymy sumy:
|
|
||||||
- `bid_base`, `bid_usd`, `ask_base`, `ask_usd` tak jak wyżej (`usd = base * price`).
|
|
||||||
|
|
||||||
To odpowiada intuicji “ile płynności jest *blisko* ceny w ±X bps”.
|
|
||||||
|
|
||||||
### Ważne doprecyzowanie
|
|
||||||
|
|
||||||
Te liczby to **notional z orderbooka** (ile “stoi” na poziomach cenowych).
|
|
||||||
Nie są to “pieniądze w kontrakcie”, tylko przybliżenie kosztu/pojemności wykonania przy danej cenie i bez przesunięcia rynku.
|
|
||||||
|
|
||||||
## Spec: Orderbook UI (L1…L10 + “liquidity bars”)
|
|
||||||
|
|
||||||
Wizualizacja orderbooka (jak na screenach) jest oparta o L2 i pokazuje tylko top‑N leveli:
|
|
||||||
- `N` = liczba leveli wyświetlanych na stronę (np. 10 → “L1…L10”).
|
|
||||||
|
|
||||||
### Kolumny / wartości
|
|
||||||
|
|
||||||
Na każdym levelu liczymy:
|
|
||||||
- `size_usd = size_base * price`
|
|
||||||
|
|
||||||
W UI pokazujemy:
|
|
||||||
- `Size (USD)` = `size_usd` dla danego poziomu,
|
|
||||||
- `Total (USD)` = suma skumulowana od best‑price “w głąb” (cumulative):
|
|
||||||
- bids: kumulacja od best bid w dół,
|
|
||||||
- asks: kumulacja od best ask w górę (w UI zwykle best ask jest bliżej środka).
|
|
||||||
|
|
||||||
### “Liquidity bars” (znormalizowane słupki tła)
|
|
||||||
|
|
||||||
Żeby “na oko” widzieć gdzie stoi płynność:
|
|
||||||
|
|
||||||
1) **Level bar (per‑poziom)** — normalizacja do największego `size_usd` w widocznych levelach danej strony:
|
|
||||||
- `level_scale = size_usd / max(size_usd w widoku)`
|
|
||||||
2) **Total bar (cumulative)** — normalizacja do największego `total_usd` w widocznych levelach danej strony:
|
|
||||||
- `total_scale = total_usd / max(total_usd w widoku)`
|
|
||||||
|
|
||||||
Żeby duże “ściany” nie zabijały kontrastu, warto użyć krzywej:
|
|
||||||
- `scale_curved = sqrt(clamp01(scale))`
|
|
||||||
|
|
||||||
Interpretacja:
|
|
||||||
- **level bar** = “ile stoi na tym poziomie”,
|
|
||||||
- **total bar** = “ile stoi łącznie do tego poziomu”.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Drift Perp: koszty wejścia/edycji/wyjścia (stan na 2026-01-31)
|
|
||||||
|
|
||||||
Ten dokument zbiera **wszystkie realne składowe kosztu** przy handlu perps na Drift, żebyśmy mogli je liczyć na backendzie i wizualizować w UI.
|
|
||||||
|
|
||||||
## 1) Składowe kosztu (per trade / per pozycja)
|
|
||||||
|
|
||||||
### A. Opłata transakcyjna Drift (maker/taker)
|
|
||||||
- **Taker fee**: procent od **notional** (wartości pozycji w USD/USDC).
|
|
||||||
- **Maker fee**: zwykle **ujemny** (rebate) dla zleceń maker (np. post-only), zgodnie z aktualnym cennikiem.
|
|
||||||
- Stawki zależą od wolumenu 30D oraz stakingu DRIFT (dodatkowe zniżki / większe rebate).
|
|
||||||
- W **High Leverage Mode** taker fee może być podbite (np. 2× najniższy tier).
|
|
||||||
> TODO: potwierdzić aktualne stawki fee (z Drift SDK / on-chain) i zapisać je jako “source of truth” dla backendu.
|
|
||||||
|
|
||||||
**Wzór (pojedynczy fill):**
|
|
||||||
- `notional = |size_base| * fill_price`
|
|
||||||
- `trade_fee_usd = notional * fee_rate` (dla maker `fee_rate` może być < 0)
|
|
||||||
|
|
||||||
### B. Slippage / spread (koszt rynkowy)
|
|
||||||
To nie jest fee protokołu, ale realny koszt wejścia/wyjścia:
|
|
||||||
- `slippage_cost_usd ≈ (fill_price - mid_price) * size_base` (znak zależy od long/short)
|
|
||||||
- U nas to powinno być liczone z DLOB (L2 + symulacja fill).
|
|
||||||
|
|
||||||
### C. Funding (koszt/zarobek w czasie trzymania pozycji)
|
|
||||||
- Funding jest naliczany w czasie i realizowany przy akcjach użytkownika (trade/deposit/withdraw) – w praktyce dla krótkich holdingów (minuty–1h) zwykle jest małym składnikiem, ale nie zawsze zerowym.
|
|
||||||
|
|
||||||
**Wzór (upraszczając):**
|
|
||||||
- `funding_usd ≈ Σ (position_notional_usd * funding_rate_interval)`
|
|
||||||
|
|
||||||
### D. P&L settlement / “unsettled P&L” (wpływ na withdraw)
|
|
||||||
- Żeby **wypłacić zysk**, czasem trzeba wykonać `settlePNL` (rozlicza P&L do P&L Pool; nie zamyka pozycji, tylko zmienia cost basis).
|
|
||||||
- Jeśli brakuje środków w per-market P&L Pool, zysk może być częściowo **unsettled** i nie będzie w pełni wypłacalny od razu.
|
|
||||||
|
|
||||||
### E. Liquidation penalty (jeśli konto spadnie poniżej maintenance)
|
|
||||||
- Przy wejściu w liquidację protokół najpierw anuluje otwarte ordery/LP, a następnie liquidator może redukować pozycje.
|
|
||||||
- “Penalty/fee” jest ustawiana per-market i zwykle jest wyższa niż zwykły taker fee (żeby dać rebate liquidatorowi).
|
|
||||||
|
|
||||||
### F. Koszt sieci Solana (per instrukcja / per tx)
|
|
||||||
To koszt “infrastrukturalny” każdej akcji on-chain (order, cancel, modify, settlePNL, deposit/withdraw, close).
|
|
||||||
- **Base fee**: 5000 lamports per signature (minimum).
|
|
||||||
- **Priority fee**: opcjonalny, zależy od congestion.
|
|
||||||
- Jednorazowo może dojść **rent/account creation** (np. token account), jeśli czegoś brakuje.
|
|
||||||
|
|
||||||
## 2) “Ile kosztuje” konkretna akcja (checklista)
|
|
||||||
|
|
||||||
### Wejście w pozycję (open / increase)
|
|
||||||
1) **Solana tx fee** (base + ewentualnie priority)
|
|
||||||
2) **Drift trading fee** (maker/taker) od notional
|
|
||||||
3) **Slippage/spread** (z DLOB)
|
|
||||||
4) (w tle) funding zaczyna naliczać się w czasie
|
|
||||||
|
|
||||||
### Zmiana pozycji (increase/decrease/flip)
|
|
||||||
To po prostu kolejny trade:
|
|
||||||
- znowu `tx fee + trading fee + slippage`
|
|
||||||
- oraz często realizacja funding (zależy od tego czy funding został zaktualizowany)
|
|
||||||
|
|
||||||
### Wyjście z pozycji (close)
|
|
||||||
1) `tx fee`
|
|
||||||
2) `trading fee` (druga strona round-trip)
|
|
||||||
3) `slippage`
|
|
||||||
4) **realized PnL** = różnica cen ± funding − fees
|
|
||||||
5) jeśli chcesz wypłacić: możliwe `settlePNL` oraz limit z P&L pool
|
|
||||||
|
|
||||||
### Edycja zlecenia (modify)
|
|
||||||
Zwykle koszt to:
|
|
||||||
- `tx fee` (czasem modify = cancel+place, zależnie od ścieżki w kliencie)
|
|
||||||
- brak trading fee, jeśli nie było fill
|
|
||||||
|
|
||||||
### Cancel zlecenia
|
|
||||||
- `tx fee`
|
|
||||||
- brak trading fee (jeśli 0 fill)
|
|
||||||
|
|
||||||
### Monitorowanie zysku / risk (PnL, margin, health)
|
|
||||||
On-chain: bez kosztu, jeśli tylko czytasz RPC/indexera.
|
|
||||||
Koszt pojawia się dopiero przy akcjach typu trade/cancel/settle/withdraw.
|
|
||||||
|
|
||||||
## 3) Przykład liczbowy (taker, round-trip)
|
|
||||||
|
|
||||||
Załóż:
|
|
||||||
- `notional = 10,000 USDC`
|
|
||||||
- `taker_fee_rate = 0.0350%` (PRZYKŁAD – realna stawka zależy od tieru)
|
|
||||||
|
|
||||||
Wtedy:
|
|
||||||
- wejście: `10,000 * 0.00035 = 3.50 USDC`
|
|
||||||
- wyjście: `3.50 USDC`
|
|
||||||
- razem fee (bez slippage/funding): `7.00 USDC` + 2× Solana tx fee (+ priority jeśli ustawisz).
|
|
||||||
|
|
||||||
## 4) Co musimy znać, żeby liczyć to “dokładnie” w backendzie
|
|
||||||
|
|
||||||
Minimalny zestaw wejść:
|
|
||||||
- market (np. `SOL-PERP`)
|
|
||||||
- order type (market/limit/post-only), przewidywany fill path (taker vs maker)
|
|
||||||
- notional/size, przewidywany fill (DLOB simulation)
|
|
||||||
- fee tier użytkownika + staking/discounty + ew. “fee adjusted markets”
|
|
||||||
- funding history + horyzont (np. 1h/4h/24h/7d)
|
|
||||||
- czy chcemy uwzględniać `settlePNL` oraz status “unsettled PnL” przed withdraw
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Słownik (kluczowe pojęcia w UI/API)
|
|
||||||
|
|
||||||
Poniżej jest skrót pojęć, których używamy w warstwach “Costs (New)” i “Costs (Active)”:
|
|
||||||
|
|
||||||
- `notional` — wartość pozycji w USD (np. 10 USD); na tym liczymy bps i fee.
|
|
||||||
- `bps` (basis points) — punkty bazowe: `1 bps = 0.01% = 0.0001`.
|
|
||||||
Przeliczenie na koszt: `koszt_usd ≈ notional_usd * bps / 10_000`.
|
|
||||||
- `fee` — opłata protokołu Drift (maker/taker) od `notional`; zwykle stała dla danego trybu/tieru.
|
|
||||||
- `tx fee` — koszt transakcji na Solanie (base fee + ewentualny priority fee).
|
|
||||||
- `slippage` — koszt rynkowy wejścia/wyjścia, bo wykonujesz się gorzej niż `mid` (zależy od płynności).
|
|
||||||
- `impact (bps)` — slippage wyrażony w bps (dla danego notionalu).
|
|
||||||
- `spread` — różnica `best_ask - best_bid`; “minimalny” koszt natychmiastowego wejścia/wyjścia w płytkim booku.
|
|
||||||
- `mid` — `(best_bid + best_ask) / 2`; punkt odniesienia ceny z orderbooka.
|
|
||||||
- `VWAP` — średnia cena wykonania dla danego rozmiaru (symulacja fill po L2).
|
|
||||||
- `breakeven (bps)` — minimalny ruch ceny (w bps), żeby koszty się zwróciły (wyjść na 0).
|
|
||||||
- `PnL` (profit and loss) — zysk/strata:
|
|
||||||
- `unrealized PnL` — “na papierze”, gdy pozycja jest otwarta (zależy od ceny teraz),
|
|
||||||
- `realized PnL` — zrealizowany po zamknięciu (lub częściowym zamknięciu) pozycji,
|
|
||||||
- `net PnL` — PnL po odjęciu kosztów (`fee + tx + slippage + funding`).
|
|
||||||
- `funding` — okresowa płatność long↔short; koszt albo zysk zależny od rynku i czasu trzymania.
|
|
||||||
- `close now` — estymata kosztu natychmiastowego zamknięcia pozycji (zwykle po przeciwnej stronie booka).
|
|
||||||
- `modify` / `reprice` — koszt “zarządzania zleceniem” (cancel+place itp.), głównie `tx fee` (czasem wielokrotnie).
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# Drift / Solana: czy mamy dostęp do danych bez Solana RPC?
|
|
||||||
|
|
||||||
Pytanie ma dwa znaczenia — rozdzielmy je jasno:
|
|
||||||
|
|
||||||
1) **bez własnego (bare metal) RPC** — czyli nie utrzymujemy swojego `solana-validator --rpc`, ale korzystamy z dostawcy RPC albo zewnętrznych serwisów,
|
|
||||||
2) **bez żadnego RPC w ogóle** — czyli nikt w naszym systemie nie pyta Solany o stan on‑chain.
|
|
||||||
|
|
||||||
TL;DR:
|
|
||||||
- **Bez własnego RPC**: tak, da się na start (hosted RPC +/lub serwisy zewnętrzne).
|
|
||||||
- **Bez żadnego RPC**: tylko częściowo (dane “rynkowe” można brać z zewnętrznego DLOB), ale **stan konta/pozycji/fille/funding** i tak pochodzi z chaina, więc ktoś musi mieć RPC.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Co z Twojego “speca” da się mieć bez własnego RPC?
|
|
||||||
|
|
||||||
Poniżej mapowanie kategorii danych (z Twojego opisu A–F) na źródła:
|
|
||||||
|
|
||||||
### B) Prices / microstructure (oracle/mark/BBO, “close now”)
|
|
||||||
|
|
||||||
**Da się bez własnego RPC**:
|
|
||||||
- Tak. W naszym stacku te dane mogą pochodzić z pipeline DLOB (`dlob_*_latest`) i ticków (`drift_ticks`), które są już w DB i dostępne przez Hasurę / `trade-api`.
|
|
||||||
|
|
||||||
**Da się bez żadnego RPC w naszej infra** (czyli “my nie łączymy się do RPC”):
|
|
||||||
- Częściowo tak, jeśli polegamy na zewnętrznym źródle L2/BBO (np. `https://dlob.drift.trade`) — ale to źródło i tak jest zasilane przez czyjeś RPC.
|
|
||||||
|
|
||||||
### A) Position snapshot (pozycja: base, entry, side)
|
|
||||||
|
|
||||||
**Bez własnego RPC**:
|
|
||||||
- Tak, jeśli mamy **jakikolwiek** komponent (executor/collector) korzystający z hosted RPC (Helius/QuickNode/itp.) i zapisujący snapshot pozycji do DB.
|
|
||||||
|
|
||||||
**Bez żadnego RPC**:
|
|
||||||
- Praktycznie nie (pozycja jest stanem konta on‑chain). Wyjątek: jeśli Twój executor/bot sam utrzymuje lokalny stan i zapisuje go do DB — ale po restarcie i tak potrzebujesz reconcile z chaina (czyli RPC).
|
|
||||||
|
|
||||||
### C) Account risk (margin/liquidation/health)
|
|
||||||
|
|
||||||
**Bez własnego RPC**:
|
|
||||||
- Tak, jeśli collector liczy to na backendzie z danych Drift (przez hosted RPC) i zapisuje do TS (`contract_metrics_ts` / analogicznie).
|
|
||||||
|
|
||||||
**Bez żadnego RPC**:
|
|
||||||
- Nie, bo margin/liq zależy od stanu konta i parametrów rynku on‑chain.
|
|
||||||
|
|
||||||
### D) Fills / trades (realized PnL + fees + slippage)
|
|
||||||
|
|
||||||
**Bez własnego RPC**:
|
|
||||||
- Tak, jeśli:
|
|
||||||
- executor składa zlecenia i loguje fille do `bot_events` (to już mamy jako koncept), albo
|
|
||||||
- collector subskrybuje eventy transakcji / kont przez hosted RPC i zapisuje fille do DB.
|
|
||||||
|
|
||||||
**Bez żadnego RPC**:
|
|
||||||
- Tylko jeśli fille są już zapisane w DB (np. przez bota). Na bieżąco — ktoś musi je wyciągać z chaina.
|
|
||||||
|
|
||||||
### E) Funding / payments
|
|
||||||
|
|
||||||
**Bez własnego RPC**:
|
|
||||||
- Tak, ale ktoś musi pobierać funding rate / funding payment (hosted RPC lub inny feed) i zapisywać do DB.
|
|
||||||
|
|
||||||
**Bez żadnego RPC**:
|
|
||||||
- Jak wyżej: tylko z historii zapisanej w DB; na żywo potrzebujesz źródła z chaina.
|
|
||||||
|
|
||||||
### F) Order lifecycle costs (cancel/replace/tx)
|
|
||||||
|
|
||||||
**Bez własnego RPC**:
|
|
||||||
- Tak, jeśli executor:
|
|
||||||
- loguje akcje (create/cancel/replace) i ich koszty (`tx_fee_usd`, priority fee) do `bot_events`, albo
|
|
||||||
- collector wyciąga metryki tx z RPC i mapuje do orderów.
|
|
||||||
|
|
||||||
**Bez żadnego RPC**:
|
|
||||||
- Tylko retrospektywnie (jeśli już w DB).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Co to znaczy praktycznie dla architektury “backend liczy, UI tylko wyświetla”?
|
|
||||||
|
|
||||||
UI/Visualizer **może działać bez bezpośredniego kontaktu z RPC** (łączy się do `trade-api` + Hasura).
|
|
||||||
|
|
||||||
Natomiast backend “compute” (k3s) ma dwie opcje zasilania:
|
|
||||||
|
|
||||||
1) **Hosted RPC** (na start)
|
|
||||||
- pro: szybciej, taniej, mniej ops,
|
|
||||||
- con: limit subskrypcji/WS, możliwe rwania, vendor lock‑in.
|
|
||||||
|
|
||||||
2) **Własny RPC + Geyser/Yellowstone** (docelowo)
|
|
||||||
- pro: kontrola, stabilność na większej skali, streaming “pro”,
|
|
||||||
- con: koszt i ops (dyski/IO, tuning, monitoring).
|
|
||||||
|
|
||||||
W obu przypadkach “backend liczy” działa tak samo — różni się tylko źródło surowych danych.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Co już mamy w DB “bez RPC w UI”
|
|
||||||
|
|
||||||
Z obecnego pipeline (VPS/k3s) mamy “rynkowe” dane pod BBO/slippage:
|
|
||||||
- `dlob_l2_latest`, `dlob_stats_latest`, `dlob_slippage_latest`, `dlob_depth_bps_latest` (+ TS przez `*_ts`)
|
|
||||||
- `drift_ticks` (ticki/ceny)
|
|
||||||
|
|
||||||
To wystarcza do:
|
|
||||||
- estymat wejścia/wyjścia (“close now”),
|
|
||||||
- wykresów spread/slippage/depth,
|
|
||||||
- części SIM (model slippage/fee) **bez** znajomości pełnego stanu konta.
|
|
||||||
|
|
||||||
Do pełnego `contract_metrics_ts` (PnL + risk) brakuje nam jeszcze stałego feedu:
|
|
||||||
- pozycji konta + margin/liq,
|
|
||||||
- filli i funding (albo z chaina, albo z logów executora).
|
|
||||||
|
|
||||||
## Zobacz też
|
|
||||||
|
|
||||||
- “Kanoniczna” architektura w pełni self-hosted (RPC + DLOB): `doc/rpc-dlob-kanoniczna-architektura.md`
|
|
||||||
- Runbook: bare metal RPC + Geyser/Yellowstone gRPC: `doc/solana-rpc-geyser-setup.md`
|
|
||||||
- Mapa dokumentów o RPC/DLOB/metrykach: `doc/solana-rpc.md`
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
# Drift PERP “kontrakt bota” (SOL-PERP) — spec intent → egzekucja → audyt
|
|
||||||
|
|
||||||
Ten dokument definiuje **przyszłościowy** kontrakt między:
|
|
||||||
- **Vast (model/transformer na GPU)**: generuje *trade intent* (bez sekretów),
|
|
||||||
- **k3s/VPS (executor)**: waliduje ryzyko, wystawia i prowadzi zlecenia na Drift, loguje zdarzenia,
|
|
||||||
- **UI (visualizer)**: tylko wizualizuje warstwy i stan kontraktów (live + historia).
|
|
||||||
|
|
||||||
Kluczowa zasada: **model nigdy nie ma kluczy** i nie “handluje”. Handluje tylko executor w k3s.
|
|
||||||
|
|
||||||
Powiązane:
|
|
||||||
- Strategia “eskalacja horyzontu” (1m→5m→15m→30m→1h z bramkami): `doc/strategy-eskalacja-horyzontu.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Co nazywamy “kontraktem” (u nas vs Drift)
|
|
||||||
|
|
||||||
Na Drift istnieją:
|
|
||||||
- **orders** (zlecenia): limit/market/trigger, post-only, reduce-only, IOC/GTC itd.
|
|
||||||
- **position** (pozycja): rozmiar, kierunek, średnia cena, PnL itd.
|
|
||||||
- **konto/margin**: collateral i health.
|
|
||||||
|
|
||||||
W naszym systemie **kontrakt bota** to byt aplikacyjny (DB + logika), który:
|
|
||||||
1) opisuje *intent* (wejście + prowadzenie + wyjście),
|
|
||||||
2) mapuje intent na 1..N orderów na Drift,
|
|
||||||
3) jest **idempotentny** (nie dubluje orderów po restarcie),
|
|
||||||
4) jest **modyfikowalny** (cancel+place / zmiana desired state),
|
|
||||||
5) jest **kończony** (exit policy lub kill-switch),
|
|
||||||
6) jest **audytowalny** (pełny log decyzji i akcji).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Wybór “lepszy i przyszłościowy”
|
|
||||||
|
|
||||||
### A) Cena jako offset, nie absolutna cena (recommended)
|
|
||||||
|
|
||||||
Model zwraca cenę wejścia/wyjścia jako **offset** (ticks/bps) względem top-of-book / mid, a nie jako `limit_price`.
|
|
||||||
|
|
||||||
Dlaczego:
|
|
||||||
- odporniejsze na latency (cena się przesuwa, offset pozostaje sensowny),
|
|
||||||
- łatwiejsze “reprice” (edit policy jest naturalna),
|
|
||||||
- mniejsze ryzyko “starej ceny” przy krótkim TTL.
|
|
||||||
|
|
||||||
Executor i tak zna:
|
|
||||||
- `best_bid/best_ask/mid`,
|
|
||||||
- tick size i step size,
|
|
||||||
- aktualne gates (spread/slippage/depth/freshness).
|
|
||||||
|
|
||||||
### B) Desired-state jako rdzeń (recommended)
|
|
||||||
|
|
||||||
Kontrakt jest prowadzony jako **desired-state loop**:
|
|
||||||
- model/kontrakt mówi “co chcę mieć” (np. `target_exposure_usd`),
|
|
||||||
- executor porównuje “observed vs desired” i wykonuje minimalne akcje.
|
|
||||||
|
|
||||||
To upraszcza:
|
|
||||||
- edycję (zmiana target, update policy),
|
|
||||||
- reconcile po restarcie,
|
|
||||||
- panic exit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Role: Vast vs executor (k3s)
|
|
||||||
|
|
||||||
### Vast (model) zwraca
|
|
||||||
- kompletny **trade_intent**: parametry wejścia/prowadzenia/wyjścia,
|
|
||||||
- sugestie gates (np. spread/slippage/depth), **ale** nie może ich omijać,
|
|
||||||
- `confidence/urgency` (metadata).
|
|
||||||
|
|
||||||
### Executor (k3s) jest “single source of execution”
|
|
||||||
- waliduje gates i limity,
|
|
||||||
- normalizuje do tick/step,
|
|
||||||
- nadaje idempotentne `client_order_id`,
|
|
||||||
- składa/canceluje/zamyka (reduce-only),
|
|
||||||
- prowadzi state machine,
|
|
||||||
- loguje eventy i mierzy koszty.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Spec: `trade_intent` (Vast → k3s)
|
|
||||||
|
|
||||||
Format jest wersjonowany: `intent_schema_version`.
|
|
||||||
|
|
||||||
### 4.1 Minimalny szkielet
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"intent_schema_version": 1,
|
|
||||||
"decision_id": "ulid-or-uuid",
|
|
||||||
"bot_id": "bot-sol-perp-01",
|
|
||||||
"ts": "2026-01-31T00:00:00.000Z",
|
|
||||||
"ttl_ms": 15000,
|
|
||||||
|
|
||||||
"market_name": "SOL-PERP",
|
|
||||||
"subaccount_id": 0,
|
|
||||||
|
|
||||||
"mode": "enter|manage|exit|panic",
|
|
||||||
"confidence": 0.0,
|
|
||||||
"urgency": 0.0,
|
|
||||||
|
|
||||||
"desired": {
|
|
||||||
"target_exposure_usd": 0,
|
|
||||||
"max_position_usd": 200,
|
|
||||||
"min_trade_usd": 5
|
|
||||||
},
|
|
||||||
|
|
||||||
"entry": {
|
|
||||||
"side": "long|short",
|
|
||||||
"order_type": "post_only_limit|limit|market",
|
|
||||||
"size_usd": 25,
|
|
||||||
"limit_offset": { "ref": "best_bid|best_ask|mid", "ticks": 1 },
|
|
||||||
"time_in_force": "GTC|IOC",
|
|
||||||
"cancel_if_not_filled_ms": 8000
|
|
||||||
},
|
|
||||||
|
|
||||||
"manage": {
|
|
||||||
"reprice_after_ms": 750,
|
|
||||||
"reprice_offset_ticks": 1,
|
|
||||||
"max_reprices_per_min": 30,
|
|
||||||
"cooldown_ms": 250
|
|
||||||
},
|
|
||||||
|
|
||||||
"exit": {
|
|
||||||
"max_hold_s": 180,
|
|
||||||
"stop_loss_bps": 25,
|
|
||||||
"take_profit_bps": 35,
|
|
||||||
"exit_order_type": "reduce_only_limit|reduce_only_market",
|
|
||||||
"exit_limit_offset": { "ref": "best_bid|best_ask|mid", "ticks": 1 }
|
|
||||||
},
|
|
||||||
|
|
||||||
"gates": {
|
|
||||||
"freshness_max_ms": 1500,
|
|
||||||
"max_spread_bps": 10,
|
|
||||||
"max_slippage_bps": 25,
|
|
||||||
"min_depth_topn_usd": 5000,
|
|
||||||
"min_depth_band": { "band_bps": 20, "min_usd": 8000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Zasady interpretacji
|
|
||||||
|
|
||||||
- `ttl_ms`: po tym czasie executor ma prawo *zignorować* intent (stary sygnał).
|
|
||||||
- `mode`:
|
|
||||||
- `enter`: wolno otwierać/rozszerzać pozycję,
|
|
||||||
- `manage`: tylko zarządzanie już istniejącą pozycją/ordreami (bez zwiększania ryzyka),
|
|
||||||
- `exit`: przejście do `target_exposure_usd=0` i zamykanie,
|
|
||||||
- `panic`: natychmiast cancel + close (reduce-only), potem `off`.
|
|
||||||
- `desired.target_exposure_usd` jest źródłem prawdy, ale executor ma **hard cap** `max_position_usd` (model może sugerować, executor egzekwuje).
|
|
||||||
- `limit_offset`:
|
|
||||||
- `ref=best_bid` dla wejścia long (maker),
|
|
||||||
- `ref=best_ask` dla wejścia short,
|
|
||||||
- ticks/bps są zaokrąglane do tick size rynku.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Mapowanie `trade_intent` → Drift order params (PERP)
|
|
||||||
|
|
||||||
Executor buduje “order template” (pseudopola):
|
|
||||||
|
|
||||||
- `marketIndex` (wynik mapowania `market_name`)
|
|
||||||
- `direction`: `long|short`
|
|
||||||
- `orderType`: `market|limit` (+ `trigger*` jeśli później dodamy SL/TP jako ordery trigger)
|
|
||||||
- `baseAssetAmount` (z `size_usd` przeliczone do base i zaokrąglone do `baseStepSize`)
|
|
||||||
- `price` (dla limit): z `limit_offset` + aktualne top-of-book, zaokrąglone do `priceTickSize`
|
|
||||||
- `postOnly`: true, jeśli `order_type=post_only_limit`
|
|
||||||
- `reduceOnly`: true na wyjściu (`exit_order_type=reduce_only_*`)
|
|
||||||
- `immediateOrCancel`: true, jeśli `time_in_force=IOC`
|
|
||||||
- `clientOrderId` / `userOrderId`: deterministycznie z `decision_id` (patrz niżej)
|
|
||||||
|
|
||||||
### 5.1 Idempotencja: `client_order_id`
|
|
||||||
|
|
||||||
Wymaganie: po restarcie executora nie może dojść do “double order”.
|
|
||||||
|
|
||||||
Zasada:
|
|
||||||
- każde wejście/wyjście ma stabilne `client_order_id` wywiedzione z `decision_id`,
|
|
||||||
- jeśli Drift nie wspiera pełnego “modify”, executor robi `cancel + place` ale zachowuje spójne ID (np. `decision_id` + suffix `-r1`, `-r2` dla reprices).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) State machine kontraktu (minimal)
|
|
||||||
|
|
||||||
Rekomendowane stany:
|
|
||||||
- `off` (nie handluje)
|
|
||||||
- `pending_entry` (intent zaakceptowany, order wysłany)
|
|
||||||
- `entered` (pozycja ≠ 0 lub entry fill)
|
|
||||||
- `managing` (utrzymuje desired state / repricing)
|
|
||||||
- `exiting` (reduce-only close w toku)
|
|
||||||
- `closed` (pozycja 0, brak orderów)
|
|
||||||
- `rejected` (gates fail / TTL expired)
|
|
||||||
- `panic` (cancel+close; potem `off`)
|
|
||||||
|
|
||||||
Każda zmiana stanu = event do DB.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Co UI wizualizuje (warstwy) a co liczy backend
|
|
||||||
|
|
||||||
UI nie liczy. UI:
|
|
||||||
- subskrybuje `*_latest` (live),
|
|
||||||
- pobiera `*_ts` (historia),
|
|
||||||
- renderuje warstwy i kontrakty.
|
|
||||||
|
|
||||||
Backend liczy:
|
|
||||||
- DLOB: `dlob_*_latest` + (docelowo) `dlob_*_ts`
|
|
||||||
- ticks/candles: `drift_ticks` + `get_drift_candles(...)`
|
|
||||||
- kontrakty: `bot_intents` / `bot_contracts` / `bot_events` (+ TS wersje)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Metryki do strojenia (co mierzyć i jakie okna czasowe)
|
|
||||||
|
|
||||||
Cel: stroić gates, politykę repricing i parametry exit.
|
|
||||||
|
|
||||||
### 8.1 Mikrostruktura (gates) — z czego stroimy progi
|
|
||||||
Źródła: `dlob_stats_*`, `dlob_depth_*`, `dlob_slippage_*`.
|
|
||||||
|
|
||||||
Mierz (per market, live + TS):
|
|
||||||
- `spread_bps`
|
|
||||||
- `impact_bps` dla docelowych `size_usd` (buy/sell)
|
|
||||||
- `depth_bid_usd`, `depth_ask_usd` (top‑N)
|
|
||||||
- depth w bandach (`band_bps`): `bid_usd/ask_usd`
|
|
||||||
- `freshness_ms` (now - updated_at)
|
|
||||||
|
|
||||||
Okno strojenia:
|
|
||||||
- start: **7 dni** TS (wystarczy do percentyli i pór doby),
|
|
||||||
- docelowo: 30+ dni (downsample 1m/5m) dla stabilniejszych reżimów.
|
|
||||||
|
|
||||||
Jak stroić:
|
|
||||||
- progi na percentylach (P90/P95), nie na średniej,
|
|
||||||
- osobne percentyle per “godzina doby” (płynność) i per “vol regime”.
|
|
||||||
|
|
||||||
### 8.2 Jakość egzekucji (czy entry/manage działa)
|
|
||||||
Źródła: `bot_events` (audyt) + snapshoty z momentu decyzji.
|
|
||||||
|
|
||||||
Mierz per kontrakt:
|
|
||||||
- `time_to_first_fill_ms`, `time_to_full_fill_ms`
|
|
||||||
- `fill_pct`, `avg_fill_price`
|
|
||||||
- `reprice_count`, `cancel_count`
|
|
||||||
- `expected_execution_bps` (z DLOB w chwili decyzji) vs `realized_execution_bps`
|
|
||||||
- “churn cost”: `tx_count` i (jeśli liczymy) `priority_fee` sumarycznie
|
|
||||||
|
|
||||||
Okno strojenia:
|
|
||||||
- 24h (szybki smoke po deployu),
|
|
||||||
- 7 dni (tuning),
|
|
||||||
- 30 dni (stabilizacja).
|
|
||||||
|
|
||||||
### 8.3 Wynik i ryzyko (czy strategia ma edge)
|
|
||||||
Mierz:
|
|
||||||
- `hold_time_s`
|
|
||||||
- reason exit: `tp|sl|time|regime|panic`
|
|
||||||
- MAE/MFE w bps w trakcie hold
|
|
||||||
- PnL (jeśli macie komplet danych) albo proxy w bps
|
|
||||||
|
|
||||||
Okno:
|
|
||||||
- 7 dni minimalnie, ale sensowniej 30+ dni (reżimy).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9) Następny krok implementacyjny (po akceptacji)
|
|
||||||
|
|
||||||
1) Dodać tabele TS dla warstw (min. 7 dni retencji).
|
|
||||||
2) Dodać tabele i logi kontraktów (`bot_contracts`, `bot_events`, opcjonalnie `bot_intents_ts`).
|
|
||||||
3) Dodać “executor” (observe → dry-run → live) oraz integrację Vast.
|
|
||||||
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).
|
||||||
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# k3s runtime map (VPS `qstack`) — co działa i po co
|
|
||||||
|
|
||||||
Ten dokument opisuje **aktualny runtime na VPS** (k3s) dla projektu `trade`: jakie komponenty działają w klastrze, jak płynie dane oraz które tabele/metrki są “źródłem prawdy” dla UI.
|
|
||||||
|
|
||||||
Zakładamy namespace: `trade-staging`.
|
|
||||||
|
|
||||||
## TL;DR (logika)
|
|
||||||
|
|
||||||
- **Dane są zbierane i liczone na backendzie** (k3s).
|
|
||||||
- UI (`trade-frontend`) **tylko wizualizuje** i proxy’uje ruch (API + GraphQL + WS).
|
|
||||||
- Hasura to **jedyny GraphQL/WS** na “metrics/live” (subscriptions).
|
|
||||||
- Postgres/Timescale trzyma:
|
|
||||||
- ticki (`drift_ticks`) + candles (funkcja `get_drift_candles`)
|
|
||||||
- “latest” warstw DLOB (`dlob_*_latest`)
|
|
||||||
- (opcjonalnie) historię warstw (`dlob_*_ts`)
|
|
||||||
|
|
||||||
## Mapa (ruch z zewnątrz)
|
|
||||||
|
|
||||||
```
|
|
||||||
Internet
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Ingress/Traefik
|
|
||||||
|
|
|
||||||
+--> trade-frontend (https://trade.mpabi.pl)
|
|
||||||
| - /api -> trade-api
|
|
||||||
| - /graphql -> hasura
|
|
||||||
| - /graphql-ws -> hasura (WS subscriptions; protokół graphql-ws)
|
|
||||||
|
|
|
||||||
+--> (opcjonalnie inne ingressy w tym samym klastrze)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `trade-frontend` (UI + reverse-proxy)
|
|
||||||
|
|
||||||
- **Rola:** UI + proxy do usług w klastrze.
|
|
||||||
- **Dlaczego:** przeglądarka nie dostaje sekretów; token read jest wstrzykiwany server‑side, a WS działa przez proxy.
|
|
||||||
- **Wejście:** HTTP/WS od użytkownika.
|
|
||||||
- **Wyjście:**
|
|
||||||
- `/api/*` → `trade-api`
|
|
||||||
- `/graphql` (HTTP) → `hasura`
|
|
||||||
- `/graphql-ws` (WS) → `hasura`
|
|
||||||
|
|
||||||
## Mapa (dane rynkowe — ticki / candles)
|
|
||||||
|
|
||||||
```
|
|
||||||
Solana RPC/WS (zewn.)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
trade-ingestor
|
|
||||||
|
|
|
||||||
v
|
|
||||||
trade-api -> Postgres/Timescale (drift_ticks)
|
|
||||||
|
|
|
||||||
+--> /v1/chart (candles + wskaźniki liczone na backendzie)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Hasura (GraphQL query/subscriptions dla wybranych tabel)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `trade-ingestor`
|
|
||||||
|
|
||||||
- **Rola:** pobiera dane (oracle/mark) i wysyła ticki do API.
|
|
||||||
- **Wyjście:** ticki zapisane do `drift_ticks` przez `trade-api`.
|
|
||||||
|
|
||||||
### `trade-api`
|
|
||||||
|
|
||||||
- **Rola:** API dla UI i algów (healthz, ticks, chart).
|
|
||||||
- **DB:** zapis do `drift_ticks`.
|
|
||||||
- **Agregacje:** candles (`get_drift_candles`) + wskaźniki (backend).
|
|
||||||
|
|
||||||
## Mapa (DLOB — orderbook + metryki warstw)
|
|
||||||
|
|
||||||
```
|
|
||||||
Solana RPC/WS (zewn.)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
dlob-publisher ----> dlob-redis <---- dlob-server (/l2, /l3)
|
|
||||||
\
|
|
||||||
\ (opcjonalne źródło L2)
|
|
||||||
v
|
|
||||||
dlob-worker (kolektor L2)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Postgres/Hasura: dlob_l2_latest + dlob_stats_latest
|
|
||||||
|
|
|
||||||
+------------+------------+
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
dlob-depth-worker dlob-slippage-worker
|
|
||||||
(bands ±bps) (impact vs size USD)
|
|
||||||
| |
|
|
||||||
v v
|
|
||||||
Postgres/Hasura: dlob_depth_bps_latest Postgres/Hasura: dlob_slippage_latest
|
|
||||||
|
|
|
||||||
v
|
|
||||||
(opcjonalnie) dlob-ts-archiver
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Postgres/Timescale: dlob_stats_ts / dlob_depth_bps_ts / dlob_slippage_ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### `dlob-publisher`
|
|
||||||
|
|
||||||
- **Rola:** utrzymuje “żywy” DLOB (on‑chain) i publikuje snapshoty do Redis.
|
|
||||||
- **Wejście:** Solana RPC/WS.
|
|
||||||
- **Wyjście:** publikacja do `dlob-redis`.
|
|
||||||
|
|
||||||
### `dlob-redis`
|
|
||||||
|
|
||||||
- **Rola:** cache/pubsub pomiędzy publisherem i serwerem HTTP.
|
|
||||||
|
|
||||||
### `dlob-server`
|
|
||||||
|
|
||||||
- **Rola:** serwuje REST `/l2` i `/l3` na podstawie cache Redis (do debugowania i/lub jako źródło L2).
|
|
||||||
|
|
||||||
### `dlob-worker` (kolektor L2 + “basic stats”)
|
|
||||||
|
|
||||||
- **Rola:** pobiera snapshoty L2 i liczy podstawowe metryki (`dlob_stats_latest`).
|
|
||||||
- **Źródło L2:** w praktyce:
|
|
||||||
- albo zewnętrzne `https://dlob.drift.trade/l2`
|
|
||||||
- albo wewnętrzne `http://dlob-server:6969/l2` (jeśli tak ustawione)
|
|
||||||
- **Zapis do tabel:**
|
|
||||||
- `dlob_l2_latest` (raw L2)
|
|
||||||
- `dlob_stats_latest` (bid/ask/mid/spread/depth/imbalance)
|
|
||||||
|
|
||||||
### `dlob-depth-worker` (depth bands ±bps)
|
|
||||||
|
|
||||||
- **Rola:** liczy płynność w pasmach ±bps wokół mid.
|
|
||||||
- **Źródło:** `dlob_l2_latest`
|
|
||||||
- **Zapis:** `dlob_depth_bps_latest` (klucz: `market_name + band_bps`)
|
|
||||||
|
|
||||||
### `dlob-slippage-worker` (slippage vs size)
|
|
||||||
|
|
||||||
- **Rola:** symuluje market fill po L2 dla zadanych rozmiarów (USD) i liczy `impact_bps`.
|
|
||||||
- **Źródło:** `dlob_l2_latest`
|
|
||||||
- **Zapis:** `dlob_slippage_latest` (klucz: `market_name + side + size_usd`)
|
|
||||||
|
|
||||||
### `dlob-ts-archiver` (historia warstw)
|
|
||||||
|
|
||||||
- **Rola:** zapisuje “timeline” dla warstw do hypertabli Timescale (historia pod UI).
|
|
||||||
- **Źródło:** `dlob_stats_latest`, `dlob_depth_bps_latest`, `dlob_slippage_latest`
|
|
||||||
- **Zapis:** `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`
|
|
||||||
- **Retencja (startowo):** ~7 dni (policy w Timescale).
|
|
||||||
|
|
||||||
## Co UI realnie czyta (dla “nowych funkcji”)
|
|
||||||
|
|
||||||
- live/subscriptions:
|
|
||||||
- `dlob_stats_latest`
|
|
||||||
- `dlob_depth_bps_latest`
|
|
||||||
- `dlob_slippage_latest`
|
|
||||||
- (jeśli dołączymy w UI wykres “historia”):
|
|
||||||
- `dlob_stats_ts`
|
|
||||||
- `dlob_depth_bps_ts`
|
|
||||||
- `dlob_slippage_ts`
|
|
||||||
|
|
||||||
## Najczęstsze miejsca problemów (diagnostyka)
|
|
||||||
|
|
||||||
- Jeśli UI nie pokazuje warstw:
|
|
||||||
- sprawdź czy Hasura trackuje tabele i ma `public select` (bootstrap job),
|
|
||||||
- sprawdź, czy workery odświeżają `updated_at` w `dlob_*_latest`.
|
|
||||||
- Jeśli `dlob-worker` loguje 503:
|
|
||||||
- to zwykle problem na ścieżce `DLOB_HTTP_URL` (upstream/LB/IPv6) — wtedy przełącz na `http://dlob-server:6969`.
|
|
||||||
- Jeśli WS subscriptions nie łączą:
|
|
||||||
- sprawdź proxy `/graphql-ws` w `trade-frontend` i origin/CORS w Hasurze.
|
|
||||||
|
|
||||||
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).
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# Kanoniczna architektura Drift: własny Solana RPC + własny DLOB
|
|
||||||
|
|
||||||
Poniżej jest krótka i konkretna notatka: **co da się wyciągnąć z własnego Solana RPC** oraz **po co jest własny DLOB** w kontekście Drift (perp) i metryk pod trading/SIM.
|
|
||||||
|
|
||||||
## 1) Własny Solana RPC — co z niego wyciągniesz
|
|
||||||
|
|
||||||
Z **własnego RPC** (full node, a do backfillu najlepiej archival) możesz pobrać **wszystkie dane kontowe i ryzyko**.
|
|
||||||
|
|
||||||
### Dane pozycji i konta (RPC)
|
|
||||||
|
|
||||||
- pozycja (long/short, size, entry)
|
|
||||||
- unrealized PnL
|
|
||||||
- realized PnL (z konta użytkownika / fills)
|
|
||||||
- margin, free collateral
|
|
||||||
- liquidation price
|
|
||||||
- health / margin ratio
|
|
||||||
- funding (naliczony + historyczny)
|
|
||||||
|
|
||||||
Źródło: **konta programu Drift** (User, PerpMarket, SpotMarket). Technicznie: subskrypcje kont + (jeśli potrzebne) `getProgramAccounts`.
|
|
||||||
|
|
||||||
### Fills / transakcje / fees (RPC)
|
|
||||||
|
|
||||||
- fill price
|
|
||||||
- fee (maker/taker)
|
|
||||||
- reduce / add
|
|
||||||
- tx fee + priority fee
|
|
||||||
|
|
||||||
Źródła:
|
|
||||||
- logi transakcji,
|
|
||||||
- eventy Drift,
|
|
||||||
- historia transakcji walleta.
|
|
||||||
|
|
||||||
Uwaga: backfill 7d+ jest ciężki bez archival RPC, ale nadal wykonalny (koszt/IO/limity).
|
|
||||||
|
|
||||||
### Ceny (RPC)
|
|
||||||
|
|
||||||
- oracle price
|
|
||||||
- mark price (ze stanu rynku)
|
|
||||||
|
|
||||||
W praktyce wystarczy RPC + subskrypcje kont.
|
|
||||||
|
|
||||||
## 2) Własny DLOB — po co i co daje
|
|
||||||
|
|
||||||
**DLOB jest off-chain**, ale jest budowany z **on-chain zleceń limit**.
|
|
||||||
|
|
||||||
Co daje DLOB (i to jest kluczowe do “close now” i slippage):
|
|
||||||
- best bid / best ask (BBO)
|
|
||||||
- mid price
|
|
||||||
- spread
|
|
||||||
- realistyczny slippage
|
|
||||||
- sensowne “close now cost” (na podstawie top-of-book / L2)
|
|
||||||
|
|
||||||
Bez DLOB zwykle zostaje heurystyka na mark/oracle + założony spread/slippage.
|
|
||||||
|
|
||||||
### Jak to zrobić praktycznie
|
|
||||||
|
|
||||||
Najprostsza opcja to uruchomienie serwisu DLOB (publisher/server) z Drift SDK, który:
|
|
||||||
- subskrybuje RPC/WS,
|
|
||||||
- buduje orderbook,
|
|
||||||
- wystawia API (BBO/depth itp.),
|
|
||||||
- a worker liczy metryki (spread/depth/slippage) i zapisuje je do DB.
|
|
||||||
|
|
||||||
W tym repo mamy opis aktualnego pipeline DLOB w `doc/dlob-services.md` oraz plan “RPC + Geyser/Yellowstone” w `doc/solana-rpc-geyser-setup.md`.
|
|
||||||
|
|
||||||
## 3) Mapowanie: metryki → źródło danych
|
|
||||||
|
|
||||||
| Metryka | RPC | DLOB |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| unrealized PnL | ✅ | ❌ |
|
|
||||||
| realized / net PnL | ✅ | ❌ |
|
|
||||||
| fees / funding / tx | ✅ | ❌ |
|
|
||||||
| margin / liq / health | ✅ | ❌ |
|
|
||||||
| time in trade | ✅ | ❌ |
|
|
||||||
| best bid / ask | ❌ | ✅ |
|
|
||||||
| spread / mid | ❌ | ✅ |
|
|
||||||
| close now cost | ⚠️ heurystyka | ✅ |
|
|
||||||
| expected slippage | ⚠️ | ✅ |
|
|
||||||
|
|
||||||
## 4) 100% self-hosted (bez vendor lock‑in)
|
|
||||||
|
|
||||||
Da się zrobić w pełni self-hosted (bez Heliusa/cudzych API).
|
|
||||||
|
|
||||||
Prosty diagram:
|
|
||||||
|
|
||||||
```
|
|
||||||
[ Solana RPC (+ WS) ]
|
|
||||||
↓
|
|
||||||
[ Drift SDK / subscriptions ]
|
|
||||||
↓
|
|
||||||
[ DLOB (publisher/server) ]
|
|
||||||
↓
|
|
||||||
[ Worker (metrics TS) ]
|
|
||||||
↓
|
|
||||||
[ API / Monitor / SIM ]
|
|
||||||
↓
|
|
||||||
[ UI (tylko rysuje) ]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5) Jedyny realny haczyk (operacyjnie)
|
|
||||||
|
|
||||||
- `getProgramAccounts` + websockety wymagają solidnego RPC.
|
|
||||||
- Tanie/limtowane RPC często:
|
|
||||||
- blokują/limitują GPA,
|
|
||||||
- ucinają payload,
|
|
||||||
- dropią WS.
|
|
||||||
|
|
||||||
Własny RPC = stabilność i przewidywalność na większej skali.
|
|
||||||
|
|
||||||
## 6) TL;DR
|
|
||||||
|
|
||||||
- Tak: wyciągniesz wszystko z własnego RPC + własnego DLOB.
|
|
||||||
- RPC = pozycja, PnL, ryzyko, funding.
|
|
||||||
- DLOB = bid/ask, spread, slippage, close-now.
|
|
||||||
- To pasuje idealnie pod scalping + SIM (backend liczy, UI tylko wyświetla).
|
|
||||||
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Professional Drift Trading Stack (Own Solana RPC + Own DLOB)</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light dark; }
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
padding: 32px 20px;
|
|
||||||
max-width: 980px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
header { margin-bottom: 24px; }
|
|
||||||
h1 { font-size: 1.6rem; margin: 0 0 8px; }
|
|
||||||
.subtitle { opacity: 0.85; margin: 0; }
|
|
||||||
.card {
|
|
||||||
border: 1px solid rgba(127,127,127,0.35);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 18px 18px;
|
|
||||||
margin: 14px 0;
|
|
||||||
background: rgba(127,127,127,0.05);
|
|
||||||
}
|
|
||||||
h2 { font-size: 1.2rem; margin: 0 0 10px; }
|
|
||||||
h3 { font-size: 1.05rem; margin: 14px 0 8px; }
|
|
||||||
ul { margin: 8px 0 0 18px; }
|
|
||||||
li { margin: 6px 0; }
|
|
||||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
||||||
.note {
|
|
||||||
border-left: 4px solid rgba(127,127,127,0.55);
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin: 10px 0 0;
|
|
||||||
background: rgba(127,127,127,0.07);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border: 1px solid rgba(127,127,127,0.35);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1>Professional Drift Trading Stack</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
Own Solana RPC + Own Drift DLOB (Orderbook). Main rule:
|
|
||||||
<strong>keep the RPC box lean</strong>, put “trading services” on your second VPS.
|
|
||||||
<span class="pill">Target: min 10 markets</span>
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Overview</h2>
|
|
||||||
<p>
|
|
||||||
Yes — you can build a professional Drift trading stack with your own Solana RPC + your own DLOB,
|
|
||||||
but you’ll want a few supporting services around them. The main rule:
|
|
||||||
keep the RPC box lean, put “trading services” on your second VPS.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>On the Solana RPC server (dedicated) — keep it lean</h2>
|
|
||||||
|
|
||||||
<h3>Must-have</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Solana validator/RPC node</strong><br />
|
|
||||||
The base RPC your whole stack reads from / sends transactions through.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>WireGuard</strong><br />
|
|
||||||
So RPC is reachable only privately (your second VPS + your admin).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Firewall (nftables/ufw)</strong><br />
|
|
||||||
Block RPC ports on public NIC; allow them only on WireGuard.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Time sync (chrony)</strong><br />
|
|
||||||
For stable networking, logs, and trading timestamps.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Monitoring exporters</strong>
|
|
||||||
<ul>
|
|
||||||
<li><strong>node_exporter</strong> (CPU/RAM/disk/iowait/network)</li>
|
|
||||||
<li><strong>solana-exporter</strong> (RPC/validator health via RPC)</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Log + disk hygiene</strong>
|
|
||||||
<ul>
|
|
||||||
<li>logrotate/journald limits</li>
|
|
||||||
<li>NVMe health (smartmontools/nvme-cli)</li>
|
|
||||||
<li>alerts on disk filling / iowait</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Optional but “pro”</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Geyser streaming (Yellowstone gRPC plugin)</strong><br />
|
|
||||||
This gives ultra-low-latency streams of accounts/tx/slots compared to polling RPC.
|
|
||||||
Useful if you build your own real-time analytics pipeline.
|
|
||||||
<div class="note">
|
|
||||||
For Drift specifically, you can run without Geyser at the beginning,
|
|
||||||
but it’s the next step when you want “faster-than-RPC” feeds.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>On the second VPS (your trading / app box) — where “pro trading” lives</h2>
|
|
||||||
|
|
||||||
<h3>Must-have</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Drift DLOB server (self-hosted)</strong><br />
|
|
||||||
This maintains the Drift decentralized orderbook view “fresh off your RPC” and exposes
|
|
||||||
REST + WS + gRPC/polling, plus health/metrics.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>(Optional but common) Drift Gateway</strong><br />
|
|
||||||
A self-hosted API gateway to interact with Drift; handy for standardized API endpoints
|
|
||||||
around trading / market info.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Cache (Redis)</strong><br />
|
|
||||||
Cache top-of-book, funding, oracle snapshots, risk checks; protects your DLOB + RPC
|
|
||||||
from bursty bot load.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Metrics + dashboards</strong><br />
|
|
||||||
Prometheus + Grafana + Alertmanager
|
|
||||||
<div class="note">
|
|
||||||
Keep Grafana off the validator box; common ops guidance is to separate monitoring UI for safety.
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Your trading services</strong>
|
|
||||||
<ul>
|
|
||||||
<li>strategy engine(s)</li>
|
|
||||||
<li>execution service (transaction builder/sender)</li>
|
|
||||||
<li>risk service (position limits, kill-switch, circuit breakers)</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Optional, depending on how “institutional” you want</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<strong>Database (Postgres/Timescale)</strong><br />
|
|
||||||
Persist fills, order events, PnL series, backtesting datasets.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Message bus (NATS/Kafka/Redis Streams)</strong><br />
|
|
||||||
Decouple ingestion (orderbook/events) from strategies/execution.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Cost model (since you asked “cost per request”)</h2>
|
|
||||||
<p>
|
|
||||||
With your own RPC, there is no per-request billing. The “cost” is:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>fixed monthly servers (your €149/m + the second VPS),</li>
|
|
||||||
<li>and capacity (CPU/RAM/NVMe/bandwidth) consumed by:
|
|
||||||
<ul>
|
|
||||||
<li>DLOB syncing from RPC,</li>
|
|
||||||
<li>number of WS subscriptions,</li>
|
|
||||||
<li>how many markets you track.</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p class="note">
|
|
||||||
DLOB exists specifically to reduce RPC load by serving orderbook/trade views to clients
|
|
||||||
instead of every client rebuilding it from chain.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<h2>Minimal “pro” starting set (recommended)</h2>
|
|
||||||
<ul>
|
|
||||||
<li><strong>RPC box:</strong> Solana RPC + WireGuard + firewall + node_exporter + solana-exporter</li>
|
|
||||||
<li><strong>App VPS:</strong> DLOB server + Redis + Prometheus/Grafana + your bot services</li>
|
|
||||||
</ul>
|
|
||||||
<p class="note">
|
|
||||||
For <strong>min 10 markets</strong>, expect the first scaling pressure to come from
|
|
||||||
continuous streaming + decoding + caching (DLOB + Redis + your strategy/execution),
|
|
||||||
and from your RPC’s WS load. Next step after the minimal set is usually:
|
|
||||||
better streaming (Geyser) or more RAM/NVMe depending on bottleneck.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer style="opacity:.75; margin-top: 22px;">
|
|
||||||
<small>Saved as HTML — you can paste this into a file like <code>drift-stack.html</code>.</small>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
# Bare metal: Solana RPC (non‑voting) + Geyser/“Yellowstone” gRPC (Ubuntu 24.04)
|
|
||||||
|
|
||||||
Cel: postawić **jedną maszynę** jako **źródło danych on‑chain**:
|
|
||||||
- Solana `validator` w trybie **non‑voting** z **RPC + WS** (tylko prywatnie),
|
|
||||||
- **Geyser gRPC** (“Yellowstone”) jako stabilny, skalowalny feed account/tx/slot,
|
|
||||||
- serwisy tradingowe (DLOB/boty/DB/UI) działają **osobno** na VPS/k3s.
|
|
||||||
|
|
||||||
Ten dokument jest runbookiem. Nie zawiera sekretów.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Powiązane dokumenty (DLOB + metryki + koszty)
|
|
||||||
|
|
||||||
Żeby spiąć “RPC/Geyser → dane → metryki → UI”, zobacz też:
|
|
||||||
|
|
||||||
- Mapa dokumentów o RPC/DLOB/metrykach: `doc/solana-rpc.md`
|
|
||||||
- DLOB (co działa w k3s, jakie tabele, skąd dane): `doc/dlob-services.md`
|
|
||||||
- DLOB basics (L1/L2/L3, pojęcia): `doc/dlob-basics.md`
|
|
||||||
- Ingest ticków / candles / źródła danych: `doc/workflow-api-ingest.md`
|
|
||||||
- Readiness do live tradingu (w tym plan pod własny RPC + streaming): `doc/trading-readiness.md`
|
|
||||||
- Czy da się bez własnego RPC / bez RPC w ogóle (mapowanie źródeł danych): `doc/drift-data-bez-solana-rpc.md`
|
|
||||||
- Kanoniczna architektura “własny RPC + własny DLOB” (co skąd bierzemy): `doc/rpc-dlob-kanoniczna-architektura.md`
|
|
||||||
- Koszty kontraktu: API compute/monitor (backend liczy, UI tylko rysuje): `doc/contract-cost-api.md`
|
|
||||||
- Kanoniczny payload eventów bota pod koszty/PnL (żeby agregacje działały): `doc/bot-events-cost-payload.md`
|
|
||||||
|
|
||||||
## 0) Założenia
|
|
||||||
|
|
||||||
- OS: **Ubuntu 24.04**
|
|
||||||
- Sprzęt: **Ryzen 9 9950X, 192GB RAM, 2× Gen5 NVMe, 1Gbps**
|
|
||||||
- Rola: **RPC node bez voting** (brak vote account)
|
|
||||||
- Prywatny dostęp: **WireGuard** między bare metal a k3s/VPS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Dlaczego RPC+WS i gRPC jednocześnie
|
|
||||||
|
|
||||||
- **RPC/WS** (HTTP + WebSocket) zostaje jako:
|
|
||||||
- wysyłka transakcji (place/cancel/close),
|
|
||||||
- odczyty ad‑hoc i fallback.
|
|
||||||
- **Geyser/Yellowstone gRPC** jest preferowany jako:
|
|
||||||
- stabilny stream updates (account/slot/tx) dla DLOB/indexerów,
|
|
||||||
- mniejsze “rwanie” niż WS przy większej skali.
|
|
||||||
|
|
||||||
W praktyce: data plane = gRPC, execution plane = RPC.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Podział dysków (must‑have)
|
|
||||||
|
|
||||||
Rekomendacja (żeby uniknąć I/O contention):
|
|
||||||
- NVMe #1: ledger / accounts
|
|
||||||
- mount: `/solana/ledger`
|
|
||||||
- NVMe #2: snapshots / logs / plugin state
|
|
||||||
- mount: `/solana/snapshots`
|
|
||||||
- ewentualnie: `/var/lib/yellowstone`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Porty (proponowane)
|
|
||||||
|
|
||||||
Publicznie:
|
|
||||||
- `22/tcp` (SSH) – tylko z Twoich IP (allowlist)
|
|
||||||
|
|
||||||
Tylko po WireGuard (private):
|
|
||||||
- `8899/tcp` RPC HTTP
|
|
||||||
- `8900/tcp` RPC WS
|
|
||||||
- `10000/tcp` Geyser gRPC (Yellowstone)
|
|
||||||
|
|
||||||
Uwaga: dokładne porty Solany (gossip/TPU) są inne i zależą od flag; one zwykle muszą być publicznie osiągalne do sieci Solany, ale **RPC ma być private**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) WireGuard (skeleton)
|
|
||||||
|
|
||||||
Założenie: bare metal = `wg0 = 10.8.0.1`, k3s/VPS = `wg0 = 10.8.0.2`.
|
|
||||||
|
|
||||||
### 4.1 Bare metal: `/etc/wireguard/wg0.conf`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Interface]
|
|
||||||
Address = 10.8.0.1/24
|
|
||||||
ListenPort = 51820
|
|
||||||
PrivateKey = <BARE_METAL_PRIVATE_KEY>
|
|
||||||
|
|
||||||
# opcjonalnie: NAT dla ruchu wychodzącego
|
|
||||||
# PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
|
||||||
# PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = <K3S_PUBLIC_KEY>
|
|
||||||
AllowedIPs = 10.8.0.2/32
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 VPS/k3s: `/etc/wireguard/wg0.conf`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Interface]
|
|
||||||
Address = 10.8.0.2/24
|
|
||||||
PrivateKey = <K3S_PRIVATE_KEY>
|
|
||||||
|
|
||||||
[Peer]
|
|
||||||
PublicKey = <BARE_METAL_PUBLIC_KEY>
|
|
||||||
Endpoint = <BARE_METAL_PUBLIC_IP>:51820
|
|
||||||
AllowedIPs = 10.8.0.1/32
|
|
||||||
PersistentKeepalive = 25
|
|
||||||
```
|
|
||||||
|
|
||||||
Start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl enable --now wg-quick@wg0
|
|
||||||
```
|
|
||||||
|
|
||||||
Test:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ping -c 2 10.8.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Firewall: zasada “RPC i gRPC tylko private”
|
|
||||||
|
|
||||||
Wariant z `ufw` (przykład, dopasuj do swojego środowiska):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ufw default deny incoming
|
|
||||||
sudo ufw default allow outgoing
|
|
||||||
|
|
||||||
# SSH – najlepiej allowlist Twoje IP
|
|
||||||
sudo ufw allow 22/tcp
|
|
||||||
|
|
||||||
# WireGuard
|
|
||||||
sudo ufw allow 51820/udp
|
|
||||||
|
|
||||||
# RPC/WS/gRPC tylko na interfejsie wg0 (ufw ma ograniczone wsparcie; alternatywnie nftables)
|
|
||||||
# Minimalnie: nie otwieraj 8899/8900/10000 na publicznym NIC.
|
|
||||||
|
|
||||||
sudo ufw enable
|
|
||||||
sudo ufw status verbose
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Instalacja i uruchomienie Solany (non‑voting)
|
|
||||||
|
|
||||||
### 6.1 Zasada bezpieczeństwa
|
|
||||||
- identity key i konfiguracja tylko na serwerze,
|
|
||||||
- nie commituj tego do gita,
|
|
||||||
- RPC ma być “private RPC”.
|
|
||||||
|
|
||||||
### 6.2 Flagi mogą się zmieniać
|
|
||||||
Solana bywa zmienna w detalach CLI. Zawsze weryfikuj:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
solana-validator --help | less
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 Minimalny szkic uruchomienia (do uzupełnienia)
|
|
||||||
|
|
||||||
Poniżej jest “kształt” – nie traktuj jako jedyny prawdziwy set flag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
solana-validator \
|
|
||||||
--identity /etc/solana/identity.json \
|
|
||||||
--ledger /solana/ledger \
|
|
||||||
--snapshots /solana/snapshots \
|
|
||||||
--rpc-port 8899 \
|
|
||||||
--rpc-bind-address 10.8.0.1 \
|
|
||||||
--private-rpc \
|
|
||||||
--ws-port 8900 \
|
|
||||||
--dynamic-port-range 8000-8020 \
|
|
||||||
--no-voting \
|
|
||||||
--entrypoint <ENTRYPOINT_1> \
|
|
||||||
--entrypoint <ENTRYPOINT_2> \
|
|
||||||
--entrypoint <ENTRYPOINT_3> \
|
|
||||||
--expected-genesis-hash <GENESIS_HASH> \
|
|
||||||
--wal-recovery-mode skip_any_corrupted_record
|
|
||||||
```
|
|
||||||
|
|
||||||
Uwagi:
|
|
||||||
- `--rpc-bind-address` ustaw na IP WireGuard (private).
|
|
||||||
- `--no-voting` = non‑voting.
|
|
||||||
- `--dynamic-port-range` i reszta portów zależą od Twojej polityki sieciowej.
|
|
||||||
|
|
||||||
### 6.4 systemd (skeleton)
|
|
||||||
|
|
||||||
Plik: `/etc/systemd/system/solana-validator.service`
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Solana Validator (non-voting, private RPC)
|
|
||||||
After=network-online.target wg-quick@wg0.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
User=solana
|
|
||||||
Group=solana
|
|
||||||
LimitNOFILE=1048576
|
|
||||||
ExecStart=/usr/local/bin/solana-validator <ARGS...>
|
|
||||||
Restart=always
|
|
||||||
RestartSec=3
|
|
||||||
TimeoutStopSec=120
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Geyser / “Yellowstone” gRPC
|
|
||||||
|
|
||||||
### 7.1 Co to jest
|
|
||||||
Geyser to plugin, który “wypycha” stream danych z runtime’u walidatora.
|
|
||||||
Yellowstone gRPC to popularny stack, który wystawia ten stream przez gRPC.
|
|
||||||
|
|
||||||
### 7.2 Model wdrożenia (recommended)
|
|
||||||
- plugin jest skonfigurowany przy starcie `solana-validator` (`--geyser-plugin-config <path>`),
|
|
||||||
- gRPC endpoint nasłuchuje na `10.8.0.1:10000` (private),
|
|
||||||
- klienci (k3s) subskrybują gRPC.
|
|
||||||
|
|
||||||
### 7.3 Konfiguracja pluginu (placeholder)
|
|
||||||
Dokładny format config zależy od wybranego pluginu/wersji.
|
|
||||||
Trzymaj config jako plik na serwerze, np. `/etc/solana/geyser-grpc.json`.
|
|
||||||
|
|
||||||
Wymagania, które chcemy mieć niezależnie od implementacji:
|
|
||||||
- bind na interfejsie WireGuard (`10.8.0.1`),
|
|
||||||
- opcjonalny auth/token dla klientów,
|
|
||||||
- limit/allowlist klientów,
|
|
||||||
- logi do journald + limitowanie.
|
|
||||||
|
|
||||||
Test (z VPS/k3s po WireGuard):
|
|
||||||
- port open: `nc -vz 10.8.0.1 10000` (albo `grpcurl` jeśli masz),
|
|
||||||
- stream slotów/health (zależy od klienta).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Integracja z naszym stackiem `trade`
|
|
||||||
|
|
||||||
### 8.1 Co zmieniamy w k3s
|
|
||||||
|
|
||||||
Aktualizujemy secreta z endpointami RPC/WS dla `dlob-publisher` i executora:
|
|
||||||
- `ENDPOINT=http://10.8.0.1:8899`
|
|
||||||
- `WS_ENDPOINT=ws://10.8.0.1:8900`
|
|
||||||
|
|
||||||
Docelowo dodamy również:
|
|
||||||
- `GEYSER_GRPC_URL=http://10.8.0.1:10000` (lub `grpc://...`) do collectorów.
|
|
||||||
|
|
||||||
### 8.2 Fallback
|
|
||||||
Zostawiamy fallback RPC endpoint (np. publiczny provider) dla:
|
|
||||||
- awarii bare-metala,
|
|
||||||
- bootstrapu,
|
|
||||||
- sanity check.
|
|
||||||
|
|
||||||
Executor ma zawsze mieć tryb degradacji:
|
|
||||||
- Vast down → `observe/off`,
|
|
||||||
- feed down → `panic` lub `off` zależnie od ryzyka.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9) Operacje i monitoring (must‑have)
|
|
||||||
|
|
||||||
Mierz/alertuj:
|
|
||||||
- slot lag / “behind”,
|
|
||||||
- iowait + saturacja NVMe,
|
|
||||||
- disk fill (`ledger` rośnie),
|
|
||||||
- restart loop serwisów,
|
|
||||||
- liczba klientów gRPC / błędy streamu,
|
|
||||||
- RPC latencja / error rate.
|
|
||||||
|
|
||||||
Minimalne narzędzia:
|
|
||||||
- `node_exporter` + Prometheus/Grafana,
|
|
||||||
- logrotate/journald limity,
|
|
||||||
- `smartctl`/`nvme smart-log` dla NVMe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10) Gotowość do startu (checklista)
|
|
||||||
|
|
||||||
- [ ] WireGuard działa (ping wg IP).
|
|
||||||
- [ ] RPC/WS/gRPC są dostępne tylko po WG.
|
|
||||||
- [ ] `solana-validator` trzyma sync, nie robi OOM, I/O stabilne.
|
|
||||||
- [ ] Geyser gRPC stream stabilny (brak częstych reconnect).
|
|
||||||
- [ ] `dlob-publisher` działa na nowych endpointach bez “No ws data … resubscribing”.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Solana RPC w tym projekcie (mapa dokumentów)
|
|
||||||
|
|
||||||
Ten plik jest “spisem treści” do dokumentów o **Solana RPC/WS**, **Geyser/Yellowstone gRPC** oraz tego, jak te źródła danych zasilają **DLOB + metryki + UI**.
|
|
||||||
|
|
||||||
## Runbooki i architektura
|
|
||||||
|
|
||||||
- Bare metal RPC (non‑voting) + Geyser/Yellowstone gRPC: `doc/solana-rpc-geyser-setup.md`
|
|
||||||
- “Kanoniczna” architektura self‑hosted (RPC + DLOB → DB/Hasura → API → UI): `doc/rpc-dlob-kanoniczna-architektura.md`
|
|
||||||
- Czy da się bez własnego RPC / bez RPC w ogóle (mapowanie źródeł danych): `doc/drift-data-bez-solana-rpc.md`
|
|
||||||
|
|
||||||
## DLOB i warstwy danych
|
|
||||||
|
|
||||||
- Serwisy DLOB w k3s/VPS + przepływ danych: `doc/dlob-services.md`
|
|
||||||
- Pojęcia i metryki DLOB (L1/L2/L3, bps, slippage): `doc/dlob-basics.md`
|
|
||||||
|
|
||||||
## Metryki i koszty kontraktów (backend liczy, UI rysuje)
|
|
||||||
|
|
||||||
- API do liczenia kosztów “new contract” + monitor kontraktu: `doc/contract-cost-api.md`
|
|
||||||
- Słownik kosztów, PnL i pojęć (UI + backend): `doc/drift-costs.md`
|
|
||||||
|
|
||||||
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).
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Strategia: eskalacja horyzontu (1m → 5m → 15m → 30m → 1h) z bramkami ryzyka i kosztów
|
|
||||||
|
|
||||||
Cel: jeżeli trade nie domyka celu w krótkim oknie (np. **1m**), możemy **kontrolowanie** przejść na dłuższy horyzont (**5m/15m/30m/1h**) *bez wpadania w “przetrzymanie straty”*.
|
|
||||||
|
|
||||||
Klucz: to ma działać jako **state machine** z twardymi bramkami (gates), a nie jako “zostawię i zobaczę”.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Model: “czas na realizację celu” + eskalacja okna
|
|
||||||
|
|
||||||
### Parametry (na start)
|
|
||||||
- `target_bps` albo `target_usd` (np. +3 bps, +6 bps…)
|
|
||||||
- `ttl_per_mode` (time-to-live per tryb):
|
|
||||||
- `1m`: 60–120 s
|
|
||||||
- `5m`: 5–10 min
|
|
||||||
- `15m`: 15–30 min
|
|
||||||
- `30m`: 30–60 min
|
|
||||||
- `1h`: 1–3 h
|
|
||||||
|
|
||||||
### Zasada
|
|
||||||
Jeśli w danym trybie **nie osiągniesz celu w TTL**, to:
|
|
||||||
- albo **zamykanie**,
|
|
||||||
- albo **eskalacja** do wyższego okna — *tylko* jeśli spełnione są bramki (ryzyko/koszty/struktura).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Bramki (gates) — kiedy eskalacja jest dozwolona
|
|
||||||
|
|
||||||
### Gate RISK (twarde)
|
|
||||||
Jeśli którykolwiek warunek jest niespełniony → **wyjście teraz** (close now).
|
|
||||||
|
|
||||||
Przykładowe progi:
|
|
||||||
- `health >= 0.70`
|
|
||||||
- `margin_ratio >= 0.15..0.20` (zależnie od dźwigni)
|
|
||||||
- odległość do `liq_price` >= `X%` albo >= `k * ATR`
|
|
||||||
- `max_drawdown` w oknie bieżącym nie przekracza limitu
|
|
||||||
|
|
||||||
### Gate COSTS (twarde)
|
|
||||||
- `close_now_cost_usd` nie “zjada” potencjału (np. koszty nie mogą przekroczyć `target_usd` albo `target_bps`)
|
|
||||||
- przy dłuższych trybach (30m/1h) uwzględnij wpływ funding:
|
|
||||||
- `funding_expected(next_window)` nie dominuje nad edge
|
|
||||||
|
|
||||||
### Gate STRUCTURE / EDGE (miękkie, ale zalecane)
|
|
||||||
Przykłady:
|
|
||||||
- trend/edge na wyższym oknie nie jest przeciw (np. 5m dla eskalacji 1m→5m)
|
|
||||||
- spread/slippage z DLOB nie rosną anormalnie
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Logika przejść (state machine)
|
|
||||||
|
|
||||||
Stan początkowy: `mode=1m`.
|
|
||||||
|
|
||||||
Jeżeli `ttl_expired` i `target_not_hit`:
|
|
||||||
- jeśli `risk_ok && costs_ok && structure_ok` → awans do następnego trybu,
|
|
||||||
- inaczej → `close_now`.
|
|
||||||
|
|
||||||
Przejścia:
|
|
||||||
- `1m` → `5m`
|
|
||||||
- `5m` → `15m`
|
|
||||||
- `15m` → `30m`
|
|
||||||
- `30m` → `1h`
|
|
||||||
|
|
||||||
Ważne: awans = **zmiana reżimu** (inne TTL/target/gates), a nie “przeciąganie”.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Pułapka i dwa “hamulce”
|
|
||||||
|
|
||||||
Pułapka:
|
|
||||||
> “nie poszło w 1m, to poczekam 5m, jak nie to 15m…” → swing bez planu
|
|
||||||
|
|
||||||
### Hamulec A: limit eskalacji
|
|
||||||
- max 1–2 awanse na trade (np. `1m→5m→15m` i koniec)
|
|
||||||
|
|
||||||
### Hamulec B: limit straty per tryb
|
|
||||||
- jeśli w trybie 1m strata przekroczy `stop_1m` → wyjście
|
|
||||||
- nie wolno “przenosić” tej samej straty w nieskończoność do 1h
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Jak to zaszyć w naszym backendzie (worker + API)
|
|
||||||
|
|
||||||
W `contract_metrics_ts` (lub w warstwie kontraktu) trzymaj:
|
|
||||||
- `mode_current`
|
|
||||||
- `mode_enter_ts`
|
|
||||||
- `ttl_s_for_mode`
|
|
||||||
- `target_bps_for_mode`
|
|
||||||
- `gates_passed`:
|
|
||||||
- `risk: boolean`
|
|
||||||
- `costs: boolean`
|
|
||||||
- `structure: boolean`
|
|
||||||
- (opcjonalnie) `escalations_used` / `escalations_remaining`
|
|
||||||
|
|
||||||
### SIM: “czy eskalacja ma sens”
|
|
||||||
Endpoint typu `POST /v1/simulate/escalate` (MVP) może zwracać:
|
|
||||||
- `expected_costs` (close now / next window)
|
|
||||||
- `risk_after` (health/margin/liq)
|
|
||||||
- `assumptions` (np. BBO+extra bps, fee tier)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) TL;DR
|
|
||||||
|
|
||||||
- Tak, eskalacja czasu ma sens **jeśli jest kontrolowana**.
|
|
||||||
- Robimy to jako **state machine** z:
|
|
||||||
- twardym `RISK gate`,
|
|
||||||
- twardym `COST gate`,
|
|
||||||
- limitem eskalacji,
|
|
||||||
- stopem per tryb.
|
|
||||||
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# TODO przed zakupem bare metal (RPC+Geyser) — żeby dzień 0 poszedł gładko
|
|
||||||
|
|
||||||
Cel: zanim kupisz bare metal, dopinamy wszystko, co nie wymaga własnego RPC, żeby po zakupie:
|
|
||||||
- tylko postawić RPC+Geyser wg runbooka,
|
|
||||||
- przepiąć endpointy w k3s i zrobić rollout,
|
|
||||||
- zweryfikować stabilność feedu i gotowość do live.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status (staging / `trade.mpabi.pl`) + TODO bieżące
|
|
||||||
|
|
||||||
- [x] **Precomputed candles cache (TF: `1s..1d`, target `1024`/TF)** na backendzie (k3s) + worker liczący “ciągle”.
|
|
||||||
- [x] **DLOB slippage v2** (tabele v2 + dual-write), żeby obsłużyć “rozmiary USD” z częściami dziesiętnymi.
|
|
||||||
- [x] **Frontend (visualizer)**: dodane TF: `1s 3s 5s 15s 30s 1m 3m 5m 15m 30m 1h 4h 12h 1d` + szybkie przełączanie (abort poprzednich requestów).
|
|
||||||
- [x] **Wdrożenie na k3s**: zbudowany i wypchnięty nowy obraz `trade-frontend` + zaktualizowany `trade-deploy` (Argo rollout).
|
|
||||||
|
|
||||||
**Do zrobienia teraz (żeby „lokalny frontend” i staging działały spójnie):**
|
|
||||||
|
|
||||||
- [ ] **Sprawdzić `/graphql` (Hasura proxy) po sesji**: potwierdzić, że po `POST /auth/login` zapytania GraphQL działają i nie ma `Malformed Authorization header`.
|
|
||||||
- [ ] **Sprawdzić czasy przełączania TF w UI**: czy klik w TF tylko czyta cache i nie czeka na liczenie (ma być natychmiast).
|
|
||||||
- [ ] **Naprawić „kafelek” w headerze market** na 100% skali (overflow/ellipsis, czytelność liczb).
|
|
||||||
- [ ] **DLOB fullscreen w stack/layers**: upewnić się, że działa tak jak chart (fullscreen / exit) i że w stack mode jest czytelne.
|
|
||||||
- [ ] **Panel warstw**: dopracować UX (auto-hide + lock, DnD kolejności, suwaki opacity/brightness na warstwach) + skrócić formatki (więcej miejsca na wykresy).
|
|
||||||
- [ ] **“New contract estimate” live**: dodać toggle “auto refresh” i rysować wykresy time-series (1 px ~ 1s) tylko dla zmiennych (cena/impact/total), a stałe (fee) jako stałe wartości.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A) Decyzje i parametry (bez kodu, ale blokują implementację)
|
|
||||||
|
|
||||||
- [ ] **Docelowe porty i adresacja WireGuard**:
|
|
||||||
- WG subnet (np. `10.8.0.0/24`), IP bare metal i IP k3s/VPS
|
|
||||||
- port WG (np. `51820/udp`)
|
|
||||||
- private bind dla: RPC `8899`, WS `8900`, gRPC `10000`
|
|
||||||
- [ ] **Polityka dostępu**:
|
|
||||||
- allowlist IP do SSH
|
|
||||||
- czy gRPC wymaga auth/token dla klientów
|
|
||||||
- [ ] **Retencja danych (start)**:
|
|
||||||
- TS: 7 dni “gęsto” (np. 1–5s) + czy robimy downsample 1m na dłużej
|
|
||||||
- [ ] **Model intent**:
|
|
||||||
- potwierdzone: offset (ticks/bps) + desired-state (jest w `doc/drift-perp-contract.md`)
|
|
||||||
- [ ] **Ryzyko (hard caps)**:
|
|
||||||
- max position USD, max reprices/min, max slippage/spread, freshness
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B) Dane i historia (żeby warstwy działały live+history)
|
|
||||||
|
|
||||||
- [ ] **DLOB TS tables**: `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts`
|
|
||||||
- indeksy `(market_name, ts)` i retencja 7 dni
|
|
||||||
- [ ] **Archiver/collector**:
|
|
||||||
- worker, który zapisuje TS (z `*_latest` do `*_ts`), albo rozszerzenie istniejących workerów
|
|
||||||
- [ ] **Downsample (opcjonalnie, ale pro)**:
|
|
||||||
- continuous aggregates (Timescale) lub job 1m/5m
|
|
||||||
- [ ] **Hasura bootstrap**:
|
|
||||||
- track tabel TS + uprawnienia `select` (public) dla UI history
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C) Kontrakty bota i audyt (must-have przed live)
|
|
||||||
|
|
||||||
- [ ] **Schema**:
|
|
||||||
- `bot_contracts` (desired-state + status)
|
|
||||||
- `bot_events` (audit log)
|
|
||||||
- mapowanie: `decision_id`, `client_order_id`, `drift_order_id`, `tx_sig`
|
|
||||||
- [ ] **Observe-only executor** (k3s):
|
|
||||||
- buduje features i zapisuje `decision` do `bot_events`, bez składania tx
|
|
||||||
- [ ] **Reconcile logic (no trade yet)**:
|
|
||||||
- start → odczyt observed state z Drift → porównanie do DB → log “diff”
|
|
||||||
- [ ] **Kill-switch w executorze**:
|
|
||||||
- env var + DB flag + safety triggers (feed stale, RPC lag)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D) Vast (GPU tylko na kilka godzin) — przygotowanie pod ephemeral training
|
|
||||||
|
|
||||||
- [ ] **Dataset export** (z k3s/DB):
|
|
||||||
- jeden plik `parquet/jsonl.zst` + `dataset_version` (hash)
|
|
||||||
- minimalny “train split / eval split”
|
|
||||||
- [ ] **Training entrypoint** (jedna komenda):
|
|
||||||
- skrypt/komenda, która: download dataset → train → eval → export
|
|
||||||
- [ ] **Artifact upload**:
|
|
||||||
- preferowane: scp na VPS/k3s albo Gitea Packages
|
|
||||||
- wersjonowanie: `model_version` + `dataset_version`
|
|
||||||
- [ ] **Predictor contract test**:
|
|
||||||
- walidator JSON schema `trade_intent`
|
|
||||||
- test: “intent TTL expired” + “gates fail” + “panic”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## E) UI (warstwy + live/history, bez liczenia w JS)
|
|
||||||
|
|
||||||
- [ ] **Tryb Live/History**:
|
|
||||||
- Live: subscriptions na `*_latest`
|
|
||||||
- History: query na `*_ts` + timeframe `tf`
|
|
||||||
- [ ] **Warstwy/Panele z `doc/stats.md`**:
|
|
||||||
- mapowanie 1:1 na tabele (brak obliczeń w UI)
|
|
||||||
- [ ] **Podgląd kontraktów**:
|
|
||||||
- panel “Contracts” z `bot_contracts` + “Event timeline” z `bot_events`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## F) Operacje (żeby bare metal nie stał się snowflake)
|
|
||||||
|
|
||||||
- [ ] **Sekrety i endpointy**:
|
|
||||||
- w k3s: secret `trade-dlob-rpc` / analogiczny na nowy endpoint (WG)
|
|
||||||
- fallback endpoint (np. public provider) jako opcjonalny drugi URL
|
|
||||||
- [ ] **Monitoring/alerty na k3s**:
|
|
||||||
- freshness DLOB/ticks, error rate workerów, restart loops
|
|
||||||
- [ ] **Checklist “Day 0”**:
|
|
||||||
- przejście krok po kroku wg `doc/solana-rpc-geyser-setup.md`
|
|
||||||
- smoke test: `dlob-publisher` bez reconnect storm
|
|
||||||
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>
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# Trading readiness (staging → live) — checklista i brakujące elementy
|
|
||||||
|
|
||||||
Ten dokument odpowiada na pytanie: **czy obecny warsztat jest “już dobry do trade”** i co musi być dopięte, zanim przejdziemy z obserwacji do live tradingu.
|
|
||||||
|
|
||||||
W skrócie:
|
|
||||||
- Fundament jest dobry do **prototypowania i stagingu**.
|
|
||||||
- Do “pro trading” brakuje kilku krytycznych elementów bezpieczeństwa, audytu i historii danych.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Co już mamy (mocne strony)
|
|
||||||
|
|
||||||
- **k3s + GitOps/snapshoty**: każdy deploy to snapshot, rollback jednym ruchem (`doc/workflow.md`).
|
|
||||||
- **DLOB pipeline**: orderbook → statsy (spread/depth/slippage) pod UI/strategie (`doc/dlob-basics.md`, `doc/dlob-services.md`).
|
|
||||||
- **Ingest ticków do DB**: dane rynkowe w Postgres/Timescale + candles przez funkcję (`doc/workflow-api-ingest.md`).
|
|
||||||
- **UI/Visualizer**: warstwy i panele do obserwacji rynku (`doc/stats.md`).
|
|
||||||
- **Model plane separation**: Vast ma robić inference bez sekretów (docelowo) (`doc/drift-perp-contract.md` + `doc/vast-gpu-runbook.md`).
|
|
||||||
- **Plan pod własny RPC + streaming**: bare metal RPC + Geyser/Yellowstone gRPC (`doc/solana-rpc-geyser-setup.md`).
|
|
||||||
- **Wyjaśnienie “czy da się bez RPC”**: co możemy policzyć z DB i DLOB, a co wymaga feedu on‑chain (`doc/drift-data-bez-solana-rpc.md`).
|
|
||||||
|
|
||||||
To jest wystarczające do:
|
|
||||||
- obserwacji live,
|
|
||||||
- strojenia UI,
|
|
||||||
- budowy i testów pipeline’u danych,
|
|
||||||
- “paper trading” / dry-run w executorze.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Co jest krytycznie brakujące do live tradingu
|
|
||||||
|
|
||||||
Poniższe punkty są “must‑have” zanim bot zacznie realnie składać zlecenia na Drift.
|
|
||||||
|
|
||||||
### 2.1 Kontrakty bota + audyt (DB)
|
|
||||||
|
|
||||||
Wymagane tabele i log:
|
|
||||||
- `bot_contracts` (stan kontraktu / desired-state)
|
|
||||||
- `bot_events` (decision, order_sent, order_ack, fill, cancel, exit, error, panic)
|
|
||||||
- mapowanie: `decision_id -> client_order_id -> drift_order_id -> tx_sig`
|
|
||||||
|
|
||||||
Cel:
|
|
||||||
- da się odtworzyć “co poszło na chain”,
|
|
||||||
- UI pokazuje stan kontraktów (live + historia),
|
|
||||||
- łatwe debugowanie i strojenie.
|
|
||||||
|
|
||||||
### 2.2 Reconcile po restarcie (must-have)
|
|
||||||
|
|
||||||
Executor po starcie zawsze:
|
|
||||||
- czyta `bot_contracts` (desired),
|
|
||||||
- pobiera observed state z Drift (pozycje + open ordery),
|
|
||||||
- porównuje i wykonuje minimalne akcje korekcyjne.
|
|
||||||
|
|
||||||
Bez tego ryzykujesz:
|
|
||||||
- “ghost orders”,
|
|
||||||
- utrzymanie pozycji mimo utraty kontekstu.
|
|
||||||
|
|
||||||
### 2.3 Kill-switch + guardian poza klastrem (must-have)
|
|
||||||
|
|
||||||
Kill-switch w executorze to za mało, bo klaster/VPS może paść.
|
|
||||||
|
|
||||||
Wzorzec:
|
|
||||||
- osobny mały serwis `bot-guardian` poza głównym VPS/k3s,
|
|
||||||
- jeśli heartbeat executora jest stary → guardian robi `cancel_all` + `reduce_only close`.
|
|
||||||
|
|
||||||
### 2.4 Hard risk caps niezależne od modelu
|
|
||||||
|
|
||||||
Nawet jeśli Vast zwraca pełny `trade_intent`, executor musi egzekwować:
|
|
||||||
- `max_position_usd`, `max_leverage` (pośrednio), `max_orders_per_min`,
|
|
||||||
- `max_slippage_bps`, `max_spread_bps`, `freshness_max_ms`,
|
|
||||||
- circuit breakers (np. feed down, RPC lag, drawdown).
|
|
||||||
|
|
||||||
Model może proponować parametry, ale nie może omijać twardych limitów.
|
|
||||||
|
|
||||||
### 2.5 Historia danych (TS), retencja i downsample
|
|
||||||
|
|
||||||
`*_latest` jest świetne do live, ale do strojenia potrzebujesz TS:
|
|
||||||
- DLOB: `dlob_stats_ts`, `dlob_depth_bps_ts`, `dlob_slippage_ts` (min. 7 dni)
|
|
||||||
- kontrakty: `bot_events_ts` / log z timestampem
|
|
||||||
|
|
||||||
Docelowo:
|
|
||||||
- trzymać gęste 7 dni,
|
|
||||||
- trzymać dłużej downsample (np. 1m/5m) pod analizy reżimów.
|
|
||||||
|
|
||||||
### 2.6 Monitoring i alerty (operacyjne)
|
|
||||||
|
|
||||||
Minimum alertów:
|
|
||||||
- feed freshness (DLOB/ticki),
|
|
||||||
- RPC slot lag / error rate,
|
|
||||||
- order reject rate,
|
|
||||||
- panic triggers,
|
|
||||||
- disk fill/iowait (RPC node).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) “Go/No-Go” do pierwszego live (small size)
|
|
||||||
|
|
||||||
**Go** jeśli:
|
|
||||||
- kontrakty i eventy są w DB,
|
|
||||||
- reconcile działa (test restartu),
|
|
||||||
- kill-switch działa (test: panic → flat),
|
|
||||||
- mamy twarde limity ryzyka,
|
|
||||||
- mamy podstawowe alerty,
|
|
||||||
- mamy 7 dni TS pod progi (albo chociaż 24h na start) i znamy percentyle spread/slippage/depth.
|
|
||||||
|
|
||||||
**No-Go** jeśli:
|
|
||||||
- nie potrafimy deterministycznie zidentyfikować orderów (brak `client_order_id` / brak logów),
|
|
||||||
- restart executora może zostawić pozycję bez kontroli,
|
|
||||||
- nie ma niezależnego guardiana.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Proponowana kolejność implementacji (minimalny path)
|
|
||||||
|
|
||||||
1) `bot_contracts` + `bot_events` + UI podgląd kontraktów (read-only).
|
|
||||||
2) “observe-only executor” (bez tx) → loguje decyzje z modelu/reguł.
|
|
||||||
3) TS history: DLOB (7 dni) + podstawowe agregacje.
|
|
||||||
4) Dry-run/paper: executor wylicza i loguje order plan (bez tx).
|
|
||||||
5) Live minimal: mały size, limit/post-only + chase, twarde caps.
|
|
||||||
6) Guardian poza klastrem.
|
|
||||||
7) Geyser/Yellowstone (jeśli WS RPC nie trzyma stabilnie na skali).
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Vast GPU (kilka godzin) — runbook do trenowania + eksportu modelu
|
|
||||||
|
|
||||||
Cel: używać Vast (GPU) jako **krótkiej sesji treningowej** (kilka godzin), a wynik (wagi/adapter) zapisać trwałe i wersjonowane.
|
|
||||||
|
|
||||||
W tym projekcie Vast:
|
|
||||||
- **nie dostaje sekretów tradingowych**,
|
|
||||||
- nie ma dostępu do kluczy,
|
|
||||||
- zwraca tylko `trade_intent`/predykcje.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Najważniejsza zasada przy krótkim wynajmie
|
|
||||||
|
|
||||||
Vast jest “ephemeral”. Żeby nie stracić pracy:
|
|
||||||
|
|
||||||
- wszystko musi być **reproducible** (konfig + kod + seed),
|
|
||||||
- model output musi być **mały** i łatwy do przeniesienia (preferuj LoRA/adaptery),
|
|
||||||
- checkpointy i final artifacts muszą być **uploadowane** poza maszynę (Gitea packages/S3/scp).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Co potrzebujemy mieć gotowe przed startem sesji
|
|
||||||
|
|
||||||
### 2.1 Kod i entrypoint
|
|
||||||
|
|
||||||
W repo powinien istnieć “one command training”, np.:
|
|
||||||
- `python -m train ...` albo `bash scripts/train.sh ...`
|
|
||||||
|
|
||||||
Zasada: komenda sama:
|
|
||||||
- pobiera dataset (albo czyta lokalny plik),
|
|
||||||
- trenuje,
|
|
||||||
- zapisuje `artifacts/`,
|
|
||||||
- robi upload.
|
|
||||||
|
|
||||||
### 2.2 Dataset (krótki transfer)
|
|
||||||
|
|
||||||
Dla sesji “kilka godzin” unikaj wielkich transferów.
|
|
||||||
|
|
||||||
Rekomendacja:
|
|
||||||
- zbuduj dataset jako plik (np. `jsonl`/`parquet`) i spakuj (`zstd`),
|
|
||||||
- trzymaj wersję datasetu (hash) i loguj ją do metryk.
|
|
||||||
|
|
||||||
Źródło danych (rekomendowane u nas):
|
|
||||||
- `Postgres/Timescale` w k3s → eksport do pliku (offline), potem upload.
|
|
||||||
|
|
||||||
### 2.3 Bazowy model
|
|
||||||
|
|
||||||
Masz 2 ścieżki:
|
|
||||||
- pobierasz z internetu (HF) na Vast (szybko, ale zależy od sieci),
|
|
||||||
- albo trzymasz bazę w swoim storage i ściągasz kontrolowanie.
|
|
||||||
|
|
||||||
Na start preferuj LoRA na umiarkowanym modelu i krótkiej sekwencji.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Co uruchamiamy na Vast
|
|
||||||
|
|
||||||
### 3.1 Kontener
|
|
||||||
|
|
||||||
Najprościej: jeden Docker image z zależnościami (PyTorch + libs).
|
|
||||||
Obraz powinien być **pinned** (digest) i gotowy do uruchomienia bez `pip install` w trakcie.
|
|
||||||
|
|
||||||
### 3.2 Konfiguracja treningu (config file)
|
|
||||||
|
|
||||||
Trzymaj config jako plik (np. YAML/JSON) i loguj go do artifactów:
|
|
||||||
- `model_name`, `model_version`
|
|
||||||
- `dataset_version` (hash)
|
|
||||||
- `seq_len`, `batch_size`, `grad_accum`, `lr`
|
|
||||||
- `lora_r`, `lora_alpha`, `lora_dropout` (jeśli LoRA)
|
|
||||||
- `seed`
|
|
||||||
|
|
||||||
### 3.3 Output
|
|
||||||
|
|
||||||
Preferowane outputy:
|
|
||||||
- LoRA adapter (mały): `adapter.safetensors` + config
|
|
||||||
- metryki treningu: `metrics.json`
|
|
||||||
- walidacja: `eval_report.json`
|
|
||||||
|
|
||||||
Opcjonalnie:
|
|
||||||
- export do ONNX/TensorRT jeśli planujesz inference poza GPU (zależne od modelu).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Gdzie trzymamy artefakty (żeby nie zniknęły)
|
|
||||||
|
|
||||||
Opcje (w kolejności prostoty):
|
|
||||||
|
|
||||||
1) **scp na VPS/k3s** (na start najprostsze)
|
|
||||||
2) **Gitea Packages** (jeśli chcesz wersjonować jako “package”)
|
|
||||||
3) **S3/MinIO** (najbardziej skalowalne)
|
|
||||||
|
|
||||||
Minimalne wymaganie: zawsze zapisuj `model_version` i `dataset_version` obok wag.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Jak to łączy się z executor / UI
|
|
||||||
|
|
||||||
Model na Vast zwraca `trade_intent` (patrz `doc/drift-perp-contract.md`).
|
|
||||||
Executor w k3s:
|
|
||||||
- buduje features z DB,
|
|
||||||
- woła predictor,
|
|
||||||
- waliduje gates,
|
|
||||||
- loguje:
|
|
||||||
- `decision_id`, `model_version`, `features_hash`, `intent`,
|
|
||||||
- outcome (fill/exit).
|
|
||||||
|
|
||||||
Jeśli Vast jest niedostępny:
|
|
||||||
- executor przechodzi w `observe/off` (nie handluje),
|
|
||||||
- UI nadal pokazuje warstwy rynku (DLOB/ticki).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Checklist “kilka godzin” (operacyjnie)
|
|
||||||
|
|
||||||
- [ ] Vast instance: GPU + wystarczająco VRAM + szybki disk.
|
|
||||||
- [ ] Pull docker image (pinned).
|
|
||||||
- [ ] Download dataset (jedna komenda).
|
|
||||||
- [ ] Train (z logowaniem metryk co N kroków).
|
|
||||||
- [ ] Eval (krótki).
|
|
||||||
- [ ] Export adapter/weights.
|
|
||||||
- [ ] Upload artifacts (scp / packages / S3).
|
|
||||||
- [ ] Zapisz `model_version` do DB/config (k3s) przed użyciem.
|
|
||||||
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Visualizer: świeczki + “brick stack” pod świecą
|
|
||||||
|
|
||||||
## Timeframe (tf)
|
|
||||||
|
|
||||||
W visualizerze `tf` to długość świecy (bucket) przekazywana do API:
|
|
||||||
|
|
||||||
- `3s`, `5s`, `15s`, `30s` — mikro‑ruchy (dużo szumu, ale świetne do obserwacji mikrostruktury)
|
|
||||||
- `1m`, `5m`, `15m`, `1h`, `4h`, `1d` — klasyczne interwały
|
|
||||||
|
|
||||||
Kiedy ma to sens:
|
|
||||||
- `3s/5s`: gdy chcesz widzieć “jak cena się buduje” w krótkich falach (np. po newsie / w dużej zmienności).
|
|
||||||
- `15s/30s`: często najlepszy kompromis między szumem a czytelnością, jeżeli patrzysz na very-short-term.
|
|
||||||
|
|
||||||
## Co pokazuje “brick stack” na dole
|
|
||||||
|
|
||||||
Pod każdą świecą rysujemy słupek złożony z “bricków” (małych segmentów) odpowiadających kolejnym krokom czasu wewnątrz świecy.
|
|
||||||
|
|
||||||
Kolory bricków:
|
|
||||||
- zielony = w tym kroku cena poszła w górę
|
|
||||||
- czerwony = w tym kroku cena poszła w dół
|
|
||||||
- niebieski = w tym kroku cena była stała (flat)
|
|
||||||
|
|
||||||
Wysokość bricków:
|
|
||||||
- zielony/czerwony: proporcjonalna do `|Δprice|` w danym kroku
|
|
||||||
- niebieski: stała (unit height)
|
|
||||||
|
|
||||||
Bricki są rozdzielone cienką czarną linią (1px), żeby było widać strukturę “krok po kroku”.
|
|
||||||
|
|
||||||
## Jakie pola musi zwracać API
|
|
||||||
|
|
||||||
Endpoint `GET /v1/chart` zwraca w każdej świecy:
|
|
||||||
|
|
||||||
- `flow`: udziały czasu `up/down/flat` w całym buckecie (0..1)
|
|
||||||
- `flowRows`: tablica kierunków per krok czasu: `-1` (down), `0` (flat), `1` (up)
|
|
||||||
- `flowMoves`: tablica “move magnitude” per krok czasu (wartości dodatnie; 0 jeśli flat)
|
|
||||||
|
|
||||||
To właśnie `flowRows` + `flowMoves` są używane do narysowania brick stacka.
|
|
||||||
|
|
||||||
## Domyślny rynek
|
|
||||||
|
|
||||||
W visualizerze domyślnie ustawiony jest `SOL-PERP`.
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Visualizer UI: kafelki → warstwy (stack)
|
|
||||||
|
|
||||||
Ten dokument opisuje MVP dla przełączania układu UI w `apps/visualizer`:
|
|
||||||
|
|
||||||
- **Grid (kafelki / standard)**: obecny layout aplikacji.
|
|
||||||
- **Stack (warstwy / fullscreen)**: jedna aktywna warstwa jest na wierzchu (fullscreen), a kolejność warstw można ustawiać przez **DnD**.
|
|
||||||
|
|
||||||
## Zachowanie (MVP)
|
|
||||||
|
|
||||||
### Wejście / wyjście ze stack
|
|
||||||
- **Chart**: przycisk `Fullscreen` w pasku narzędzi wykresu przełącza tryb `grid` ↔ `stack` (dla warstwy `chart`).
|
|
||||||
- **DLOB**: przycisk `Fullscreen` w nagłówku DLOB przełącza tryb `grid` ↔ `stack` (dla warstwy `dlob`).
|
|
||||||
- Wyjście ze stack:
|
|
||||||
- przycisk `Back` w panelu warstw
|
|
||||||
- klawisz `Esc` **dwa razy** (w ciągu ~0.8s)
|
|
||||||
|
|
||||||
### Warstwy i DnD
|
|
||||||
- W stack pojawia się panel `Layers` jako **wysuwany drawer z boku**.
|
|
||||||
- Lista w panelu jest **od góry (top) do dołu (bottom)**:
|
|
||||||
- element na górze listy jest `active` (czyli jest “na wierzchu”).
|
|
||||||
- **DnD** na liście:
|
|
||||||
- przeciągnij element i upuść na inny — zmienisz kolejność (top/bottom).
|
|
||||||
- Kliknięcie elementu na liście przenosi go na wierzch (`active`).
|
|
||||||
|
|
||||||
### Warstwy kosztów (Costs)
|
|
||||||
- Są dwie osobne warstwy kosztów:
|
|
||||||
- `Costs (New)` — estymata nowego kontraktu (domyślnie **na samej górze**),
|
|
||||||
- `Costs (Active)` — monitoring puszczonego kontraktu (pojawia się i jest ustawiana **jeden poziom niżej** po wpisaniu `contract_id`).
|
|
||||||
- Możesz zmienić kolejność DnD (to nadpisuje domyślne ustawienie).
|
|
||||||
- Po pierwszym DnD aplikacja oznacza układ jako “manualny” i nie przestawia już automatycznie warstw przy pojawieniu się `contract_id`.
|
|
||||||
|
|
||||||
### Auto-hide + lock
|
|
||||||
- Domyślnie drawer ma **auto-hide** (chowa się po ~1s po zjechaniu kursorem z panelu).
|
|
||||||
- Przycisk `Auto/Locked`:
|
|
||||||
- `Auto` = auto-hide włączony,
|
|
||||||
- `Locked` = auto-hide wyłączony (drawer zostaje otwarty).
|
|
||||||
- Drawer otwiera się po najechaniu kursorem na wąski “hotspot” przy lewym brzegu ekranu.
|
|
||||||
|
|
||||||
### Opacity (UI)
|
|
||||||
- W drawerze są suwaki:
|
|
||||||
- `Backdrop` (przyciemnienie tła w stack)
|
|
||||||
- `Panel` (tło drawera)
|
|
||||||
- Wartości są zapisywane w localStorage (`trade.stackBackdropOpacity`, `trade.stackDrawerOpacity`).
|
|
||||||
|
|
||||||
### Opacity (warstwy)
|
|
||||||
- Każda warstwa ma swój suwak opacity (0–100%):
|
|
||||||
- `0%` = warstwa niewidoczna,
|
|
||||||
- `100%` = pełna widoczność.
|
|
||||||
- DnD nadal ustawia kolejność (z-index), a interakcje trafiają do warstwy `active` (top).
|
|
||||||
- Wartości są zapisywane w localStorage (`trade.layerOpacity`).
|
|
||||||
|
|
||||||
### Jasność (warstwy)
|
|
||||||
- Każda warstwa ma suwak `brightness` (60–180%).
|
|
||||||
- To jest filtr UI (nie wpływa na dane), przydatny gdy chcesz rozjaśnić wykres/DLOB w stack.
|
|
||||||
- Wartości są zapisywane w localStorage (`trade.layerBrightness`).
|
|
||||||
|
|
||||||
### Widoczność + lock (warstwy)
|
|
||||||
- Ikona “oka” przełącza widoczność warstwy (nie resetuje suwaka opacity).
|
|
||||||
- Ikona “kłódki” blokuje warstwę:
|
|
||||||
- nie da się jej przeciągać (DnD),
|
|
||||||
- suwak opacity jest wyłączony,
|
|
||||||
- klik w wiersz nie zmienia kolejności (nie “wypycha” na top).
|
|
||||||
- Ustawienia są zapisywane w localStorage (`trade.layerVisible`, `trade.layerLocked`).
|
|
||||||
|
|
||||||
## Implementacja (gdzie w kodzie)
|
|
||||||
- Sterowanie układem i panel `Layers`: `apps/visualizer/src/App.tsx`
|
|
||||||
- `trade.layoutMode` w localStorage: `'grid' | 'stack'`
|
|
||||||
- `trade.stackOrder` w localStorage: kolejność warstw (z-index; ostatni = top)
|
|
||||||
- `trade.stackOrderManual` w localStorage: czy użytkownik zmienił kolejność przez DnD (blokuje auto-przestawianie)
|
|
||||||
- `trade.stackPanelLocked` w localStorage: blokada auto-hide panelu warstw
|
|
||||||
- `trade.contractId` w localStorage: aktywny kontrakt do monitoringu
|
|
||||||
- Fullscreen chart przez warstwy: `apps/visualizer/src/features/chart/ChartPanel.tsx`
|
|
||||||
- `fullscreenOverride` + `onToggleFullscreenOverride` (fullscreen kontrolowany z zewnątrz, bez backdropu)
|
|
||||||
- Fullscreen DLOB: `apps/visualizer/src/features/market/DlobDashboard.tsx`
|
|
||||||
- `isFullscreen` + `onToggleFullscreen` (przycisk w nagłówku)
|
|
||||||
- Panel kosztów: `apps/visualizer/src/features/contracts/ContractCostsPanel.tsx`
|
|
||||||
|
|
||||||
## Co dalej (kolejne iteracje)
|
|
||||||
|
|
||||||
MVP nie robi jeszcze “prawdziwego” układu kafelków z:
|
|
||||||
- drag/resize okien w trybie stack,
|
|
||||||
- wyświetlaniem kilku warstw jednocześnie (np. jako nakładające się okna),
|
|
||||||
- przełączaniem grid→stack przez zoom kafelka (z animacją).
|
|
||||||
|
|
||||||
Proponowane następne kroki:
|
|
||||||
1) wydzielić `Pane` abstraction (id, title, render, hotkeys),
|
|
||||||
2) zrobić `grid` jako faktyczne kafelki (Chart, DLOB, Orderbook, TradeForm),
|
|
||||||
3) dodać “zoom” kafelka → wejście do stack,
|
|
||||||
4) dodać opcjonalnie drag/resize w stack (na start tylko z-index + focus).
|
|
||||||
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`.
|
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.
|
- 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`.
|
- 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`.
|
- 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).
|
- 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
|
```bash
|
||||||
cd apps/visualizer
|
cd apps/visualizer
|
||||||
API_PROXY_TARGET=https://trade.mpabi.pl \
|
bash __start
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Jeśli staging ma dodatkowy basic auth (np. Traefik `basicAuth`), dodaj:
|
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 fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
|
import net from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import tls from 'node:tls';
|
||||||
|
|
||||||
const PORT = Number.parseInt(process.env.PORT || '8081', 10);
|
const PORT = Number.parseInt(process.env.PORT || '8081', 10);
|
||||||
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
|
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error(`Invalid PORT: ${process.env.PORT}`);
|
||||||
@@ -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 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_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 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')
|
const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
@@ -57,15 +62,15 @@ function timingSafeEqualBuf(a, b) {
|
|||||||
|
|
||||||
function loadBasicAuth() {
|
function loadBasicAuth() {
|
||||||
const j = readJson(BASIC_AUTH_FILE);
|
const j = readJson(BASIC_AUTH_FILE);
|
||||||
const username = (j?.username || '').toString().trim();
|
const username = (j?.username || '').toString();
|
||||||
const password = (j?.password || '').toString().trim();
|
const password = (j?.password || '').toString();
|
||||||
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
|
if (!username || !password) throw new Error(`Invalid BASIC_AUTH_FILE: ${BASIC_AUTH_FILE}`);
|
||||||
return { username, password };
|
return { username, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadApiReadToken() {
|
function loadApiReadToken() {
|
||||||
const j = readJson(API_READ_TOKEN_FILE);
|
const j = readJson(API_READ_TOKEN_FILE);
|
||||||
const token = (j?.token || '').toString().trim();
|
const token = (j?.token || '').toString();
|
||||||
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
|
if (!token) throw new Error(`Invalid API_READ_TOKEN_FILE: ${API_READ_TOKEN_FILE}`);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -413,6 +418,122 @@ function proxyApi(req, res, apiReadToken) {
|
|||||||
req.pipe(upstreamReq);
|
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) {
|
async function handler(req, res) {
|
||||||
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
|
if (req.method === 'GET' && (req.url === '/healthz' || req.url?.startsWith('/healthz?'))) {
|
||||||
send(
|
send(
|
||||||
@@ -425,6 +546,25 @@ async function handler(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
|
if (isGraphqlPath(url.pathname)) {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
withCors(res);
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
|
||||||
|
const user = resolveAuthenticatedUser(req);
|
||||||
|
if (!user) {
|
||||||
|
withCors(res);
|
||||||
|
unauthorized(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withCors(res);
|
||||||
|
proxyGraphqlHttp(req, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === 'GET' && url.pathname === '/whoami') {
|
if (req.method === 'GET' && url.pathname === '/whoami') {
|
||||||
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
|
sendJson(res, 200, { ok: true, user: resolveAuthenticatedUser(req), mode: AUTH_MODE });
|
||||||
return;
|
return;
|
||||||
@@ -532,6 +672,30 @@ const server = http.createServer((req, res) => {
|
|||||||
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
|
send(res, 500, { 'content-type': 'text/plain; charset=utf-8' }, String(e?.message || e));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
|
if (!isGraphqlPath(url.pathname)) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (AUTH_MODE !== 'off' && AUTH_MODE !== 'none' && AUTH_MODE !== 'disabled') {
|
||||||
|
const user = resolveAuthenticatedUser(req);
|
||||||
|
if (!user) {
|
||||||
|
try {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxyGraphqlWs(req, socket, head);
|
||||||
|
} catch {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -540,6 +704,7 @@ server.listen(PORT, () => {
|
|||||||
port: PORT,
|
port: PORT,
|
||||||
staticDir: STATIC_DIR,
|
staticDir: STATIC_DIR,
|
||||||
apiUpstream: API_UPSTREAM,
|
apiUpstream: API_UPSTREAM,
|
||||||
|
hasuraUpstream: HASURA_UPSTREAM,
|
||||||
basicAuthFile: BASIC_AUTH_FILE,
|
basicAuthFile: BASIC_AUTH_FILE,
|
||||||
basicAuthMode: BASIC_AUTH_MODE,
|
basicAuthMode: BASIC_AUTH_MODE,
|
||||||
apiReadTokenFile: API_READ_TOKEN_FILE,
|
apiReadTokenFile: API_READ_TOKEN_FILE,
|
||||||
|
|||||||
Reference in New Issue
Block a user