From a1883080133bba4f08a10efd966d6d0c34dde771 Mon Sep 17 00:00:00 2001 From: u1 Date: Sun, 29 Mar 2026 13:14:30 +0200 Subject: [PATCH] chore(snapshot): sync frontend workspace state --- apps/visualizer/.env.example | 13 +- apps/visualizer/__r | 112 + apps/visualizer/__start | 29 + apps/visualizer/diagram.html | 12 + apps/visualizer/package-lock.json | 216 +- apps/visualizer/package.json | 1 + apps/visualizer/src/App.tsx | 662 ++++- apps/visualizer/src/diagram-main.tsx | 11 + apps/visualizer/src/diagram.css | 943 +++++++ .../src/diagram/TradeSystemFlowPage.tsx | 2327 +++++++++++++++++ .../src/features/bot/BotObserverPanel.tsx | 282 ++ .../features/bot/BotObserverSummaryCard.tsx | 185 ++ .../src/features/bot/useBotObserver.ts | 290 ++ .../src/features/chart/ChartIcons.tsx | 15 + .../src/features/chart/ChartLayersPanel.tsx | 206 ++ .../src/features/chart/ChartPanel.tsx | 406 ++- .../src/features/chart/ChartPanel.types.ts | 8 + .../src/features/chart/ChartSideToolbar.tsx | 16 +- .../src/features/chart/ChartToolbar.tsx | 20 +- .../features/chart/FibRetracementPrimitive.ts | 44 +- .../features/chart/KeyboardCursorPrimitive.ts | 147 ++ .../src/features/chart/TradingChart.tsx | 1375 +++++++++- .../src/features/chart/useChartData.ts | 212 +- .../src/features/market/DlobDashboard.tsx | 137 + .../features/market/DlobDepthBandsPanel.tsx | 75 + .../src/features/market/DlobSlippageChart.tsx | 112 + .../src/features/market/dlobDerivedMetrics.ts | 244 ++ .../src/features/market/dlobSource.ts | 32 + .../src/features/market/useDlobDepthBands.ts | 19 + .../src/features/market/useDlobL2.ts | 304 +++ .../src/features/market/useDlobSlippage.ts | 17 + .../src/features/market/useDlobStats.ts | 134 + .../src/features/tickerbar/TickerBar.tsx | 6 +- apps/visualizer/src/layout/AppShell.tsx | 56 +- apps/visualizer/src/layout/AuthStatus.tsx | 51 +- apps/visualizer/src/layout/LoginScreen.tsx | 21 +- apps/visualizer/src/layout/TopNav.tsx | 11 +- apps/visualizer/src/lib/api.ts | 331 ++- apps/visualizer/src/lib/graphqlWs.ts | 294 +++ apps/visualizer/src/lib/hasura.ts | 7 +- apps/visualizer/src/lib/runtime.ts | 366 +++ apps/visualizer/src/styles.css | 1398 +++++++++- apps/visualizer/vite.config.ts | 232 +- services/frontend/server.mjs | 170 +- 44 files changed, 11187 insertions(+), 362 deletions(-) create mode 100755 apps/visualizer/__r create mode 100644 apps/visualizer/__start create mode 100644 apps/visualizer/diagram.html create mode 100644 apps/visualizer/src/diagram-main.tsx create mode 100644 apps/visualizer/src/diagram.css create mode 100644 apps/visualizer/src/diagram/TradeSystemFlowPage.tsx create mode 100644 apps/visualizer/src/features/bot/BotObserverPanel.tsx create mode 100644 apps/visualizer/src/features/bot/BotObserverSummaryCard.tsx create mode 100644 apps/visualizer/src/features/bot/useBotObserver.ts create mode 100644 apps/visualizer/src/features/chart/ChartLayersPanel.tsx create mode 100644 apps/visualizer/src/features/chart/ChartPanel.types.ts create mode 100644 apps/visualizer/src/features/chart/KeyboardCursorPrimitive.ts create mode 100644 apps/visualizer/src/features/market/DlobDashboard.tsx create mode 100644 apps/visualizer/src/features/market/DlobDepthBandsPanel.tsx create mode 100644 apps/visualizer/src/features/market/DlobSlippageChart.tsx create mode 100644 apps/visualizer/src/features/market/dlobDerivedMetrics.ts create mode 100644 apps/visualizer/src/features/market/dlobSource.ts create mode 100644 apps/visualizer/src/features/market/useDlobDepthBands.ts create mode 100644 apps/visualizer/src/features/market/useDlobL2.ts create mode 100644 apps/visualizer/src/features/market/useDlobSlippage.ts create mode 100644 apps/visualizer/src/features/market/useDlobStats.ts create mode 100644 apps/visualizer/src/lib/graphqlWs.ts create mode 100644 apps/visualizer/src/lib/runtime.ts mode change 100644 => 100755 services/frontend/server.mjs diff --git a/apps/visualizer/.env.example b/apps/visualizer/.env.example index 9e85877..aae6f87 100644 --- a/apps/visualizer/.env.example +++ b/apps/visualizer/.env.example @@ -1,12 +1,15 @@ # Default: UI reads ticks from the same-origin API proxy at `/api`. VITE_API_URL=/api -# Fallback (optional): query Hasura directly (not recommended in browser). -VITE_HASURA_URL=http://localhost:8080/v1/graphql -# Optional (only if you intentionally query Hasura directly from the browser): +# Hasura GraphQL endpoint (supports subscriptions via WS). +# On VPS, `trade-frontend` proxies Hasura at the same origin under `/graphql`. +VITE_HASURA_URL=/graphql +# Optional explicit WS URL; when omitted the app derives it from `VITE_HASURA_URL`. +# Can be absolute (wss://...) or a same-origin path (e.g. /graphql-ws). +# VITE_HASURA_WS_URL=/graphql-ws +# Optional auth (only if Hasura is not configured with `HASURA_GRAPHQL_UNAUTHORIZED_ROLE=public`): # VITE_HASURA_AUTH_TOKEN=YOUR_JWT -# VITE_HASURA_ADMIN_SECRET=devsecret -VITE_SYMBOL=PUMP-PERP +VITE_SYMBOL=SOL-PERP # Optional: filter by source (leave empty for all) # VITE_SOURCE=drift_oracle VITE_POLL_MS=1000 diff --git a/apps/visualizer/__r b/apps/visualizer/__r new file mode 100755 index 0000000..f586fef --- /dev/null +++ b/apps/visualizer/__r @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SSH_TUNNEL_TARGET="${SSH_TUNNEL_TARGET:-mevnode_bot}" +K8S_NAMESPACE="${K8S_NAMESPACE:-trade-staging}" + +SSH_FRONTEND_LOCAL_PORT="${SSH_FRONTEND_LOCAL_PORT:-13081}" +SSH_HASURA_LOCAL_PORT="${SSH_HASURA_LOCAL_PORT:-18080}" + +SSH_FRONTEND_REMOTE_HOST="${SSH_FRONTEND_REMOTE_HOST:-127.0.0.1}" +SSH_FRONTEND_REMOTE_PORT="${SSH_FRONTEND_REMOTE_PORT:-30081}" +SSH_HASURA_REMOTE_PORT="${SSH_HASURA_REMOTE_PORT:-8080}" + +VITE_DEV_PORT="${VITE_DEV_PORT:-5174}" + +SSH_PID="" + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +wait_for_listen() { + local port="$1" + local label="$2" + local tries="${3:-80}" + local delay="${4:-0.25}" + local i + + for ((i = 0; i < tries; i += 1)); do + if ss -ltnH | awk '{print $4}' | grep -Eq "(^|[:])${port}$"; then + return 0 + fi + sleep "${delay}" + done + + echo "Timed out waiting for ${label} on port ${port}" >&2 + return 1 +} + +get_cluster_ip() { + local service="$1" + ssh \ + -o BatchMode=yes \ + -o StrictHostKeyChecking=accept-new \ + -T "${SSH_TUNNEL_TARGET}" \ + "kubectl -n ${K8S_NAMESPACE} get svc ${service} -o jsonpath='{.spec.clusterIP}'" +} + +cleanup() { + local code=$? + trap - EXIT INT TERM + + if [[ -n "${SSH_PID}" ]] && kill -0 "${SSH_PID}" 2>/dev/null; then + kill "${SSH_PID}" 2>/dev/null || true + wait "${SSH_PID}" 2>/dev/null || true + fi + + exit "${code}" +} + +trap cleanup EXIT INT TERM + +require_cmd ssh +require_cmd npm +require_cmd ss + +HASURA_CLUSTER_IP="${HASURA_CLUSTER_IP:-$(get_cluster_ip hasura)}" + +if [[ -z "${HASURA_CLUSTER_IP}" ]]; then + echo "Could not resolve ClusterIP for hasura in namespace ${K8S_NAMESPACE}" >&2 + exit 1 +fi + +echo "SSH target: ${SSH_TUNNEL_TARGET}" +echo "Namespace: ${K8S_NAMESPACE}" +echo "hasura ClusterIP: ${HASURA_CLUSTER_IP}" + +echo "Starting SSH tunnel..." +ssh \ + -o StrictHostKeyChecking=accept-new \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -N \ + -L "${SSH_FRONTEND_LOCAL_PORT}:${SSH_FRONTEND_REMOTE_HOST}:${SSH_FRONTEND_REMOTE_PORT}" \ + -L "${SSH_HASURA_LOCAL_PORT}:${HASURA_CLUSTER_IP}:${SSH_HASURA_REMOTE_PORT}" \ + "${SSH_TUNNEL_TARGET}" & +SSH_PID=$! + +wait_for_listen "${SSH_FRONTEND_LOCAL_PORT}" "frontend SSH tunnel" +wait_for_listen "${SSH_HASURA_LOCAL_PORT}" "Hasura SSH tunnel" + +echo "Starting Vite on :${VITE_DEV_PORT}..." +cd "${SCRIPT_DIR}" + +export VITE_DEV_PORT +export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}" +export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-ws://127.0.0.1:${SSH_HASURA_LOCAL_PORT}/v1/graphql}" +export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-http://127.0.0.1:${SSH_FRONTEND_LOCAL_PORT}}" +export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-http://127.0.0.1:${SSH_HASURA_LOCAL_PORT}}" +export GRAPHQL_PROXY_PATH="${GRAPHQL_PROXY_PATH:-/v1/graphql}" + +echo "Frontend auth proxy: ${FRONTEND_PROXY_TARGET}" +echo "GraphQL proxy: ${GRAPHQL_PROXY_TARGET}${GRAPHQL_PROXY_PATH}" +echo "Open: http://localhost:${VITE_DEV_PORT}/" + +exec npm run dev diff --git a/apps/visualizer/__start b/apps/visualizer/__start new file mode 100644 index 0000000..16e027f --- /dev/null +++ b/apps/visualizer/__start @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +cd "${SCRIPT_DIR}" + +if [[ -n "${SSH_TUNNEL_PORT:-}" ]]; then + export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-http://127.0.0.1:${SSH_TUNNEL_PORT}}" + export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-http://127.0.0.1:${SSH_TUNNEL_PORT}}" +else + export FRONTEND_PROXY_TARGET="${FRONTEND_PROXY_TARGET:-https://trade.mpabi.pl}" + export GRAPHQL_PROXY_TARGET="${GRAPHQL_PROXY_TARGET:-https://trade.mpabi.pl}" +fi + +export VITE_HASURA_URL="${VITE_HASURA_URL:-/graphql}" +export VITE_HASURA_WS_URL="${VITE_HASURA_WS_URL:-/graphql-ws}" + +if [[ -z "${API_PROXY_BASIC_AUTH:-}" && -z "${API_PROXY_BASIC_AUTH_FILE:-}" ]]; then + if [[ -f "${ROOT_DIR}/tokens/frontend.json" ]]; then + export API_PROXY_BASIC_AUTH_FILE="tokens/frontend.json" + else + echo "Missing basic auth config for VPS proxy." + echo "Set API_PROXY_BASIC_AUTH='USER:PASS' or create tokens/frontend.json" >&2 + fi +fi + +npm run dev diff --git a/apps/visualizer/diagram.html b/apps/visualizer/diagram.html new file mode 100644 index 0000000..f810b49 --- /dev/null +++ b/apps/visualizer/diagram.html @@ -0,0 +1,12 @@ + + + + + + Trade system diagram + + +
+ + + diff --git a/apps/visualizer/package-lock.json b/apps/visualizer/package-lock.json index 38368dd..c15303f 100644 --- a/apps/visualizer/package-lock.json +++ b/apps/visualizer/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "trade-visualizer", "dependencies": { + "@xyflow/react": "^12.8.5", "chart.js": "^4.4.1", "chartjs-adapter-luxon": "^1.3.1", "lightweight-charts": "^5.0.8", @@ -1110,6 +1111,49 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1121,14 +1165,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1166,6 +1210,36 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -1253,6 +1327,11 @@ "luxon": ">=1.0.0" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1264,9 +1343,105 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1664,6 +1839,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1730,6 +1913,33 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/apps/visualizer/package.json b/apps/visualizer/package.json index 757fbe7..4c4d59f 100644 --- a/apps/visualizer/package.json +++ b/apps/visualizer/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "@xyflow/react": "^12.8.5", "chart.js": "^4.4.1", "chartjs-adapter-luxon": "^1.3.1", "lightweight-charts": "^5.0.8", diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index e36805f..58b93b2 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocalStorageState } from './app/hooks/useLocalStorageState'; import AppShell from './layout/AppShell'; import ChartPanel from './features/chart/ChartPanel'; @@ -11,6 +12,14 @@ import Button from './ui/Button'; import TopNav from './layout/TopNav'; import AuthStatus from './layout/AuthStatus'; import LoginScreen from './layout/LoginScreen'; +import { useDlobStats } from './features/market/useDlobStats'; +import { useDlobL2 } from './features/market/useDlobL2'; +import { useDlobSlippage } from './features/market/useDlobSlippage'; +import { useDlobDepthBands } from './features/market/useDlobDepthBands'; +import DlobDashboard from './features/market/DlobDashboard'; +import BotObserverPanel from './features/bot/BotObserverPanel'; +import BotObserverSummaryCard from './features/bot/BotObserverSummaryCard'; +import { useBotObserver } from './features/bot/useBotObserver'; function envNumber(name: string, fallback: number): number { const v = (import.meta as any).env?.[name]; @@ -26,7 +35,8 @@ function envString(name: string, fallback: string): string { function formatUsd(v: number | null | undefined): string { if (v == null || !Number.isFinite(v)) return '—'; - if (v >= 1000) return `$${v.toFixed(0)}`; + if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`; if (v >= 1) return `$${v.toFixed(2)}`; return `$${v.toPrecision(4)}`; } @@ -36,6 +46,111 @@ function formatQty(v: number | null | undefined, decimals: number): string { return v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } +function formatCompact(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + const abs = Math.abs(v); + if (abs >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`; + if (abs >= 1000) return `${(v / 1000).toFixed(0)}K`; + if (abs >= 1) return v.toFixed(2); + return v.toPrecision(4); +} + +function formatOrderbookUsd(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 >= 100_000) return `${(v / 1000).toFixed(1)}K`; + if (abs >= 10_000) return `${(v / 1000).toFixed(2)}K`; + if (abs >= 1000) return `${(v / 1000).toFixed(2)}K`; + if (abs >= 100) return v.toFixed(0); + if (abs >= 1) return v.toFixed(2); + return v.toPrecision(4); +} + +function formatClockTime(iso: string | null | undefined): string { + if (!iso) return '—'; + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return '—'; + return new Date(ts).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function resolveOrderbookGroupingLabel(symbol: string, mode: string, referencePrice: number | null): string { + const normalizedMode = String(mode || 'drift').trim().toLowerCase(); + if (normalizedMode === 'raw') return 'Raw'; + if (normalizedMode !== 'drift') return normalizedMode; + const px = referencePrice == null || !Number.isFinite(referencePrice) ? 0 : Math.abs(referencePrice); + let step = 0.000001; + if (px >= 1000) step = 0.1; + else if (px >= 100) step = 0.01; + else if (px >= 10) step = 0.001; + else if (px >= 1) step = 0.0001; + else if (px >= 0.1) step = 0.00001; + return `Drift (${step.toFixed(step >= 0.1 ? 1 : step >= 0.01 ? 2 : step >= 0.001 ? 3 : step >= 0.0001 ? 4 : 6).replace(/0+$/, '').replace(/\.$/, '')})`; +} + +function clamp01(scale: number): number { + return Number.isFinite(scale) && scale > 0 ? Math.min(1, scale) : 0; +} + +function orderbookRowBarStyle(totalScale: number): CSSProperties { + return { + ['--ob-bar-scale' as any]: clamp01(totalScale), + } as CSSProperties; +} + +const ORDERBOOK_LEVELS_STACKED = 12; +const ORDERBOOK_LEVELS_LADDER = 20; +type OrderbookLayoutMode = 'stacked' | 'ladder'; +type OrderbookBarScaleMode = 'per-side' | 'shared-log'; +type VisualTheme = 'day' | 'night'; + +function normalizeVisualTheme(value: unknown): VisualTheme { + return value === 'day' ? 'day' : 'night'; +} + +function OrderbookLayoutIcon({ mode }: { mode: OrderbookLayoutMode }) { + if (mode === 'ladder') { + return ( + + ); + } + + return ( + + ); +} + +function OrderbookScaleIcon({ mode }: { mode: OrderbookBarScaleMode }) { + if (mode === 'shared-log') { + return ( + + ); + } + + return ( + + ); +} + +function liquidityStyle(bid: number, ask: number): CSSProperties { + const max = Math.max(1e-9, bid, ask); + const b = Number.isFinite(bid) && bid > 0 ? Math.min(1, bid / max) : 0; + const a = Number.isFinite(ask) && ask > 0 ? Math.min(1, ask / max) : 0; + return { ['--liq-bid' as any]: b, ['--liq-ask' as any]: a } as CSSProperties; +} + type WhoamiResponse = { ok?: boolean; user?: string | null; @@ -45,8 +160,24 @@ type WhoamiResponse = { export default function App() { const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); + const [storedTheme, setStoredTheme] = useLocalStorageState('trade.theme.v1', 'night'); + const devAuthUser = useMemo(() => envString('VITE_DEV_AUTH_USER', '').trim(), []); + const theme = normalizeVisualTheme(storedTheme); useEffect(() => { + document.documentElement.dataset.theme = theme; + return () => { + delete document.documentElement.dataset.theme; + }; + }, [theme]); + + useEffect(() => { + if (devAuthUser) { + setUser(devAuthUser); + setAuthLoading(false); + return; + } + let cancelled = false; setAuthLoading(true); fetch('/whoami', { cache: 'no-store' }) @@ -71,7 +202,7 @@ export default function App() { return () => { cancelled = true; }; - }, []); + }, [devAuthUser]); const logout = async () => { try { @@ -95,21 +226,39 @@ export default function App() { return ; } - return void logout()} />; + return ( + setStoredTheme(theme === 'day' ? 'night' : 'day')} + onLogout={() => void logout()} + /> + ); } -function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { - const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', 'BTC-PERP', 'ETH-PERP'], []); +function TradeApp({ + user, + theme, + onToggleTheme, + onLogout, +}: { + user: string; + theme: VisualTheme; + onToggleTheme: () => void; + onLogout: () => void; +}) { + const markets = useMemo(() => ['PUMP-PERP', 'SOL-PERP', '1MBONK-PERP', 'BTC-PERP', 'ETH-PERP'], []); - const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'PUMP-PERP')); + const [symbol, setSymbol] = useLocalStorageState('trade.symbol', envString('VITE_SYMBOL', 'SOL-PERP')); const [source, setSource] = useLocalStorageState('trade.source', envString('VITE_SOURCE', '')); - const [tf, setTf] = useLocalStorageState('trade.tf', envString('VITE_TF', '1m')); + const [tf, setTf] = useLocalStorageState('trade.tf.v2', envString('VITE_TF', '1s')); const [pollMs, setPollMs] = useLocalStorageState('trade.pollMs', envNumber('VITE_POLL_MS', 1000)); const [limit, setLimit] = useLocalStorageState('trade.limit', envNumber('VITE_LIMIT', 300)); const [showIndicators, setShowIndicators] = useLocalStorageState('trade.showIndicators', true); + const [showBuild, setShowBuild] = useLocalStorageState('trade.showBuild', true); const [tab, setTab] = useLocalStorageState<'orderbook' | 'trades'>('trade.sidebarTab', 'orderbook'); const [bottomTab, setBottomTab] = useLocalStorageState< - 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' + 'dlob' | 'bot' | 'positions' | 'orders' | 'balances' | 'orderHistory' | 'positionHistory' >('trade.bottomTab', 'positions'); const [tradeSide, setTradeSide] = useLocalStorageState<'long' | 'short'>('trade.form.side', 'long'); const [tradeOrderType, setTradeOrderType] = useLocalStorageState<'market' | 'limit' | 'other'>( @@ -118,8 +267,29 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { ); const [tradePrice, setTradePrice] = useLocalStorageState('trade.form.price', 0); const [tradeSize, setTradeSize] = useLocalStorageState('trade.form.size', 0.1); + const [orderbookGrouping, setOrderbookGrouping] = useLocalStorageState('trade.orderbookGrouping.v1', 'drift'); + const [orderbookLayout, setOrderbookLayout] = useLocalStorageState( + 'trade.orderbookLayout.v1', + 'ladder' + ); + const [orderbookBarScaleMode, setOrderbookBarScaleMode] = useLocalStorageState( + 'trade.orderbookBarScale.v1', + 'per-side' + ); + const [chartResetView, setChartResetView] = useState<(() => void) | null>(null); + const orderbookLevels = orderbookLayout === 'ladder' ? ORDERBOOK_LEVELS_LADDER : ORDERBOOK_LEVELS_STACKED; - const { candles, indicators, loading, error, refresh } = useChartData({ + useEffect(() => { + if (symbol === 'BONK-PERP') { + setSymbol('1MBONK-PERP'); + return; + } + if (!markets.includes(symbol)) { + setSymbol('SOL-PERP'); + } + }, [markets, setSymbol, symbol]); + + const { candles, indicators, meta, loading, error, refresh, hasMoreHistory, requestHistoryBefore } = useChartData({ symbol, source: source.trim() ? source : undefined, tf, @@ -127,43 +297,165 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { pollMs, }); + const { stats: dlob, connected: dlobConnected, error: dlobError } = useDlobStats(symbol); + const { l2: dlobL2, connected: dlobL2Connected, error: dlobL2Error } = useDlobL2(symbol, { + levels: orderbookLevels, + grouping: orderbookGrouping, + }); + const { rows: slippageRows, connected: slippageConnected, error: slippageError } = useDlobSlippage(symbol); + const { rows: depthBands, connected: depthBandsConnected, error: depthBandsError } = useDlobDepthBands(symbol); + const botObserver = useBotObserver(symbol); + const latest = candles.length ? candles[candles.length - 1] : null; const first = candles.length ? candles[0] : null; const changePct = first && latest && first.close > 0 ? ((latest.close - first.close) / first.close) * 100 : null; + const liveLastPrice = latest?.close ?? dlob?.markPrice ?? dlob?.mid ?? null; + const liveOraclePrice = latest?.oracle ?? dlob?.oraclePrice ?? null; const orderbook = useMemo(() => { - if (!latest) return { asks: [], bids: [], mid: null as number | null }; + if (dlobL2) { + return { + asks: dlobL2.asks, + bids: dlobL2.bids, + mid: dlobL2.mid as number | null, + bestBid: dlobL2.bestBid, + bestAsk: dlobL2.bestAsk, + updatedAt: dlobL2.updatedAt, + }; + } + if (!latest) { + return { + asks: [], + bids: [], + mid: null as number | null, + bestBid: null as number | null, + bestAsk: null as number | null, + updatedAt: null as string | null, + }; + } const mid = latest.close; const step = Math.max(mid * 0.00018, 0.0001); - const levels = 14; + const levels = orderbookLevels; const asksRaw = Array.from({ length: levels }, (_, i) => ({ price: mid + (i + 1) * step, - size: 0.1 + ((i * 7) % 15) * 0.1, + sizeBase: 0.1 + ((i * 7) % 15) * 0.1, })); const bidsRaw = Array.from({ length: levels }, (_, i) => ({ price: mid - (i + 1) * step, - size: 0.1 + ((i * 5) % 15) * 0.1, + sizeBase: 0.1 + ((i * 5) % 15) * 0.1, })); - let askTotal = 0; + let askTotalBase = 0; + let askTotalUsd = 0; const asks = asksRaw .slice() .reverse() .map((r) => { - askTotal += r.size; - return { ...r, total: askTotal }; + const sizeUsd = r.sizeBase * r.price; + askTotalBase += r.sizeBase; + askTotalUsd += sizeUsd; + return { ...r, sizeUsd, totalBase: askTotalBase, totalUsd: askTotalUsd }; }); - let bidTotal = 0; + let bidTotalBase = 0; + let bidTotalUsd = 0; const bids = bidsRaw.map((r) => { - bidTotal += r.size; - return { ...r, total: bidTotal }; + const sizeUsd = r.sizeBase * r.price; + bidTotalBase += r.sizeBase; + bidTotalUsd += sizeUsd; + return { ...r, sizeUsd, totalBase: bidTotalBase, totalUsd: bidTotalUsd }; }); - return { asks, bids, mid }; - }, [latest]); + return { asks, bids, mid, bestBid: bidsRaw[0]?.price ?? null, bestAsk: asksRaw[0]?.price ?? null, updatedAt: null }; + }, [dlobL2, latest, orderbookLevels]); + + const maxAskSize = useMemo(() => { + let max = 0; + for (const r of orderbook.asks) max = Math.max(max, (r as any).sizeUsd || 0); + return max; + }, [orderbook.asks]); + + const maxBidSize = useMemo(() => { + let max = 0; + for (const r of orderbook.bids) max = Math.max(max, (r as any).sizeUsd || 0); + return max; + }, [orderbook.bids]); + + const liquidity = useMemo(() => { + const bid = orderbook.bids.length ? (orderbook.bids[orderbook.bids.length - 1] as any).totalUsd || 0 : 0; + const ask = orderbook.asks.length ? (orderbook.asks[0] as any).totalUsd || 0 : 0; + const bestBid = orderbook.bestBid; + const bestAsk = orderbook.bestAsk; + const spreadAbs = bestBid != null && bestAsk != null ? bestAsk - bestBid : null; + const spreadPct = + spreadAbs != null && orderbook.mid != null && orderbook.mid > 0 ? (spreadAbs / orderbook.mid) * 100 : null; + return { bidUsd: bid, askUsd: ask, spreadAbs, spreadPct }; + }, [orderbook.asks, orderbook.bids, orderbook.bestAsk, orderbook.bestBid, orderbook.mid]); + + const orderbookVersion = orderbook.updatedAt ?? String(latest?.time ?? ''); + const orderbookGroupingLabel = useMemo( + () => resolveOrderbookGroupingLabel(symbol, orderbookGrouping, orderbook.mid), + [orderbook.mid, orderbookGrouping, symbol] + ); + const previousOrderbookMid = useRef(null); + const [orderbookMidTone, setOrderbookMidTone] = useState<'flat' | 'up' | 'down'>('flat'); + + useEffect(() => { + const nextMid = orderbook.mid; + const prevMid = previousOrderbookMid.current; + + if (nextMid == null || !Number.isFinite(nextMid)) { + previousOrderbookMid.current = null; + setOrderbookMidTone('flat'); + return; + } + + if (prevMid != null && Number.isFinite(prevMid) && nextMid !== prevMid) { + setOrderbookMidTone(nextMid > prevMid ? 'up' : 'down'); + } + + previousOrderbookMid.current = nextMid; + }, [orderbook.mid]); + + const orderbookLadderRows = useMemo(() => { + const asksNearMid = orderbook.asks.slice().reverse(); + const bidsNearMid = orderbook.bids; + const rowCount = Math.max(asksNearMid.length, bidsNearMid.length); + return Array.from({ length: rowCount }, (_, index) => ({ + ask: asksNearMid[index] ?? null, + bid: bidsNearMid[index] ?? null, + })); + }, [orderbook.asks, orderbook.bids]); + + const orderbookLadderViewRows = useMemo(() => { + let maxAsk = 0; + let maxBid = 0; + + for (const row of orderbookLadderRows) { + maxAsk = Math.max(maxAsk, (row.ask as any)?.sizeUsd || 0); + maxBid = Math.max(maxBid, (row.bid as any)?.sizeUsd || 0); + } + + const maxShared = Math.max(maxAsk, maxBid); + const logDen = Math.log1p(maxShared); + + const scaleValue = (value: number, side: 'bid' | 'ask'): number => { + if (!Number.isFinite(value) || value <= 0) return 0; + if (orderbookBarScaleMode === 'shared-log') { + return logDen > 0 ? Math.log1p(value) / logDen : 0; + } + const denom = side === 'bid' ? maxBid : maxAsk; + return denom > 0 ? value / denom : 0; + }; + + return orderbookLadderRows.map((row) => ({ + ...row, + bidScale: scaleValue((row.bid as any)?.sizeUsd || 0, 'bid'), + askScale: scaleValue((row.ask as any)?.sizeUsd || 0, 'ask'), + })); + }, [orderbookBarScaleMode, orderbookLadderRows]); const trades = useMemo(() => { const slice = candles.slice(-24).reverse(); @@ -180,13 +472,31 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { const effectiveTradePrice = useMemo(() => { if (tradeOrderType === 'limit') return tradePrice; - return latest?.close ?? tradePrice; - }, [latest?.close, tradeOrderType, tradePrice]); + return liveLastPrice ?? tradePrice; + }, [liveLastPrice, tradeOrderType, tradePrice]); + + const orderValueUsd = useMemo(() => { + if (!Number.isFinite(tradeSize) || tradeSize <= 0) return null; + if (!Number.isFinite(effectiveTradePrice) || effectiveTradePrice <= 0) return null; + const v = effectiveTradePrice * tradeSize; + return Number.isFinite(v) && v > 0 ? v : null; + }, [effectiveTradePrice, tradeSize]); + + const dynamicSlippage = useMemo(() => { + if (orderValueUsd == null) return null; + const side = tradeSide === 'short' ? 'sell' : 'buy'; + const rows = slippageRows.filter((r) => r.side === side).slice(); + rows.sort((a, b) => a.sizeUsd - b.sizeUsd); + if (!rows.length) return null; + const biggest = rows[rows.length - 1]; + const match = rows.find((r) => r.sizeUsd >= orderValueUsd) || biggest; + return match; + }, [orderValueUsd, slippageRows, tradeSide]); const topItems = useMemo( () => [ { key: 'BTC', label: 'BTC', changePct: 1.28, active: false }, - { key: 'SOL', label: 'SOL', changePct: 1.89, active: false }, + { key: 'SOL', label: 'SOL', changePct: 1.89, active: true }, ], [] ); @@ -196,7 +506,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { { key: 'last', label: 'Last', - value: formatUsd(latest?.close), + value: formatUsd(liveLastPrice), sub: changePct == null ? ( '—' @@ -207,20 +517,50 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { ), }, - { key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) }, - { key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' }, - { key: 'oi', label: 'Open Interest', value: '—' }, - { key: 'vol', label: '24h Volume', value: '—' }, - { key: 'details', label: 'Market Details', value: View }, + { key: 'oracle', label: 'Oracle', value: formatUsd(liveOraclePrice) }, + { key: 'bid', label: 'Bid', value: formatUsd(dlob?.bestBid ?? null) }, + { key: 'ask', label: 'Ask', value: formatUsd(dlob?.bestAsk ?? null) }, + { + key: 'spread', + label: 'Spread', + value: dlob?.spreadBps == null ? '—' : `${dlob.spreadBps.toFixed(1)} bps`, + sub: formatUsd(dlob?.spreadAbs ?? null), + }, + { + key: 'dlob', + label: 'DLOB', + value: dlobConnected ? 'live' : '—', + sub: dlobError ? {dlobError} : dlob?.updatedAt || '—', + }, + { + key: 'l2', + label: 'L2', + value: dlobL2Connected ? 'live' : '—', + sub: dlobL2Error ? {dlobL2Error} : dlobL2?.updatedAt || '—', + }, ]; - }, [latest?.close, latest?.oracle, changePct]); + }, [changePct, dlob, dlobConnected, dlobError, dlobL2, dlobL2Connected, dlobL2Error, liveLastPrice, liveOraclePrice]); const seriesLabel = useMemo(() => `Candles: Mark (oracle overlay)`, []); + const seriesKey = useMemo(() => `${symbol}|${source}|${tf}`, [symbol, source, tf]); + const bucketSeconds = meta?.bucketSeconds ?? 60; return ( } />} - top={} + header={ + + } + /> + } main={
void }) { setShowIndicators((v) => !v)} + showBuild={showBuild} + onToggleBuild={() => setShowBuild((v) => !v)} + onResetViewReady={(handler) => setChartResetView(() => handler)} seriesLabel={seriesLabel} + hasMoreHistory={hasMoreHistory} + onRequestHistoryBefore={requestHistoryBefore} + dlobQuotes={{ bid: dlob?.bestBid ?? null, ask: dlob?.bestAsk ?? null, mid: dlob?.mid ?? null }} /> + ), + }, + { + id: 'bot', + label: 'Bot', + content: , + }, { id: 'positions', label: 'Positions', content:
Positions (next)
}, { id: 'orders', label: 'Orders', content:
Orders (next)
}, { id: 'balances', label: 'Balances', content:
Balances (next)
}, @@ -311,48 +684,173 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
} sidebar={ - -
Orderbook
-
{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}
- - } - > + -
- Price - Size - Total -
-
- {orderbook.asks.map((r) => ( -
- {formatQty(r.price, 3)} - {formatQty(r.size, 2)} - {formatQty(r.total, 2)} +
+
+ + {orderbookLayout === 'ladder' ? ( +
+ + +
+ ) : null} +
+ {orderbookLayout === 'ladder' ? ( + <> +
+ mid + {formatQty(orderbook.mid, 3)} +
+
+ Size (USD) + Price + Size (USD) +
+
+ {orderbookLadderViewRows.map((row, index) => ( +
+ + {row.bid ? formatOrderbookUsd((row.bid as any).sizeUsd) : '—'} + + + {row.bid ? formatQty(row.bid.price, 3) : '—'} + + + {row.ask ? formatQty(row.ask.price, 3) : '—'} + + + {row.ask ? formatOrderbookUsd((row.ask as any).sizeUsd) : '—'} + +
+ ))} +
+ + ) : ( + <> +
+ Price + Size (USD) + Cum. (USD) +
+
+
+ {formatQty(orderbook.mid, 3)} + mid +
+ {orderbook.asks.map((r, index) => ( +
0 ? (r as any).sizeUsd / maxAskSize : 0)} + > + {formatQty(r.price, 3)} + {formatOrderbookUsd((r as any).sizeUsd)} + {formatOrderbookUsd((r as any).totalUsd)} +
+ ))} + {orderbook.bids.map((r, index) => ( +
0 ? (r as any).sizeUsd / maxBidSize : 0)} + > + {formatQty(r.price, 3)} + {formatOrderbookUsd((r as any).sizeUsd)} + {formatOrderbookUsd((r as any).totalUsd)} +
+ ))} +
+ + )} +
+
+ Updated + {formatClockTime(orderbook.updatedAt)} +
+
+ Spread + + {liquidity.spreadAbs == null || liquidity.spreadPct == null + ? '—' + : `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`} +
- ))} -
- {latest ? formatQty(latest.close, 3) : '—'} - mid -
- {orderbook.bids.map((r) => ( -
- {formatQty(r.price, 3)} - {formatQty(r.size, 2)} - {formatQty(r.total, 2)} +
+ {`Liquidity (L1–L${orderbookLevels})`} + + {formatUsd(liquidity.bidUsd)} /{' '} + {formatUsd(liquidity.askUsd)} +
- ))} -
-
+
+
+
+
+
+
+ + +
+
), }, { @@ -452,7 +950,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) { setTradePrice(Number(e.target.value))} inputMode="decimal" @@ -480,7 +978,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
Slippage (Dynamic) - + + {slippageError ? ( + {slippageError} + ) : dynamicSlippage?.impactBps == null ? ( + slippageConnected ? ( + '—' + ) : ( + 'offline' + ) + ) : ( + <> + {dynamicSlippage.impactBps.toFixed(1)} bps{' '} + + ({dynamicSlippage.sizeUsd.toLocaleString()} USD) + {dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9 + ? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill` + : ''} + + + )} +
Margin Required @@ -491,6 +1009,10 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
+ +
+ +
} diff --git a/apps/visualizer/src/diagram-main.tsx b/apps/visualizer/src/diagram-main.tsx new file mode 100644 index 0000000..bc85393 --- /dev/null +++ b/apps/visualizer/src/diagram-main.tsx @@ -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( + + + +); diff --git a/apps/visualizer/src/diagram.css b/apps/visualizer/src/diagram.css new file mode 100644 index 0000000..b957cec --- /dev/null +++ b/apps/visualizer/src/diagram.css @@ -0,0 +1,943 @@ +:root { + --rf-bg: #efe7db; + --rf-panel: rgba(255, 252, 246, 0.94); + --rf-card: #fffdf9; + --rf-ink: #1b1714; + --rf-muted: #625b52; + --rf-line: rgba(99, 91, 82, 0.18); + --rf-shadow: 0 24px 54px rgba(27, 23, 20, 0.12); +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + width: 100%; + height: 100%; +} + +body { + font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; + color: var(--rf-ink); + background: + radial-gradient(circle at top left, rgba(31, 111, 139, 0.14), transparent 26%), + radial-gradient(circle at top right, rgba(176, 79, 53, 0.14), transparent 24%), + linear-gradient(180deg, #f8f3eb 0%, var(--rf-bg) 100%); +} + +.rfPage { + min-height: 100%; + padding: 16px; +} + +.rfToolbar, +.rfCanvas, +.rfInspector { + background: var(--rf-panel); + border: 1px solid rgba(217, 204, 187, 0.92); + border-radius: 24px; + box-shadow: var(--rf-shadow); + backdrop-filter: blur(10px); +} + +.rfToolbar { + min-height: 76px; + padding: 14px 18px; + display: grid; + grid-template-columns: minmax(260px, 340px) minmax(220px, auto) minmax(0, 1fr); + gap: 16px; + align-items: center; +} + +.rfToolbar__eyebrow, +.rfInspector__eyebrow, +.rfCard__subtitle { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfToolbar__brand { + display: flex; + align-items: center; + gap: 14px; +} + +.rfToolbar__mark { + width: 16px; + height: 16px; + border-radius: 5px; + background: + linear-gradient(135deg, #1f6f8b 0%, #1f6f8b 46%, #b04f35 46%, #b04f35 100%); + box-shadow: 0 0 0 4px rgba(31, 111, 139, 0.1); +} + +.rfToolbar__copy { + min-width: 0; +} + +.rfToolbar__title { + margin-top: 2px; + font-size: clamp(1.1rem, 1.3vw, 1.45rem); + font-weight: 800; + line-height: 1.05; + font-family: 'IBM Plex Serif', Georgia, serif; +} + +.rfToolbar__summary, +.rfInspector p, +.rfCard__host, +.rfCard__bullets, +.rfSection li { + color: var(--rf-muted); + line-height: 1.5; +} + +.rfInspector__metricGrid { + margin: 10px 0 4px; +} + +.rfToolbar__summary { + margin-top: 3px; + font-size: 13px; +} + +.rfFilters { + display: flex; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.rfHistoryWrap { + position: relative; +} + +.rfFilter { + border: 1px solid rgba(99, 91, 82, 0.15); + border-radius: 999px; + background: #fffaf2; + color: var(--rf-ink); + font: inherit; + font-weight: 800; + padding: 9px 13px; + cursor: pointer; + transition: 140ms ease; +} + +.rfFilter:hover, +.rfFilter.isActive { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08); +} + +.rfFilter.isMeta { + cursor: default; +} + +.rfFilter:disabled { + opacity: 0.44; + cursor: default; + transform: none; + box-shadow: none; +} + +.rfHistoryPanel { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 24; + width: 340px; + max-height: 420px; + overflow: auto; + padding: 14px; + border: 1px solid rgba(99, 91, 82, 0.16); + border-radius: 18px; + background: rgba(255, 252, 246, 0.98); + box-shadow: 0 24px 48px rgba(27, 23, 20, 0.18); +} + +.rfHistoryPanel__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.rfHistoryPanel__eyebrow { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfHistoryPanel__title { + margin-top: 3px; + font-size: 1rem; + font-weight: 800; +} + +.rfHistoryPanel__close, +.rfHistoryItem__restore { + border: 1px solid rgba(99, 91, 82, 0.14); + border-radius: 999px; + background: #fffaf2; + color: var(--rf-ink); + font: inherit; + font-size: 12px; + font-weight: 800; + padding: 8px 11px; + cursor: pointer; +} + +.rfHistoryPanel__close:hover, +.rfHistoryItem__restore:hover { + transform: translateY(-1px); + box-shadow: 0 10px 22px rgba(27, 23, 20, 0.08); +} + +.rfHistoryItem__restore:disabled { + opacity: 0.46; + cursor: default; + transform: none; + box-shadow: none; +} + +.rfHistoryPanel__note { + margin-top: 12px; + font-size: 13px; + color: var(--rf-muted); +} + +.rfHistoryPanel__note.isError { + color: #8b3d24; +} + +.rfHistoryPanel__list { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.rfHistoryItem { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + align-items: center; + padding: 12px; + border: 1px solid rgba(99, 91, 82, 0.12); + border-radius: 14px; + background: rgba(255, 255, 255, 0.74); +} + +.rfHistoryItem.isCurrent { + border-color: rgba(31, 111, 139, 0.25); + box-shadow: inset 0 0 0 1px rgba(31, 111, 139, 0.12); +} + +.rfHistoryItem__meta { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; +} + +.rfHistoryItem__action { + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.rfHistoryItem__time, +.rfHistoryItem__sub { + font-size: 12px; + color: var(--rf-muted); +} + +.rfStatusRail { + min-width: 0; +} + +.rfStatusRail__summary { + margin-bottom: 8px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfStatusRail__list { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.rfStatusPill { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 36px; + padding: 0 11px; + border-radius: 999px; + border: 1px solid rgba(99, 91, 82, 0.12); + background: rgba(255, 250, 242, 0.92); + color: var(--rf-ink); + font: inherit; + cursor: pointer; + transition: 140ms ease; +} + +.rfStatusPill:hover, +.rfStatusPill.isSelected { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08); +} + +.rfStatusPill__name { + font-size: 12px; + font-weight: 800; +} + +.rfStatusPill__state { + font-size: 10px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rfStatusPill.is-ok .rfStatusPill__state { + color: #185f39; +} + +.rfStatusPill.is-warn .rfStatusPill__state { + color: #8b3d24; +} + +.rfStatusPill.is-info .rfStatusPill__state { + color: #425466; +} + +.rfLayout { + margin-top: 12px; + display: grid; + grid-template-columns: minmax(0, 1.2fr) 410px; + gap: 18px; + align-items: stretch; +} + +.rfCanvas { + height: calc(100vh - 126px); + min-height: 760px; + overflow: hidden; +} + +.rfInspector { + padding: 22px; + max-height: calc(100vh - 126px); + overflow: auto; +} + +.rfInspector h2 { + margin: 10px 0 8px; + font-size: 1.8rem; + line-height: 1.05; + font-family: 'IBM Plex Serif', Georgia, serif; +} + +.rfInspector__host { + font-weight: 700; +} + +.rfSection { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid rgba(99, 91, 82, 0.12); +} + +.rfSection h3 { + margin: 0 0 8px; + font-size: 0.98rem; +} + +.rfSection ul { + margin: 0; + padding-left: 18px; +} + +.rfSection li { + margin: 6px 0; +} + +.rfRouteList { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; +} + +.rfRouteItem { + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(99, 91, 82, 0.12); + background: rgba(255, 252, 246, 0.84); +} + +.rfRouteItem.isEmpty { + color: var(--rf-muted); + font-size: 12px; +} + +.rfRouteItem__head { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 13px; +} + +.rfRouteItem__meta { + margin-top: 6px; + font-size: 12px; + font-weight: 700; + color: var(--rf-ink); +} + +.rfRouteItem__desc { + margin-top: 4px; + font-size: 12px; + line-height: 1.45; + color: var(--rf-muted); +} + +.rfRouteBadge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.rfRouteBadge.is-in { + background: rgba(75, 85, 99, 0.12); + color: #3f4854; +} + +.rfRouteBadge.is-out { + background: rgba(20, 114, 109, 0.14); + color: #126863; +} + +.rfRouteBadge.is-path-read { + background: rgba(31, 111, 139, 0.12); + color: #1f6f8b; +} + +.rfRouteBadge.is-path-live { + background: rgba(176, 79, 53, 0.12); + color: #a04a33; +} + +.rfRouteBadge.is-path-write { + background: rgba(122, 98, 19, 0.14); + color: #7a6213; +} + +.rfRouteBadge.is-path-control { + background: rgba(75, 85, 99, 0.12); + color: #4b5563; +} + +.rfLegend { + background: rgba(255, 252, 246, 0.9); + border: 1px solid rgba(99, 91, 82, 0.12); + border-radius: 16px; + padding: 12px 14px; + box-shadow: 0 12px 28px rgba(27, 23, 20, 0.08); +} + +.rfLegend__title { + margin-bottom: 8px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfLegend__row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + font-size: 13px; + color: var(--rf-ink); +} + +.rfLegend__row span { + width: 12px; + height: 12px; + border-radius: 999px; + flex: 0 0 12px; +} + +.rfCard { + width: 100%; + height: 100%; + border: 1px solid rgba(99, 91, 82, 0.14); + border-left: 6px solid var(--lane-color); + border-radius: 18px; + background: var(--rf-card); + box-shadow: 0 16px 32px rgba(27, 23, 20, 0.08); + padding: 12px; + display: flex; + flex-direction: column; + overflow: hidden; + user-select: none; + cursor: grab; +} + +.rfCard.isWorkflow { + border-color: rgba(50, 130, 80, 0.72); + box-shadow: + inset 0 0 0 2px rgba(88, 175, 112, 0.22), + 0 18px 34px rgba(32, 92, 52, 0.12); +} + +.rfCard.isWorkflow .rfCard__subtitle { + color: #2d6b3f; +} + +.rfCard.isSelected { + box-shadow: 0 18px 38px rgba(27, 23, 20, 0.16); + transform: translateY(-1px); +} + +.rfCard.isDragging { + border-color: rgba(176, 48, 35, 0.78); + box-shadow: + 0 0 0 3px rgba(176, 48, 35, 0.3), + 0 20px 42px rgba(176, 48, 35, 0.16); + cursor: grabbing; +} + +.rfCard__head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: flex-start; +} + +.rfDragHandle { + touch-action: none; +} + +.rfCard__title { + margin-top: 4px; + font-size: 0.94rem; + font-weight: 800; + color: var(--rf-ink); +} + +.rfCard__host { + margin-top: 8px; + font-size: 12px; +} + +.rfCard__runtime { + margin-top: 10px; + padding: 9px 10px; + border-radius: 14px; + border: 1px solid rgba(99, 91, 82, 0.12); + background: linear-gradient(180deg, rgba(251, 247, 239, 0.96), rgba(246, 239, 227, 0.82)); +} + +.rfCard__runtimeLabel { + margin-bottom: 8px; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfCard__metricGrid, +.rfInspector__metricGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.rfCard__metricGrid { + margin-top: 10px; +} + +.rfMetricChip { + display: grid; + gap: 4px; + padding: 8px 9px; + border-radius: 12px; + background: rgba(255, 253, 249, 0.92); + border: 1px solid rgba(99, 91, 82, 0.1); +} + +.rfMetricChip span { + font-size: 10px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfMetricChip strong { + font-size: 12px; + line-height: 1.3; + color: var(--rf-ink); +} + +.rfFactGrid, +.rfPanelGrid { + display: grid; + gap: 10px; +} + +.rfFactGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.rfFact { + padding: 11px 12px; + border-radius: 14px; + border: 1px solid rgba(99, 91, 82, 0.12); + background: rgba(255, 252, 246, 0.84); +} + +.rfFact span { + display: block; + margin-bottom: 4px; + font-size: 10px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfFact strong { + font-size: 13px; + line-height: 1.4; + color: var(--rf-ink); +} + +.rfPanelCard { + padding: 12px 13px; + border-radius: 16px; + border: 1px solid rgba(99, 91, 82, 0.12); + background: rgba(255, 252, 246, 0.84); +} + +.rfPanelCard__title { + margin-bottom: 8px; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--rf-muted); +} + +.rfPanelCard p, +.rfPanelCard li { + margin: 0; +} + +.rfPanelCard ul { + margin: 0; + padding-left: 18px; +} + +.rfPanelCard li + li { + margin-top: 6px; +} + +.rfCard__bullets { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + overflow: hidden; +} + +.rfCard__bullets li { + margin: 4px 0; +} + +.rfStatus { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 5px 8px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} + +.rfStatus.isOk { + background: rgba(30, 107, 67, 0.11); + color: #185f39; +} + +.rfStatus.isWarn { + background: rgba(176, 79, 53, 0.11); + color: #8b3d24; +} + +.rfHandle { + width: 10px; + height: 10px; + background: var(--lane-color); + border: 2px solid #fff; +} + +.rfHandle.isRouteHandle { + width: 12px; + height: 12px; + border: 2px solid rgba(255, 253, 249, 0.98); + box-shadow: 0 0 0 2px rgba(27, 23, 20, 0.08); +} + +.rfZone { + width: 100%; + height: 100%; + border-radius: 26px; + border: 1px solid rgba(110, 101, 91, 0.14); + padding: 12px 14px; + user-select: none; + cursor: grab; +} + +.rfZone__head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-height: 64px; + width: min(100%, 320px); + padding: 2px 2px 10px; +} + +.rfZone__title { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: rgba(255, 251, 244, 0.96); + box-shadow: 0 6px 16px rgba(27, 23, 20, 0.06); + font-size: 0.96rem; + font-weight: 900; + color: var(--rf-ink); + font-family: 'IBM Plex Serif', Georgia, serif; +} + +.rfZone__subtitle { + margin-top: 6px; + max-width: 250px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--rf-muted); + line-height: 1.35; +} + +.rfZone.is-host { + background: + linear-gradient(180deg, rgba(255, 253, 249, 0.92), rgba(244, 236, 225, 0.66)); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); +} + +.rfZone.is-namespace { + background: + linear-gradient(180deg, rgba(255, 251, 246, 0.78), rgba(236, 228, 214, 0.52)); + border-style: dashed; + border-color: rgba(98, 91, 82, 0.22); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22); +} + +.rfZone.is-subzone { + background: + linear-gradient(180deg, rgba(255, 254, 250, 0.72), rgba(248, 242, 233, 0.46)); + border-color: rgba(98, 91, 82, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); +} + +.rfZone.isDragging { + border-color: rgba(176, 48, 35, 0.72); + box-shadow: + inset 0 0 0 2px rgba(176, 48, 35, 0.22), + 0 0 0 3px rgba(176, 48, 35, 0.14); + cursor: grabbing; +} + +.rfZone.isDragging .rfZone__title { + box-shadow: + 0 0 0 2px rgba(176, 48, 35, 0.18), + 0 8px 18px rgba(176, 48, 35, 0.16); +} + +.react-flow__renderer { + background: + linear-gradient(rgba(225, 214, 198, 0.22) 1px, transparent 1px), + linear-gradient(90deg, rgba(225, 214, 198, 0.22) 1px, transparent 1px), + linear-gradient(180deg, #fffdf8 0%, #faf4ea 100%); + background-size: 20px 20px, 20px 20px, 100% 100%; +} + +.react-flow__nodes { + z-index: 10; +} + +.react-flow__edges, +.react-flow__edgelabel-renderer { + z-index: 30 !important; +} + +.react-flow__attribution { + display: none; +} + +.react-flow__controls, +.react-flow__minimap { + border: 1px solid rgba(99, 91, 82, 0.14); + border-radius: 14px; + overflow: hidden; + box-shadow: 0 12px 24px rgba(27, 23, 20, 0.08); +} + +.react-flow__controls button { + background: rgba(255, 252, 246, 0.92); + color: var(--rf-ink); + border-bottom-color: rgba(99, 91, 82, 0.12); +} + +.react-flow__edge-text { + font-weight: 700; +} + +.rfResizeHandle { + width: 12px !important; + height: 12px !important; + border-radius: 999px !important; + border: 2px solid rgba(255, 253, 249, 0.96) !important; + background: rgba(31, 111, 139, 0.72) !important; + box-shadow: 0 0 0 2px rgba(27, 23, 20, 0.08); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease, background 120ms ease; +} + +.rfResizeLine { + border-color: rgba(31, 111, 139, 0.28) !important; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, border-color 120ms ease; +} + +.rfCard:hover .rfResizeHandle, +.rfCard:hover .rfResizeLine, +.rfCard.isSelected .rfResizeHandle, +.rfCard.isSelected .rfResizeLine, +.rfCard.isDragging .rfResizeHandle, +.rfCard.isDragging .rfResizeLine, +.rfZone:hover .rfResizeHandle, +.rfZone:hover .rfResizeLine, +.rfZone.isSelected .rfResizeHandle, +.rfZone.isSelected .rfResizeLine, +.rfZone.isDragging .rfResizeHandle, +.rfZone.isDragging .rfResizeLine { + opacity: 1; + pointer-events: auto; +} + +.rfCard:hover .rfResizeHandle, +.rfCard.isSelected .rfResizeHandle, +.rfCard.isDragging .rfResizeHandle, +.rfZone:hover .rfResizeHandle, +.rfZone.isSelected .rfResizeHandle, +.rfZone.isDragging .rfResizeHandle { + transform: scale(1.05); +} + +.rfCard:hover .rfResizeLine, +.rfCard.isSelected .rfResizeLine, +.rfCard.isDragging .rfResizeLine { + border-color: rgba(31, 111, 139, 0.42) !important; +} + +.rfZone:hover .rfResizeLine, +.rfZone.isSelected .rfResizeLine, +.rfZone.isDragging .rfResizeLine { + border-color: rgba(176, 48, 35, 0.26) !important; +} + +.rfZone .rfResizeHandle { + background: rgba(176, 48, 35, 0.72) !important; +} + +.rfPage.isUltraWide .rfLayout { + grid-template-columns: minmax(0, 1.45fr) 430px; +} + +.rfPage.isUltraWide .rfCanvas { + height: calc(100vh - 118px); + min-height: 820px; +} + +code { + font-family: 'IBM Plex Mono', monospace; + background: rgba(27, 23, 20, 0.05); + padding: 2px 6px; + border-radius: 6px; +} + +@media (max-width: 1180px) { + .rfPage { + padding: 14px; + } + + .rfToolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .rfStatusRail__list { + justify-content: flex-start; + } + + .rfLayout { + grid-template-columns: 1fr; + } + + .rfCanvas { + height: 72vh; + min-height: 620px; + } + + .rfCard__metricGrid, + .rfInspector__metricGrid, + .rfFactGrid { + grid-template-columns: 1fr; + } +} diff --git a/apps/visualizer/src/diagram/TradeSystemFlowPage.tsx b/apps/visualizer/src/diagram/TradeSystemFlowPage.tsx new file mode 100644 index 0000000..708d1fe --- /dev/null +++ b/apps/visualizer/src/diagram/TradeSystemFlowPage.tsx @@ -0,0 +1,2327 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { + ReactFlow, + Background, + BaseEdge, + Controls, + Handle, + MarkerType, + MiniMap, + NodeResizer, + Panel, + Position, +} from '@xyflow/react'; +import type { Edge, EdgeProps, Node, NodeChange, NodeProps, ReactFlowInstance } from '@xyflow/react'; +import { + deleteDiagramLayout, + fetchDiagramLayoutHistory, + fetchDiagramLayout, + fetchDiagramRuntime, + postDiagramDebugEvent, + restoreDiagramLayoutRevision, + saveDiagramLayout, +} from '../lib/runtime'; +import type { + DiagramLayoutState as BackendDiagramLayoutState, + DiagramLayoutRevision, + DiagramLayoutView, + DiagramRuntimePublisher, + DiagramRuntimeResponse, +} from '../lib/runtime'; + +type Lane = 'external' | 'web' | 'api' | 'data' | 'chain' | 'exec'; +type PathType = 'read' | 'write' | 'control' | 'live'; + +type FlowNodeData = { + title: string; + subtitle: string; + lane: Lane; + host: string; + status?: 'ok' | 'warn'; + bullets: string[]; + exposes?: string[]; + consumes?: string[]; + payloads?: string[]; + runtime?: { + summary: string; + chips: Array<{ label: string; value: string }>; + details: string[]; + errors: string[]; + fetchedAt?: string; + }; +}; + +type FlowEdgeData = { + path: PathType; + description: string; + waypoints?: Array<{ x: number; y: number }>; +}; + +type ZoneNodeData = { + title: string; + subtitle: string; + tone: 'host' | 'namespace' | 'subzone'; +}; + +type HandleSpec = { + id: string; + top: string; + color: string; +}; + +type SelectedRoute = { + id: string; + direction: 'in' | 'out'; + path: PathType; + peerTitle: string; + label: string; + description: string; +}; + +const laneColors: Record = { + external: '#4b5563', + web: '#1f6f8b', + api: '#14726d', + data: '#425466', + chain: '#b04f35', + exec: '#7a6213', +}; + +const laneLabels: Record = { + external: 'external', + web: 'web entry', + api: 'api / graphql', + data: 'data plane', + chain: 'chain access', + exec: 'execution', +}; + +const pathLabels: Record = { + read: 'read', + write: 'write', + control: 'control', + live: 'live', +}; + +const showZonePanels = false; + +const toolbarTitles: Record = { + frontend: 'frontend', + hasura: 'hasura', + postgres: 'postgres', + redis: 'redis', + publisherHot: 'publisher-hot', + publisher: 'publisher-all', + hotRawWriter: 'hot raw writer', + hotDerivedWriter: 'hot derived writer', + allDerivedWriter: 'all derived writer', +}; + +const currentWorkflowNodeIds = new Set([ + 'frontend', + 'hasura', + 'postgres', + 'redis', + 'publisherHot', + 'publisher', + 'hotRawWriter', + 'hotDerivedWriter', + 'allDerivedWriter', +]); + +const showCanvasPaths = false; +const canvasSnapGrid: [number, number] = [20, 20]; +const layoutStorageBaseKey = 'trade-system-flow-layout:v2'; +const diagramDebugStorageKey = 'trade-system-flow-debug'; + +type LayoutState = BackendDiagramLayoutState; + +function statusMeta(status?: FlowNodeData['status']) { + if (status === 'ok') return { label: 'ready', tone: 'ok' as const }; + if (status === 'warn') return { label: 'attention', tone: 'warn' as const }; + return { label: 'mapped', tone: 'info' as const }; +} + +function formatCompactInteger(value: number | null | undefined) { + if (value == null || !Number.isFinite(value)) return 'n/a'; + return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format( + value + ); +} + +function formatRuntimeAge(iso: string | undefined) { + if (!iso) return 'fetch pending'; + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return 'fetch pending'; + const diffSec = Math.max(0, Math.round((Date.now() - ts) / 1000)); + if (diffSec < 10) return 'updated now'; + if (diffSec < 60) return `updated ${diffSec}s ago`; + const diffMin = Math.round(diffSec / 60); + return `updated ${diffMin}m ago`; +} + +function formatLayoutRevisionTime(iso: string) { + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return iso || 'unknown'; + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(ts)); +} + +function formatLayoutRevisionAction(action: string) { + switch (String(action || '').trim().toLowerCase()) { + case 'autosave': + return 'autosave'; + case 'manual-save': + return 'manual save'; + case 'restore': + return 'restore'; + case 'reset': + return 'reset'; + default: + return action || 'save'; + } +} + +function buildPublisherRuntime( + publisher: DiagramRuntimePublisher | undefined, + state: { loading: boolean; error: string | null; fetchedAt?: string } +): FlowNodeData['runtime'] { + if (!publisher) { + const runtimeLabel = state.loading ? 'loading' : state.error ? 'storage error' : 'local model'; + return { + summary: state.loading + ? 'loading frontend-local diagram runtime' + : 'diagram runtime is frontend-local and no longer depends on trade-api', + chips: [ + { label: 'runtime', value: runtimeLabel }, + { label: 'source', value: 'local storage' }, + ], + details: ['layout history and debug events are persisted in the browser'], + errors: state.error ? [state.error] : [], + fetchedAt: state.fetchedAt, + }; + } + + return { + summary: + publisher.status === 'ok' + ? 'healthy full-market feed published into Redis' + : 'publisher is reachable, but health or scrape is degraded', + chips: [ + { label: 'cadence', value: `${publisher.metrics.updateIntervalMs} ms` }, + { label: 'markets', value: String(publisher.metrics.totalMarkets) }, + { + label: 'split', + value: `${publisher.metrics.perpMarkets} perp / ${publisher.metrics.spotMarkets} spot`, + }, + { label: 'slot', value: formatCompactInteger(publisher.metrics.latestSlot) }, + ], + details: [ + `transport: ${publisher.metrics.transport}`, + `health: ${publisher.health.httpOk ? 'http ok' : 'http degraded'} / gauge ${ + publisher.health.gauge == null ? 'n/a' : String(publisher.health.gauge) + }`, + formatRuntimeAge(state.fetchedAt), + ], + errors: state.error ? [...publisher.errors, state.error] : publisher.errors, + fetchedAt: state.fetchedAt, + }; +} + +const ultraWidePositions: Record = { + frontend: { x: 340, y: 270 }, + hasura: { x: 700, y: 260 }, + postgres: { x: 1080, y: 170 }, + redis: { x: 1080, y: 420 }, + hotRawWriter: { x: 1460, y: 170 }, + hotDerivedWriter: { x: 1460, y: 430 }, + allDerivedWriter: { x: 1460, y: 690 }, + publisherHot: { x: 1080, y: 930 }, + publisher: { x: 1460, y: 930 }, +}; + +function ServiceNode({ id, data, selected, dragging }: NodeProps>) { + const style = { ['--lane-color' as string]: laneColors[data.lane] } as CSSProperties; + const incomingHandles = showCanvasPaths ? routeHandles.incoming.get(id) || [] : []; + const outgoingHandles = showCanvasPaths ? routeHandles.outgoing.get(id) || [] : []; + const cardBullets = data.bullets.slice(0, 2); + const runtimeChips = data.runtime?.chips.slice(0, 2) || []; + const isWorkflow = currentWorkflowNodeIds.has(id); + + return ( +
+ + {incomingHandles.map((handle) => ( + + ))} +
+
+
{data.subtitle}
+
{data.title}
+
+ {data.status ? ( + + {data.status === 'ok' ? 'ready' : 'needs attention'} + + ) : null} +
+
{data.host}
+ {runtimeChips.length ? ( +
+
runtime
+
+ {runtimeChips.map((chip) => ( +
+ {chip.label} + {chip.value} +
+ ))} +
+
+ ) : null} +
    + {cardBullets.map((bullet) => ( +
  • {bullet}
  • + ))} +
+ {outgoingHandles.map((handle) => ( + + ))} +
+ ); +} + +function ZoneNode({ id, data, selected, dragging }: NodeProps>) { + return ( +
+ +
+
{data.title}
+
{data.subtitle}
+
+
+ ); +} + +function buildOrthogonalPath(points: Array<{ x: number; y: number }>) { + if (points.length === 0) return ''; + return points.reduce((acc, point, index) => { + if (index === 0) return `M ${point.x} ${point.y}`; + return `${acc} L ${point.x} ${point.y}`; + }, ''); +} + +type Rect = { left: number; right: number; top: number; bottom: number }; + +const serviceNodeHeights: Record = { + frontend: 160, + api: 168, + postgres: 150, + hasura: 156, + redis: 126, + ingestor: 136, + publisherHot: 150, + publisher: 188, + hotRawWriter: 156, + hotDerivedWriter: 156, + allDerivedWriter: 168, +}; + +const serviceNodeWidths: Record = Object.fromEntries( + Object.keys(serviceNodeHeights).map((id) => [id, 248]) +) as Record; + +function compressPoints(points: Array<{ x: number; y: number }>) { + const compact: Array<{ x: number; y: number }> = []; + for (const point of points) { + const last = compact[compact.length - 1]; + if (last && last.x === point.x && last.y === point.y) continue; + compact.push(point); + } + + if (compact.length < 3) return compact; + + const reduced: Array<{ x: number; y: number }> = [compact[0]]; + for (let index = 1; index < compact.length - 1; index += 1) { + const prev = reduced[reduced.length - 1]; + const current = compact[index]; + const next = compact[index + 1]; + const sameX = prev.x === current.x && current.x === next.x; + const sameY = prev.y === current.y && current.y === next.y; + if (!sameX && !sameY) reduced.push(current); + } + reduced.push(compact[compact.length - 1]); + return reduced; +} + +function layoutPositions() { + const isUltraWide = typeof window !== 'undefined' ? window.innerWidth >= 1900 : true; + return isUltraWide + ? ultraWidePositions + : (Object.fromEntries(baseNodes.map((node) => [node.id, node.position])) as Record< + string, + { x: number; y: number } + >); +} + +function serviceObstacles(excludedIds: string[]) { + const positions = layoutPositions(); + return baseNodes + .filter((node) => !excludedIds.includes(node.id)) + .map((node) => { + const position = positions[node.id] || node.position; + const height = serviceNodeHeights[node.id] || 150; + const paddingX = 28; + const paddingY = 24; + return { + left: position.x - paddingX, + right: position.x + 248 + paddingX, + top: position.y - paddingY, + bottom: position.y + height + paddingY, + } satisfies Rect; + }); +} + +function pointInsideRect(point: { x: number; y: number }, rect: Rect) { + return point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom; +} + +function snap(value: number, step: number) { + return Math.round(value / step) * step; +} + +function keyFor(x: number, y: number) { + return `${x}:${y}`; +} + +function snapPositionToGrid(position: { x: number; y: number }) { + return { + x: Math.round(position.x / canvasSnapGrid[0]) * canvasSnapGrid[0], + y: Math.round(position.y / canvasSnapGrid[1]) * canvasSnapGrid[1], + }; +} + +function snapSizeToGrid(size: { width: number; height: number }) { + return { + width: Math.max(canvasSnapGrid[0], Math.round(size.width / canvasSnapGrid[0]) * canvasSnapGrid[0]), + height: Math.max(canvasSnapGrid[1], Math.round(size.height / canvasSnapGrid[1]) * canvasSnapGrid[1]), + }; +} + +function normalizeLayoutState(value: unknown): LayoutState { + if (!value || typeof value !== 'object') return {}; + + const rawEntries = Object.entries(value as Record); + if (!showZonePanels && rawEntries.some(([id]) => id.startsWith('zone-'))) { + return {}; + } + + const next: LayoutState = {}; + for (const [id, rawEntry] of rawEntries) { + if (!(id in defaultNodeHierarchyParents)) continue; + if (!rawEntry || typeof rawEntry !== 'object') continue; + + const entry: LayoutState[string] = {}; + const rawPosition = (rawEntry as { position?: unknown }).position; + const rawSize = (rawEntry as { size?: unknown }).size; + const rawParentId = (rawEntry as { parentId?: unknown }).parentId; + + if (rawPosition && typeof rawPosition === 'object') { + const x = Number((rawPosition as { x?: unknown }).x); + const y = Number((rawPosition as { y?: unknown }).y); + if (Number.isFinite(x) && Number.isFinite(y)) { + entry.position = snapPositionToGrid({ x, y }); + } + } + + if (rawSize && typeof rawSize === 'object') { + const width = Number((rawSize as { width?: unknown }).width); + const height = Number((rawSize as { height?: unknown }).height); + if (Number.isFinite(width) && Number.isFinite(height)) { + entry.size = snapSizeToGrid({ width, height }); + } + } + + if (rawParentId === null || typeof rawParentId === 'string') { + entry.parentId = rawParentId; + } + + if (entry.position || entry.size || 'parentId' in entry) next[id] = entry; + } + + return next; +} + +function serializeLayoutState(layout: LayoutState) { + return JSON.stringify( + Object.fromEntries( + Object.entries(layout) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, entry]) => [ + id, + { + ...(entry.position ? { position: snapPositionToGrid(entry.position) } : {}), + ...(entry.size ? { size: snapSizeToGrid(entry.size) } : {}), + ...('parentId' in entry ? { parentId: entry.parentId ?? null } : {}), + }, + ]) + ) + ); +} + +function readDiagramDebugEnabled() { + if (typeof window === 'undefined') return false; + const params = new URLSearchParams(window.location.search); + if (params.get('diagramDebug') === '1') return true; + return window.localStorage.getItem(diagramDebugStorageKey) === '1'; +} + +const defaultNodeHierarchyParents: Record = showZonePanels + ? { + frontend: 'zone-web-surface', + api: 'zone-api-query', + hasura: 'zone-api-query', + postgres: 'zone-data-plane', + redis: 'zone-data-plane', + ingestor: 'zone-api-query', + publisherHot: 'zone-data-plane', + publisher: 'zone-data-plane', + hotRawWriter: 'zone-data-plane', + hotDerivedWriter: 'zone-data-plane', + allDerivedWriter: 'zone-data-plane', + 'zone-trade-staging': 'zone-bot', + 'zone-web-surface': 'zone-trade-staging', + 'zone-api-query': 'zone-trade-staging', + 'zone-data-plane': 'zone-trade-staging', + 'zone-bot': null, + } + : { + frontend: null, + api: null, + hasura: null, + postgres: null, + redis: null, + ingestor: null, + publisherHot: null, + publisher: null, + hotRawWriter: null, + hotDerivedWriter: null, + allDerivedWriter: null, + }; + +function buildDescendantsMap(parentMap: Record) { + const descendants = new Map(); + for (const id of Object.keys(parentMap)) descendants.set(id, []); + + for (const id of Object.keys(parentMap)) { + let current = parentMap[id]; + while (current) { + descendants.set(current, [...(descendants.get(current) || []), id]); + current = parentMap[current]; + } + } + + return descendants; +} + +function buildAncestorChains(parentMap: Record) { + const ancestors: Record = {}; + + for (const id of Object.keys(parentMap)) { + const chain: string[] = []; + let current = parentMap[id]; + while (current) { + chain.push(current); + current = parentMap[current]; + } + ancestors[id] = chain; + } + + return ancestors; +} + +function buildParentMap(layout: LayoutState) { + return Object.fromEntries( + Object.keys(defaultNodeHierarchyParents).map((id) => [ + id, + Object.prototype.hasOwnProperty.call(layout[id] || {}, 'parentId') + ? (layout[id]?.parentId ?? null) + : defaultNodeHierarchyParents[id], + ]) + ) as Record; +} + +function resolveNodeFrame( + id: string, + defaults: Record, + layout: LayoutState +) { + const frame = defaults[id]; + const override = layout[id]; + return { + position: override?.position || frame.position, + size: override?.size || frame.size, + }; +} + +function isFrameInsideParent( + childFrame: { position: { x: number; y: number }; size: { width: number; height: number } }, + parentFrame: { position: { x: number; y: number }; size: { width: number; height: number } } +) { + return ( + childFrame.position.x >= parentFrame.position.x && + childFrame.position.y >= parentFrame.position.y && + childFrame.position.x + childFrame.size.width <= parentFrame.position.x + parentFrame.size.width && + childFrame.position.y + childFrame.size.height <= parentFrame.position.y + parentFrame.size.height + ); +} + +const defaultAncestorChains = buildAncestorChains(defaultNodeHierarchyParents); + +function findContainingAncestor( + id: string, + defaults: Record, + layout: LayoutState +) { + const nodeFrame = resolveNodeFrame(id, defaults, layout); + + for (const ancestorId of defaultAncestorChains[id] || []) { + const ancestorFrame = resolveNodeFrame(ancestorId, defaults, layout); + if (isFrameInsideParent(nodeFrame, ancestorFrame)) return ancestorId; + } + + return null; +} + +function obstacleRoute( + sourceId: string, + targetId: string, + sourceX: number, + sourceY: number, + targetX: number, + targetY: number +) { + const step = 18; + const start = { x: sourceX + 24, y: sourceY }; + const end = { x: targetX - 24, y: targetY }; + const startGrid = { x: snap(start.x, step), y: snap(start.y, step) }; + const endGrid = { x: snap(end.x, step), y: snap(end.y, step) }; + const bounds = { minX: -72, maxX: 1880, minY: 48, maxY: 1440 }; + const obstacles = serviceObstacles([sourceId, targetId]); + + const blocked = new Set(); + for (const rect of obstacles) { + const left = Math.floor(rect.left / step) * step; + const right = Math.ceil(rect.right / step) * step; + const top = Math.floor(rect.top / step) * step; + const bottom = Math.ceil(rect.bottom / step) * step; + for (let x = left; x <= right; x += step) { + for (let y = top; y <= bottom; y += step) { + if (pointInsideRect({ x, y }, rect)) blocked.add(keyFor(x, y)); + } + } + } + + blocked.delete(keyFor(startGrid.x, startGrid.y)); + blocked.delete(keyFor(endGrid.x, endGrid.y)); + + const queue: Array<{ x: number; y: number }> = [startGrid]; + const prev = new Map(); + prev.set(keyFor(startGrid.x, startGrid.y), null); + + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index]; + if (current.x === endGrid.x && current.y === endGrid.y) break; + + const neighbors = [ + { x: current.x + step, y: current.y }, + { x: current.x - step, y: current.y }, + { x: current.x, y: current.y + step }, + { x: current.x, y: current.y - step }, + ]; + + for (const next of neighbors) { + if (next.x < bounds.minX || next.x > bounds.maxX || next.y < bounds.minY || next.y > bounds.maxY) continue; + const key = keyFor(next.x, next.y); + if (blocked.has(key) || prev.has(key)) continue; + prev.set(key, keyFor(current.x, current.y)); + queue.push(next); + } + } + + const endKey = keyFor(endGrid.x, endGrid.y); + if (!prev.has(endKey)) { + return compressPoints([ + start, + { x: startGrid.x, y: start.y }, + { x: startGrid.x, y: end.y }, + end, + ]); + } + + const gridPath: Array<{ x: number; y: number }> = []; + let cursor: string | null = endKey; + while (cursor) { + const [x, y] = cursor.split(':').map(Number); + gridPath.push({ x, y }); + cursor = prev.get(cursor) || null; + } + gridPath.reverse(); + + return compressPoints([ + start, + { x: startGrid.x, y: start.y }, + { x: startGrid.x, y: startGrid.y }, + ...gridPath.slice(1, -1), + { x: endGrid.x, y: endGrid.y }, + { x: endGrid.x, y: end.y }, + end, + ]); +} + +function RoutedEdge({ + id, + source, + target, + sourceX, + sourceY, + targetX, + targetY, + markerEnd, + style, + data, +}: EdgeProps>) { + const points = [ + { x: sourceX, y: sourceY }, + ...(data?.waypoints || obstacleRoute(source, target, sourceX, sourceY, targetX, targetY)), + { x: targetX, y: targetY }, + ]; + return ; +} + +const nodeTypes = { + service: ServiceNode, + zone: ZoneNode, +}; + +const edgeTypes = { + routed: RoutedEdge, +}; + +const baseNodes: Node[] = [ + { + id: 'frontend', + type: 'service', + position: { x: 320, y: 140 }, + data: { + title: 'trade-frontend', + subtitle: 'web entrypoint', + lane: 'web', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['NodePort 30081 -> 8081', 'serves SPA', 'proxies /graphql', 'owns auth/session flow'], + exposes: ['GET /whoami', 'POST /auth/login', 'POST /auth/logout', '/graphql', '/graphql-ws'], + consumes: ['Hasura upstream', 'frontend auth secrets'], + payloads: ['HTML', 'session cookie', 'proxied GraphQL WS'], + }, + }, + { + id: 'hasura', + type: 'service', + position: { x: 700, y: 220 }, + data: { + title: 'Hasura', + subtitle: 'query + subscription plane', + lane: 'api', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['ClusterIP :8080', 'GraphQL HTTP', 'GraphQL WS subscriptions', 'feeds DLOB widgets to UI'], + exposes: ['/v1/graphql via frontend proxy'], + consumes: ['Postgres', 'derived DLOB tables/views'], + payloads: ['GraphQL query results', 'subscription rows'], + }, + }, + { + id: 'postgres', + type: 'service', + position: { x: 1080, y: 70 }, + data: { + title: 'Postgres', + subtitle: 'stateful storage', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: [ + 'StatefulSet + headless service', + 'stores hot/all derived DLOB tables', + 'persists hot raw + hot/all derived DLOB tables', + 'backing DB for Hasura', + ], + exposes: ['TCP 5432 inside cluster'], + consumes: ['Hasura', 'hot/all DLOB writers'], + payloads: ['rows', 'hot raw snapshots', 'derived DLOB tables'], + }, + }, + { + id: 'redis', + type: 'service', + position: { x: 1080, y: 330 }, + data: { + title: 'dlob-redis', + subtitle: 'fast DLOB state', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: ['ClusterIP :6379', 'ephemeral state for DLOB', 'fast cache and pub/sub style fanout'], + exposes: ['TCP 6379 inside cluster'], + consumes: ['publisher writes'], + payloads: ['serialized orderbook state'], + }, + }, + { + id: 'publisherHot', + type: 'service', + position: { x: 1080, y: 850 }, + data: { + title: 'dlob-publisher-hot', + subtitle: 'hot-path / few-market feed', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'warn', + bullets: [ + 'tracks only the hot market subset', + 'publishes Redis prefix dlob-hot:*', + 'feeds dedicated raw -> derived persistence path', + ], + exposes: ['/health', '/startup'], + consumes: ['Agave point reads', 'Yellowstone gRPC', 'Drift websocket'], + payloads: ['hot L2/L3 snapshots', 'execution-focused Redis state'], + }, + }, + { + id: 'publisher', + type: 'service', + position: { x: 1420, y: 850 }, + data: { + title: 'dlob-publisher-all', + subtitle: 'background / full-market feed', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'warn', + bullets: [ + 'tracks all perp + spot markets', + 'publishes Redis prefix dlob-all:*', + 'feeds the all-derived writer and read-side cache', + ], + exposes: ['/health', '/startup', 'Prometheus metrics on :9464'], + consumes: ['Agave point reads', 'Yellowstone gRPC', 'Drift websocket'], + payloads: ['L2/L3 snapshots', 'best makers', 'grouped orderbook levels'], + }, + }, + { + id: 'hotRawWriter', + type: 'service', + position: { x: 1420, y: 70 }, + data: { + title: 'hot raw writer', + subtitle: 'dlob-hot -> Postgres raw', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: [ + 'deployment: dlob-hot-redis-to-postgres-raw-writer', + 'polls dlob-hot:* every 1000 ms', + 'writes dlob_hot_snapshot_latest and dlob_hot_snapshot_ts', + ], + consumes: ['Redis prefix dlob-hot:*', 'Postgres'], + payloads: ['raw payload', 'snapshot_kind', 'event_ts / ts_ms'], + }, + }, + { + id: 'hotDerivedWriter', + type: 'service', + position: { x: 1420, y: 330 }, + data: { + title: 'hot derived writer', + subtitle: 'hot raw PG -> hot derived PG', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: [ + 'deployment: dlob-hot-postgres-to-postgres-derived-writer', + 'reads hot raw tables from Postgres', + 'writes dlob_hot_derived_latest and dlob_hot_derived_ts', + ], + consumes: ['Postgres raw hot tables'], + payloads: ['bids_norm', 'asks_norm', 'raw_payload_hash'], + }, + }, + { + id: 'allDerivedWriter', + type: 'service', + position: { x: 1420, y: 590 }, + data: { + title: 'all derived writer', + subtitle: 'dlob-all -> Postgres derived', + lane: 'data', + host: 'mevnode_bot / trade-staging', + status: 'ok', + bullets: [ + 'deployment: dlob-all-redis-to-postgres-derived-writer', + 'polls dlob-all:* every 1000 ms', + 'writes only derived latest + history for all markets', + ], + consumes: ['Redis prefix dlob-all:*', 'Postgres'], + payloads: ['derived orderbook features', 'event_ts / ts_ms', 'no raw payload'], + }, + }, +]; + +function zoneNodes(isUltraWide: boolean): Node[] { + if (!showZonePanels) return []; + + if (isUltraWide) { + return [ + { + id: 'zone-bot', + type: 'zone', + position: { x: 250, y: 30 }, + draggable: true, + selectable: true, + data: { + title: 'mevnode_bot', + subtitle: 'single-node k3s / web + api + DLOB runtime', + tone: 'host', + }, + style: { width: 1700, height: 1240, zIndex: 0 }, + }, + { + id: 'zone-trade-staging', + type: 'zone', + position: { x: 290, y: 110 }, + draggable: true, + selectable: true, + data: { + title: 'trade-staging namespace', + subtitle: 'runtime workloads currently deployed in k3s', + tone: 'namespace', + }, + style: { width: 1630, height: 1120, zIndex: 1 }, + }, + { + id: 'zone-web-surface', + type: 'zone', + position: { x: 300, y: 170 }, + draggable: true, + selectable: true, + data: { + title: 'web surface', + subtitle: 'public entrypoint and browser-facing flow', + tone: 'subzone', + }, + style: { width: 330, height: 340, zIndex: 2 }, + }, + { + id: 'zone-api-query', + type: 'zone', + position: { x: 670, y: 170 }, + draggable: true, + selectable: true, + data: { + title: 'api / query plane', + subtitle: 'GraphQL, subscriptions and browser-facing query flow', + tone: 'subzone', + }, + style: { width: 330, height: 840, zIndex: 2 }, + }, + { + id: 'zone-data-plane', + type: 'zone', + position: { x: 825, y: 170 }, + draggable: true, + selectable: true, + data: { + title: 'data plane', + subtitle: 'redis cache, writers and persistent DLOB storage', + tone: 'subzone', + }, + style: { width: 860, height: 980, zIndex: 2 }, + }, + ]; + } + + return [ + { + id: 'zone-bot', + type: 'zone', + position: { x: 260, y: 10 }, + draggable: true, + selectable: true, + data: { + title: 'mevnode_bot', + subtitle: 'single-node k3s / trade-staging workloads', + tone: 'host', + }, + style: { width: 1630, height: 1160, zIndex: 0 }, + }, + { + id: 'zone-trade-staging', + type: 'zone', + position: { x: 290, y: 75 }, + draggable: true, + selectable: true, + data: { + title: 'trade-staging namespace', + subtitle: 'current app runtime in k3s', + tone: 'namespace', + }, + style: { width: 1560, height: 1070, zIndex: 1 }, + }, + { + id: 'zone-web-surface', + type: 'zone', + position: { x: 315, y: 120 }, + draggable: true, + selectable: true, + data: { + title: 'web surface', + subtitle: 'frontend and browser-facing auth', + tone: 'subzone', + }, + style: { width: 360, height: 250, zIndex: 2 }, + }, + { + id: 'zone-api-query', + type: 'zone', + position: { x: 700, y: 120 }, + draggable: true, + selectable: true, + data: { + title: 'api / query plane', + subtitle: 'GraphQL and browser-facing query flow', + tone: 'subzone', + }, + style: { width: 360, height: 820, zIndex: 2 }, + }, + { + id: 'zone-data-plane', + type: 'zone', + position: { x: 840, y: 120 }, + draggable: true, + selectable: true, + data: { + title: 'data plane', + subtitle: 'DB, redis and DLOB writers', + tone: 'subzone', + }, + style: { width: 790, height: 980, zIndex: 2 }, + }, + ]; +} + +const baseEdges: Edge[] = [ + { + id: 'frontend-hasura', + source: 'frontend', + target: 'hasura', + label: '/graphql + WS', + data: { path: 'live', description: 'frontend proxies GraphQL HTTP and websocket subscriptions' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'hasura-postgres', + source: 'hasura', + target: 'postgres', + label: 'query / subscription backing', + data: { path: 'read', description: 'Hasura uses Postgres as source for queries and subscriptions' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'publisherHot-redis', + source: 'publisherHot', + target: 'redis', + label: 'dlob-hot:*', + data: { path: 'live', description: 'hot publisher pushes the few-market execution path into Redis under the hot prefix' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'publisher-redis', + source: 'publisher', + target: 'redis', + label: 'dlob-all:*', + data: { path: 'live', description: 'full-market publisher pushes all-market Redis state used by read-side services and the all-derived writer' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'redis-hotRawWriter', + source: 'redis', + target: 'hotRawWriter', + label: 'hot raw poll', + data: { path: 'read', description: 'hot raw writer polls the dlob-hot prefix from Redis' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'redis-allDerivedWriter', + source: 'redis', + target: 'allDerivedWriter', + label: 'all derived poll', + data: { path: 'read', description: 'all-derived writer polls the dlob-all prefix from Redis' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'hotRawWriter-postgres', + source: 'hotRawWriter', + target: 'postgres', + label: 'hot raw tables', + data: { path: 'write', description: 'hot raw writer stores latest + history snapshot tables in Postgres' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'postgres-hotDerivedWriter', + source: 'postgres', + target: 'hotDerivedWriter', + label: 'hot raw read', + data: { path: 'read', description: 'hot derived writer reads hot raw snapshot tables from Postgres' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'hotDerivedWriter-postgres', + source: 'hotDerivedWriter', + target: 'postgres', + label: 'hot derived tables', + data: { path: 'write', description: 'hot derived writer writes normalized latest + history tables back into Postgres' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, + { + id: 'allDerivedWriter-postgres', + source: 'allDerivedWriter', + target: 'postgres', + label: 'all derived tables', + data: { path: 'write', description: 'all-derived writer writes normalized latest + history tables for all markets into Postgres' }, + type: 'smoothstep', + markerEnd: { type: MarkerType.ArrowClosed }, + }, +]; + +const nodeTitleById: Record = Object.fromEntries( + baseNodes.map((node) => [node.id, node.data.title]) +) as Record; + +function handleOffset(index: number) { + return `${34 + index * 20}px`; +} + +function buildHandleMaps(edges: Edge[]) { + const incoming = new Map(); + const outgoing = new Map(); + + for (const edge of edges) { + const sourceSpecs = outgoing.get(edge.source) || []; + sourceSpecs.push({ + id: `source-${edge.id}`, + top: handleOffset(sourceSpecs.length), + color: edgeColor(edge.data.path), + }); + outgoing.set(edge.source, sourceSpecs); + + const targetSpecs = incoming.get(edge.target) || []; + targetSpecs.push({ + id: `target-${edge.id}`, + top: handleOffset(targetSpecs.length), + color: edgeColor(edge.data.path), + }); + incoming.set(edge.target, targetSpecs); + } + + return { incoming, outgoing }; +} + +const routeHandles = buildHandleMaps(baseEdges); + +function edgeColor(path: PathType) { + switch (path) { + case 'read': + return '#1f6f8b'; + case 'live': + return '#b04f35'; + case 'write': + return '#7a6213'; + case 'control': + return '#4b5563'; + default: + return '#4b5563'; + } +} + +function withFilter( + items: T[], + activePath: 'all' | PathType +) { + return items.map((item) => { + const path = item.data?.path; + const dimmed = activePath !== 'all' && path && path !== activePath; + return { + ...item, + style: { + ...(item.style || {}), + opacity: dimmed ? 0.2 : 1, + }, + }; + }); +} + +export function TradeSystemFlowPage() { + const [selectedId, setSelectedId] = useState('frontend'); + const [selectedCanvasId, setSelectedCanvasId] = useState('frontend'); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [activePath, setActivePath] = useState<'all' | PathType>('all'); + const [isUltraWide, setIsUltraWide] = useState(() => + typeof window !== 'undefined' ? window.innerWidth >= 1900 : false + ); + const [diagramRuntime, setDiagramRuntime] = useState(null); + const [runtimeLoading, setRuntimeLoading] = useState(true); + const [runtimeError, setRuntimeError] = useState(null); + const [layoutState, setLayoutState] = useState({}); + const [savedLayoutSnapshot, setSavedLayoutSnapshot] = useState('{}'); + const [layoutSyncError, setLayoutSyncError] = useState(null); + const [layoutLoading, setLayoutLoading] = useState(true); + const [layoutSaving, setLayoutSaving] = useState(false); + const [layoutHistoryOpen, setLayoutHistoryOpen] = useState(false); + const [layoutHistory, setLayoutHistory] = useState([]); + const [layoutHistoryLoading, setLayoutHistoryLoading] = useState(false); + const [layoutHistoryError, setLayoutHistoryError] = useState(null); + const [layoutCurrentRevisionId, setLayoutCurrentRevisionId] = useState(null); + const [flowInstance, setFlowInstance] = useState(null); + const [fitViewNonce, setFitViewNonce] = useState(0); + const layoutStorageKey = `${layoutStorageBaseKey}:${isUltraWide ? 'ultra' : 'standard'}`; + const layoutView: DiagramLayoutView = isUltraWide ? 'ultra' : 'standard'; + const debugEnabled = readDiagramDebugEnabled(); + const debugThrottleRef = useRef>({}); + const fitViewOptions = isUltraWide + ? { padding: 0.015, minZoom: 0.9, maxZoom: 1.25 } + : { padding: 0.07, minZoom: 0.72, maxZoom: 1.2 }; + + useEffect(() => { + if (typeof window === 'undefined') return; + const onResize = () => setIsUltraWide(window.innerWidth >= 1900); + onResize(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + const debugTrace = ( + event: string, + payload: Record = {}, + options: { always?: boolean; throttleMs?: number; nodeId?: string | null } = {} + ) => { + if (!debugEnabled) return; + + const now = Date.now(); + const throttleMs = options.throttleMs ?? 250; + const lastAt = debugThrottleRef.current[event] || 0; + if (!options.always && now - lastAt < throttleMs) return; + debugThrottleRef.current[event] = now; + + const entry = { + view: layoutView, + event, + nodeId: options.nodeId ?? undefined, + payload, + }; + + console.debug('[diagram-debug]', entry); + void postDiagramDebugEvent(entry).catch(() => {}); + }; + + useEffect(() => { + let cancelled = false; + + const loadRuntime = async () => { + try { + const next = await fetchDiagramRuntime(); + if (cancelled) return; + setDiagramRuntime(next); + setRuntimeError(null); + } catch (err) { + if (cancelled) return; + setRuntimeError(String((err as Error)?.message || err)); + } finally { + if (!cancelled) setRuntimeLoading(false); + } + }; + + void loadRuntime(); + const timer = window.setInterval(() => void loadRuntime(), 15_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, []); + + const serviceNodes = useMemo[]>(() => { + const publisherRuntime = buildPublisherRuntime(diagramRuntime?.nodes?.publisher, { + loading: runtimeLoading, + error: runtimeError, + fetchedAt: diagramRuntime?.fetchedAt, + }); + + return baseNodes.map((node) => { + if (node.id !== 'publisher') return node; + const publisher = diagramRuntime?.nodes?.publisher; + return { + ...node, + data: { + ...node.data, + status: publisher?.status || (runtimeLoading ? 'warn' : node.data.status), + runtime: publisherRuntime, + }, + }; + }); + }, [diagramRuntime, runtimeError, runtimeLoading]); + + const defaultNodeFrames = useMemo(() => { + const frames: Record = + {}; + + for (const node of zoneNodes(isUltraWide)) { + frames[node.id] = { + position: node.position, + size: { + width: Number(node.style?.width || 0), + height: Number(node.style?.height || 0), + }, + }; + } + + for (const node of serviceNodes) { + frames[node.id] = { + position: isUltraWide ? ultraWidePositions[node.id] || node.position : node.position, + size: { + width: serviceNodeWidths[node.id] || 248, + height: serviceNodeHeights[node.id] || 150, + }, + }; + } + + return frames; + }, [isUltraWide, serviceNodes]); + + useEffect(() => { + let cancelled = false; + + const loadLayoutHistory = async () => { + setLayoutHistoryLoading(true); + try { + const remote = await fetchDiagramLayoutHistory(layoutView, 12); + if (cancelled) return; + setLayoutHistory(remote.revisions || []); + setLayoutCurrentRevisionId(remote.currentRevisionId ?? null); + setLayoutHistoryError(null); + } catch (err) { + if (cancelled) return; + setLayoutHistory([]); + setLayoutCurrentRevisionId(null); + setLayoutHistoryError(String((err as Error)?.message || err)); + } finally { + if (!cancelled) setLayoutHistoryLoading(false); + } + }; + + void loadLayoutHistory(); + return () => { + cancelled = true; + }; + }, [layoutView]); + + useEffect(() => { + if (typeof window === 'undefined') return; + + let cancelled = false; + setLayoutLoading(true); + + try { + const loadLayout = async () => { + try { + const remote = await fetchDiagramLayout(layoutView); + if (cancelled) return; + const next = normalizeLayoutState(remote.layout); + window.localStorage.setItem(layoutStorageKey, serializeLayoutState(next)); + setLayoutState(next); + setSavedLayoutSnapshot(serializeLayoutState(next)); + setLayoutCurrentRevisionId(remote.currentRevisionId ?? null); + setLayoutSyncError(null); + debugTrace( + 'layout.load.remote', + { keys: Object.keys(next).length, updatedAt: remote.updatedAt || 'n/a' }, + { always: true } + ); + } catch (remoteErr) { + if (cancelled) return; + try { + const raw = window.localStorage.getItem(layoutStorageKey); + const next = raw ? normalizeLayoutState(JSON.parse(raw)) : {}; + setLayoutState(next); + setSavedLayoutSnapshot(serializeLayoutState(next)); + setLayoutCurrentRevisionId(null); + setLayoutSyncError(String((remoteErr as Error)?.message || remoteErr)); + debugTrace( + 'layout.load.local_fallback', + { + keys: Object.keys(next).length, + error: String((remoteErr as Error)?.message || remoteErr), + }, + { always: true } + ); + } catch { + setLayoutState({}); + setSavedLayoutSnapshot(serializeLayoutState({})); + setLayoutCurrentRevisionId(null); + setLayoutSyncError(String((remoteErr as Error)?.message || remoteErr)); + } + } finally { + if (!cancelled) setLayoutLoading(false); + } + }; + + void loadLayout(); + } catch (err) { + setLayoutState({}); + setSavedLayoutSnapshot(serializeLayoutState({})); + setLayoutCurrentRevisionId(null); + setLayoutSyncError(String((err as Error)?.message || err)); + setLayoutLoading(false); + } + + return () => { + cancelled = true; + }; + }, [layoutStorageKey, layoutView]); + + const selectedNode = useMemo( + () => serviceNodes.find((node) => node.id === selectedId) || serviceNodes[0], + [selectedId, serviceNodes] + ); + + const selectedEdge = useMemo( + () => (selectedEdgeId ? baseEdges.find((edge) => edge.id === selectedEdgeId) || null : null), + [selectedEdgeId] + ); + + const selectedRoutes = useMemo( + () => + baseEdges + .filter((edge) => edge.source === selectedId || edge.target === selectedId) + .map((edge) => { + const outgoing = edge.source === selectedId; + const peerId = outgoing ? edge.target : edge.source; + const peer = serviceNodes.find((node) => node.id === peerId); + return { + id: edge.id, + direction: outgoing ? 'out' : 'in', + path: edge.data.path, + peerTitle: peer?.data.title || nodeTitleById[peerId] || peerId, + label: String(edge.label || ''), + description: edge.data.description, + }; + }) + .sort((a, b) => { + if (a.direction === b.direction) return a.peerTitle.localeCompare(b.peerTitle); + return a.direction === 'out' ? -1 : 1; + }), + [selectedId, serviceNodes] + ); + + const incomingRoutes = useMemo( + () => selectedRoutes.filter((route) => route.direction === 'in'), + [selectedRoutes] + ); + + const outgoingRoutes = useMemo( + () => selectedRoutes.filter((route) => route.direction === 'out'), + [selectedRoutes] + ); + + const statusSummary = useMemo(() => { + const counts = serviceNodes.reduce( + (acc, node) => { + const tone = statusMeta(node.data.status).tone; + acc[tone] += 1; + return acc; + }, + { ok: 0, warn: 0, info: 0 } + ); + return `${counts.ok} ready / ${counts.warn} attention / ${counts.info} mapped`; + }, [serviceNodes]); + + const layoutDirty = useMemo( + () => serializeLayoutState(layoutState) !== savedLayoutSnapshot, + [layoutState, savedLayoutSnapshot] + ); + const currentParentMap = useMemo(() => buildParentMap(layoutState), [layoutState]); + const currentDescendantsMap = useMemo(() => buildDescendantsMap(currentParentMap), [currentParentMap]); + + const reloadLayoutHistory = async (options: { silent?: boolean } = {}) => { + if (!options.silent) setLayoutHistoryLoading(true); + try { + const remote = await fetchDiagramLayoutHistory(layoutView, 12); + setLayoutHistory(remote.revisions || []); + setLayoutCurrentRevisionId(remote.currentRevisionId ?? null); + setLayoutHistoryError(null); + } catch (err) { + setLayoutHistoryError(String((err as Error)?.message || err)); + } finally { + if (!options.silent) setLayoutHistoryLoading(false); + } + }; + + const saveLayout = async (action: 'manual-save' | 'autosave' = 'manual-save') => { + if (typeof window === 'undefined') return; + setLayoutSaving(true); + const normalized = normalizeLayoutState(layoutState); + const snapshot = serializeLayoutState(normalized); + window.localStorage.setItem(layoutStorageKey, snapshot); + setLayoutState(normalized); + + try { + const response = await saveDiagramLayout(layoutView, normalized, action); + setSavedLayoutSnapshot(snapshot); + setLayoutCurrentRevisionId(response.revisionId ?? response.currentRevisionId ?? null); + setLayoutSyncError(null); + await reloadLayoutHistory({ silent: true }); + debugTrace( + 'layout.save', + { keys: Object.keys(normalized).length, action }, + { always: true } + ); + } catch (err) { + setLayoutSyncError(String((err as Error)?.message || err)); + debugTrace( + 'layout.save.error', + { error: String((err as Error)?.message || err) }, + { always: true } + ); + } finally { + setLayoutSaving(false); + } + }; + + const resetLayout = async () => { + if (typeof window === 'undefined') return; + setLayoutSaving(true); + window.localStorage.removeItem(layoutStorageKey); + try { + const response = await deleteDiagramLayout(layoutView); + setLayoutState({}); + setSavedLayoutSnapshot(serializeLayoutState({})); + setLayoutCurrentRevisionId(response.revisionId ?? response.currentRevisionId ?? null); + setLayoutSyncError(null); + setFitViewNonce((value) => value + 1); + await reloadLayoutHistory({ silent: true }); + debugTrace('layout.reset', {}, { always: true }); + } catch (err) { + setLayoutSyncError(String((err as Error)?.message || err)); + debugTrace( + 'layout.reset.error', + { error: String((err as Error)?.message || err) }, + { always: true } + ); + } finally { + setLayoutSaving(false); + } + }; + + const restoreLayout = async (revisionId: number) => { + if (typeof window === 'undefined') return; + setLayoutSaving(true); + try { + const response = await restoreDiagramLayoutRevision(layoutView, revisionId); + const normalized = normalizeLayoutState(response.layout); + const snapshot = serializeLayoutState(normalized); + window.localStorage.setItem(layoutStorageKey, snapshot); + setLayoutState(normalized); + setSavedLayoutSnapshot(snapshot); + setLayoutCurrentRevisionId(response.revisionId ?? response.currentRevisionId ?? null); + setLayoutSyncError(null); + setLayoutHistoryOpen(false); + setFitViewNonce((value) => value + 1); + await reloadLayoutHistory({ silent: true }); + debugTrace( + 'layout.restore', + { revisionId, keys: Object.keys(normalized).length }, + { always: true } + ); + } catch (err) { + setLayoutSyncError(String((err as Error)?.message || err)); + debugTrace( + 'layout.restore.error', + { error: String((err as Error)?.message || err), revisionId }, + { always: true } + ); + } finally { + setLayoutSaving(false); + } + }; + + useEffect(() => { + if (layoutLoading || layoutSaving || !layoutDirty) return; + + const timer = window.setTimeout(() => { + void saveLayout('autosave'); + }, 450); + + return () => window.clearTimeout(timer); + }, [layoutDirty, layoutLoading, layoutSaving, layoutState, isUltraWide]); + + const handleNodesChange = (changes: NodeChange[]) => { + setLayoutState((previous) => { + let next = previous; + + for (const change of changes) { + if (change.type === 'position' && change.position) { + const frame = defaultNodeFrames[change.id]; + if (!frame) continue; + + const currentPosition = next[change.id]?.position || frame.position; + const snappedPosition = snapPositionToGrid(change.position); + const deltaX = snappedPosition.x - currentPosition.x; + const deltaY = snappedPosition.y - currentPosition.y; + if (!deltaX && !deltaY) continue; + + if (next === previous) next = { ...previous }; + next[change.id] = { + ...(next[change.id] || {}), + position: snappedPosition, + }; + + for (const descendantId of currentDescendantsMap.get(change.id) || []) { + const descendantFrame = defaultNodeFrames[descendantId]; + if (!descendantFrame) continue; + + const descendantPosition = next[descendantId]?.position || descendantFrame.position; + next[descendantId] = { + ...(next[descendantId] || {}), + position: snapPositionToGrid({ + x: descendantPosition.x + deltaX, + y: descendantPosition.y + deltaY, + }), + }; + } + + const resolvedParentId = findContainingAncestor(change.id, defaultNodeFrames, next); + if (resolvedParentId !== currentParentMap[change.id]) { + debugTrace( + 'node.parent.rebind', + { + from: currentParentMap[change.id] || null, + to: resolvedParentId, + }, + { always: true, nodeId: change.id } + ); + next[change.id] = { + ...(next[change.id] || {}), + parentId: resolvedParentId, + }; + } + + continue; + } + + if (change.type === 'dimensions' && change.dimensions) { + const currentSize = next[change.id]?.size || defaultNodeFrames[change.id]?.size; + const width = Number(change.dimensions.width ?? currentSize?.width); + const height = Number(change.dimensions.height ?? currentSize?.height); + if (!Number.isFinite(width) || !Number.isFinite(height)) continue; + + if (next === previous) next = { ...previous }; + next[change.id] = { + ...(next[change.id] || {}), + size: snapSizeToGrid({ width, height }), + }; + } + } + + return next; + }); + }; + + const nodes = useMemo( + () => [ + ...zoneNodes(isUltraWide).map((node) => { + const frame = defaultNodeFrames[node.id]; + const override = layoutState[node.id]; + const size = override?.size || frame.size; + return { + ...node, + position: override?.position || frame.position, + dragHandle: '.rfDragHandle', + selected: node.id === selectedCanvasId, + style: { + ...(node.style || {}), + width: size.width, + height: size.height, + }, + }; + }), + ...serviceNodes.map((node) => { + const frame = defaultNodeFrames[node.id]; + const override = layoutState[node.id]; + const size = override?.size || frame.size; + return { + ...node, + position: override?.position || frame.position, + dragHandle: '.rfDragHandle', + selected: node.id === selectedCanvasId, + style: { + width: size.width, + height: size.height, + zIndex: 10, + opacity: activePath === 'all' || node.id === selectedId ? 1 : 0.95, + }, + }; + }), + ], + [activePath, defaultNodeFrames, isUltraWide, layoutState, selectedCanvasId, selectedId, serviceNodes] + ); + + useEffect(() => { + if (!flowInstance || layoutLoading || nodes.length === 0 || typeof window === 'undefined') return; + + let raf1 = 0; + let raf2 = 0; + raf1 = window.requestAnimationFrame(() => { + raf2 = window.requestAnimationFrame(() => { + void flowInstance.fitView(fitViewOptions); + }); + }); + + return () => { + window.cancelAnimationFrame(raf1); + window.cancelAnimationFrame(raf2); + }; + }, [fitViewNonce, fitViewOptions, flowInstance, layoutLoading, nodes.length]); + + const edges = useMemo( + () => { + if (!showCanvasPaths) return []; + + const mapped = baseEdges.map((edge) => { + const isSelected = edge.id === selectedEdgeId; + const isConnected = edge.source === selectedId || edge.target === selectedId; + const matchesPath = activePath === 'all' || edge.data.path === activePath; + return { + ...edge, + type: 'routed', + sourceHandle: `source-${edge.id}`, + targetHandle: `target-${edge.id}`, + label: isSelected || isConnected ? edge.label : undefined, + style: { + stroke: edgeColor(edge.data.path), + strokeWidth: isSelected ? 4.4 : isConnected ? 3.1 : 1.8, + opacity: matchesPath ? (isSelected ? 1 : isConnected ? 0.96 : 0.4) : 0.08, + }, + labelStyle: { + fill: '#f3efe8', + fontSize: isSelected ? 13 : 12, + fontWeight: 800, + }, + labelBgStyle: { + fill: edgeColor(edge.data.path), + opacity: isSelected ? 1 : 0.92, + }, + labelBgPadding: [7, 4] as [number, number], + labelBgBorderRadius: 10, + interactionWidth: 28, + }; + }); + + return mapped.sort((a, b) => { + const weight = (edge: (typeof mapped)[number]) => { + const selected = edge.id === selectedEdgeId ? 100 : 0; + const connected = edge.source === selectedId || edge.target === selectedId ? 10 : 0; + const pathMatch = activePath === 'all' || edge.data.path === activePath ? 1 : 0; + return selected + connected + pathMatch; + }; + return weight(a) - weight(b); + }); + }, + [activePath, selectedEdgeId, selectedId] + ); + + return ( +
+
+
+
+
+
runtime architecture / react flow
+
Trade system flow
+
endpoints, payloads, read paths, live feeds
+
+
+ + {showCanvasPaths ? ( +
+ {(['all', 'read', 'live', 'control', 'write'] as const).map((path) => ( + + ))} +
{`grid ${canvasSnapGrid[0]} x ${canvasSnapGrid[1]}`}
+ {layoutSyncError ?
layout storage degraded
: null} + + +
+ + {layoutHistoryOpen ? ( +
+
+
+
layout snapshots
+
recent revisions
+
+ +
+ {layoutHistoryError ? ( +
{layoutHistoryError}
+ ) : null} + {layoutHistoryLoading ?
loading history...
: null} + {!layoutHistoryLoading && !layoutHistory.length ? ( +
no saved revisions yet
+ ) : null} +
+ {layoutHistory.map((revision) => { + const isCurrent = revision.id === layoutCurrentRevisionId; + return ( +
+
+ + {formatLayoutRevisionAction(revision.action)} + + + {formatLayoutRevisionTime(revision.updatedAt)} + +
+
+ {revision.entryCount} items + {isCurrent ? ' / current' : ''} +
+ +
+ ); + })} +
+
+ ) : null} +
+
+ ) : ( +
+
paths hidden on canvas
+
{`grid ${canvasSnapGrid[0]} x ${canvasSnapGrid[1]}`}
+
+ {layoutDirty ? 'layout has unsaved edits' : layoutLoading ? 'loading layout' : 'layout loaded'} +
+ {layoutSyncError ?
layout storage degraded
: null} + + + +
+ + {layoutHistoryOpen ? ( +
+
+
+
layout snapshots
+
recent revisions
+
+ +
+ {layoutHistoryError ? ( +
{layoutHistoryError}
+ ) : null} + {layoutHistoryLoading ?
loading history...
: null} + {!layoutHistoryLoading && !layoutHistory.length ? ( +
no saved revisions yet
+ ) : null} +
+ {layoutHistory.map((revision) => { + const isCurrent = revision.id === layoutCurrentRevisionId; + return ( +
+
+ + {formatLayoutRevisionAction(revision.action)} + + + {formatLayoutRevisionTime(revision.updatedAt)} + +
+
+ {revision.entryCount} items + {isCurrent ? ' / current' : ''} +
+ +
+ ); + })} +
+
+ ) : null} +
+
+ )} + +
+
{statusSummary}
+
+ {serviceNodes.map((node) => { + const meta = statusMeta(node.data.status); + return ( + + ); + })} +
+
+
+ +
+
+ { + setFlowInstance(instance); + setFitViewNonce((value) => value + 1); + }} + onNodesChange={handleNodesChange} + onNodeClick={(_, node) => { + setSelectedCanvasId(node.id); + if (!String(node.id).startsWith('zone-')) setSelectedId(node.id); + setSelectedEdgeId(null); + }} + onNodeDragStart={(_, node) => { + debugTrace( + 'node.drag.start', + { + x: node.position.x, + y: node.position.y, + parentId: currentParentMap[node.id] || null, + }, + { always: true, nodeId: node.id } + ); + }} + onNodeDrag={(_, node) => { + debugTrace( + 'node.drag', + { + x: node.position.x, + y: node.position.y, + parentId: currentParentMap[node.id] || null, + }, + { nodeId: node.id, throttleMs: 150 } + ); + }} + onNodeDragStop={(_, node) => { + debugTrace( + 'node.drag.stop', + { + x: node.position.x, + y: node.position.y, + parentId: currentParentMap[node.id] || null, + }, + { always: true, nodeId: node.id } + ); + }} + onEdgeClick={(_, edge) => setSelectedEdgeId(edge.id)} + onPaneClick={() => { + setSelectedCanvasId(null); + setSelectedEdgeId(null); + }} + onMove={(_, viewport) => { + debugTrace( + 'viewport.move', + { x: viewport.x, y: viewport.y, zoom: viewport.zoom }, + { throttleMs: 150 } + ); + }} + onMoveEnd={(_, viewport) => { + debugTrace( + 'viewport.move.end', + { x: viewport.x, y: viewport.y, zoom: viewport.zoom }, + { always: true } + ); + }} + proOptions={{ hideAttribution: true }} + elevateEdgesOnSelect + minZoom={0.55} + maxZoom={1.35} + panOnDrag={false} + autoPanOnNodeDrag + autoPanSpeed={24} + snapToGrid + snapGrid={canvasSnapGrid} + fitViewOptions={fitViewOptions} + defaultEdgeOptions={ + showCanvasPaths + ? { + type: 'routed', + markerEnd: { type: MarkerType.ArrowClosed }, + } + : undefined + } + > + + laneColors[(node.data as FlowNodeData).lane] || '#d9cfbf'} + maskColor="rgba(11, 13, 18, 0.54)" + /> + + +
lanes
+
external
+
web entry
+
api / graphql
+
data plane
+
chain access
+
execution
+
+
+
+ + +
+
+ ); +} diff --git a/apps/visualizer/src/features/bot/BotObserverPanel.tsx b/apps/visualizer/src/features/bot/BotObserverPanel.tsx new file mode 100644 index 0000000..300fea5 --- /dev/null +++ b/apps/visualizer/src/features/bot/BotObserverPanel.tsx @@ -0,0 +1,282 @@ +import type { BotEventRow, BotObserverData, BotObserverRuntimeState } from './useBotObserver'; + +function formatUsd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(1)}K`; + return `$${v.toFixed(2)}`; +} + +function formatBps(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + return `${v.toFixed(2)} bps`; +} + +function formatPct(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + return `${(v * 100).toFixed(0)}%`; +} + +function formatAge(iso: string | null | undefined): string { + if (!iso) return '—'; + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return '—'; + const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ago`; +} + +function formatClock(iso: string | null | undefined): string { + if (!iso) return '—'; + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return '—'; + return new Date(ts).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function numericClass(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v) || v === 0) return ''; + return v > 0 ? 'pos' : 'neg'; +} + +function gateTone(value: boolean | null | undefined): 'ok' | 'bad' | 'muted' { + if (value === true) return 'ok'; + if (value === false) return 'bad'; + return 'muted'; +} + +function eventSummary(event: BotEventRow): string { + const payload = event.payload || {}; + if (event.type === 'decision' || event.type === 'skip') { + const decision = (payload as any).decision || {}; + const side = typeof decision.side === 'string' ? decision.side.toUpperCase() : '—'; + const confidence = typeof decision.confidence === 'number' ? `${Math.round(decision.confidence * 100)}%` : '—'; + const target = typeof decision.target_notional_usd === 'number' ? formatUsd(decision.target_notional_usd) : '—'; + const skipReason = typeof decision.skip_reason === 'string' && decision.skip_reason ? ` • ${decision.skip_reason}` : ''; + return `${side} • ${confidence} • ${target}${skipReason}`; + } + if (event.type === 'status') { + const status = (payload as any).status || {}; + const bot = (payload as any).bot || {}; + const mode = typeof bot.mode === 'string' ? bot.mode : '—'; + const active = status.active === true ? 'active' : 'inactive'; + const note = typeof status.note === 'string' && status.note ? ` • ${status.note}` : ''; + return `${mode} • ${active}${note}`; + } + if (event.type === 'error') { + return typeof (payload as any).error === 'string' ? (payload as any).error : 'error'; + } + return JSON.stringify(payload); +} + +function kpiValueClass(side: string | null | undefined): string { + if (side === 'long') return 'pos'; + if (side === 'short') return 'neg'; + return ''; +} + +function renderFeatureRow(label: string, value: string, valueClass = '') { + return ( +
+ {label} + {value} +
+ ); +} + +export default function BotObserverPanel({ + marketName, + observer, +}: { + marketName: string; + observer: BotObserverData; +}) { + const { bot, resolvedBy, state, events, loading, error, warning, lastUpdatedAt } = observer; + const runtimeState: BotObserverRuntimeState | null = state?.state || null; + const lastDecision = runtimeState?.last_decision || null; + const lastFeatures = runtimeState?.last_features || null; + const lastGates = runtimeState?.last_gates || null; + const lastSnapshot = runtimeState?.last_snapshot || null; + const counters = runtimeState?.counters || null; + + if (loading && !bot) { + return
Loading bot observer…
; + } + + if (error && !bot) { + return
{error}
; + } + + if (!bot) { + if (warning === 'bot_control_plane_schema_missing') { + return ( +
+
Bot control plane is not deployed here.
+
This Hasura environment does not expose `bot_config`, `bot_state`, or `bot_events` yet.
+
+ ); + } + return ( +
+
No bot configured for this market.
+
Set `VITE_BOT_ID` / `VITE_BOT_NAME` or create a bot for {marketName}.
+
+ ); + } + + return ( +
+
+
Bot Observer
+
+ {bot.name} + {bot.market_name} + {resolvedBy ? `resolved ${resolvedBy}` : 'manual'} +
+
+ + {error ?
{error}
: null} + + {bot.market_name !== marketName ? ( +
Current chart market is {marketName}, panel shows bot for {bot.market_name}.
+ ) : null} + +
+
+ mode + {bot.mode} +
+
+ status + {runtimeState?.status || '—'} +
+
+ heartbeat + {formatAge(state?.last_heartbeat_at)} +
+
+ updated + {lastUpdatedAt ? formatClock(lastUpdatedAt) : '—'} +
+
+ kill switch + + {bot.kill_switch ? 'enabled' : 'off'} + +
+
+ +
+
+
Decision
+
+ {lastDecision?.side || '—'} +
+
{lastDecision?.skip_reason || 'current signal'}
+
+
+
Confidence
+
{formatPct(lastDecision?.confidence ?? null)}
+
+
+
Target
+
{formatUsd(lastDecision?.target_notional_usd ?? null)}
+
{lastDecision?.horizon_s != null ? `${lastDecision.horizon_s}s horizon` : '—'}
+
+
+
Long Score
+
+ {lastDecision?.long_score == null ? '—' : lastDecision.long_score.toFixed(2)} +
+
+
+
Short Score
+
+ {lastDecision?.short_score == null ? '—' : lastDecision.short_score.toFixed(2)} +
+
+
+
Loops
+
{counters?.loops ?? '—'}
+
+ {counters ? `${counters.decisions ?? 0} decisions / ${counters.skips ?? 0} skips` : '—'} +
+
+
+ +
+
+
+
Gates
+
{lastSnapshot?.data_age_ms != null ? `${lastSnapshot.data_age_ms} ms age` : '—'}
+
+
+ {[ + ['fresh', lastGates?.fresh], + ['spread_ok', lastGates?.spread_ok], + ['slippage_ok', lastGates?.slippage_ok], + ['depth_ok', lastGates?.depth_ok], + ['has_candles', lastGates?.has_candles], + ['all', lastGates?.all], + ].map(([label, value]) => ( +
+ {label} + {value == null ? '—' : value ? 'pass' : 'fail'} +
+ ))} +
+ +
+
Feature Snapshot
+
{lastSnapshot?.query_latency_ms != null ? `${lastSnapshot.query_latency_ms} ms query` : '—'}
+
+
+ {renderFeatureRow('spread_bps', formatBps(lastFeatures?.spread_bps ?? null))} + {renderFeatureRow('mark_vs_oracle', formatBps(lastFeatures?.mark_vs_oracle_bps ?? null), numericClass(lastFeatures?.mark_vs_oracle_bps ?? null))} + {renderFeatureRow('mom_3s', formatBps(lastFeatures?.mom_3s ?? null), numericClass(lastFeatures?.mom_3s ?? null))} + {renderFeatureRow('mom_10s', formatBps(lastFeatures?.mom_10s ?? null), numericClass(lastFeatures?.mom_10s ?? null))} + {renderFeatureRow('mom_30s', formatBps(lastFeatures?.mom_30s ?? null), numericClass(lastFeatures?.mom_30s ?? null))} + {renderFeatureRow('vol_30s', formatBps(lastFeatures?.vol_30s ?? null))} + {renderFeatureRow('depth_bid_usd', formatUsd(lastFeatures?.depth_bid_usd ?? null), 'pos')} + {renderFeatureRow('depth_ask_usd', formatUsd(lastFeatures?.depth_ask_usd ?? null), 'neg')} + {renderFeatureRow('depth_imbalance', formatPct(lastFeatures?.depth_imbalance ?? null), numericClass(lastFeatures?.depth_imbalance ?? null))} + {renderFeatureRow('buy_slippage', formatBps(lastFeatures?.buy_slippage_bps ?? null))} + {renderFeatureRow('sell_slippage', formatBps(lastFeatures?.sell_slippage_bps ?? null))} + {renderFeatureRow('last_error', state?.last_error || runtimeState?.error || '—', state?.last_error || runtimeState?.error ? 'neg' : '')} +
+
+ +
+
+
Event Feed
+
{events.length ? `${events.length} rows` : '—'}
+
+ {events.length ? ( +
+ {events.map((event) => ( +
+
+ {event.type} + {formatClock(event.ts)} +
+
{eventSummary(event)}
+
+ ))} +
+ ) : ( +
No bot events yet.
+ )} +
+
+
+ ); +} diff --git a/apps/visualizer/src/features/bot/BotObserverSummaryCard.tsx b/apps/visualizer/src/features/bot/BotObserverSummaryCard.tsx new file mode 100644 index 0000000..00153f2 --- /dev/null +++ b/apps/visualizer/src/features/bot/BotObserverSummaryCard.tsx @@ -0,0 +1,185 @@ +import type { BotObserverData, BotObserverRuntimeState } from './useBotObserver'; + +function formatPct(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + return `${(v * 100).toFixed(0)}%`; +} + +function formatUsd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(1)}K`; + return `$${v.toFixed(2)}`; +} + +function formatAge(iso: string | null | undefined): string { + if (!iso) return '—'; + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return '—'; + const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + +function gateTone(value: boolean | null | undefined): 'ok' | 'bad' | 'muted' { + if (value === true) return 'ok'; + if (value === false) return 'bad'; + return 'muted'; +} + +function stateTone(value: string | null | undefined): string { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'long') return 'pos'; + if (normalized === 'short' || normalized === 'panic') return 'neg'; + return ''; +} + +function resolveMachineState(runtimeState: BotObserverRuntimeState | null, desiredMode: string, killSwitch: boolean): string { + if (killSwitch) return 'panic'; + const status = String(runtimeState?.status || runtimeState?.mode || desiredMode || '') + .trim() + .toLowerCase(); + if (['off', 'observe', 'paper', 'trade', 'panic'].includes(status)) return status; + return 'off'; +} + +export default function BotObserverSummaryCard({ + marketName, + observer, +}: { + marketName: string; + observer: BotObserverData; +}) { + const { bot, state, loading, error, warning } = observer; + const runtimeState: BotObserverRuntimeState | null = state?.state || null; + const machineState = resolveMachineState(runtimeState, bot?.mode || 'off', Boolean(bot?.kill_switch)); + const lastDecision = runtimeState?.last_decision || null; + const lastSnapshot = runtimeState?.last_snapshot || null; + const lastGates = runtimeState?.last_gates || null; + const counters = runtimeState?.counters || null; + + if (loading && !bot) { + return
Loading bot…
; + } + + if (error && !bot) { + return
{error}
; + } + + if (!bot) { + if (warning === 'bot_control_plane_schema_missing') { + return ( +
+
Bot control plane missing
+
Current Hasura environment does not publish bot tables yet.
+
+ ); + } + return ( +
+
No bot configured
+
No observer found for {marketName}.
+
+ ); + } + + return ( +
+
+
+
Bot State Machine
+
{bot.name}
+
+
+ {(lastDecision?.side || 'flat').toUpperCase()} +
+
+ + {error ?
{error}
: null} + + {bot.market_name !== marketName ? ( +
Chart: {marketName} • Bot: {bot.market_name}
+ ) : null} + +
+ {['off', 'observe', 'paper', 'trade', 'panic'].map((stage) => ( +
+ {stage} +
+ ))} +
+ +
+
+ Desired + {bot.mode} +
+
+ Runtime + {runtimeState?.status || runtimeState?.mode || '—'} +
+
+ Heartbeat + {formatAge(state?.last_heartbeat_at)} +
+
+ Data Age + + {lastSnapshot?.data_age_ms != null ? `${lastSnapshot.data_age_ms} ms` : '—'} + +
+
+ Query + + {lastSnapshot?.query_latency_ms != null ? `${lastSnapshot.query_latency_ms} ms` : '—'} + +
+
+ Confidence + {formatPct(lastDecision?.confidence ?? null)} +
+
+ Target + {formatUsd(lastDecision?.target_notional_usd ?? null)} +
+
+ Loops + {counters?.loops ?? '—'} +
+
+ +
+ {[ + ['fresh', lastGates?.fresh], + ['spread', lastGates?.spread_ok], + ['slippage', lastGates?.slippage_ok], + ['depth', lastGates?.depth_ok], + ].map(([label, value]) => ( + + {label} + + ))} +
+ + {lastDecision?.skip_reason ? ( +
skip: {lastDecision.skip_reason}
+ ) : runtimeState?.inactive_reason ? ( +
{runtimeState.inactive_reason}
+ ) : state?.last_error ? ( +
{state.last_error}
+ ) : null} +
+ ); +} diff --git a/apps/visualizer/src/features/bot/useBotObserver.ts b/apps/visualizer/src/features/bot/useBotObserver.ts new file mode 100644 index 0000000..a2629f6 --- /dev/null +++ b/apps/visualizer/src/features/bot/useBotObserver.ts @@ -0,0 +1,290 @@ +import { useEffect, useRef, useState } from 'react'; +import { useInterval } from '../../app/hooks/useInterval'; + +export type BotConfigSummary = { + id: string; + name: string; + market_name: string; + market_type: string; + mode: string; + kill_switch: boolean; + updated_at: string; +}; + +export type BotObserverRuntimeState = { + service?: string; + version?: string; + appVersion?: string; + observe_only?: boolean; + market_name?: string; + market_type?: string; + mode?: string; + kill_switch?: boolean; + status?: string; + strategy_type?: string; + loop_ms?: number; + last_snapshot?: { + query_latency_ms?: number | null; + data_age_ms?: number | null; + candles_count?: number | null; + stats_age_ms?: number | null; + depth_age_ms?: number | null; + buy_slippage_age_ms?: number | null; + sell_slippage_age_ms?: number | null; + } | null; + last_features?: { + mark_price?: number | null; + oracle_price?: number | null; + mid_price?: number | null; + spread_bps?: number | null; + stats_imbalance?: number | null; + depth_bid_usd?: number | null; + depth_ask_usd?: number | null; + depth_imbalance?: number | null; + buy_slippage_bps?: number | null; + sell_slippage_bps?: number | null; + mark_vs_oracle_bps?: number | null; + mom_3s?: number | null; + mom_10s?: number | null; + mom_30s?: number | null; + vol_30s?: number | null; + } | null; + last_gates?: { + fresh?: boolean; + spread_ok?: boolean; + slippage_ok?: boolean; + depth_ok?: boolean; + has_candles?: boolean; + all?: boolean; + } | null; + last_decision?: { + side?: string; + confidence?: number | null; + long_score?: number | null; + short_score?: number | null; + target_notional_usd?: number | null; + horizon_s?: number | null; + skip_reason?: string | null; + } | null; + counters?: { + loops?: number; + decisions?: number; + skips?: number; + errors?: number; + } | null; + inactive_reason?: string; + error?: string; + trade_requested_but_observer_only?: boolean; +}; + +export type BotStateRow = { + bot_id: string; + last_heartbeat_at: string | null; + last_action_at: string | null; + last_error: string | null; + state: BotObserverRuntimeState | null; + updated_at: string; +}; + +export type BotEventRow = { + id: number; + ts: string; + type: string; + payload?: Record | null; +}; + +export type BotObserverData = { + bot: BotConfigSummary | null; + resolvedBy: string | null; + state: BotStateRow | null; + events: BotEventRow[]; + loading: boolean; + error: string | null; + warning: string | null; + lastUpdatedAt: string | null; +}; + +function envString(name: string): string | undefined { + const value = (import.meta as any).env?.[name]; + if (value == null) return undefined; + const text = String(value).trim(); + return text || undefined; +} + +function getApiBase(): string { + return envString('VITE_API_URL') || '/api'; +} + +function buildApiUrl(pathname: string): string { + const base = new URL(getApiBase(), window.location.origin); + const basePath = base.pathname && base.pathname !== '/' ? base.pathname.replace(/\/$/, '') : ''; + const [pathOnly, searchOnly = ''] = String(pathname || '').split('?'); + base.pathname = `${basePath}${pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`}`; + base.search = searchOnly ? `?${searchOnly}` : ''; + base.hash = ''; + return base.toString(); +} + +async function apiRequest(pathname: string): Promise { + const res = await fetch(buildApiUrl(pathname), { + cache: 'no-store', + credentials: 'same-origin', + }); + const text = await res.text(); + let json: any = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + throw new Error(`Invalid API JSON for ${pathname}`); + } + if (!res.ok) throw new Error(json?.error || `API HTTP ${res.status}`); + return json as T; +} + +function preferredBotId(): string | undefined { + return envString('VITE_BOT_ID'); +} + +function preferredBotName(): string | undefined { + return envString('VITE_BOT_NAME'); +} + +function resolveBot(bots: BotConfigSummary[], marketName: string): { bot: BotConfigSummary | null; by: string | null } { + const configuredId = preferredBotId(); + if (configuredId) { + const match = bots.find((entry) => entry.id === configuredId); + if (match) return { bot: match, by: 'env:id' }; + } + + const configuredName = preferredBotName(); + if (configuredName) { + const match = bots.find((entry) => entry.name === configuredName); + if (match) return { bot: match, by: 'env:name' }; + } + + const match = bots.find((entry) => String(entry.market_name || '').trim().toUpperCase() === marketName.trim().toUpperCase()); + if (match) return { bot: match, by: 'market' }; + + return { bot: null, by: null }; +} + +export function useBotObserver(marketName: string): BotObserverData { + const mountedRef = useRef(true); + const resolveInFlightRef = useRef(false); + const detailsInFlightRef = useRef(false); + const [bot, setBot] = useState(null); + const [resolvedBy, setResolvedBy] = useState(null); + const [state, setState] = useState(null); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [warning, setWarning] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + async function refreshBotResolution() { + if (resolveInFlightRef.current) return; + resolveInFlightRef.current = true; + try { + const json = await apiRequest<{ ok?: boolean; bots?: BotConfigSummary[]; error?: string; warning?: string }>( + '/v1/bots?limit=100' + ); + const bots = Array.isArray(json?.bots) ? json.bots : []; + const resolved = resolveBot(bots, marketName); + if (!mountedRef.current) return; + setBot((current) => { + if (current?.id === resolved.bot?.id) return current; + return resolved.bot; + }); + setResolvedBy(resolved.by); + if (!resolved.bot) { + setState(null); + setEvents([]); + setLoading(false); + } + setWarning(typeof json?.warning === 'string' && json.warning ? json.warning : null); + setError(null); + } catch (err) { + if (!mountedRef.current) return; + setError(String((err as Error)?.message || err)); + setLoading(false); + } finally { + resolveInFlightRef.current = false; + } + } + + async function refreshBotDetails(botId: string) { + if (detailsInFlightRef.current) return; + detailsInFlightRef.current = true; + try { + const [stateJson, eventsJson] = await Promise.all([ + apiRequest<{ ok?: boolean; state?: BotStateRow | null; error?: string; warning?: string }>( + `/v1/bots/${botId}/state` + ), + apiRequest<{ ok?: boolean; events?: BotEventRow[]; error?: string; warning?: string }>( + `/v1/bots/${botId}/events?limit=24` + ), + ]); + if (!mountedRef.current) return; + setState(stateJson?.state || null); + setEvents(Array.isArray(eventsJson?.events) ? eventsJson.events : []); + setLastUpdatedAt(new Date().toISOString()); + setWarning( + (typeof stateJson?.warning === 'string' && stateJson.warning) || + (typeof eventsJson?.warning === 'string' && eventsJson.warning) || + null + ); + setError(null); + setLoading(false); + } catch (err) { + if (!mountedRef.current) return; + setError(String((err as Error)?.message || err)); + setLoading(false); + } finally { + detailsInFlightRef.current = false; + } + } + + useEffect(() => { + setLoading(true); + setBot(null); + setResolvedBy(null); + setState(null); + setEvents([]); + setError(null); + setWarning(null); + setLastUpdatedAt(null); + void refreshBotResolution(); + }, [marketName]); + + useEffect(() => { + if (!bot?.id) return; + void refreshBotDetails(bot.id); + }, [bot?.id]); + + useInterval(() => { + void refreshBotResolution(); + }, 5000); + + useInterval(() => { + if (!bot?.id) return; + void refreshBotDetails(bot.id); + }, bot?.id ? 1000 : null); + + return { + bot, + resolvedBy, + state, + events, + loading, + error, + warning, + lastUpdatedAt, + }; +} diff --git a/apps/visualizer/src/features/chart/ChartIcons.tsx b/apps/visualizer/src/features/chart/ChartIcons.tsx index 74d051b..3604cff 100644 --- a/apps/visualizer/src/features/chart/ChartIcons.tsx +++ b/apps/visualizer/src/features/chart/ChartIcons.tsx @@ -156,6 +156,21 @@ export function IconEye(props: IconProps) { ); } +export function IconLayers(props: IconProps) { + return ( + + + + + + ); +} + export function IconTrash(props: IconProps) { return ( diff --git a/apps/visualizer/src/features/chart/ChartLayersPanel.tsx b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx new file mode 100644 index 0000000..3942e45 --- /dev/null +++ b/apps/visualizer/src/features/chart/ChartLayersPanel.tsx @@ -0,0 +1,206 @@ +import { useMemo } from 'react'; +import type { ReactNode } from 'react'; +import type { OverlayLayer } from './ChartPanel.types'; +import { IconEye, IconLock, IconTrash } from './ChartIcons'; + +type Props = { + open: boolean; + layers: OverlayLayer[]; + onRequestClose: () => void; + + onToggleLayerVisible: (layerId: string) => void; + onToggleLayerLocked: (layerId: string) => void; + onSetLayerOpacity: (layerId: string, opacity: number) => void; + + fibPresent: boolean; + fibSelected: boolean; + fibVisible: boolean; + fibLocked: boolean; + fibOpacity: number; + onSelectFib: () => void; + onToggleFibVisible: () => void; + onToggleFibLocked: () => void; + onSetFibOpacity: (opacity: number) => void; + onDeleteFib: () => void; +}; + +function clamp01(v: number): number { + if (!Number.isFinite(v)) return 1; + return Math.max(0, Math.min(1, v)); +} + +function opacityToPct(opacity: number): number { + return Math.round(clamp01(opacity) * 100); +} + +function pctToOpacity(pct: number): number { + if (!Number.isFinite(pct)) return 1; + return clamp01(pct / 100); +} + +function IconButton({ + title, + active, + disabled, + onClick, + children, +}: { + title: string; + active?: boolean; + disabled?: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function OpacitySlider({ + value, + disabled, + onChange, +}: { + value: number; + disabled?: boolean; + onChange: (next: number) => void; +}) { + const pct = opacityToPct(value); + return ( +
+ onChange(pctToOpacity(Number(e.target.value)))} + disabled={disabled} + /> +
{pct}%
+
+ ); +} + +export default function ChartLayersPanel({ + open, + layers, + onRequestClose, + onToggleLayerVisible, + onToggleLayerLocked, + onSetLayerOpacity, + fibPresent, + fibSelected, + fibVisible, + fibLocked, + fibOpacity, + onSelectFib, + onToggleFibVisible, + onToggleFibLocked, + onSetFibOpacity, + onDeleteFib, +}: Props) { + const drawingsLayer = useMemo(() => layers.find((l) => l.id === 'drawings'), [layers]); + + return ( + <> +
+
+
+
Layers
+ +
+ +
+
+
+ +
+
+ +
+
Name
+
Opacity
+
Actions
+
+ + {layers.map((layer) => ( +
+
+ onToggleLayerVisible(layer.id)}> + + +
+
+ onToggleLayerLocked(layer.id)}> + + +
+
+
+ {layer.name} + {layer.id === 'drawings' ? {fibPresent ? ' (1)' : ' (0)'} : null} +
+
+
+ onSetLayerOpacity(layer.id, next)} /> +
+
+
+ ))} + + {drawingsLayer && fibPresent ? ( +
+
+ + + +
+
+ + + +
+
+
Fib Retracement
+
+
+ +
+
+ + + +
+
+ ) : null} +
+
+ + ); +} diff --git a/apps/visualizer/src/features/chart/ChartPanel.tsx b/apps/visualizer/src/features/chart/ChartPanel.tsx index 8151989..b219de1 100644 --- a/apps/visualizer/src/features/chart/ChartPanel.tsx +++ b/apps/visualizer/src/features/chart/ChartPanel.tsx @@ -1,41 +1,102 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { Candle, ChartIndicators } from '../../lib/api'; +import type { TickerItem } from '../tickerbar/TickerBar'; import Card from '../../ui/Card'; +import ChartLayersPanel from './ChartLayersPanel'; import ChartSideToolbar from './ChartSideToolbar'; import ChartToolbar from './ChartToolbar'; import TradingChart from './TradingChart'; import type { FibAnchor, FibRetracement } from './FibRetracementPrimitive'; -import type { IChartApi } from 'lightweight-charts'; +import { LineStyle, type IChartApi, type ISeriesApi, type UTCTimestamp } from 'lightweight-charts'; +import type { OverlayLayer } from './ChartPanel.types'; type Props = { candles: Candle[]; indicators: ChartIndicators; + dlobQuotes?: { bid: number | null; ask: number | null; mid: number | null } | null; + tickerItems: TickerItem[]; + theme: 'day' | 'night'; timeframe: string; + bucketSeconds: number; + seriesKey: string; onTimeframeChange: (tf: string) => void; showIndicators: boolean; onToggleIndicators: () => void; + showBuild: boolean; + onToggleBuild: () => void; + onResetViewReady?: ((handler: (() => void) | null) => void) | undefined; seriesLabel: string; + hasMoreHistory?: boolean; + onRequestHistoryBefore?: (beforeTime: number) => Promise | void; }; +type FibDragMode = 'move' | 'edit-b'; + +type FibDrag = { + pointerId: number; + mode: FibDragMode; + startClientX: number; + startClientY: number; + start: FibAnchor; + origin: FibRetracement; + moved: boolean; +}; + +function isEditableTarget(t: EventTarget | null): boolean { + if (!(t instanceof HTMLElement)) return false; + const tag = t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return t.isContentEditable; +} + export default function ChartPanel({ candles, indicators, + dlobQuotes, + tickerItems, + theme, timeframe, + bucketSeconds, + seriesKey, onTimeframeChange, showIndicators, onToggleIndicators, + showBuild, + onToggleBuild, + onResetViewReady, seriesLabel, + hasMoreHistory = false, + onRequestHistoryBefore, }: Props) { const [isFullscreen, setIsFullscreen] = useState(false); const [activeTool, setActiveTool] = useState<'cursor' | 'fib-retracement'>('cursor'); const [fibStart, setFibStart] = useState(null); const [fib, setFib] = useState(null); const [fibDraft, setFibDraft] = useState(null); + const [layers, setLayers] = useState([ + { id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 }, + { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 }, + ]); + const [layersOpen, setLayersOpen] = useState(false); + const [fibVisible, setFibVisible] = useState(true); + const [fibLocked, setFibLocked] = useState(false); + const [fibOpacity, setFibOpacity] = useState(1); + const [selectedOverlayId, setSelectedOverlayId] = useState(null); + const [priceAutoScale, setPriceAutoScale] = useState(true); + const chartApiRef = useRef(null); + const candleSeriesRef = useRef | null>(null); const activeToolRef = useRef(activeTool); const fibStartRef = useRef(fibStart); const pendingMoveRef = useRef(null); + const pendingDragRef = useRef<{ anchor: FibAnchor; clientX: number; clientY: number } | null>(null); const rafRef = useRef(null); + const spaceDownRef = useRef(false); + const dragRef = useRef(null); + const selectPointerRef = useRef(null); + const selectedOverlayIdRef = useRef(selectedOverlayId); + const fibRef = useRef(fib); + const resetChartViewRef = useRef<() => void>(() => {}); useEffect(() => { if (!isFullscreen) return; @@ -67,16 +128,70 @@ export default function ChartPanel({ fibStartRef.current = fibStart; }, [fibStart]); + useEffect(() => { + selectedOverlayIdRef.current = selectedOverlayId; + }, [selectedOverlayId]); + + useEffect(() => { + fibRef.current = fib; + }, [fib]); + useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - if (activeToolRef.current !== 'fib-retracement') return; - setFibStart(null); - setFibDraft(null); - setActiveTool('cursor'); + if (isEditableTarget(e.target)) return; + + if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && e.key.toLowerCase() === 'r') { + e.preventDefault(); + resetChartViewRef.current(); + return; + } + + if (e.code === 'Space') { + spaceDownRef.current = true; + e.preventDefault(); + return; + } + + if (e.key === 'Escape') { + if (dragRef.current) { + dragRef.current = null; + pendingDragRef.current = null; + selectPointerRef.current = null; + setFibDraft(null); + return; + } + + if (activeToolRef.current === 'fib-retracement') { + setFibStart(null); + setFibDraft(null); + setActiveTool('cursor'); + return; + } + + if (selectedOverlayIdRef.current) setSelectedOverlayId(null); + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + if (selectedOverlayIdRef.current === 'fib') { + clearFib(); + } + } }; + + const onKeyUp = (e: KeyboardEvent) => { + if (e.code === 'Space') { + spaceDownRef.current = false; + } + }; + window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -98,16 +213,144 @@ export default function ChartPanel({ ts.setVisibleLogicalRange({ from: center - span / 2, to: center + span / 2 }); } + function resetChartView() { + const chart = chartApiRef.current; + if (!chart) return; + + chart.timeScale().resetTimeScale(); + candleSeriesRef.current?.priceScale().setAutoScale(true); + setPriceAutoScale(true); + } + + resetChartViewRef.current = resetChartView; + + function clamp01(v: number): number { + if (!Number.isFinite(v)) return 1; + return Math.max(0, Math.min(1, v)); + } + + const quotesLayer = useMemo(() => layers.find((l) => l.id === 'dlob-quotes'), [layers]); + const quotesVisible = Boolean(quotesLayer?.visible); + const quotesOpacity = clamp01(quotesLayer?.opacity ?? 1); + + const priceLines = useMemo(() => { + if (!quotesVisible) return []; + return [ + { + id: 'dlob-bid', + title: 'DLOB Bid', + price: dlobQuotes?.bid ?? null, + color: `rgba(34,197,94,${quotesOpacity})`, + lineStyle: LineStyle.Dotted, + }, + { + id: 'dlob-mid', + title: 'DLOB Mid', + price: dlobQuotes?.mid ?? null, + color: `rgba(230,233,239,${quotesOpacity})`, + lineStyle: LineStyle.Dashed, + }, + { + id: 'dlob-ask', + title: 'DLOB Ask', + price: dlobQuotes?.ask ?? null, + color: `rgba(239,68,68,${quotesOpacity})`, + lineStyle: LineStyle.Dotted, + }, + ]; + }, [dlobQuotes?.ask, dlobQuotes?.bid, dlobQuotes?.mid, quotesOpacity, quotesVisible, theme]); + + function updateLayer(layerId: string, patch: Partial) { + setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l))); + } + + function clearFib() { + setFib(null); + setFibStart(null); + setFibDraft(null); + dragRef.current = null; + pendingDragRef.current = null; + selectPointerRef.current = null; + setSelectedOverlayId(null); + } + + function computeFibFromDrag(drag: FibDrag, pointer: FibAnchor): FibRetracement { + if (drag.mode === 'edit-b') return { a: drag.origin.a, b: pointer }; + const deltaLogical = pointer.logical - drag.start.logical; + const deltaPrice = pointer.price - drag.start.price; + return { + a: { logical: drag.origin.a.logical + deltaLogical, price: drag.origin.a.price + deltaPrice }, + b: { logical: drag.origin.b.logical + deltaLogical, price: drag.origin.b.price + deltaPrice }, + }; + } + + function scheduleFrame() { + if (rafRef.current != null) return; + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + + const drag = dragRef.current; + const pendingDrag = pendingDragRef.current; + if (drag && pendingDrag) { + if (!drag.moved) { + const dx = pendingDrag.clientX - drag.startClientX; + const dy = pendingDrag.clientY - drag.startClientY; + if (dx * dx + dy * dy >= 16) drag.moved = true; // ~4px threshold + } + if (drag.moved) { + setFibDraft(computeFibFromDrag(drag, pendingDrag.anchor)); + } + return; + } + + const pointer = pendingMoveRef.current; + if (!pointer) return; + if (activeToolRef.current !== 'fib-retracement') return; + + const start2 = fibStartRef.current; + if (!start2) return; + setFibDraft({ a: start2, b: pointer }); + }); + } + + const drawingsLayer = + layers.find((l) => l.id === 'drawings') ?? { id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 }; + const fibEffectiveVisible = fibVisible && drawingsLayer.visible; + const fibEffectiveOpacity = fibOpacity * drawingsLayer.opacity; + const fibEffectiveLocked = fibLocked || drawingsLayer.locked; + const fibSelected = selectedOverlayId === 'fib'; + const fibRenderable = fibEffectiveVisible ? (fibDraft ?? fib) : null; + + useEffect(() => { + if (selectedOverlayId !== 'fib') return; + if (!fib) { + setSelectedOverlayId(null); + return; + } + if (!fibEffectiveVisible) setSelectedOverlayId(null); + }, [fib, fibEffectiveVisible, selectedOverlayId]); + + useEffect(() => { + if (!onResetViewReady) return; + onResetViewReady(() => resetChartViewRef.current()); + return () => onResetViewReady(null); + }, [onResetViewReady]); + return ( <> {isFullscreen ?
setIsFullscreen(false)} /> : null}
setPriceAutoScale((v) => !v)} seriesLabel={seriesLabel} isFullscreen={isFullscreen} onToggleFullscreen={() => setIsFullscreen((v) => !v)} @@ -118,56 +361,147 @@ export default function ChartPanel({ timeframe={timeframe} activeTool={activeTool} hasFib={fib != null || fibDraft != null} + isLayersOpen={layersOpen} onToolChange={setActiveTool} + onToggleLayers={() => setLayersOpen((v) => !v)} onZoomIn={() => zoomTime(0.8)} onZoomOut={() => zoomTime(1.25)} - onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()} - onClearFib={() => { - setFib(null); - setFibStart(null); - setFibDraft(null); - }} + onResetView={resetChartView} + onClearFib={clearFib} />
{ + showBuild={showBuild} + bucketSeconds={bucketSeconds} + seriesKey={seriesKey} + priceLines={priceLines} + fib={fibRenderable} + fibOpacity={fibEffectiveOpacity} + fibSelected={fibSelected} + priceAutoScale={priceAutoScale} + hasMoreHistory={hasMoreHistory} + onRequestHistoryBefore={onRequestHistoryBefore} + onReady={({ chart, candles: candleSeries }) => { chartApiRef.current = chart; + candleSeriesRef.current = candleSeries; }} onChartClick={(p) => { - if (activeTool !== 'fib-retracement') return; - if (!fibStartRef.current) { - fibStartRef.current = p; - setFibStart(p); - setFibDraft({ a: p, b: p }); + if (activeTool === 'fib-retracement') { + if (!fibStartRef.current) { + fibStartRef.current = p; + setFibStart(p); + setFibDraft({ a: p, b: p }); + return; + } + setFib({ a: fibStartRef.current, b: p }); + setFibStart(null); + fibStartRef.current = null; + setFibDraft(null); + setActiveTool('cursor'); return; } - setFib({ a: fibStartRef.current, b: p }); - setFibStart(null); - fibStartRef.current = null; - setFibDraft(null); - setActiveTool('cursor'); + + if (p.target === 'chart') setSelectedOverlayId(null); }} onChartCrosshairMove={(p) => { - if (activeToolRef.current !== 'fib-retracement') return; - const start = fibStartRef.current; - if (!start) return; pendingMoveRef.current = p; - if (rafRef.current != null) return; - rafRef.current = window.requestAnimationFrame(() => { - rafRef.current = null; - const move = pendingMoveRef.current; - const start2 = fibStartRef.current; - if (!move || !start2) return; - setFibDraft({ a: start2, b: move }); - }); + scheduleFrame(); }} + onPointerEvent={({ type, logical, price, target, event }) => { + const pointer: FibAnchor = { logical, price }; + + if (type === 'pointerdown') { + if (event.button !== 0) return; + if (spaceDownRef.current) return; + if (activeToolRef.current !== 'cursor') return; + if (target !== 'fib') return; + if (!fibRef.current) return; + if (!fibEffectiveVisible) return; + + if (selectedOverlayIdRef.current !== 'fib') { + setSelectedOverlayId('fib'); + selectPointerRef.current = event.pointerId; + return { consume: true, capturePointer: true }; + } + + if (fibEffectiveLocked) { + selectPointerRef.current = event.pointerId; + return { consume: true, capturePointer: true }; + } + + dragRef.current = { + pointerId: event.pointerId, + mode: event.ctrlKey ? 'edit-b' : 'move', + startClientX: event.clientX, + startClientY: event.clientY, + start: pointer, + origin: fibRef.current, + moved: false, + }; + pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY }; + setFibDraft(fibRef.current); + return { consume: true, capturePointer: true }; + } + + const drag = dragRef.current; + if (drag && drag.pointerId === event.pointerId) { + if (type === 'pointermove') { + pendingDragRef.current = { anchor: pointer, clientX: event.clientX, clientY: event.clientY }; + scheduleFrame(); + return { consume: true }; + } + if (type === 'pointerup' || type === 'pointercancel') { + if (drag.moved) setFib(computeFibFromDrag(drag, pointer)); + dragRef.current = null; + pendingDragRef.current = null; + setFibDraft(null); + return { consume: true }; + } + return; + } + + if (selectPointerRef.current != null && selectPointerRef.current === event.pointerId) { + if (type === 'pointermove') return { consume: true }; + if (type === 'pointerup' || type === 'pointercancel') { + selectPointerRef.current = null; + return { consume: true }; + } + } + }} + /> + + setLayersOpen(false)} + onToggleLayerVisible={(layerId) => { + const layer = layers.find((l) => l.id === layerId); + if (!layer) return; + updateLayer(layerId, { visible: !layer.visible }); + }} + onToggleLayerLocked={(layerId) => { + const layer = layers.find((l) => l.id === layerId); + if (!layer) return; + updateLayer(layerId, { locked: !layer.locked }); + }} + onSetLayerOpacity={(layerId, opacity) => updateLayer(layerId, { opacity: clamp01(opacity) })} + fibPresent={fib != null} + fibSelected={fibSelected} + fibVisible={fibVisible} + fibLocked={fibLocked} + fibOpacity={fibOpacity} + onSelectFib={() => setSelectedOverlayId('fib')} + onToggleFibVisible={() => setFibVisible((v) => !v)} + onToggleFibLocked={() => setFibLocked((v) => !v)} + onSetFibOpacity={(opacity) => setFibOpacity(clamp01(opacity))} + onDeleteFib={clearFib} />
diff --git a/apps/visualizer/src/features/chart/ChartPanel.types.ts b/apps/visualizer/src/features/chart/ChartPanel.types.ts new file mode 100644 index 0000000..26baec5 --- /dev/null +++ b/apps/visualizer/src/features/chart/ChartPanel.types.ts @@ -0,0 +1,8 @@ +export type OverlayLayer = { + id: string; + name: string; + visible: boolean; + locked: boolean; + opacity: number; // 0..1 +}; + diff --git a/apps/visualizer/src/features/chart/ChartSideToolbar.tsx b/apps/visualizer/src/features/chart/ChartSideToolbar.tsx index f41bcc7..19c9c1d 100644 --- a/apps/visualizer/src/features/chart/ChartSideToolbar.tsx +++ b/apps/visualizer/src/features/chart/ChartSideToolbar.tsx @@ -4,8 +4,8 @@ import { IconBrush, IconCrosshair, IconCursor, - IconEye, IconFib, + IconLayers, IconLock, IconPlus, IconRuler, @@ -24,7 +24,9 @@ type Props = { timeframe: string; activeTool: ActiveTool; hasFib: boolean; + isLayersOpen: boolean; onToolChange: (tool: ActiveTool) => void; + onToggleLayers: () => void; onZoomIn: () => void; onZoomOut: () => void; onResetView: () => void; @@ -35,7 +37,9 @@ export default function ChartSideToolbar({ timeframe, activeTool, hasFib, + isLayersOpen, onToolChange, + onToggleLayers, onZoomIn, onZoomOut, onResetView, @@ -195,9 +199,15 @@ export default function ChartSideToolbar({ -
diff --git a/apps/visualizer/src/features/chart/ChartToolbar.tsx b/apps/visualizer/src/features/chart/ChartToolbar.tsx index 71ed170..e647c0f 100644 --- a/apps/visualizer/src/features/chart/ChartToolbar.tsx +++ b/apps/visualizer/src/features/chart/ChartToolbar.tsx @@ -1,22 +1,33 @@ import Button from '../../ui/Button'; +import TickerBar, { type TickerItem } from '../tickerbar/TickerBar'; type Props = { + tickerItems: TickerItem[]; timeframe: string; onTimeframeChange: (tf: string) => void; showIndicators: boolean; onToggleIndicators: () => void; + showBuild: boolean; + onToggleBuild: () => void; + priceAutoScale: boolean; + onTogglePriceAutoScale: () => void; seriesLabel: string; isFullscreen: boolean; onToggleFullscreen: () => void; }; -const timeframes = ['1m', '5m', '15m', '1h', '4h', '1D'] as const; +const timeframes = ['1s', '3s', '5s', '15s', '30s', '1m', '5m', '15m', '1h', '4h', '1D'] as const; export default function ChartToolbar({ + tickerItems, timeframe, onTimeframeChange, showIndicators, onToggleIndicators, + showBuild, + onToggleBuild, + priceAutoScale, + onTogglePriceAutoScale, seriesLabel, isFullscreen, onToggleFullscreen, @@ -24,6 +35,7 @@ export default function ChartToolbar({ return (
+ {timeframes.map((tf) => ( + + diff --git a/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts b/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts index c058add..18ff45b 100644 --- a/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts +++ b/apps/visualizer/src/features/chart/FibRetracementPrimitive.ts @@ -54,6 +54,8 @@ type State = { fib: FibRetracement | null; series: ISeriesApi<'Candlestick', Time> | null; chart: SeriesAttachedParameter