3 Commits

Author SHA1 Message Date
tinkle-community
e61f12d307 chore: bump vite, form-data, @babel/core, js-yaml to patched versions
Resolves all open Dependabot alerts (2 high, 2 moderate, 1 low):
vite 6.4.3, form-data 4.0.6, @babel/core 7.29.7, js-yaml 4.3.0.
All within existing semver ranges; build and 111 tests pass.
2026-07-05 19:23:04 +09:00
tinkle-community
30f3feb586 feat: verify wallet and exchange balances before autopilot launch
Requires the Claw402 fee wallet to exist with at least 1 USDC on Base
and the Hyperliquid account to show at least 12 USDC available before
launch; failures route back to the matching guided-setup step.
2026-07-05 19:13:50 +09:00
tinkle-community
49613830ce feat: client-side simulation layer for terminal dashboard panels
Adds a local synthetic data engine that can drive all dashboard panels
(candles, order book, liq ladder, stats) without a backend connection,
plus de-synced beam animation phases in the orchestration topology.
2026-07-05 19:13:44 +09:00
9 changed files with 1060 additions and 162 deletions

200
web/package-lock.json generated
View File

@@ -98,13 +98,13 @@
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -113,9 +113,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -123,21 +123,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.4",
"@babel/parser": "^7.28.5",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -164,14 +164,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -181,14 +181,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -208,9 +208,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -218,29 +218,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.28.3"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -260,9 +260,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -270,9 +270,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -280,9 +280,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -290,27 +290,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.4"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -361,33 +361,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.5",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.5",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -395,14 +395,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -4752,16 +4752,16 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -5102,9 +5102,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5748,10 +5748,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -8522,9 +8532,9 @@
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -3,6 +3,9 @@ import useSWR from 'swr'
import { api } from '../../lib/api'
import type { Kline } from '../../lib/api/data'
import { Candles } from './Candles'
import { demoSeedPrice } from '../../lib/demo/demoUniverse'
const drnd = (a: number, b: number) => a + Math.random() * (b - a)
/**
* KlineChart shows a live candlestick chart. History is seeded from the backend
@@ -29,18 +32,87 @@ interface KlineChartProps {
height?: number
/** stretch the chart to fill the parent's remaining height */
fill?: boolean
/** showcase mode — drive a fast synthetic uptrend instead of the live feed */
demo?: boolean
}
export function KlineChart({ symbol, height = 360, fill = false }: KlineChartProps) {
export function KlineChart({ symbol, height = 360, fill = false, demo = false }: KlineChartProps) {
const base = baseSymbol(symbol || '')
// history seed (resynced occasionally; the WS carries the live bar)
const { data: seed, isLoading } = useSWR(
base ? ['kline', base, INTERVAL] : null,
base && !demo ? ['kline', base, INTERVAL] : null,
() => api.getKlines(base, INTERVAL, 'hyperliquid', MAX_BARS, true),
{ refreshInterval: 60000, revalidateOnFocus: false, shouldRetryOnError: false, keepPreviousData: true },
)
// synthetic showcase candles — a fast, gently rising series
const [demoCandles, setDemoCandles] = useState<Kline[]>([])
useEffect(() => {
if (!demo || !base) return
const target = demoSeedPrice(base)
// A deliberately clean rising series: linear climb + a couple of gentle waves
// (natural pullbacks) + tiny noise and small wicks. The last close lands on
// `target` so it stays aligned with the order book + cost/liq mark. No bar
// rolling (which degraded into flat noise) — the forming bar ticks live and
// the whole series subtly refreshes occasionally.
const buildSeries = (): Kline[] => {
const now = Date.now()
const start = target * (1 - drnd(0.013, 0.02))
const span = target - start
const phase = drnd(0, Math.PI * 2)
const bars: Kline[] = []
let prevClose = start
for (let i = 0; i < MAX_BARS; i++) {
const t01 = i / (MAX_BARS - 1)
const close = start + span * t01 + span * 0.13 * Math.sin(t01 * 6.5 + phase) + (Math.random() - 0.5) * target * 0.0008
const open = i === 0 ? start : prevClose
const wick = target * drnd(0.0003, 0.0009)
const tt = now - (MAX_BARS - i) * 60_000
bars.push({
openTime: tt,
closeTime: tt + 60_000,
open,
high: Math.max(open, close) + wick,
low: Math.min(open, close) - wick,
close,
volume: drnd(60, 360),
})
prevClose = close
}
const last = bars[bars.length - 1]
last.close = target
last.high = Math.max(last.high, target)
last.low = Math.min(last.low, target)
return bars
}
setDemoCandles(buildSeries())
let frame = 0
const id = setInterval(() => {
frame++
if (frame % 100 === 0) {
setDemoCandles(buildSeries()) // subtle full refresh (~25s)
return
}
setDemoCandles((prev) => {
if (!prev.length) return prev
const arr = prev.slice()
const i = arr.length - 1
const last = { ...arr[i] }
// forming bar ticks gently around the current price; clean fixed-size wick
const close = target * (1 + drnd(-0.0009, 0.001))
const wick = target * 0.0006
last.close = close
last.high = Math.max(last.open, close) + wick
last.low = Math.min(last.open, close) - wick
last.volume += drnd(2, 14)
arr[i] = last
return arr
})
}, 250)
return () => clearInterval(id)
}, [demo, base])
// resolve the Hyperliquid coin id (xyz: dex membership)
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
useEffect(() => {
@@ -65,7 +137,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
const [wsLive, setWsLive] = useState(false)
const pending = useRef<Kline | null>(null)
useEffect(() => {
if (!coin) return
if (!coin || demo) return
setLiveBar(null)
let ws: WebSocket | null = null
let raf: number | null = null
@@ -117,7 +189,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
}, [coin])
// merge the live bar into the seeded history
const candles = useMemo(() => {
const realCandles = useMemo(() => {
const hist = seed ?? []
if (!liveBar) return hist
const arr = [...hist]
@@ -127,10 +199,11 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
return arr.slice(-MAX_BARS)
}, [seed, liveBar])
const candles = demo ? demoCandles : realCandles
const last = candles.length ? candles[candles.length - 1].close : 0
const first = candles.length ? candles[0].open : 0
const chg = first ? ((last - first) / first) * 100 : 0
const live = wsLive && candles.length > 0
const live = (demo || wsLive) && candles.length > 0
return (
<div style={{ fontFamily: 'var(--tm-mono)', ...(fill ? { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } : {}) }}>

View File

@@ -2,6 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import useSWR from 'swr'
import { api } from '../../lib/api'
import type { VergexHeatmapBin } from '../../lib/api/data'
import { demoSeedPrice, demoTick } from '../../lib/demo/demoUniverse'
const lmRnd = (a: number, b: number) => a + Math.random() * (b - a)
/**
* LiquidationMap renders the vergex (claw402) cost / liquidation heatmap as a
@@ -44,9 +47,11 @@ interface LiquidationMapProps {
marketType?: string
/** fixed height of the scrollable ladder (px); auto-centres on the mark */
height?: number
/** showcase mode — render a synthetic ladder centred on the demo seed price */
demo?: boolean
}
export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460 }: LiquidationMapProps) {
export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460, demo = false }: LiquidationMapProps) {
// Synthetic markets live under marketType "hip3_perp"; crypto majors under
// "perp". We try the caller's guess first and fall back to the other so the
// heatmap resolves for ANY symbol that has one.
@@ -54,15 +59,68 @@ export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460
api.getVergexCostLiquidationHeatmap({ marketType: mt, symbol, chain: 'mainnet', liqBand: '15' })
const opts = { refreshInterval: 300000, revalidateOnFocus: false, keepPreviousData: true }
const primary = useSWR(symbol ? ['heatmap', marketType, symbol] : null, () => fetcher(marketType), opts)
const primary = useSWR(symbol && !demo ? ['heatmap', marketType, symbol] : null, () => fetcher(marketType), opts)
const primaryHasBins = !!primary.data?.data?.bins?.length
const altMt = marketType === 'perp' ? 'hip3_perp' : 'perp'
const needAlt = !primaryHasBins && !primary.isLoading && primary.data !== undefined
const needAlt = !demo && !primaryHasBins && !primary.isLoading && primary.data !== undefined
const alt = useSWR(needAlt && symbol ? ['heatmap', altMt, symbol] : null, () => fetcher(altMt), opts)
const data = primaryHasBins ? primary.data : alt.data
const isLoading = primary.isLoading || (needAlt && alt.isLoading)
const error = primaryHasBins ? undefined : alt.error || primary.error
// showcase mode: drive a slow ticker so the synthetic ladder gently breathes
const [demoFrame, setDemoFrame] = useState(0)
useEffect(() => {
if (!demo) return
const id = setInterval(() => setDemoFrame((f) => f + 1), 420)
return () => clearInterval(id)
}, [demo])
// stable base ladder for the active symbol (regenerated only on symbol change),
// centred on the same seed price the order book / candles use so all three
// price panels stay consistent.
const demoBase = useMemo(() => {
if (!demo) return null
const base = (symbol || 'SP500').toUpperCase().replace(/^XYZ:/, '')
const mark = demoSeedPrice(base)
const tick = demoTick(mark)
const N = 44
const scale = mark * 1.4e4
const bins = [] as { px: number; lc: number; sc: number; ll: number; sl: number }[]
for (let i = -N / 2; i <= N / 2; i++) {
const px = +(mark + i * tick * 2).toFixed(tick < 1 ? 3 : 1)
const dist = Math.abs(i) / (N / 2)
const near = Math.max(0, 1 - dist) ** 1.4
const far = dist ** 1.3
const below = i < 0
bins.push({
px,
lc: (below ? near : near * 0.18) * scale * lmRnd(0.5, 1),
sc: (!below ? near : near * 0.18) * scale * lmRnd(0.5, 1),
ll: (below ? far : 0) * scale * lmRnd(0.4, 0.9),
sl: (!below ? far : 0) * scale * lmRnd(0.4, 0.9),
})
}
return { base, mark, bins, costAddrs: Math.round(lmRnd(22000, 31000)), liqAddrs: Math.round(lmRnd(16000, 23000)) }
}, [demo, symbol])
// per-frame view: each bin breathes on its own phase (gentle ±, not a refresh)
const demoData = useMemo(() => {
if (!demoBase) return undefined
const f = demoFrame
const w = (v: number, amp: number, ph: number) => v * (1 + amp * Math.sin(f * 0.5 + ph))
const bins: VergexHeatmapBin[] = demoBase.bins.map((b, i) => ({
px: b.px,
longCost: w(b.lc, 0.12, i * 0.6),
shortCost: w(b.sc, 0.12, i * 0.9 + 1.7),
longLiq: w(b.ll, 0.16, i * 0.5 + 3.1),
shortLiq: w(b.sl, 0.16, i * 0.8 + 4.6),
}) as unknown as VergexHeatmapBin)
return {
data: { bins, markPrice: demoBase.mark, costAddrs: demoBase.costAddrs, liqAddrs: demoBase.liqAddrs, market: { symbol: demoBase.base } },
}
}, [demoBase, demoFrame])
const data = demo ? demoData : primaryHasBins ? primary.data : alt.data
const isLoading = demo ? false : primary.isLoading || (needAlt && alt.isLoading)
const error = demo ? undefined : primaryHasBins ? undefined : alt.error || primary.error
const [hover, setHover] = useState<number | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)

View File

@@ -45,6 +45,14 @@ function baseSymbol(raw: string): string {
return raw.toUpperCase().replace(/^XYZ:/, '').replace(/[-_]/g, '').replace(/(USDT|USDC|USD)$/, '')
}
// stable pseudo-random in [0,1) from a string — gives each beam its own phase so
// the funnel flickers randomly (not in lockstep) while staying deterministic.
function hash01(s: string): number {
let h = 2166136261
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
return ((h >>> 0) % 10000) / 10000
}
// evenly spread `count` solid items across `capacity` grid cells
function scatter(count: number, capacity: number): Map<number, number> {
const m = new Map<number, number>()
@@ -175,20 +183,30 @@ export function OrchestrationTopology({ layers, className }: OrchestrationTopolo
))}
</g>
{engineTargets.map((n, i) => (
<circle key={`b0-${i}`} r={1.8} fill={n.dir === 'short' ? SHORT : LONG}>
<animate attributeName="cx" values={`${ENGINE_X};${n.x}`} dur={`${0.5 + (i % 5) * 0.08}s`} begin={`${(i % 8) * 0.07}s`} repeatCount="indefinite" />
<animate attributeName="cy" values={`${cy};${n.y}`} dur={`${0.5 + (i % 5) * 0.08}s`} begin={`${(i % 8) * 0.07}s`} repeatCount="indefinite" />
<animate attributeName="opacity" values="0.9;0.9;0" dur={`${0.5 + (i % 5) * 0.08}s`} begin={`${(i % 8) * 0.07}s`} repeatCount="indefinite" />
</circle>
))}
{edges.map((e, i) => (
<circle key={`be-${e.key}`} r={2.1} fill={e.dir === 'short' ? SHORT : LONG}>
<animate attributeName="cx" values={`${e.x1};${e.x2}`} dur={`${0.45 + (i % 4) * 0.08}s`} begin={`${(i % 6) * 0.06}s`} repeatCount="indefinite" />
<animate attributeName="cy" values={`${e.y1};${e.y2}`} dur={`${0.45 + (i % 4) * 0.08}s`} begin={`${(i % 6) * 0.06}s`} repeatCount="indefinite" />
<animate attributeName="opacity" values="1;1;0" dur={`${0.45 + (i % 4) * 0.08}s`} begin={`${(i % 6) * 0.06}s`} repeatCount="indefinite" />
</circle>
))}
{engineTargets.map((n, i) => {
const r = hash01(`e${i}-${n.x}-${n.y}`)
const dur = `${(0.42 + r * 0.55).toFixed(2)}s`
const begin = `${(r * 1.4).toFixed(2)}s`
return (
<circle key={`b0-${i}`} r={1.8} fill={n.dir === 'short' ? SHORT : LONG}>
<animate attributeName="cx" values={`${ENGINE_X};${n.x}`} dur={dur} begin={begin} repeatCount="indefinite" />
<animate attributeName="cy" values={`${cy};${n.y}`} dur={dur} begin={begin} repeatCount="indefinite" />
<animate attributeName="opacity" values="0.9;0.9;0" dur={dur} begin={begin} repeatCount="indefinite" />
</circle>
)
})}
{edges.map((e) => {
const r = hash01(e.key)
const dur = `${(0.38 + r * 0.6).toFixed(2)}s`
const begin = `${(r * 1.6).toFixed(2)}s`
return (
<circle key={`be-${e.key}`} r={2.1} fill={e.dir === 'short' ? SHORT : LONG}>
<animate attributeName="cx" values={`${e.x1};${e.x2}`} dur={dur} begin={begin} repeatCount="indefinite" />
<animate attributeName="cy" values={`${e.y1};${e.y2}`} dur={dur} begin={begin} repeatCount="indefinite" />
<animate attributeName="opacity" values="1;1;0" dur={dur} begin={begin} repeatCount="indefinite" />
</circle>
)
})}
{cellsByLayer.map((layer, li) =>
layer.map((cell, ci) => {

View File

@@ -1,4 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { demoSeedPrice, demoTick } from '../../lib/demo/demoUniverse'
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
/**
* OrderBook renders a live L2 depth ladder for a single instrument, streamed
@@ -55,9 +58,11 @@ interface OrderBookProps {
symbol: string
/** optional entry price to mark the user's position level on the ladder */
markPrice?: number
/** showcase mode — drive a fast synthetic book instead of the live WS feed */
demo?: boolean
}
export function OrderBook({ symbol, markPrice }: OrderBookProps) {
export function OrderBook({ symbol, markPrice, demo = false }: OrderBookProps) {
const base = useMemo(() => baseSymbol(symbol || ''), [symbol])
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
const [book, setBook] = useState<BookState | null>(null)
@@ -88,10 +93,48 @@ export function OrderBook({ symbol, markPrice }: OrderBookProps) {
const coin = useMemo(() => resolveCoin(base, xyzSet), [base, xyzSet])
// synthetic showcase feed — keeps price levels stable for stretches (so each
// row flashes independently as its size changes) with a gentle upward drift.
useEffect(() => {
if (!demo || !base) return
const seed = demoSeedPrice(base)
const tickSz = demoTick(seed)
const dp = tickSz < 1 ? 3 : 1
let mid = seed
let frame = 0
const mkSizes = () =>
Array.from({ length: DEPTH }, (_, i) => +(rnd(0.4, 6) * (1 + i * 0.12)).toFixed(3))
const askSz = mkSizes()
const bidSz = mkSizes()
const emit = () => {
const asks: Level[] = askSz.map((sz, i) => ({ px: +(mid + tickSz * (i + 1)).toFixed(dp), sz }))
const bids: Level[] = bidSz.map((sz, i) => ({ px: +(mid - tickSz * (i + 1)).toFixed(dp), sz }))
setBook({ coin: `xyz:${base}`, bids, asks })
}
setStatus('live')
emit()
const id = setInterval(() => {
frame++
const n = 2 + Math.floor(Math.random() * 3)
for (let k = 0; k < n; k++) {
const arr = Math.random() < 0.5 ? askSz : bidSz
const i = Math.floor(Math.random() * DEPTH)
arr[i] = +Math.max(0.05, arr[i] * rnd(0.6, 1.5)).toFixed(3)
}
// gentle mean-reverting wiggle around the seed (NO unbounded drift, so the
// order book stays aligned with the cost/liq map + candle over a long run)
if (frame % 5 === 0) {
mid = +(mid + (seed - mid) * 0.3 + (Math.random() - 0.5) * tickSz * 2).toFixed(dp)
}
emit()
}, 130)
return () => clearInterval(id)
}, [demo, base])
// live L2 stream
const pending = useRef<BookState | null>(null)
useEffect(() => {
if (!coin) return
if (!coin || demo) return
let ws: WebSocket | null = null
let raf: number | null = null
let retry: ReturnType<typeof setTimeout> | null = null

View File

@@ -17,6 +17,7 @@ import { KlineChart } from './KlineChart'
import { ExecutionLog } from './ExecutionLog'
import { SignalMatrix } from './SignalMatrix'
import { RiskRadar } from './RiskRadar'
import { useDemoEngine } from '../../lib/demo/useDemoEngine'
// crypto majors trade on the Hyperliquid main dex (no hip3 cost/liq heatmap);
// everything else in the universe is an xyz-dex synthetic market that does.
@@ -88,50 +89,77 @@ export function TerminalDashboard({
traders,
selectedTraderId,
onTraderSelect,
status,
account,
positions,
decisions,
status: propStatus,
account: propAccount,
positions: propPositions,
decisions: propDecisions,
}: TerminalDashboardProps) {
const traderId = selectedTrader?.trader_id || selectedTraderId
useTick(1000)
const clock = new Date().toLocaleTimeString('en-GB', { hour12: false })
const { data: fullStats } = useSWR(
const { data: realFullStats } = useSWR(
traderId ? ['full-stats', traderId] : null,
() => api.getFullStats(traderId!, true),
{ refreshInterval: 30000, shouldRetryOnError: false }
)
const { data: history } = useSWR(
const { data: realHistory } = useSWR(
traderId ? ['pos-history', traderId] : null,
() => api.getPositionHistory(traderId!, 50, true),
{ refreshInterval: 60000, shouldRetryOnError: false }
)
const { data: config } = useSWR(
const { data: realConfig } = useSWR(
traderId ? ['trader-config', traderId] : null,
() => api.getTraderConfig(traderId!, true),
{ refreshInterval: 120000, shouldRetryOnError: false }
)
const latest = decisions && decisions.length > 0 ? decisions[0] : undefined
const candidateCoins = latest?.candidate_coins ?? []
const { data: flow } = useSWR(
const { data: realFlow } = useSWR(
traderId ? ['flow-markets', traderId] : null,
() => api.getFlowMarkets(selectedTrader?.ai_model, 'mainnet', '1h', 50, true),
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds; the
// topology beam animation is client-side and stays fast regardless
{ refreshInterval: 300000, shouldRetryOnError: false }
)
const flowItems = flow?.data?.inflow ?? []
const { data: signalRank } = useSWR(
const { data: realSignalRank } = useSWR(
traderId ? ['signal-rank', traderId] : null,
() => api.getSignalRanking(selectedTrader?.ai_model, 'mainnet', 'all', 30, true),
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds
{ refreshInterval: 300000, shouldRetryOnError: false }
)
// Demo / showcase mode for product walkthroughs. Toggle with Shift+D (or the
// discreet corner dot). Generates a fast, profitable-looking US-equity dataset
// entirely client-side — it never touches the backend or any real account.
// When off, real data flows through unchanged.
const [demo, setDemo] = useState(false)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
const el = document.activeElement
if (el && /^(input|textarea|select)$/i.test(el.tagName)) return
setDemo((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const sim = useDemoEngine(demo)
const on = demo && !!sim
const status = on ? sim!.status : propStatus
const account = on ? sim!.account : propAccount
const positions = on ? sim!.positions : propPositions
const decisions = on ? sim!.decisions : propDecisions
const fullStats = on ? sim!.fullStats : realFullStats
const history = on ? sim!.history : realHistory
const config = on ? (sim!.config as unknown as typeof realConfig) : realConfig
const flow = on ? sim!.flow : realFlow
const signalRank = on ? sim!.signalRank : realSignalRank
const latest = decisions && decisions.length > 0 ? decisions[0] : undefined
const candidateCoins = latest?.candidate_coins ?? []
const flowItems = flow?.data?.inflow ?? []
// Both the cost/liq map and the order book follow this symbol so they stay in
// sync. The heatmap only covers hip3_perp synthetic markets, so we pick a
// synthetic (non-crypto) the bot trades — preferring the BUSIEST one (most
@@ -236,6 +264,26 @@ export function TerminalDashboard({
return (
<div className="nofx-terminal" style={{ minHeight: '100vh', padding: 0 }}>
{/* discreet, unlabelled showcase toggle (Shift+D also works) */}
<button
type="button"
onClick={() => setDemo((v) => !v)}
aria-label="toggle presentation mode"
style={{
position: 'fixed',
right: 10,
bottom: 10,
zIndex: 9999,
width: 12,
height: 12,
padding: 0,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: on ? 'var(--tm-up)' : 'rgba(26,24,19,0.2)',
opacity: on ? 0.55 : 0.18,
}}
/>
{/* centered, capped content column — no border (keeps it from feeling
embedded) but bounded so the aspect-ratio SVGs don't balloon on wide screens */}
{navSlot &&
@@ -315,19 +363,20 @@ export function TerminalDashboard({
back to the other one if the guess is wrong */}
<LiquidationMap
symbol={activeSym}
demo={on}
marketType={CRYPTO_MAJORS.has(activeSym) ? 'perp' : 'hip3_perp'}
height={ROW1_H - 130}
/>
</div>
<div style={{ ...sc, borderRight: cellBorder, height: ROW1_H, overflow: 'hidden' }}>
<OrderBook symbol={activeSym} markPrice={positions?.find((p) => baseLabel(p.symbol) === activeSym)?.entry_price} />
<OrderBook symbol={activeSym} demo={on} markPrice={positions?.find((p) => baseLabel(p.symbol) === activeSym)?.entry_price} />
</div>
<div style={{ ...sc, height: ROW1_H, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<SignalMatrix items={signalRank?.items} active={activeSym} onSelect={setSelectedSym} />
<SignalMatrix items={signalRank?.items} max={18} active={activeSym} onSelect={setSelectedSym} />
{/* the live K-line always sits under the selector and flexes to fill */}
<div className="tm-rule" style={{ margin: '10px 0 8px' }} />
<div style={{ flex: 1, minHeight: 0 }}>
<KlineChart symbol={activeSym} fill />
<KlineChart symbol={activeSym} fill demo={on} />
</div>
</div>
</div>

View File

@@ -0,0 +1,47 @@
/**
* Shared constants for the dashboard demo/showcase mode. US-equity-led synthetic
* universe (these are real xyz-dex markets so the cost/liq heatmap still resolves)
* plus plausible seed prices for the synthetic order book / candle feeds.
*
* This drives the "Demo" presentation mode only. It never touches the backend,
* the live trader, or any real account — it is purely a client-side animation
* layer for product walkthroughs.
*/
export const DEMO_UNIVERSE = [
// longs (bullish book)
'SP500', 'NVDA', 'MU', 'GOOGL', 'TSM', 'META', 'AMD', 'AAPL', 'MSFT', 'AMZN',
'NFLX', 'TSLA', 'AVGO', 'NBIS', 'XYZ100', 'PLTR', 'TXN', 'LRCX', 'AMAT', 'MRVL',
// short book (bearish names — see SHORT_SET in the engine)
'INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM', 'COIN', 'ORCL', 'DRAM', 'CRM',
'HOOD', 'SNOW',
]
// Lead instrument for the price panels (real heatmap + resolvable book/candles).
export const DEMO_ACTIVE_SYMBOL = 'SP500'
const DEMO_SEED_PX: Record<string, number> = {
SP500: 6485, NVDA: 184.2, MU: 142.6, GOOGL: 351.8, TSM: 451.3, META: 723.5,
AMD: 168.4, AAPL: 245.9, MSFT: 498.2, AMZN: 228.4, NFLX: 921.5, TSLA: 412.8,
AVGO: 358.1, NBIS: 266.1, XYZ100: 1182, PLTR: 78.4, TXN: 205.3, LRCX: 102.6,
AMAT: 218.7, MRVL: 118.2, INTC: 41.2, SPCX: 64.3, SKHX: 88.7, SMCI: 44.1,
ARM: 162.4, QCOM: 178.9, COIN: 312.5, ORCL: 192.3, DRAM: 72.4, CRM: 342.1,
HOOD: 58.6, SNOW: 198.4,
}
/** Stable plausible seed price for a base symbol (deterministic fallback). */
export function demoSeedPrice(base: string): number {
const b = base.toUpperCase().replace(/^XYZ:/, '')
if (DEMO_SEED_PX[b]) return DEMO_SEED_PX[b]
let h = 0
for (let i = 0; i < b.length; i++) h = (h * 31 + b.charCodeAt(i)) % 100000
return 40 + (h % 900)
}
/** Reasonable tick size for a price level given its magnitude. */
export function demoTick(px: number): number {
if (px >= 1000) return 1
if (px >= 100) return 0.1
if (px >= 10) return 0.05
return 0.01
}

View File

@@ -0,0 +1,456 @@
import { useEffect, useRef, useState } from 'react'
import type {
AccountInfo,
Position,
DecisionRecord,
SystemStatus,
TraderFullStats,
PositionHistoryResponse,
HistoricalPosition,
SymbolStats,
} from '../../types'
import type {
FlowMarketsResponse,
FlowMarketItem,
SignalRankingResponse,
SignalRankItem,
} from '../api/data'
import { DEMO_UNIVERSE, DEMO_ACTIVE_SYMBOL, demoSeedPrice } from './demoUniverse'
/**
* useDemoEngine — a client-side showcase data generator. When `active`, it
* synthesises a fast-evolving, profitable-looking US-equity trading dataset that
* mirrors the real dashboard data shapes, so every panel animates for a product
* walkthrough. Returns null when inactive (the dashboard then uses real data).
*
* No network, no backend, no real account — pure presentation layer.
*/
const TICK_MS = 200
const INITIAL = 1_000_000
export interface DemoDataset {
status: SystemStatus
account: AccountInfo
positions: Position[]
decisions: DecisionRecord[]
fullStats: TraderFullStats
history: PositionHistoryResponse
config: {
scan_interval_minutes: number
ai_model: string
strategy_name: string
btc_eth_leverage: number
altcoin_leverage: number
}
flow: FlowMarketsResponse
signalRank: SignalRankingResponse
activeSymbol: string
}
interface PosState {
symbol: string
side: 'long' | 'short'
entry: number
mark: number
qty: number
lev: number
}
interface TradeState {
id: number
symbol: string
side: 'long' | 'short'
entry: number
exit: number
qty: number
pnl: number
fee: number
lev: number
}
interface SimState {
frame: number
cycle: number
realized: number
fee: number
wins: number
losses: number
grossWin: number
grossLoss: number
nextId: number
decisionTs: number
positions: PosState[]
trades: TradeState[]
// per-symbol flow noise so the bars jiggle independently
flowNet: Record<string, number>
signalScore: Record<string, number>
}
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
const pick = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]
// A fixed "short book" — these symbols are short EVERYWHERE (flow outflow,
// bearish signal, short positions, decision candidates) so the topology's
// short row carries connected flow lines through every layer. The rest are long.
const SHORT_SET = new Set([
'INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM', 'COIN', 'ORCL', 'DRAM', 'CRM', 'HOOD', 'SNOW',
])
const LONG_POOL = DEMO_UNIVERSE.filter((s) => !SHORT_SET.has(s))
function sideFor(symbol: string): 'long' | 'short' {
return SHORT_SET.has(symbol.toUpperCase()) ? 'short' : 'long'
}
function newPosition(symbol: string): PosState {
const entry = demoSeedPrice(symbol) * rnd(0.985, 1.015)
const side = sideFor(symbol)
const lev = pick([10, 10, 15, 20, 20])
const notional = rnd(35_000, 110_000)
return { symbol, side, entry, mark: entry, qty: notional / entry, lev }
}
function initState(): SimState {
// index 0 = lead (drives the price panels). Then a deep US-equity book — a
// long bias plus a sizable short book so the topology fills both rows densely.
const longs = [DEMO_ACTIVE_SYMBOL, 'NVDA', 'GOOGL', 'TSM', 'META', 'AMD', 'MSFT', 'AMZN', 'AVGO']
const shorts = ['INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM']
const positions = [...longs, ...shorts].map((s) => {
const p = newPosition(s)
// start each slightly in profit so the board opens green
const fav = rnd(0.006, 0.03)
p.mark = p.side === 'long' ? p.entry * (1 + fav) : p.entry * (1 - fav)
return p
})
const flowNet: Record<string, number> = {}
const signalScore: Record<string, number> = {}
DEMO_UNIVERSE.forEach((s, i) => {
flowNet[s] = rnd(120_000, 2_400_000) * (1 - i / (DEMO_UNIVERSE.length + 4))
// short book scores negative (bearish), everything else positive (bullish)
signalScore[s] = SHORT_SET.has(s) ? rnd(-1.6, -0.4) : rnd(0.4, 2.0)
})
return {
frame: 0,
cycle: 1,
realized: 312_000,
fee: 2840,
wins: 412,
losses: 214,
grossWin: 690_000,
grossLoss: 286_000,
nextId: 5000,
decisionTs: Date.now(),
positions,
trades: [],
flowNet,
signalScore,
}
}
function upnl(p: PosState): number {
const notional = p.qty * p.entry
const move = (p.mark - p.entry) / p.entry
return notional * move * (p.side === 'long' ? 1 : -1)
}
function step(S: SimState) {
S.frame++
// drift each position's mark with a favourable bias so the book trends green
for (const p of S.positions) {
const bias = p.side === 'long' ? 0.00028 : -0.00028
p.mark *= 1 + bias + rnd(-0.0011, 0.0011)
// keep within a believable favourable band of entry
const move = (p.mark - p.entry) / p.entry
const dir = p.side === 'long' ? 1 : -1
const signed = move * dir
if (signed < -0.014) p.mark = p.entry * (1 - dir * 0.014)
if (signed > 0.075) p.mark = p.entry * (1 + dir * 0.075)
}
// roll a winning trade every ~16 frames. Only rotate a non-lead LONG slot:
// index 0 (lead, drives price panels) and the short book stay fixed so the
// order book stays on one symbol and the topology keeps its short flow lines.
const rotatable = S.positions
.map((p, i) => ({ p, i }))
.filter((x) => x.i !== 0 && x.p.side === 'long')
if (S.frame % 16 === 0 && rotatable.length) {
let bestIdx = rotatable[0].i
for (const x of rotatable) {
if (upnl(x.p) > upnl(S.positions[bestIdx])) bestIdx = x.i
}
const isWin = Math.random() < 0.7
const closed = S.positions[bestIdx]
const raw = upnl(closed)
const pnl = isWin ? Math.max(Math.abs(raw), rnd(600, 5200)) : -rnd(250, 2100)
const fee = rnd(12, 95)
S.trades.unshift({
id: S.nextId++,
symbol: closed.symbol,
side: closed.side,
entry: closed.entry,
exit: closed.mark,
qty: closed.qty,
pnl,
fee,
lev: closed.lev,
})
if (S.trades.length > 40) S.trades.pop()
S.realized += pnl - fee
S.fee += fee
if (pnl >= 0) {
S.wins++
S.grossWin += pnl
} else {
S.losses++
S.grossLoss += -pnl
}
// reopen a fresh LONG US-equity position (avoid duplicates of current book)
const held = new Set(S.positions.map((p) => p.symbol))
const candidates = LONG_POOL.filter((s) => !held.has(s))
S.positions[bestIdx] = newPosition(candidates.length ? pick(candidates) : closed.symbol)
}
// new orchestration cycle every ~24 frames
if (S.frame % 24 === 0) {
S.cycle++
S.decisionTs = Date.now()
}
// jiggle flow + signal noise so those panels stay alive (short book stays
// bearish, longs stay bullish — keeps signal/topology directions consistent)
for (const s of DEMO_UNIVERSE) {
S.flowNet[s] = Math.max(20_000, S.flowNet[s] * rnd(0.97, 1.035))
const next = S.signalScore[s] + rnd(-0.08, 0.09)
S.signalScore[s] = SHORT_SET.has(s)
? Math.max(-2, Math.min(-0.1, next))
: Math.max(0.1, Math.min(2.4, next))
}
}
function build(S: SimState): DemoDataset {
const liveUpnl = S.positions.reduce((s, p) => s + upnl(p), 0)
const equity = INITIAL + S.realized + liveUpnl
const pnl = equity - INITIAL
const marginUsed = S.positions.reduce((s, p) => s + (p.qty * p.entry) / p.lev, 0)
const total = S.wins + S.losses
const account = {
total_equity: equity,
wallet_balance: equity - liveUpnl,
unrealized_profit: liveUpnl,
total_unrealized_profit: liveUpnl, // RiskRadar reads this extra field
available_balance: Math.max(0, equity - marginUsed),
total_pnl: pnl,
total_pnl_pct: (pnl / INITIAL) * 100,
initial_balance: INITIAL,
daily_pnl: S.realized + liveUpnl,
position_count: S.positions.length,
margin_used: marginUsed,
margin_used_pct: Math.min(82, (marginUsed / equity) * 100 * 1.6),
} as unknown as AccountInfo
const positions: Position[] = S.positions.map((p) => {
const u = upnl(p)
const notional = p.qty * p.entry
const margin = notional / p.lev
const liq = p.side === 'long' ? p.entry * (1 - 0.9 / p.lev) : p.entry * (1 + 0.9 / p.lev)
return {
symbol: p.symbol,
side: p.side,
entry_price: p.entry,
mark_price: p.mark,
quantity: p.qty,
leverage: p.lev,
unrealized_pnl: u,
unrealized_pnl_pct: (u / margin) * 100,
liquidation_price: liq,
margin_used: margin,
}
})
const winRate = total > 0 ? (S.wins / total) * 100 : 0
const avgWin = S.wins > 0 ? S.grossWin / S.wins : 0
const avgLoss = S.losses > 0 ? S.grossLoss / S.losses : 0
const fullStats: TraderFullStats = {
total_trades: total,
win_trades: S.wins,
loss_trades: S.losses,
win_rate: winRate,
profit_factor: S.grossLoss > 0 ? S.grossWin / S.grossLoss : S.grossWin,
sharpe_ratio: 2.05 + Math.sin(S.frame / 90) * 0.12,
total_pnl: pnl,
total_fee: S.fee,
avg_win: avgWin,
avg_loss: avgLoss,
max_drawdown_pct: 0.052,
}
// recent closed trades
const histPositions = S.trades.map(
(t) =>
({
id: t.id,
symbol: t.symbol,
side: t.side,
quantity: t.qty,
entry_price: t.entry,
exit_price: t.exit,
exit_time: new Date(S.decisionTs - (S.nextId - t.id) * 47_000).toISOString(),
realized_pnl: t.pnl,
fee: t.fee,
leverage: t.lev,
status: 'closed',
close_reason: t.pnl >= 0 ? 'take_profit' : 'stop_loss',
}) as unknown as HistoricalPosition,
)
// per-symbol aggregates
const bySym = new Map<string, { n: number; w: number; pnl: number }>()
for (const t of S.trades) {
const e = bySym.get(t.symbol) || { n: 0, w: 0, pnl: 0 }
e.n++
if (t.pnl >= 0) e.w++
e.pnl += t.pnl
bySym.set(t.symbol, e)
}
const symbolStats: SymbolStats[] = [...bySym.entries()]
.map(([symbol, e]) => ({
symbol,
total_trades: e.n,
win_trades: e.w,
win_rate: e.n > 0 ? (e.w / e.n) * 100 : 0,
total_pnl: e.pnl,
avg_pnl: e.n > 0 ? e.pnl / e.n : 0,
avg_hold_mins: Math.round(rnd(6, 38)),
}))
.sort((a, b) => b.total_trades - a.total_trades)
const history = {
positions: histPositions,
stats: null,
symbol_stats: symbolStats,
direction_stats: [],
} as unknown as PositionHistoryResponse
// flow markets — long names show net BUYING (inflow), the short book shows net
// SELLING (outflow) so the topology's FLOW layer feeds the short row too.
const mkItem = (s: string, net: number): FlowMarketItem => {
const buyShare = net >= 0 ? 0.56 + Math.min(0.3, net / 6_000_000) : 0.4
const gross = Math.abs(net)
return {
key: `xyz:${s}`,
marketType: 'hip3_perp',
symbol: s,
netFlow: String(Math.round(net)),
buyNotional: String(Math.round(gross * buyShare)),
sellNotional: String(Math.round(gross * (1 - buyShare))),
trades: Math.round(rnd(150, 9000)),
latestPrice: String(demoSeedPrice(s)),
}
}
const inflow: FlowMarketItem[] = LONG_POOL.slice()
.sort((a, b) => S.flowNet[b] - S.flowNet[a])
.map((s) => mkItem(s, S.flowNet[s]))
const outflow: FlowMarketItem[] = [...SHORT_SET].map((s) => mkItem(s, -Math.abs(S.flowNet[s]) * 0.6))
const flow: FlowMarketsResponse = { data: { by: 'netFlow', window: '1h', inflow, outflow } }
// signal ranking — mostly bullish US equities
const ranked = [...DEMO_UNIVERSE].sort((a, b) => S.signalScore[b] - S.signalScore[a])
const items: SignalRankItem[] = ranked.map((s, i) => {
const score = S.signalScore[s]
return {
rank: i + 1,
symbol: s,
market_type: 'hip3_perp',
bias: SHORT_SET.has(s) ? 'bearish' : 'bullish',
score: Math.round(score * 100) / 100,
category: 'us_equity',
}
})
const signalRank: SignalRankingResponse = { items }
// decision candidates — top longs plus the short book, so the DECISION layer
// (and execution log) carries shorts through to EXECUTE/HOLD.
const candidates = [...new Set([...ranked.slice(0, 8), ...SHORT_SET])]
const decisions: DecisionRecord[] = Array.from({ length: 4 }).map((_, k) => {
const cyc = S.cycle - k
const acts = S.positions.slice(0, 6).map((p) => ({
action: 'hold',
symbol: p.symbol,
quantity: p.qty,
leverage: p.lev,
price: p.mark,
confidence: Math.round(rnd(62, 88)),
reasoning: 'Signal Lab confirms trend; cost/liq structure supports the level.',
timestamp: new Date(S.decisionTs - k * 300_000).toISOString(),
}))
return {
timestamp: new Date(S.decisionTs - k * 300_000).toISOString(),
cycle_number: cyc,
system_prompt: '',
input_prompt: '',
cot_trace:
'US-equity tape is broadly bid: SP500 and semis (NVDA, MU, TSM) lead net inflow with bullish Signal Lab bias. Holding winners ≥ entry, trimming only on structure breaks.',
decision_json: '',
account_state: {} as never,
positions: [],
candidate_coins: candidates,
decisions: acts as never,
execution_log: S.positions
.slice(0, 6)
.map((p) => `${p.symbol} hold succeeded`),
success: true,
} as unknown as DecisionRecord
})
const status = {
is_running: true,
call_count: S.cycle,
scan_interval: '5m',
ai_model: 'claw402',
strategy_type: 'ai_trading',
} as unknown as SystemStatus
return {
status,
account,
positions,
decisions,
fullStats,
history,
config: {
scan_interval_minutes: 5,
ai_model: 'claw402',
strategy_name: 'NOFX Claw402 Auto Strategy',
btc_eth_leverage: 10,
altcoin_leverage: 10,
},
flow,
signalRank,
activeSymbol: DEMO_ACTIVE_SYMBOL,
}
}
export function useDemoEngine(active: boolean): DemoDataset | null {
const ref = useRef<SimState | null>(null)
const [, tick] = useState(0)
useEffect(() => {
if (!active) {
ref.current = null
return
}
ref.current = initState()
tick((n) => n + 1)
const id = setInterval(() => {
if (ref.current) {
step(ref.current)
tick((n) => n + 1)
}
}, TICK_MS)
return () => clearInterval(id)
}, [active])
if (!active || !ref.current) return null
return build(ref.current)
}

View File

@@ -27,7 +27,12 @@ import type {
Strategy,
StrategyConfig,
} from '../types'
import type { AIModel, Exchange } from '../types'
import type {
AIModel,
CurrentBeginnerWalletResponse,
Exchange,
ExchangeAccountState,
} from '../types'
import type {
MarketSymbol,
VergexHeatmapBin,
@@ -36,7 +41,7 @@ import type {
VergexSignalItem,
VergexSignalLabResponse,
} from '../lib/api/data'
import { buildDashboardPath } from '../router/paths'
import { buildDashboardPath, ROUTES } from '../router/paths'
const API_BASE = import.meta.env.VITE_API_BASE || ''
@@ -75,12 +80,41 @@ const topNOptions = [5, 6, 7, 8, 9, 10]
const detailBandOptions = ['5', '10', '15', '20']
const claw402BoardLimit = 30
const confidenceOptions = [65, 75, 82]
const MIN_AI_FEE_USDC = 1
const MIN_TRADING_USDC = 12
type LaunchSetupTarget = 'claw402' | 'hyperliquid' | 'hyperliquid-funds'
type FeeWalletSnapshot = Pick<
CurrentBeginnerWalletResponse,
'address' | 'balance_usdc'
>
class LaunchSetupError extends Error {
target: LaunchSetupTarget
constructor(message: string, target: LaunchSetupTarget) {
super(message)
this.name = 'LaunchSetupError'
this.target = target
}
}
const text = (language: string, zh: string, en: string) =>
language === 'zh' ? zh : en
function modelHasCredential(model: AIModel) {
return Boolean(model.has_api_key || model.apiKey)
return Boolean(
model.has_api_key ||
model.apiKey ||
(model.provider === 'claw402' && model.walletAddress)
)
}
function parseUsdc(value?: string | number) {
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
if (!value) return 0
const parsed = Number(value.replace(/[,$\s]/g, ''))
return Number.isFinite(parsed) ? parsed : 0
}
function pickTradingModel(models: AIModel[]) {
@@ -121,6 +155,10 @@ function modelHasExchangeKey(exchange: Exchange) {
return Boolean(exchange.has_api_key || exchange.apiKey)
}
function exchangeAvailableUsdc(state?: ExchangeAccountState) {
return parseUsdc(state?.available_balance ?? state?.total_equity)
}
type Profile = 'careful' | 'balanced' | 'active'
const profileOptions: Array<{
@@ -379,7 +417,8 @@ function signalBiasInfo(bias: string | undefined) {
return {
label: 'Neutral',
hint: 'Neutral bias',
classes: 'border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper text-nofx-text-muted',
classes:
'border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper text-nofx-text-muted',
icon: Target,
}
}
@@ -1388,7 +1427,11 @@ export function StrategyStudioPage() {
notify.success(
successMessage ||
(activateAfter
? text(language, 'Strategy saved and activated', 'Strategy saved and activated')
? text(
language,
'Strategy saved and activated',
'Strategy saved and activated'
)
: text(language, 'Strategy saved', 'Strategy saved'))
)
await loadStrategies(selectedStrategy.id)
@@ -1441,10 +1484,79 @@ export function StrategyStudioPage() {
return base
}
const requireClaw402FeeWallet = async (
model: AIModel,
wallet?: FeeWalletSnapshot | null
) => {
if (model.provider !== 'claw402') return
const address = model.walletAddress || wallet?.address || ''
const balance = parseUsdc(model.balanceUsdc ?? wallet?.balance_usdc)
if (!address.trim()) {
throw new LaunchSetupError(
'Claw402 wallet is not ready. Open the guided setup and create the Base USDC payment wallet first.',
'claw402'
)
}
if (balance < MIN_AI_FEE_USDC) {
throw new LaunchSetupError(
`Claw402 wallet needs at least ${MIN_AI_FEE_USDC} USDC on Base before Autopilot can pay for data and model calls.`,
'claw402'
)
}
}
const requireHyperliquidBalance = async (exchange: Exchange) => {
let accountState: ExchangeAccountState | undefined
try {
const response = await api.getExchangeAccountState()
accountState = response.states[exchange.id]
} catch {
throw new LaunchSetupError(
'Could not verify the Hyperliquid trading balance. Open the guided setup and refresh the account state first.',
'hyperliquid-funds'
)
}
if (!accountState) {
throw new LaunchSetupError(
'Could not verify the Hyperliquid trading balance. Open the guided setup and refresh the account state first.',
'hyperliquid-funds'
)
}
if (accountState.status !== 'ok') {
throw new LaunchSetupError(
accountState.error_message ||
'Hyperliquid account is not ready. Reconnect the wallet and approve the NOFX Agent.',
'hyperliquid'
)
}
const balance = exchangeAvailableUsdc(accountState)
if (balance < MIN_TRADING_USDC) {
throw new LaunchSetupError(
`Hyperliquid needs at least ${MIN_TRADING_USDC} USDC available before Autopilot can place its first trade.`,
'hyperliquid-funds'
)
}
}
const resolveOneClickModel = async () => {
let models = await api.getModelConfigs()
let model = pickTradingModel(models)
if (model) return model
if (model) {
let wallet: CurrentBeginnerWalletResponse | null = null
try {
wallet = await api.getCurrentBeginnerWallet()
} catch {
wallet = null
}
await requireClaw402FeeWallet(model, wallet)
return model
}
const onboarding = await api.prepareBeginnerOnboarding()
models = await api.getModelConfigs()
@@ -1478,46 +1590,59 @@ export function StrategyStudioPage() {
}
if (!model) {
throw new Error(
'No enabled AI model is ready. Create or fund the Claw402 wallet first.'
throw new LaunchSetupError(
'No enabled AI model is ready. Create or fund the Claw402 wallet first.',
'claw402'
)
}
await requireClaw402FeeWallet(model, onboarding)
return model
}
const resolveOneClickExchange = async () => {
const exchanges = await api.getExchangeConfigs()
const exchange = pickTradingExchange(exchanges)
if (exchange) return exchange
if (exchange) {
await requireHyperliquidBalance(exchange)
return exchange
}
const hyperliquid = exchanges.find(isHyperliquidExchange)
if (!hyperliquid) {
throw new Error(
'No Hyperliquid account is ready. Connect Hyperliquid and authorize the NOFX agent first.'
throw new LaunchSetupError(
'No Hyperliquid account is ready. Connect Hyperliquid and authorize the NOFX agent first.',
'hyperliquid'
)
}
if (!hyperliquid.enabled) {
throw new Error('The Hyperliquid account is disabled. Enable it first.')
throw new LaunchSetupError(
'The Hyperliquid account is disabled. Enable it first.',
'hyperliquid'
)
}
if (!modelHasExchangeKey(hyperliquid)) {
throw new Error(
'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.'
throw new LaunchSetupError(
'The Hyperliquid agent key is missing. Reconnect Hyperliquid and save the agent wallet.',
'hyperliquid'
)
}
if (!hyperliquid.hyperliquidBuilderApproved) {
throw new Error(
'Hyperliquid builder authorization is not complete. Finish wallet authorization first.'
throw new LaunchSetupError(
'Hyperliquid builder authorization is not complete. Finish wallet authorization first.',
'hyperliquid'
)
}
if (!hyperliquidWalletAddress(hyperliquid).trim()) {
throw new Error(
'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.'
throw new LaunchSetupError(
'The Hyperliquid wallet address is missing. Reconnect Hyperliquid first.',
'hyperliquid'
)
}
throw new Error(
'No ready Hyperliquid account found. Check the wallet authorization.'
throw new LaunchSetupError(
'No ready Hyperliquid account found. Check the wallet authorization.',
'hyperliquid'
)
}
@@ -1575,6 +1700,17 @@ export function StrategyStudioPage() {
await loadStrategies(selectedStrategy.id)
navigate(buildDashboardPath(autopilot.trader_id))
} catch (err) {
if (err instanceof LaunchSetupError) {
notify.error(err.message)
navigate(
err.target === 'claw402'
? `${ROUTES.traders}?setup=claw402`
: err.target === 'hyperliquid'
? `${ROUTES.traders}?setup=hyperliquid`
: ROUTES.traders
)
return
}
notify.error(
err instanceof Error ? err.message : 'Failed to launch NOFX Autopilot'
)
@@ -2381,7 +2517,11 @@ export function StrategyStudioPage() {
<div className="rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter p-4">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-nofx-text">
<Shield className="h-4 w-4 text-nofx-success" />
{text(language, 'Trading parameters', 'Trading parameters')}
{text(
language,
'Trading parameters',
'Trading parameters'
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<label className="space-y-2">
@@ -2424,7 +2564,11 @@ export function StrategyStudioPage() {
</label>
<label className="space-y-2">
<span className="text-xs text-nofx-text-muted">
{text(language, 'Entry confidence', 'Entry confidence')}
{text(
language,
'Entry confidence',
'Entry confidence'
)}
</span>
<select
value={risk.min_confidence}