2 Commits

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

13
.gitignore vendored
View File

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

View File

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

112
apps/visualizer/__r Executable file
View File

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

29
apps/visualizer/__start Normal file
View File

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

View File

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

View File

@@ -6,6 +6,7 @@
"": {
"name": "trade-visualizer",
"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
}
}
}
}
}

View File

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

View File

@@ -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 (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M1.5 4.5h4v2h-4zm9 0h4v2h-4zM1.5 7.5h4v2h-4zm9 0h4v2h-4zM1.5 10.5h4v2h-4zm9 0h4v2h-4zM6.75 3.5h2.5v9h-2.5z" />
</svg>
);
}
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 3.5h12v2H2zm0 3.5h12v2H2zm0 3.5h12v2H2z" />
</svg>
);
}
function OrderbookScaleIcon({ mode }: { mode: OrderbookBarScaleMode }) {
if (mode === 'shared-log') {
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 12.5h2v-2h2v-2h2v-2h2v-2h2v8H2z" />
</svg>
);
}
return (
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M2 11.5h3v-6H2zm4.5 2h3v-10h-3zm4.5-4h3v-2h-3z" />
</svg>
);
}
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<string | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [storedTheme, setStoredTheme] = useLocalStorageState<VisualTheme>('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 <LoginScreen onLoggedIn={setUser} />;
}
return <TradeApp user={user} onLogout={() => void logout()} />;
return (
<TradeApp
user={user}
theme={theme}
onToggleTheme={() => 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<number>('trade.form.price', 0);
const [tradeSize, setTradeSize] = useLocalStorageState<number>('trade.form.size', 0.1);
const [orderbookGrouping, setOrderbookGrouping] = useLocalStorageState('trade.orderbookGrouping.v1', 'drift');
const [orderbookLayout, setOrderbookLayout] = useLocalStorageState<OrderbookLayoutMode>(
'trade.orderbookLayout.v1',
'ladder'
);
const [orderbookBarScaleMode, setOrderbookBarScaleMode] = useLocalStorageState<OrderbookBarScaleMode>(
'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<number | null>(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 }) {
</span>
),
},
{ key: 'oracle', label: 'Oracle', value: formatUsd(latest?.oracle ?? null) },
{ key: 'funding', label: 'Funding / 24h', value: '—', sub: '—' },
{ key: 'oi', label: 'Open Interest', value: '—' },
{ key: 'vol', label: '24h Volume', value: '—' },
{ key: 'details', label: 'Market Details', value: <a href="#">View</a> },
{ key: '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 ? <span className="neg">{dlobError}</span> : dlob?.updatedAt || '—',
},
{
key: 'l2',
label: 'L2',
value: dlobL2Connected ? 'live' : '—',
sub: dlobL2Error ? <span className="neg">{dlobL2Error}</span> : dlobL2?.updatedAt || '—',
},
];
}, [latest?.close, latest?.oracle, changePct]);
}, [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 (
<AppShell
header={<TopNav active="trade" rightEndSlot={<AuthStatus user={user} onLogout={onLogout} />} />}
top={<TickerBar items={topItems} />}
header={
<TopNav
active="trade"
rightSlot={
<AuthStatus
user={user}
theme={theme}
onResetView={chartResetView}
onToggleTheme={onToggleTheme}
onLogout={onLogout}
/>
}
/>
}
main={
<div className="tradeMain">
<Card
@@ -280,16 +620,49 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
<ChartPanel
candles={candles}
indicators={indicators}
theme={theme}
tickerItems={topItems}
timeframe={tf}
bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
onTimeframeChange={setTf}
showIndicators={showIndicators}
onToggleIndicators={() => 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 }}
/>
<Card className="bottomCard">
<Tabs
items={[
{
id: 'dlob',
label: 'DLOB',
content: (
<DlobDashboard
market={symbol}
stats={dlob}
statsConnected={dlobConnected}
statsError={dlobError}
depthBands={depthBands}
depthBandsConnected={depthBandsConnected}
depthBandsError={depthBandsError}
slippageRows={slippageRows}
slippageConnected={slippageConnected}
slippageError={slippageError}
/>
),
},
{
id: 'bot',
label: 'Bot',
content: <BotObserverPanel marketName={symbol} observer={botObserver} />,
},
{ id: 'positions', label: 'Positions', content: <div className="placeholder">Positions (next)</div> },
{ id: 'orders', label: 'Orders', content: <div className="placeholder">Orders (next)</div> },
{ id: 'balances', label: 'Balances', content: <div className="placeholder">Balances (next)</div> },
@@ -311,15 +684,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</div>
}
sidebar={
<Card
className="orderbookCard"
title={
<div className="sideHead">
<div className="sideHead__title">Orderbook</div>
<div className="sideHead__subtitle">{loading ? 'loading…' : latest ? formatUsd(latest.close) : '—'}</div>
</div>
}
>
<Card className="orderbookCard">
<Tabs
items={[
{
@@ -327,30 +692,163 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
label: 'Orderbook',
content: (
<div className="orderbook">
<div className="orderbook__toolbar">
<label className="marketSelect orderbook__grouping">
<span className="marketSelect__label">Grouping</span>
<select
className="marketSelect__input orderbook__groupingInput"
value={orderbookGrouping}
onChange={(e) => setOrderbookGrouping(e.target.value)}
>
<option value="drift">{orderbookGroupingLabel}</option>
<option value="raw">Raw</option>
<option value="0.001">0.001</option>
<option value="0.005">0.005</option>
<option value="0.01">0.01</option>
<option value="0.05">0.05</option>
<option value="0.1">0.1</option>
</select>
</label>
{orderbookLayout === 'ladder' ? (
<div className="orderbookToolbarSwitch" role="group" aria-label="Orderbook bar scale">
<button
type="button"
className={`orderbookToolbarSwitch__btn${orderbookBarScaleMode === 'per-side' ? ' is-active' : ''}`}
onClick={() => setOrderbookBarScaleMode('per-side')}
title="Per-side scale"
aria-pressed={orderbookBarScaleMode === 'per-side'}
>
<OrderbookScaleIcon mode="per-side" />
</button>
<button
type="button"
className={`orderbookToolbarSwitch__btn${orderbookBarScaleMode === 'shared-log' ? ' is-active' : ''}`}
onClick={() => setOrderbookBarScaleMode('shared-log')}
title="Shared log scale"
aria-pressed={orderbookBarScaleMode === 'shared-log'}
>
<OrderbookScaleIcon mode="shared-log" />
</button>
</div>
) : null}
</div>
{orderbookLayout === 'ladder' ? (
<>
<div className={`orderbookMid orderbookMid--${orderbookMidTone} orderbookMid--ladder`}>
<span className="orderbookMid__label">mid</span>
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
</div>
<div className="orderbook__header orderbook__header--ladder">
<span className="orderbookLadderHead__side">Size (USD)</span>
<span className="orderbookLadderHead__price">Price</span>
<span className="orderbookLadderHead__side orderbookLadderHead__side--right">Size (USD)</span>
</div>
<div className="orderbook__rows orderbook__rows--ladder">
{orderbookLadderViewRows.map((row, index) => (
<div key={`pair-${index}-${orderbookVersion}`} className="orderbookPair">
<span
className="orderbookPair__num orderbookPair__num--bid"
style={orderbookRowBarStyle(row.bidScale)}
>
{row.bid ? formatOrderbookUsd((row.bid as any).sizeUsd) : '—'}
</span>
<span className="orderbookPair__price orderbookPair__price--bid">
{row.bid ? formatQty(row.bid.price, 3) : '—'}
</span>
<span className="orderbookPair__price orderbookPair__price--ask">
{row.ask ? formatQty(row.ask.price, 3) : '—'}
</span>
<span
className="orderbookPair__num orderbookPair__num--ask"
style={orderbookRowBarStyle(row.askScale)}
>
{row.ask ? formatOrderbookUsd((row.ask as any).sizeUsd) : '—'}
</span>
</div>
))}
</div>
</>
) : (
<>
<div className="orderbook__header">
<span>Price</span>
<span className="orderbook__num">Size</span>
<span className="orderbook__num">Total</span>
<span className="orderbook__num">Size (USD)</span>
<span className="orderbook__num">Cum. (USD)</span>
</div>
<div className="orderbook__rows">
{orderbook.asks.map((r) => (
<div key={`a-${r.price}`} className="orderbookRow orderbookRow--ask">
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
</div>
))}
<div className="orderbookMid">
<span className="orderbookMid__price">{latest ? formatQty(latest.close, 3) : '—'}</span>
<div className={`orderbookMid orderbookMid--${orderbookMidTone}`}>
<span className="orderbookMid__price">{formatQty(orderbook.mid, 3)}</span>
<span className="orderbookMid__label">mid</span>
</div>
{orderbook.bids.map((r) => (
<div key={`b-${r.price}`} className="orderbookRow orderbookRow--bid">
{orderbook.asks.map((r, index) => (
<div
key={`a-${index}-${r.price}-${orderbookVersion}`}
className="orderbookRow orderbookRow--ask"
style={orderbookRowBarStyle(maxAskSize > 0 ? (r as any).sizeUsd / maxAskSize : 0)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatQty(r.size, 2)}</span>
<span className="orderbookRow__num">{formatQty(r.total, 2)}</span>
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).totalUsd)}</span>
</div>
))}
{orderbook.bids.map((r, index) => (
<div
key={`b-${index}-${r.price}-${orderbookVersion}`}
className="orderbookRow orderbookRow--bid"
style={orderbookRowBarStyle(maxBidSize > 0 ? (r as any).sizeUsd / maxBidSize : 0)}
>
<span className="orderbookRow__price">{formatQty(r.price, 3)}</span>
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).sizeUsd)}</span>
<span className="orderbookRow__num">{formatOrderbookUsd((r as any).totalUsd)}</span>
</div>
))}
</div>
</>
)}
<div className="orderbookMeta">
<div className="orderbookMeta__row">
<span className="muted">Updated</span>
<span className="orderbookMeta__val">{formatClockTime(orderbook.updatedAt)}</span>
</div>
<div className="orderbookMeta__row">
<span className="muted">Spread</span>
<span className="orderbookMeta__val">
{liquidity.spreadAbs == null || liquidity.spreadPct == null
? '—'
: `${formatUsd(liquidity.spreadAbs)} (${liquidity.spreadPct.toFixed(3)}%)`}
</span>
</div>
<div className="orderbookMeta__row">
<span className="muted">{`Liquidity (L1L${orderbookLevels})`}</span>
<span className="orderbookMeta__val">
<span className="pos">{formatUsd(liquidity.bidUsd)}</span> <span className="muted">/</span>{' '}
<span className="neg">{formatUsd(liquidity.askUsd)}</span>
</span>
</div>
<div className="liquidityBar" style={liquidityStyle(liquidity.bidUsd, liquidity.askUsd)}>
<div className="liquidityBar__bid" />
<div className="liquidityBar__ask" />
</div>
</div>
<div className="orderbookLayoutSwitch" role="group" aria-label="Orderbook layout">
<button
type="button"
className={`orderbookLayoutSwitch__btn${orderbookLayout === 'stacked' ? ' is-active' : ''}`}
onClick={() => setOrderbookLayout('stacked')}
title="Stacked book"
aria-pressed={orderbookLayout === 'stacked'}
>
<OrderbookLayoutIcon mode="stacked" />
</button>
<button
type="button"
className={`orderbookLayoutSwitch__btn${orderbookLayout === 'ladder' ? ' is-active' : ''}`}
onClick={() => setOrderbookLayout('ladder')}
title="Ladder book"
aria-pressed={orderbookLayout === 'ladder'}
>
<OrderbookLayoutIcon mode="ladder" />
</button>
</div>
</div>
),
@@ -452,7 +950,7 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
<input
className="formField__input"
value={tradeOrderType === 'market' ? '' : String(tradePrice)}
placeholder={tradeOrderType === 'market' ? formatQty(latest?.close ?? null, 3) : '0'}
placeholder={tradeOrderType === 'market' ? formatQty(liveLastPrice ?? null, 3) : '0'}
disabled={tradeOrderType !== 'limit'}
onChange={(e) => setTradePrice(Number(e.target.value))}
inputMode="decimal"
@@ -480,7 +978,27 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Slippage (Dynamic)</span>
<span className="tradeMeta__value"></span>
<span className="tradeMeta__value">
{slippageError ? (
<span className="neg">{slippageError}</span>
) : dynamicSlippage?.impactBps == null ? (
slippageConnected ? (
'—'
) : (
'offline'
)
) : (
<>
{dynamicSlippage.impactBps.toFixed(1)} bps{' '}
<span className="muted">
({dynamicSlippage.sizeUsd.toLocaleString()} USD)
{dynamicSlippage.fillPct != null && dynamicSlippage.fillPct < 99.9
? `, ${dynamicSlippage.fillPct.toFixed(0)}% fill`
: ''}
</span>
</>
)}
</span>
</div>
<div className="tradeMeta__row">
<span className="tradeMeta__label">Margin Required</span>
@@ -491,6 +1009,10 @@ function TradeApp({ user, onLogout }: { user: string; onLogout: () => void }) {
<span className="tradeMeta__value"></span>
</div>
</div>
<div className="tradeBotSummary">
<BotObserverSummaryCard marketName={symbol} observer={botObserver} />
</div>
</div>
</Card>
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -142,38 +142,30 @@ export default function ChartLayersPanel({
<div className="chartLayersCell chartLayersCell--actions">Actions</div>
</div>
{drawingsLayer ? (
<div className="chartLayersRow chartLayersRow--layer">
{layers.map((layer) => (
<div key={layer.id} className="chartLayersRow chartLayersRow--layer">
<div className="chartLayersCell chartLayersCell--icon">
<IconButton
title="Toggle visible"
active={drawingsLayer.visible}
onClick={() => onToggleLayerVisible(drawingsLayer.id)}
>
<IconButton title="Toggle visible" active={layer.visible} onClick={() => onToggleLayerVisible(layer.id)}>
<IconEye />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--icon">
<IconButton
title="Toggle lock"
active={drawingsLayer.locked}
onClick={() => onToggleLayerLocked(drawingsLayer.id)}
>
<IconButton title="Toggle lock" active={layer.locked} onClick={() => onToggleLayerLocked(layer.id)}>
<IconLock />
</IconButton>
</div>
<div className="chartLayersCell chartLayersCell--name">
<div className="layersName layersName--layer">
{drawingsLayer.name}
<span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span>
{layer.name}
{layer.id === 'drawings' ? <span className="layersName__meta">{fibPresent ? ' (1)' : ' (0)'}</span> : null}
</div>
</div>
<div className="chartLayersCell chartLayersCell--opacity">
<OpacitySlider value={drawingsLayer.opacity} onChange={(next) => onSetLayerOpacity(drawingsLayer.id, next)} />
<OpacitySlider value={layer.opacity} onChange={(next) => onSetLayerOpacity(layer.id, next)} />
</div>
<div className="chartLayersCell chartLayersCell--actions" />
</div>
) : null}
))}
{drawingsLayer && fibPresent ? (
<div
@@ -212,4 +204,3 @@ export default function ChartLayersPanel({
</>
);
}

View File

@@ -1,22 +1,33 @@
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> | void;
};
type FibDragMode = 'move' | 'edit-b';
@@ -41,11 +52,21 @@ function isEditableTarget(t: EventTarget | null): boolean {
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');
@@ -53,6 +74,7 @@ export default function ChartPanel({
const [fib, setFib] = useState<FibRetracement | null>(null);
const [fibDraft, setFibDraft] = useState<FibRetracement | null>(null);
const [layers, setLayers] = useState<OverlayLayer[]>([
{ id: 'dlob-quotes', name: 'DLOB Quotes', visible: true, locked: false, opacity: 0.9 },
{ id: 'drawings', name: 'Drawings', visible: true, locked: false, opacity: 1 },
]);
const [layersOpen, setLayersOpen] = useState(false);
@@ -63,6 +85,7 @@ export default function ChartPanel({
const [priceAutoScale, setPriceAutoScale] = useState(true);
const chartApiRef = useRef<IChartApi | null>(null);
const candleSeriesRef = useRef<ISeriesApi<'Candlestick', UTCTimestamp> | null>(null);
const activeToolRef = useRef(activeTool);
const fibStartRef = useRef<FibAnchor | null>(fibStart);
const pendingMoveRef = useRef<FibAnchor | null>(null);
@@ -73,6 +96,7 @@ export default function ChartPanel({
const selectPointerRef = useRef<number | null>(null);
const selectedOverlayIdRef = useRef<string | null>(selectedOverlayId);
const fibRef = useRef<FibRetracement | null>(fib);
const resetChartViewRef = useRef<() => void>(() => {});
useEffect(() => {
if (!isFullscreen) return;
@@ -116,6 +140,12 @@ export default function ChartPanel({
const onKeyDown = (e: KeyboardEvent) => {
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();
@@ -183,11 +213,53 @@ 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<OverlayLayer>) {
setLayers((prev) => prev.map((l) => (l.id === layerId ? { ...l, ...patch } : l)));
}
@@ -234,6 +306,7 @@ export default function ChartPanel({
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 });
@@ -257,16 +330,25 @@ export default function ChartPanel({
if (!fibEffectiveVisible) setSelectedOverlayId(null);
}, [fib, fibEffectiveVisible, selectedOverlayId]);
useEffect(() => {
if (!onResetViewReady) return;
onResetViewReady(() => resetChartViewRef.current());
return () => onResetViewReady(null);
}, [onResetViewReady]);
return (
<>
{isFullscreen ? <div className="chartBackdrop" onClick={() => setIsFullscreen(false)} /> : null}
<Card className={cardClassName}>
<div className="chartCard__toolbar">
<ChartToolbar
tickerItems={tickerItems}
timeframe={timeframe}
onTimeframeChange={onTimeframeChange}
showIndicators={showIndicators}
onToggleIndicators={onToggleIndicators}
showBuild={showBuild}
onToggleBuild={onToggleBuild}
priceAutoScale={priceAutoScale}
onTogglePriceAutoScale={() => setPriceAutoScale((v) => !v)}
seriesLabel={seriesLabel}
@@ -284,23 +366,31 @@ export default function ChartPanel({
onToggleLayers={() => setLayersOpen((v) => !v)}
onZoomIn={() => zoomTime(0.8)}
onZoomOut={() => zoomTime(1.25)}
onResetView={() => chartApiRef.current?.timeScale().resetTimeScale()}
onResetView={resetChartView}
onClearFib={clearFib}
/>
<div className="chartCard__chart">
<TradingChart
candles={candles}
theme={theme}
oracle={indicators.oracle}
sma20={indicators.sma20}
ema20={indicators.ema20}
bb20={indicators.bb20}
showIndicators={showIndicators}
showBuild={showBuild}
bucketSeconds={bucketSeconds}
seriesKey={seriesKey}
priceLines={priceLines}
fib={fibRenderable}
fibOpacity={fibEffectiveOpacity}
fibSelected={fibSelected}
priceAutoScale={priceAutoScale}
onReady={({ chart }) => {
hasMoreHistory={hasMoreHistory}
onRequestHistoryBefore={onRequestHistoryBefore}
onReady={({ chart, candles: candleSeries }) => {
chartApiRef.current = chart;
candleSeriesRef.current = candleSeries;
}}
onChartClick={(p) => {
if (activeTool === 'fib-retracement') {

View File

@@ -1,10 +1,14 @@
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;
@@ -12,13 +16,16 @@ type Props = {
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,
@@ -28,6 +35,7 @@ export default function ChartToolbar({
return (
<div className="chartToolbar">
<div className="chartToolbar__group">
<TickerBar items={tickerItems} className="tickerBar--toolbar" />
{timeframes.map((tf) => (
<Button
key={tf}
@@ -45,6 +53,9 @@ export default function ChartToolbar({
<Button size="sm" variant={showIndicators ? 'primary' : 'ghost'} onClick={onToggleIndicators} type="button">
Indicators
</Button>
<Button size="sm" variant={showBuild ? 'primary' : 'ghost'} onClick={onToggleBuild} type="button">
Build
</Button>
<Button size="sm" variant={priceAutoScale ? 'primary' : 'ghost'} onClick={onTogglePriceAutoScale} type="button">
Auto Scale
</Button>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,137 @@
import type { ReactNode } from 'react';
import type { DlobStats } from './useDlobStats';
import type { DlobDepthBandRow } from './useDlobDepthBands';
import type { DlobSlippageRow } from './useDlobSlippage';
import DlobDepthBandsPanel from './DlobDepthBandsPanel';
import DlobSlippageChart from './DlobSlippageChart';
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
if (v >= 1) return `$${v.toFixed(2)}`;
return `$${v.toPrecision(4)}`;
}
function formatBps(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${v.toFixed(1)} bps`;
}
function formatPct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${(v * 100).toFixed(0)}%`;
}
function statusLabel(connected: boolean, error: string | null): ReactNode {
if (error) return <span className="neg">{error}</span>;
return connected ? <span className="pos">live</span> : <span className="muted">offline</span>;
}
export default function DlobDashboard({
market,
stats,
statsConnected,
statsError,
depthBands,
depthBandsConnected,
depthBandsError,
slippageRows,
slippageConnected,
slippageError,
}: {
market: string;
stats: DlobStats | null;
statsConnected: boolean;
statsError: string | null;
depthBands: DlobDepthBandRow[];
depthBandsConnected: boolean;
depthBandsError: string | null;
slippageRows: DlobSlippageRow[];
slippageConnected: boolean;
slippageError: string | null;
}) {
const updatedAt = stats?.updatedAt || depthBands[0]?.updatedAt || slippageRows[0]?.updatedAt || null;
return (
<div className="dlobDash">
<div className="dlobDash__head">
<div className="dlobDash__title">DLOB</div>
<div className="dlobDash__meta">
<span className="dlobDash__market">{market}</span>
<span className="muted">{updatedAt ? `updated ${updatedAt}` : '—'}</span>
</div>
</div>
<div className="dlobDash__statuses">
<div className="dlobStatus">
<span className="dlobStatus__label">stats</span>
<span className="dlobStatus__value">{statusLabel(statsConnected, statsError)}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">depth bands</span>
<span className="dlobStatus__value">{statusLabel(depthBandsConnected, depthBandsError)}</span>
</div>
<div className="dlobStatus">
<span className="dlobStatus__label">slippage</span>
<span className="dlobStatus__value">{statusLabel(slippageConnected, slippageError)}</span>
</div>
</div>
<div className="dlobDash__grid">
<div className="dlobKpi">
<div className="dlobKpi__label">Bid</div>
<div className="dlobKpi__value pos">{formatUsd(stats?.bestBid ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Ask</div>
<div className="dlobKpi__value neg">{formatUsd(stats?.bestAsk ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Mid</div>
<div className="dlobKpi__value">{formatUsd(stats?.mid ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Spread</div>
<div className="dlobKpi__value">{formatBps(stats?.spreadBps ?? null)}</div>
<div className="dlobKpi__sub muted">{formatUsd(stats?.spreadAbs ?? null)}</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Depth (bid/ask)</div>
<div className="dlobKpi__value">
<span className="pos">{formatUsd(stats?.depthBidUsd ?? null)}</span>{' '}
<span className="muted">/</span> <span className="neg">{formatUsd(stats?.depthAskUsd ?? null)}</span>
</div>
</div>
<div className="dlobKpi">
<div className="dlobKpi__label">Imbalance</div>
<div className="dlobKpi__value">{formatPct(stats?.imbalance ?? null)}</div>
<div className="dlobKpi__sub muted">[-1..1]</div>
</div>
</div>
<div className="dlobDash__panes">
<div className="dlobDash__pane">
<DlobDepthBandsPanel rows={depthBands} />
</div>
<div className="dlobDash__pane">
<div className="dlobSlippage">
<div className="dlobSlippage__head">
<div className="dlobSlippage__title">Slippage (impact bps)</div>
<div className="dlobSlippage__meta muted">by size (USD)</div>
</div>
{slippageRows.length ? (
<div className="dlobSlippage__chartWrap">
<DlobSlippageChart rows={slippageRows} />
</div>
) : (
<div className="dlobSlippage__empty muted">No slippage rows yet.</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import type { CSSProperties } from 'react';
import { useMemo } from 'react';
import type { DlobDepthBandRow } from './useDlobDepthBands';
function formatUsd(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
if (v >= 1000) return `$${(v / 1000).toFixed(0)}K`;
if (v >= 1) return `$${v.toFixed(2)}`;
return `$${v.toPrecision(4)}`;
}
function formatPct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—';
return `${v.toFixed(0)}%`;
}
function bandRowStyle(askScale: number, bidScale: number): CSSProperties {
const a = Number.isFinite(askScale) && askScale > 0 ? Math.min(1, askScale) : 0;
const b = Number.isFinite(bidScale) && bidScale > 0 ? Math.min(1, bidScale) : 0;
return { ['--ask-scale' as any]: a, ['--bid-scale' as any]: b } as CSSProperties;
}
export default function DlobDepthBandsPanel({ rows }: { rows: DlobDepthBandRow[] }) {
const sorted = useMemo(() => rows.slice().sort((a, b) => a.bandBps - b.bandBps), [rows]);
const maxUsd = useMemo(() => {
let max = 0;
for (const r of sorted) {
if (r.askUsd != null && Number.isFinite(r.askUsd)) max = Math.max(max, r.askUsd);
if (r.bidUsd != null && Number.isFinite(r.bidUsd)) max = Math.max(max, r.bidUsd);
}
return max;
}, [sorted]);
return (
<div className="dlobDepth">
<div className="dlobDepth__head">
<div className="dlobDepth__title">Depth (bands)</div>
<div className="dlobDepth__meta">±bps around mid</div>
</div>
<div className="dlobDepth__table">
<div className="dlobDepthRow dlobDepthRow--head">
<span>Band</span>
<span className="dlobDepthRow__num">Ask USD</span>
<span className="dlobDepthRow__num">Bid USD</span>
<span className="dlobDepthRow__num">Bid %</span>
</div>
{sorted.length ? (
sorted.map((r) => (
<div
key={r.bandBps}
className="dlobDepthRow"
style={bandRowStyle(maxUsd > 0 ? (r.askUsd || 0) / maxUsd : 0, maxUsd > 0 ? (r.bidUsd || 0) / maxUsd : 0)}
title={`band=${r.bandBps}bps bid=${r.bidUsd ?? '—'} ask=${r.askUsd ?? '—'} imbalance=${r.imbalance ?? '—'}`}
>
<span className="dlobDepthRow__band">{r.bandBps} bps</span>
<span className="dlobDepthRow__num neg">{formatUsd(r.askUsd)}</span>
<span className="dlobDepthRow__num pos">{formatUsd(r.bidUsd)}</span>
<span className="dlobDepthRow__num muted">
{r.imbalance == null || !Number.isFinite(r.imbalance)
? '—'
: formatPct(((r.imbalance + 1) / 2) * 100)}
</span>
</div>
))
) : (
<div className="dlobDepth__empty muted">No depth band rows yet.</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useMemo, useRef } from 'react';
import Chart from 'chart.js/auto';
import type { DlobSlippageRow } from './useDlobSlippage';
type Point = { x: number; y: number | null };
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
return Math.max(0, Math.min(1, v));
}
export default function DlobSlippageChart({ rows }: { rows: DlobSlippageRow[] }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart | null>(null);
const { buy, sell, maxImpact } = useMemo(() => {
const buy: Point[] = [];
const sell: Point[] = [];
let maxImpact = 0;
for (const r of rows) {
if (!Number.isFinite(r.sizeUsd) || r.sizeUsd <= 0) continue;
const y = r.impactBps == null || !Number.isFinite(r.impactBps) ? null : r.impactBps;
if (y != null) maxImpact = Math.max(maxImpact, y);
if (r.side === 'buy') buy.push({ x: r.sizeUsd, y });
if (r.side === 'sell') sell.push({ x: r.sizeUsd, y });
}
buy.sort((a, b) => a.x - b.x);
sell.sort((a, b) => a.x - b.x);
return { buy, sell, maxImpact };
}, [rows]);
useEffect(() => {
if (!canvasRef.current) return;
if (chartRef.current) return;
chartRef.current = new Chart(canvasRef.current, {
type: 'line',
data: {
datasets: [
{
label: 'Buy',
data: [],
borderColor: 'rgba(34,197,94,0.9)',
backgroundColor: 'rgba(34,197,94,0.15)',
pointRadius: 2,
pointHoverRadius: 4,
borderWidth: 2,
tension: 0.2,
fill: false,
},
{
label: 'Sell',
data: [],
borderColor: 'rgba(239,68,68,0.9)',
backgroundColor: 'rgba(239,68,68,0.15)',
pointRadius: 2,
pointHoverRadius: 4,
borderWidth: 2,
tension: 0.2,
fill: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
parsing: false,
interaction: { mode: 'nearest', intersect: false },
plugins: {
legend: { labels: { color: '#e6e9ef' } },
tooltip: { enabled: true },
},
scales: {
x: {
type: 'linear',
title: { display: true, text: 'Size (USD)', color: '#c7cbd4' },
ticks: { color: '#c7cbd4' },
grid: { color: 'rgba(255,255,255,0.06)' },
},
y: {
type: 'linear',
beginAtZero: true,
suggestedMax: Math.max(10, maxImpact * (1 + clamp01(0.15))),
title: { display: true, text: 'Impact (bps)', color: '#c7cbd4' },
ticks: { color: '#c7cbd4' },
grid: { color: 'rgba(255,255,255,0.06)' },
},
},
},
});
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [maxImpact]);
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
chart.data.datasets[0]!.data = buy as any;
chart.data.datasets[1]!.data = sell as any;
chart.update('none');
}, [buy, sell]);
return <canvas className="dlobSlippageChart" ref={canvasRef} />;
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import { useMemo } from 'react';
import { computeDepthBandsFromDlobL2, type ComputedDlobDepthBandRow } from './dlobDerivedMetrics';
import { useDlobL2 } from './useDlobL2';
const COMPUTED_DLOB_LEVELS = 512;
export type DlobDepthBandRow = ComputedDlobDepthBandRow;
export function useDlobDepthBands(
marketName: string
): { rows: DlobDepthBandRow[]; connected: boolean; error: string | null } {
const { l2, connected, error } = useDlobL2(marketName, {
levels: COMPUTED_DLOB_LEVELS,
grouping: 'raw',
});
const rows = useMemo(() => computeDepthBandsFromDlobL2(l2), [l2]);
return { rows, connected, error };
}

View File

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

View File

@@ -0,0 +1,17 @@
import { useMemo } from 'react';
import { computeSlippageFromDlobL2, type ComputedDlobSlippageRow } from './dlobDerivedMetrics';
import { useDlobL2 } from './useDlobL2';
const COMPUTED_DLOB_LEVELS = 512;
export type DlobSlippageRow = ComputedDlobSlippageRow;
export function useDlobSlippage(marketName: string): { rows: DlobSlippageRow[]; connected: boolean; error: string | null } {
const { l2, connected, error } = useDlobL2(marketName, {
levels: COMPUTED_DLOB_LEVELS,
grouping: 'raw',
});
const rows = useMemo(() => computeSlippageFromDlobL2(l2), [l2]);
return { rows, connected, error };
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useMemo, useState } from 'react';
import { subscribeGraphqlWs } from '../../lib/graphqlWs';
import { getLatestDlobTable } from './dlobSource';
const ORDERBOOK_STATS_POLL_MS = 400;
export type DlobStats = {
marketName: string;
markPrice: number | null;
oraclePrice: number | null;
bestBid: number | null;
bestAsk: number | null;
mid: number | null;
spreadAbs: number | null;
spreadBps: number | null;
depthBidBase: number | null;
depthAskBase: number | null;
depthBidUsd: number | null;
depthAskUsd: number | null;
imbalance: number | null;
updatedAt: string | null;
};
function toNum(v: unknown): number | null {
if (v == null) return null;
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'string') {
const s = v.trim();
if (!s) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
return null;
}
type HasuraDlobStatsRow = {
market_name: string;
mark_price?: unknown;
oracle_price?: unknown;
best_bid_price?: unknown;
best_ask_price?: unknown;
mid_price?: unknown;
spread_quote?: unknown;
spread_bps?: unknown;
depth_bid_base?: unknown;
depth_ask_base?: unknown;
depth_bid_quote?: unknown;
depth_ask_quote?: unknown;
imbalance?: unknown;
updated_at?: string | null;
is_indicative?: boolean | null;
};
type SubscriptionData = Record<string, HasuraDlobStatsRow[] | undefined>;
export function useDlobStats(marketName: string): { stats: DlobStats | null; connected: boolean; error: string | null } {
const [stats, setStats] = useState<DlobStats | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalizedMarket = useMemo(() => (marketName || '').trim(), [marketName]);
useEffect(() => {
if (!normalizedMarket) {
setStats(null);
setError(null);
setConnected(false);
return;
}
setError(null);
const table = getLatestDlobTable(normalizedMarket);
const query = `
subscription DlobStats($market: String!) {
${table}(
where: {market_name: {_eq: $market}, is_indicative: {_eq: false}}
limit: 1
) {
market_name
mark_price
oracle_price
best_bid_price
best_ask_price
mid_price
spread_quote
spread_bps
depth_bid_base
depth_ask_base
depth_bid_quote
depth_ask_quote
imbalance
updated_at
is_indicative
}
}
`;
const sub = subscribeGraphqlWs<SubscriptionData>({
query,
variables: { market: normalizedMarket },
pollMs: ORDERBOOK_STATS_POLL_MS,
onStatus: ({ connected }) => setConnected(connected),
onError: (e) => setError(e),
onData: (data) => {
const row = data?.[table]?.[0];
if (!row?.market_name) {
setStats(null);
return;
}
setStats({
marketName: row.market_name,
markPrice: toNum(row.mark_price),
oraclePrice: toNum(row.oracle_price),
bestBid: toNum(row.best_bid_price),
bestAsk: toNum(row.best_ask_price),
mid: toNum(row.mid_price),
spreadAbs: toNum(row.spread_quote),
spreadBps: toNum(row.spread_bps),
depthBidBase: toNum(row.depth_bid_base),
depthAskBase: toNum(row.depth_ask_base),
depthBidUsd: toNum(row.depth_bid_quote),
depthAskUsd: toNum(row.depth_ask_quote),
imbalance: toNum(row.imbalance),
updatedAt: row.updated_at ?? null,
});
},
});
return () => sub.unsubscribe();
}, [normalizedMarket]);
return { stats, connected, error };
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,294 @@
type HeadersMap = Record<string, string>;
type GraphqlError = { message: string };
type SubscribeParams<T> = {
query: string;
variables?: Record<string, unknown>;
onData: (data: T) => void;
onError?: (err: string) => void;
onStatus?: (s: { connected: boolean }) => void;
pollMs?: number;
};
function envString(name: string): string | undefined {
const viteValue = (import.meta as any).env?.[name];
const viteString = viteValue == null ? '' : String(viteValue).trim();
if (viteString) return viteString;
const nodeValue = (globalThis as any)?.process?.env?.[name];
const nodeString = nodeValue == null ? '' : String(nodeValue).trim();
return nodeString || undefined;
}
function envBool(name: string): boolean {
const v = envString(name);
if (!v) return false;
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase());
}
function envInt(name: string, fallback: number): number {
const raw = envString(name);
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function resolveGraphqlHttpUrl(): string {
return envString('VITE_HASURA_URL') || '/graphql';
}
function resolveGraphqlWsUrl(): string {
const explicit = envString('VITE_HASURA_WS_URL');
if (explicit) {
if (explicit.startsWith('ws://') || explicit.startsWith('wss://')) return explicit;
if (explicit.startsWith('http://')) return `ws://${explicit.slice('http://'.length)}`;
if (explicit.startsWith('https://')) return `wss://${explicit.slice('https://'.length)}`;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const path = explicit.startsWith('/') ? explicit : `/${explicit}`;
return `${proto}//${host}${path}`;
}
const httpUrl = resolveGraphqlHttpUrl();
if (httpUrl.startsWith('ws://') || httpUrl.startsWith('wss://')) return httpUrl;
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const path = httpUrl.startsWith('/') ? httpUrl : `/${httpUrl}`;
return `${proto}//${host}${path}`;
}
function resolveAuthHeaders(): HeadersMap | undefined {
const token = envString('VITE_HASURA_AUTH_TOKEN');
if (token) return { authorization: `Bearer ${token}` };
const secret = envString('VITE_HASURA_ADMIN_SECRET');
if (secret) return { 'x-hasura-admin-secret': secret };
return undefined;
}
type WsMessage =
| { type: 'connection_ack' | 'ka' | 'complete' }
| { type: 'connection_error'; payload?: any }
| { type: 'data'; id: string; payload: { data?: any; errors?: Array<{ message: string }> } }
| { type: 'error'; id: string; payload?: any };
export type SubscriptionHandle = {
unsubscribe: () => void;
};
async function fetchGraphqlOnce<T>(
httpUrl: string,
headers: HeadersMap | undefined,
query: string,
variables: Record<string, unknown> | undefined
): Promise<T> {
const operation = query.replace(/^\s*subscription\b/, 'query');
const res = await fetch(httpUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'content-type': 'application/json',
...(headers || {}),
},
body: JSON.stringify({ query: operation, variables: variables ?? {} }),
});
const text = await res.text();
if (!res.ok) throw new Error(`Hasura HTTP ${res.status}: ${text}`);
let json: { data?: T; errors?: GraphqlError[] };
try {
json = JSON.parse(text);
} catch {
throw new Error(`Hasura invalid JSON: ${text}`);
}
if (Array.isArray(json.errors) && json.errors.length) {
throw new Error(json.errors.map((e) => e.message).join(' | '));
}
if (json.data == null) throw new Error('Hasura: empty response');
return json.data;
}
export function subscribeGraphqlWs<T>({
query,
variables,
onData,
onError,
onStatus,
pollMs: pollMsOverride,
}: SubscribeParams<T>): SubscriptionHandle {
const httpUrl = resolveGraphqlHttpUrl();
const wsUrl = resolveGraphqlWsUrl();
const headers = resolveAuthHeaders();
const pollOnly = envBool('VITE_GRAPHQL_POLL_ONLY');
const pollMs = Math.max(50, pollMsOverride ?? envInt('VITE_GRAPHQL_POLL_MS', 1000));
let ws: WebSocket | null = null;
let closed = false;
let started = false;
let polling = false;
let reconnectTimer: number | null = null;
let pollTimer: number | null = null;
const subId = '1';
const emitError = (e: unknown) => {
const msg = typeof e === 'string' ? e : String((e as any)?.message || e);
onError?.(msg);
};
const setConnected = (connected: boolean) => onStatus?.({ connected });
const clearReconnectTimer = () => {
if (reconnectTimer != null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
const clearPollTimer = () => {
if (pollTimer != null) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
};
const closeSocket = () => {
if (!ws) return;
try {
ws.close();
} catch {
// ignore
}
ws = null;
};
const start = () => {
if (!ws || started) return;
started = true;
ws.send(
JSON.stringify({
id: subId,
type: 'start',
payload: { query, variables: variables ?? {} },
})
);
};
const pollOnce = async () => {
try {
const data = await fetchGraphqlOnce<T>(httpUrl, headers, query, variables);
if (closed) return;
setConnected(true);
onData(data);
} catch (e) {
if (closed) return;
setConnected(false);
emitError(e);
} finally {
if (!closed && polling) {
pollTimer = window.setTimeout(() => void pollOnce(), pollMs);
}
}
};
const startPolling = () => {
if (closed || polling) return;
polling = true;
clearReconnectTimer();
closeSocket();
started = false;
void pollOnce();
};
const connect = () => {
if (closed) return;
if (polling) return;
started = false;
try {
ws = new WebSocket(wsUrl, 'graphql-ws');
} catch (e) {
startPolling();
return;
}
ws.onopen = () => {
setConnected(true);
const payload = headers ? { headers } : {};
ws?.send(JSON.stringify({ type: 'connection_init', payload }));
};
ws.onmessage = (ev) => {
let msg: WsMessage;
try {
msg = JSON.parse(String(ev.data));
} catch (e) {
emitError(e);
return;
}
if (msg.type === 'connection_ack') {
start();
return;
}
if (msg.type === 'connection_error') {
startPolling();
return;
}
if (msg.type === 'ka' || msg.type === 'complete') return;
if (msg.type === 'error') {
startPolling();
return;
}
if (msg.type === 'data') {
const errors = msg.payload?.errors;
if (Array.isArray(errors) && errors.length) {
emitError(errors.map((e) => e.message).join(' | '));
return;
}
if (msg.payload?.data != null) onData(msg.payload.data as T);
}
};
ws.onerror = () => {
setConnected(false);
startPolling();
};
ws.onclose = () => {
setConnected(false);
if (closed) return;
startPolling();
};
};
if (pollOnly) {
startPolling();
} else {
connect();
}
return {
unsubscribe: () => {
closed = true;
polling = false;
setConnected(false);
clearReconnectTimer();
clearPollTimer();
if (!ws) return;
try {
ws.send(JSON.stringify({ id: subId, type: 'stop' }));
ws.send(JSON.stringify({ type: 'connection_terminate' }));
} catch {
// ignore
}
closeSocket();
},
};
}

View File

@@ -18,7 +18,7 @@ function getApiUrl(): string | undefined {
}
function getHasuraUrl(): string {
return (import.meta as any).env?.VITE_HASURA_URL || 'http://localhost:8080/v1/graphql';
return (import.meta as any).env?.VITE_HASURA_URL || '/graphql';
}
function getAuthToken(): string | undefined {
@@ -45,6 +45,7 @@ export async function hasuraRequest<T>(query: string, variables: Record<string,
const res = await fetch(getHasuraUrl(), {
method: 'POST',
credentials: 'same-origin',
headers,
body: JSON.stringify({ query, variables }),
});
@@ -67,7 +68,9 @@ export async function fetchLatestTicks(symbol: string, limit: number, source?: s
u.searchParams.set('limit', String(limit));
if (source && source.trim()) u.searchParams.set('source', source.trim());
const res = await fetch(u.toString());
const res = await fetch(u.toString(), {
credentials: 'same-origin',
});
const text = await res.text();
if (!res.ok) throw new Error(`API HTTP ${res.status}: ${text}`);
const json = JSON.parse(text) as { ok?: boolean; ticks?: any[]; error?: string };

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,21 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
const DIR = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(DIR, '../..');
function readApiReadToken(): string | undefined {
if (process.env.API_READ_TOKEN) return process.env.API_READ_TOKEN;
type BasicAuth = { username: string; password: string };
function stripTrailingSlashes(p: string): string {
const out = p.replace(/\/+$/, '');
return out || '/';
}
function readApiReadToken(env: Record<string, string | undefined>): string | undefined {
if (env.API_READ_TOKEN) return env.API_READ_TOKEN;
const p = path.join(ROOT, 'tokens', 'read.json');
if (!fs.existsSync(p)) return undefined;
try {
@@ -20,24 +27,213 @@ function readApiReadToken(): string | undefined {
}
}
const apiReadToken = readApiReadToken();
function readApiAdminSecret(env: Record<string, string | undefined>): string | undefined {
if (Object.prototype.hasOwnProperty.call(env, 'API_PROXY_ADMIN_SECRET')) {
const raw = String(env.API_PROXY_ADMIN_SECRET || '').trim();
return raw || undefined;
}
const p = path.join(ROOT, 'tokens', 'api.json');
if (!fs.existsSync(p)) return undefined;
try {
const raw = fs.readFileSync(p, 'utf8');
const json = JSON.parse(raw) as { adminSecret?: string };
return json?.adminSecret ? String(json.adminSecret) : undefined;
} catch {
return undefined;
}
}
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: process.env.API_PROXY_TARGET || 'http://localhost:8787',
function parseBasicAuth(value: string | undefined): BasicAuth | undefined {
const raw = String(value || '').trim();
if (!raw) return undefined;
const idx = raw.indexOf(':');
if (idx <= 0) return undefined;
const username = raw.slice(0, idx).trim();
const password = raw.slice(idx + 1);
if (!username || !password) return undefined;
return { username, password };
}
function readProxyBasicAuth(env: Record<string, string | undefined>): BasicAuth | undefined {
const fromEnv = parseBasicAuth(env.API_PROXY_BASIC_AUTH);
if (fromEnv) return fromEnv;
const fileRaw = String(env.API_PROXY_BASIC_AUTH_FILE || '').trim();
if (!fileRaw) return undefined;
const p = path.isAbsolute(fileRaw) ? fileRaw : path.join(ROOT, fileRaw);
if (!fs.existsSync(p)) return undefined;
try {
const raw = fs.readFileSync(p, 'utf8');
const json = JSON.parse(raw) as { username?: string; password?: string };
const username = typeof json?.username === 'string' ? json.username.trim() : '';
const password = typeof json?.password === 'string' ? json.password : '';
if (!username || !password) return undefined;
return { username, password };
} catch {
return undefined;
}
}
function parseUrl(v: string): URL | undefined {
try {
return new URL(v);
} catch {
return undefined;
}
}
function inferUiProxyTarget(apiTarget: string): string | undefined {
try {
const u = new URL(apiTarget);
const p = stripTrailingSlashes(u.pathname || '/');
if (!p.endsWith('/api')) return undefined;
const basePath = p.slice(0, -'/api'.length) || '/';
u.pathname = basePath;
u.search = '';
u.hash = '';
const out = u.toString();
return out.endsWith('/') ? out.slice(0, -1) : out;
} catch {
return undefined;
}
}
function applyProxyBasicAuth(proxyReq: any) {
if (!proxyBasicAuth) return false;
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
proxyReq.setHeader('Authorization', `Basic ${b64}`);
return true;
}
function rewriteSetCookieForLocalDevHttp(proxyRes: any) {
const v = proxyRes?.headers?.['set-cookie'];
if (!v) return;
const rewrite = (cookie: string) => {
let out = cookie.replace(/;\s*secure\b/gi, '');
out = out.replace(/;\s*domain=[^;]+/gi, '');
out = out.replace(/;\s*samesite=none\b/gi, '; SameSite=Lax');
return out;
};
proxyRes.headers['set-cookie'] = Array.isArray(v) ? v.map(rewrite) : rewrite(String(v));
}
function readDevProxyAuthUser(env: Record<string, string | undefined>): string | undefined {
const raw = String(env.DEV_PROXY_AUTH_USER || env.VITE_DEV_AUTH_USER || '').trim();
return raw || undefined;
}
export default defineConfig(({ mode }) => {
const env = { ...process.env, ...loadEnv(mode, DIR, '') } as Record<string, string | undefined>;
const apiReadToken = readApiReadToken(env);
const apiAdminSecret = readApiAdminSecret(env);
const proxyBasicAuth = readProxyBasicAuth(env);
const devProxyAuthUser = readDevProxyAuthUser(env);
const apiProxyTargetRaw = String(env.API_PROXY_TARGET || '').trim();
const apiProxyTarget = apiProxyTargetRaw || undefined;
const apiProxyTargetUrl = apiProxyTarget ? parseUrl(apiProxyTarget) : undefined;
const apiProxyTargetPath = stripTrailingSlashes(apiProxyTargetUrl?.pathname || '/');
const apiProxyTargetEndsWithApi = apiProxyTargetPath.endsWith('/api');
const apiProxyStripPrefixRaw = String(env.API_PROXY_STRIP_PREFIX || (apiProxyTargetEndsWithApi ? '/api' : '')).trim();
const apiProxyStripPrefix = apiProxyStripPrefixRaw ? stripTrailingSlashes(apiProxyStripPrefixRaw) : '';
const uiProxyTarget =
env.FRONTEND_PROXY_TARGET ||
env.UI_PROXY_TARGET ||
env.AUTH_PROXY_TARGET ||
inferUiProxyTarget(apiProxyTarget) ||
(apiProxyTargetUrl && apiProxyTargetPath === '/'
? stripTrailingSlashes(apiProxyTargetUrl.toString())
: undefined);
const graphqlProxyTarget = env.GRAPHQL_PROXY_TARGET || env.HASURA_PROXY_TARGET || uiProxyTarget;
const graphqlProxyPathRaw = String(env.GRAPHQL_PROXY_PATH || '').trim();
const graphqlProxyPath = graphqlProxyPathRaw ? stripTrailingSlashes(graphqlProxyPathRaw) : '';
const portRaw = env.VITE_DEV_PORT || env.DEV_PORT || '5173';
const port = Number.parseInt(String(portRaw), 10);
function applyProxyBasicAuth(proxyReq: any) {
if (!proxyBasicAuth) return false;
const b64 = Buffer.from(`${proxyBasicAuth.username}:${proxyBasicAuth.password}`, 'utf8').toString('base64');
proxyReq.setHeader('Authorization', `Basic ${b64}`);
return true;
}
function applyDevProxyAuth(proxyReq: any) {
if (!devProxyAuthUser) return false;
proxyReq.setHeader('x-trade-user', devProxyAuthUser);
return true;
}
const proxy: Record<string, any> = {
};
if (apiProxyTarget) {
proxy['/api'] = {
target: apiProxyTarget,
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
rewrite: (p: string) => {
if (!apiProxyStripPrefix) return p;
const rewritten = p.replace(new RegExp(`^${apiProxyStripPrefix}`), '');
return rewritten || '/';
},
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
applyDevProxyAuth(proxyReq);
if (apiAdminSecret) {
proxyReq.setHeader('x-admin-secret', apiAdminSecret);
return;
}
if (apiReadToken) proxyReq.setHeader('Authorization', `Bearer ${apiReadToken}`);
});
},
};
}
if (graphqlProxyTarget) {
for (const prefix of ['/graphql', '/graphql-ws']) {
proxy[prefix] = {
target: graphqlProxyTarget,
changeOrigin: true,
ws: true,
rewrite: (p: string) => (graphqlProxyPath ? p.replace(new RegExp(`^${prefix}`), graphqlProxyPath) : p),
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
applyDevProxyAuth(proxyReq);
});
p.on('proxyReqWs', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
applyDevProxyAuth(proxyReq);
});
},
};
}
}
if (uiProxyTarget) {
for (const prefix of ['/whoami', '/auth', '/logout']) {
proxy[prefix] = {
target: uiProxyTarget,
changeOrigin: true,
configure: (p: any) => {
p.on('proxyReq', (proxyReq: any) => {
applyProxyBasicAuth(proxyReq);
applyDevProxyAuth(proxyReq);
});
p.on('proxyRes', (proxyRes: any) => {
rewriteSetCookieForLocalDevHttp(proxyRes);
});
},
};
}
}
return {
plugins: [react()],
server: {
port: Number.isInteger(port) && port > 0 ? port : 5173,
strictPort: true,
proxy,
},
};
});

View File

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