diff --git a/web/.env.example b/web/.env.example
new file mode 100644
index 0000000..9a6377c
--- /dev/null
+++ b/web/.env.example
@@ -0,0 +1 @@
+IPADS_API_KEY=
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..f238711
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+dist/
+data/
+.env
+.DS_Store
+*.log
+.qoder/
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..5ebc818
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,70 @@
+# Personal Stock Agent
+
+统一个人股票监控 MVP,支持 A股、港股、美股自选股,按日生成监控报告。数据接口固定使用 IPADS Responses API,密钥从 `.env` 的 `IPADS_API_KEY` 读取。
+
+## Run
+
+```bash
+npm install
+npm run dev
+```
+
+Frontend: http://127.0.0.1:5173
+
+Backend: http://127.0.0.1:8787
+
+LAN access:
+
+```bash
+HOST=0.0.0.0 CLIENT_PORT=5174 PORT=8787 npm run dev
+```
+
+Open `http://gahow-pc.ipads-lab.se.sjtu.edu.cn:5174` from another device on the same network.
+
+## IPADS API
+
+配置文件:
+
+```env
+IPADS_API_KEY=your_api_key
+```
+
+固定服务端配置:
+
+```text
+base_url = "http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1"
+wire_api = "responses"
+env_key = "IPADS_API_KEY"
+```
+
+The backend calls:
+
+```text
+POST http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1/responses
+Authorization: Bearer ${IPADS_API_KEY}
+```
+
+The response output text must be JSON matching the stock snapshot schema used by the agent. If `IPADS_API_KEY` is blank, the app uses deterministic demo data so the dashboard remains usable.
+
+## Features
+
+- Add/remove watchlist symbols such as `600519`, `000001.SZ`, `00700.HK`, `AAPL`, `MSFT.US`.
+- Normalize symbols into A股、港股、美股 markets.
+- Generate a daily report with earnings timing, peer stocks, valuation discount/premium, related news, and buy-watch/hold/avoid recommendations.
+- Refreshes the cached daily report every 24 hours while the backend process is running.
+- View each watchlist stock with locally cached D/W/M/Y K-line chart data, zoom controls, MA5/MA20/MA60, RSI14, MACD, volume, and cached financial statements. US K-line and quote/research fields use Nasdaq as the fixed data source and target at least 5 years of verified daily OHLCV history when available.
+- Cache related market context such as sector, liquidity, volatility, drawdown, relative strength, capital flow, sentiment, and risk flags.
+- Run an agent-backed quantitative analysis across technical, valuation, growth, quality, liquidity, sentiment, and risk factors to classify each stock as buy-watch/hold/avoid.
+- Add tags to every watchlist stock and filter the watchlist by tag.
+- In configured provider mode, prompts require verified data and do not permit approximate or fabricated values; missing data is surfaced instead of being filled with synthetic values.
+- K-line cache updates incrementally: once 5 years of daily bars exist locally, refreshes request only the latest missing window and merge by date.
+
+## Local cache
+
+Persistent app data defaults to `./data` and can be moved with:
+
+```env
+STOCK_AGENT_DATA_DIR=/path/to/stock-agent-data
+```
+
+The backend stores watchlist data in `watchlist.json` and per-stock cached snapshot/K-line/financial data under `cache/`.
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..7c00985
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Personal Stock Monitor
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..65b6e16
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,3491 @@
+{
+ "name": "personal-stock-agent",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "personal-stock-agent",
+ "version": "0.1.0",
+ "dependencies": {
+ "@vitejs/plugin-react": "^5.0.0",
+ "cors": "^2.8.5",
+ "dotenv": "^17.4.2",
+ "express": "^5.1.0",
+ "lightweight-charts": "^5.2.0",
+ "lucide-react": "^1.14.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "zod": "^4.1.5"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.3",
+ "@types/node": "^24.3.0",
+ "@types/react": "^19.1.12",
+ "@types/react-dom": "^19.1.9",
+ "concurrently": "^9.2.1",
+ "tsx": "^4.20.5",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.3",
+ "vitest": "^3.2.4"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
+ "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz",
+ "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz",
+ "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz",
+ "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz",
+ "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz",
+ "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz",
+ "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz",
+ "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz",
+ "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz",
+ "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz",
+ "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz",
+ "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz",
+ "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz",
+ "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz",
+ "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz",
+ "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz",
+ "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz",
+ "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz",
+ "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz",
+ "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz",
+ "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz",
+ "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz",
+ "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz",
+ "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz",
+ "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/cors": {
+ "version": "2.8.19",
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+ "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.12.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.3.tgz",
+ "integrity": "sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
+ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
+ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.4",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
+ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
+ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.4",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
+ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
+ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
+ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.4",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.29",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
+ "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001792",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
+ "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chalk/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concurrently": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
+ "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "4.1.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.8.3",
+ "supports-color": "8.1.1",
+ "tree-kill": "1.2.2",
+ "yargs": "17.7.2"
+ },
+ "bin": {
+ "conc": "dist/bin/concurrently.js",
+ "concurrently": "dist/bin/concurrently.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.353",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz",
+ "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==",
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fancy-canvas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
+ "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
+ "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightweight-charts": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz",
+ "integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "fancy-canvas": "2.1.0"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
+ "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "license": "MIT"
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
+ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.6",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
+ "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.6"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "devOptional": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
+ "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.3",
+ "@rollup/rollup-android-arm64": "4.60.3",
+ "@rollup/rollup-darwin-arm64": "4.60.3",
+ "@rollup/rollup-darwin-x64": "4.60.3",
+ "@rollup/rollup-freebsd-arm64": "4.60.3",
+ "@rollup/rollup-freebsd-x64": "4.60.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.3",
+ "@rollup/rollup-linux-arm64-musl": "4.60.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.3",
+ "@rollup/rollup-linux-loong64-musl": "4.60.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-gnu": "4.60.3",
+ "@rollup/rollup-linux-x64-musl": "4.60.3",
+ "@rollup/rollup-openbsd-x64": "4.60.3",
+ "@rollup/rollup-openharmony-arm64": "4.60.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.3",
+ "@rollup/rollup-win32-x64-gnu": "4.60.3",
+ "@rollup/rollup-win32-x64-msvc": "4.60.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
+ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.4",
+ "@vitest/mocker": "3.2.4",
+ "@vitest/pretty-format": "^3.2.4",
+ "@vitest/runner": "3.2.4",
+ "@vitest/snapshot": "3.2.4",
+ "@vitest/spy": "3.2.4",
+ "@vitest/utils": "3.2.4",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.4",
+ "@vitest/ui": "3.2.4",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..d055849
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "personal-stock-agent",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
+ "dev:server": "tsx watch src/server/index.ts",
+ "dev:client": "vite",
+ "test": "vitest run",
+ "build": "tsc --noEmit && vite build"
+ },
+ "dependencies": {
+ "@vitejs/plugin-react": "^5.0.0",
+ "cors": "^2.8.5",
+ "dotenv": "^17.4.2",
+ "express": "^5.1.0",
+ "lightweight-charts": "^5.2.0",
+ "lucide-react": "^1.14.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "zod": "^4.1.5"
+ },
+ "devDependencies": {
+ "@types/cors": "^2.8.19",
+ "@types/express": "^5.0.3",
+ "@types/node": "^24.3.0",
+ "@types/react": "^19.1.12",
+ "@types/react-dom": "^19.1.9",
+ "concurrently": "^9.2.1",
+ "tsx": "^4.20.5",
+ "typescript": "^5.9.2",
+ "vite": "^7.1.3",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/web/scripts/start-server.sh b/web/scripts/start-server.sh
new file mode 100755
index 0000000..77687f8
--- /dev/null
+++ b/web/scripts/start-server.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${ROOT_DIR}"
+
+export PATH="/home/gahow/.local/bin:/home/gahow/.nvm/versions/node/v24.13.1/bin:$PATH"
+export HOST="127.0.0.1"
+export PORT="${PORT:-8787}"
+# Node's built-in fetch does not read HTTPS_PROXY unless this opt-in is enabled.
+# The configured Nasdaq/Yahoo data sources require the host's local proxy.
+export NODE_USE_ENV_PROXY="${NODE_USE_ENV_PROXY:-1}"
+
+exec npx tsx src/server/index.ts
diff --git a/web/scripts/start-vite.sh b/web/scripts/start-vite.sh
new file mode 100755
index 0000000..8e72090
--- /dev/null
+++ b/web/scripts/start-vite.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${ROOT_DIR}"
+
+export PATH="/home/gahow/.nvm/versions/node/v24.13.1/bin:$PATH"
+
+# The public HTTPS entrypoint is nginx on :5443. Keep Vite as a localhost-only
+# upstream so the development server is not exposed as a second LAN entrypoint.
+exec npx vite --host 127.0.0.1 --port 5174
diff --git a/web/src/agent/indicators.ts b/web/src/agent/indicators.ts
new file mode 100644
index 0000000..16dac49
--- /dev/null
+++ b/web/src/agent/indicators.ts
@@ -0,0 +1,119 @@
+import type { IndicatorPoint, PriceCandle } from "../shared/types";
+
+export function calculateIndicators(candles: PriceCandle[]): IndicatorPoint[] {
+ const closes = candles.map((candle) => candle.close);
+ const ema12 = ema(closes, 12);
+ const ema26 = ema(closes, 26);
+ const macdLine = closes.map((_, index) => valueOrUndefined(ema12[index], ema26[index], (fast, slow) => fast - slow));
+ const signalLine = ema(macdLine, 9);
+ const rsi = rsi14(closes);
+
+ return candles.map((candle, index) => {
+ const macd = macdLine[index];
+ const macdSignal = signalLine[index];
+ return {
+ date: candle.date,
+ ma5: movingAverage(closes, index, 5),
+ ma20: movingAverage(closes, index, 20),
+ ma60: movingAverage(closes, index, 60),
+ ma120: movingAverage(closes, index, 120),
+ ma200: movingAverage(closes, index, 200),
+ rsi14: rsi[index],
+ macd,
+ macdSignal,
+ macdHistogram: valueOrUndefined(macd, macdSignal, (line, signal) => line - signal)
+ };
+ });
+}
+
+function movingAverage(values: number[], index: number, window: number): number | undefined {
+ if (index + 1 < window) {
+ return undefined;
+ }
+ const slice = values.slice(index + 1 - window, index + 1);
+ return round(slice.reduce((sum, value) => sum + value, 0) / window, 4);
+}
+
+function ema(values: Array, window: number): Array {
+ const result: Array = [];
+ const multiplier = 2 / (window + 1);
+ let previous: number | undefined;
+ let seedValues: number[] = [];
+
+ for (const value of values) {
+ if (value === undefined || !Number.isFinite(value)) {
+ result.push(undefined);
+ continue;
+ }
+
+ if (previous === undefined) {
+ seedValues.push(value);
+ if (seedValues.length < window) {
+ result.push(undefined);
+ continue;
+ }
+ previous = seedValues.reduce((sum, item) => sum + item, 0) / window;
+ result.push(round(previous, 4));
+ continue;
+ }
+
+ previous = value * multiplier + previous * (1 - multiplier);
+ result.push(round(previous, 4));
+ }
+
+ return result;
+}
+
+function rsi14(closes: number[]): Array {
+ const result: Array = [undefined];
+ let gains = 0;
+ let losses = 0;
+
+ for (let index = 1; index < closes.length; index += 1) {
+ const change = closes[index] - closes[index - 1];
+ const gain = Math.max(0, change);
+ const loss = Math.max(0, -change);
+
+ if (index <= 14) {
+ gains += gain;
+ losses += loss;
+ if (index === 14) {
+ gains /= 14;
+ losses /= 14;
+ result.push(rsiValue(gains, losses));
+ } else {
+ result.push(undefined);
+ }
+ continue;
+ }
+
+ gains = (gains * 13 + gain) / 14;
+ losses = (losses * 13 + loss) / 14;
+ result.push(rsiValue(gains, losses));
+ }
+
+ return result;
+}
+
+function rsiValue(gain: number, loss: number): number {
+ if (loss === 0) {
+ return 100;
+ }
+ return round(100 - 100 / (1 + gain / loss), 4);
+}
+
+function valueOrUndefined(
+ left: number | undefined,
+ right: number | undefined,
+ calculate: (left: number, right: number) => number
+): number | undefined {
+ if (left === undefined || right === undefined) {
+ return undefined;
+ }
+ return round(calculate(left, right), 4);
+}
+
+function round(value: number, digits: number): number {
+ const factor = 10 ** digits;
+ return Math.round(value * factor) / factor;
+}
diff --git a/web/src/agent/provider.ts b/web/src/agent/provider.ts
new file mode 100644
index 0000000..2ea8939
--- /dev/null
+++ b/web/src/agent/provider.ts
@@ -0,0 +1,1240 @@
+import { z } from "zod";
+import type {
+ FinancialStatement,
+ MarketDataProvider,
+ PriceCandle,
+ QuantAnalysis,
+ QuantAnalysisInput,
+ RelatedNews,
+ RelatedStockData,
+ StockSnapshot,
+ WatchStock
+} from "../shared/types";
+
+type FetchLike = typeof fetch;
+type NasdaqAssetClass = "stocks" | "etf";
+interface NasdaqHistoricalRow {
+ date: string;
+ close: string;
+ volume: string;
+ open: string;
+ high: string;
+ low: string;
+}
+
+interface SinaKlineRow {
+ day: string;
+ open: string;
+ high: string;
+ low: string;
+ close: string;
+ volume: string;
+}
+
+export const IPADS_BASE_URL = "http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1";
+export const IPADS_WIRE_API = "responses";
+export const IPADS_MODEL = "gpt-5.5";
+const NASDAQ_ASSET_CLASSES: NasdaqAssetClass[] = ["stocks", "etf"];
+
+const snapshotSchema = z.object({
+ price: z.number(),
+ currency: z.string().min(1),
+ pe: z.number().optional(),
+ forwardPe: z.number().optional(),
+ revenueGrowth: z.number().optional(),
+ profitGrowth: z.number().optional(),
+ nextEarningsDate: z.string().optional(),
+ peers: z.array(z.object({ display: z.string(), pe: z.number() })).default([]),
+ news: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]),
+ asOf: z.string().optional(),
+ sources: z.array(z.object({ name: z.string(), url: z.string().optional(), asOf: z.string().optional() })).default([])
+});
+
+const financialStatementSchema = z.object({
+ period: z.string(),
+ reportDate: z.string().optional(),
+ revenue: z.number(),
+ netIncome: z.number(),
+ eps: z.number().optional(),
+ grossMargin: z.number().optional(),
+ operatingCashFlow: z.number().optional(),
+ debtToAsset: z.number().optional()
+});
+
+const fundamentalsSchema = z.array(financialStatementSchema).min(1);
+
+const relatedDataSchema = z.object({
+ sector: z.string().min(1).optional(),
+ industryRank: z.number().optional(),
+ beta: z.number().optional(),
+ avgVolume20: z.number().optional(),
+ volumeRatio: z.number().optional(),
+ turnoverRate: z.number().optional(),
+ volatility20: z.number().optional(),
+ drawdownFromHigh: z.number().optional(),
+ relativeStrength60: z.number().optional(),
+ support: z.number().optional(),
+ resistance: z.number().optional(),
+ capitalFlow: z.number().optional(),
+ sentimentScore: z.number().optional(),
+ newsHeat: z.number().optional(),
+ marketCap: z.number().optional(),
+ enterpriseValue: z.number().optional(),
+ sharesOutstanding: z.number().optional(),
+ freeFloat: z.number().optional(),
+ priceToBook: z.number().optional(),
+ priceToSales: z.number().optional(),
+ pegRatio: z.number().optional(),
+ evToEbitda: z.number().optional(),
+ dividendYield: z.number().optional(),
+ freeCashFlowYield: z.number().optional(),
+ grossMarginTtm: z.number().optional(),
+ netMarginTtm: z.number().optional(),
+ roeTtm: z.number().optional(),
+ roaTtm: z.number().optional(),
+ debtToEquity: z.number().optional(),
+ currentRatio: z.number().optional(),
+ revenueCagr3y: z.number().optional(),
+ epsGrowthTtm: z.number().optional(),
+ analystRating: z.string().optional(),
+ analystTargetPrice: z.number().optional(),
+ institutionalOwnership: z.number().optional(),
+ insiderOwnership: z.number().optional(),
+ shortInterestRatio: z.number().optional(),
+ nextExDividendDate: z.string().optional(),
+ indexMembership: z.array(z.string()).default([]),
+ recentNews: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]),
+ socialMentions: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]),
+ socialDiscussionCount: z.number().optional(),
+ webSentimentLabel: z.string().optional(),
+ riskFlags: z.array(z.string()).default([]),
+ asOf: z.string().optional(),
+ sources: z.array(z.object({ name: z.string(), url: z.string().optional(), asOf: z.string().optional() })).default([])
+});
+
+const quantAnalysisSchema = z.object({
+ action: z.enum(["buy_watch", "hold", "avoid"]),
+ score: z.number().min(0).max(100),
+ confidence: z.number().min(0).max(1),
+ horizon: z.string().min(1),
+ summary: z.string().min(1),
+ factors: z
+ .array(
+ z.object({
+ name: z.string().min(1),
+ score: z.number().min(0).max(100),
+ weight: z.number().min(0).max(1),
+ evidence: z.string().min(1)
+ })
+ )
+ .min(3),
+ tradePlan: z
+ .object({
+ direction: z.enum(["long", "wait", "reduce", "avoid"]),
+ entryPrice: z.number().optional(),
+ targetPrice: z.number().optional(),
+ stopLoss: z.number().optional(),
+ expectedReturnPct: z.number().optional(),
+ invalidation: z.string().optional(),
+ rationale: z.string().min(1)
+ })
+ .optional(),
+ marketContext: z.array(z.string()).default([]),
+ riskNotes: z.array(z.string()).default([])
+});
+
+const responsesOutputSchema = z.object({
+ output_text: z.string().optional(),
+ output: z
+ .array(
+ z.object({
+ content: z
+ .array(
+ z.object({
+ type: z.string().optional(),
+ text: z.string().optional()
+ })
+ )
+ .optional()
+ })
+ )
+ .optional()
+});
+
+export class IpadsResponsesProvider implements MarketDataProvider {
+ constructor(
+ private readonly apiKey: string,
+ private readonly fetchImpl: FetchLike = fetch
+ ) {}
+
+ async snapshot(stock: WatchStock): Promise {
+ if (stock.market === "CN") {
+ return fetchSinaChinaSnapshot(stock, this.fetchImpl);
+ }
+ return fetchNasdaqSnapshot(stock, this.fetchImpl);
+ }
+
+ async candles(stock: WatchStock, startDate?: string): Promise {
+ if (stock.market === "CN") {
+ return fetchSinaChinaCandles(stock, this.fetchImpl, startDate);
+ }
+ return fetchNasdaqCandles(stock, this.fetchImpl, startDate);
+ }
+
+ async fundamentals(stock: WatchStock): Promise {
+ return this.requestJson(buildFundamentalsPrompt(stock), fundamentalsSchema, "fundamentals").then((statements) =>
+ statements.sort((left, right) => right.period.localeCompare(left.period))
+ );
+ }
+
+ async relatedData(stock: WatchStock): Promise {
+ if (stock.market === "CN") {
+ return fetchSinaChinaRelatedData(stock, this.fetchImpl);
+ }
+ return fetchNasdaqRelatedData(stock, this.fetchImpl);
+ }
+
+ async quantAnalysis(input: QuantAnalysisInput): Promise {
+ try {
+ return await this.requestJson(buildQuantAnalysisPrompt(input), quantAnalysisSchema, "quant analysis");
+ } catch (error) {
+ return buildRuleBasedQuantAnalysis(input, error instanceof Error ? error.message : "远端 agent 未返回有效结果");
+ }
+ }
+
+ private async requestJson(prompt: string, schema: z.ZodType, context: string): Promise {
+ const response = await this.fetchImpl(`${IPADS_BASE_URL}/${IPADS_WIRE_API}`, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ Authorization: `Bearer ${this.apiKey}`,
+ "Content-Type": "application/json"
+ },
+ signal: AbortSignal.timeout(60_000),
+ body: JSON.stringify({
+ model: IPADS_MODEL,
+ input: prompt
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`IPADS Responses request failed: ${response.status}`);
+ }
+
+ return parseResponsesJson(await response.json(), schema, context);
+ }
+}
+
+function buildSnapshotPrompt(stock: WatchStock): string {
+ return [
+ "Return only a valid JSON object for a stock monitoring snapshot using verified current market, filing, exchange, or reputable financial data.",
+ "Use this exact schema:",
+ '{"price": 100, "currency": "USD", "pe": 20, "forwardPe": 18, "revenueGrowth": 0.08, "profitGrowth": 0.05, "nextEarningsDate": "2026-05-13", "peers": [{"display": "MSFT", "pe": 28}], "news": [{"title": "Earnings preview", "url": "https://example.com/news"}], "asOf": "2026-05-12", "sources": [{"name": "exchange or data vendor", "url": "https://example.com", "asOf": "2026-05-12"}]}',
+ "Do not infer, fabricate, or fill with approximate values. Required numeric fields must be the latest verified values available from sources. Optional fields may be omitted when not verified.",
+ `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}`
+ ].join("\n");
+}
+
+function buildFundamentalsPrompt(stock: WatchStock): string {
+ return [
+ "Return only a valid JSON array of recent financial statements for this stock using verified company filings or reputable financial data providers.",
+ "Use the latest 8 quarterly or semiannual periods available, sorted newest to oldest.",
+ "Use this exact item schema:",
+ '{"period":"2026Q1","reportDate":"2026-04-30","revenue":1000000000,"netIncome":120000000,"eps":1.2,"grossMargin":0.42,"operatingCashFlow":150000000,"debtToAsset":0.34}',
+ "Do not infer, fabricate, annualize, or estimate fields. Optional fields may be omitted when not verified.",
+ `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}`
+ ].join("\n");
+}
+
+function buildRelatedDataPrompt(stock: WatchStock): string {
+ return [
+ "Return only a valid JSON object of verified market, valuation, quality, liquidity, ownership, analyst, and risk context for quantitative stock analysis.",
+ "Use this exact schema:",
+ '{"sector":"Technology","industryRank":12,"beta":1.1,"avgVolume20":12000000,"volumeRatio":1.2,"turnoverRate":0.018,"volatility20":0.026,"drawdownFromHigh":0.12,"relativeStrength60":0.08,"support":98,"resistance":115,"capitalFlow":0.03,"sentimentScore":0.62,"newsHeat":0.55,"marketCap":10000000000,"enterpriseValue":11000000000,"sharesOutstanding":1000000000,"freeFloat":0.82,"priceToBook":4.2,"priceToSales":5.1,"pegRatio":1.6,"evToEbitda":18,"dividendYield":0.012,"freeCashFlowYield":0.025,"grossMarginTtm":0.42,"netMarginTtm":0.18,"roeTtm":0.22,"roaTtm":0.11,"debtToEquity":0.35,"currentRatio":1.8,"revenueCagr3y":0.1,"epsGrowthTtm":0.08,"analystRating":"Buy","analystTargetPrice":115,"institutionalOwnership":0.61,"insiderOwnership":0.04,"shortInterestRatio":1.8,"nextExDividendDate":"2026-06-10","indexMembership":["S&P 500"],"riskFlags":["high valuation"],"asOf":"2026-05-12","sources":[{"name":"verified data source","url":"https://example.com","asOf":"2026-05-12"}]}',
+ "All ratios are decimals, for example 0.12 for 12%. capitalFlow is -1 to 1 if verified. sentimentScore and newsHeat are 0 to 1 only if derived from verified data.",
+ "Do not infer, fabricate, or approximate. Omit optional fields when not verified. Always include riskFlags and sources arrays, even if empty.",
+ `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}`
+ ].join("\n");
+}
+
+function buildQuantAnalysisPrompt(input: QuantAnalysisInput): string {
+ const compactInput = {
+ stock: input.stock,
+ snapshot: input.snapshot,
+ latestCandles: input.candles.slice(-90),
+ latestIndicators: input.indicators.slice(-90),
+ fundamentals: input.fundamentals.slice(0, 8),
+ relatedData: input.relatedData
+ };
+ return [
+ "只返回一个合法 JSON 对象,用中文做标准量化监控分析。这不是个性化投资建议。",
+ "判断当前数据下这只股票是否值得短期买入观察、继续持有观察,或回避/减仓。",
+ "必须使用动量、趋势、波动率、流动性、估值、成长质量、资金/舆情、新闻和社交媒体讨论、风险控制进行综合判断。",
+ "如果输入里有 recentNews、socialMentions、webSentimentLabel、socialDiscussionCount,必须把它们作为市场舆情证据;如果缺失,要在 riskNotes 里说明。",
+ "不要捏造缺失数据。只能基于提供的已验证输入结论;缺失或过期数据必须降低 confidence 或写入 riskNotes。",
+ "所有 summary、factor evidence、riskNotes、marketContext、tradePlan.rationale 和 invalidation 都必须是中文。",
+ "使用这个精确 schema:",
+ '{"action":"buy_watch","score":72,"confidence":0.68,"horizon":"1-3个月","summary":"趋势和成交量改善,但波动较高,适合小仓位分批观察。","factors":[{"name":"趋势动量","score":75,"weight":0.2,"evidence":"收盘价站上MA20和MA60。"}],"tradePlan":{"direction":"long","entryPrice":18.6,"targetPrice":22.3,"stopLoss":17.2,"expectedReturnPct":0.1989,"invalidation":"跌破17.2且成交量放大。","rationale":"目标价取20日压力位、分析师目标价和波动区间的保守值。"},"marketContext":["近7天新闻热度有限,社交讨论不足。"],"riskNotes":["波动率较高,需控制仓位。"]}',
+ "action 必须是 buy_watch、hold、avoid 之一。score 0-100。confidence 0-1。factor weights 加总应接近 1。",
+ "如果 action=buy_watch,tradePlan 必须给出 entryPrice、targetPrice、stopLoss、expectedReturnPct 和开始卖出的价格依据。",
+ "如果 action=avoid,tradePlan.direction 应为 avoid 或 reduce,并给出短期回避/卖出触发价或风险价位。hold 时 direction 用 wait。",
+ `Analysis input: ${JSON.stringify(compactInput)}`
+ ].join("\n");
+}
+
+export class DemoMarketDataProvider implements MarketDataProvider {
+ async snapshot(stock: WatchStock): Promise {
+ const seed = stock.display.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0);
+ const marketCurrency = stock.market === "US" ? "USD" : stock.market === "HK" ? "HKD" : "CNY";
+ const pe = round(10 + (seed % 24), 2);
+ const peerBase = pe + ((seed % 2 === 0 ? 1 : -1) * (4 + (seed % 6)));
+ const nextEarnings = new Date(Date.UTC(2026, 4, 11 + (seed % 21))).toISOString().slice(0, 10);
+
+ return {
+ price: round(20 + (seed % 180) + (seed % 13) / 10, 2),
+ currency: marketCurrency,
+ pe,
+ forwardPe: round(pe * 0.92, 2),
+ revenueGrowth: round(((seed % 18) - 4) / 100, 4),
+ profitGrowth: round(((seed % 16) - 6) / 100, 4),
+ nextEarningsDate: nextEarnings,
+ peers: [
+ { display: peerDisplay(stock, 1), pe: round(peerBase, 2) },
+ { display: peerDisplay(stock, 2), pe: round(peerBase + 2.3, 2) }
+ ],
+ news: [
+ {
+ title: `${stock.display} related sector signal updated`,
+ url: `https://example.local/news/${encodeURIComponent(stock.display)}`
+ }
+ ],
+ asOf: new Date().toISOString().slice(0, 10),
+ sources: [{ name: "demo provider", asOf: new Date().toISOString().slice(0, 10) }]
+ };
+ }
+
+ async candles(stock: WatchStock, startDate?: string): Promise {
+ const seed = symbolSeed(stock);
+ const candles: PriceCandle[] = [];
+ const end = new Date();
+ const start = startDate ? new Date(`${startDate}T00:00:00.000Z`) : new Date(Date.UTC(end.getUTCFullYear() - 10, end.getUTCMonth(), end.getUTCDate()));
+ let close = 26 + (seed % 160);
+
+ for (let index = 0; candles.length < (startDate ? 12 : 2520); index += 1) {
+ const date = new Date(start.getTime() + index * 24 * 60 * 60 * 1000);
+ const day = date.getUTCDay();
+ if (day === 0 || day === 6) {
+ continue;
+ }
+
+ const wave = Math.sin((index + seed) / 7) * 1.9 + Math.cos((index + seed) / 19) * 1.1;
+ const drift = ((seed % 11) - 4) * 0.015;
+ const open = close + Math.sin((index + seed) / 5) * 0.9;
+ close = Math.max(2, open + wave * 0.55 + drift);
+ const high = Math.max(open, close) + 0.7 + Math.abs(Math.sin(index / 3)) * 1.4;
+ const low = Math.max(1, Math.min(open, close) - 0.7 - Math.abs(Math.cos(index / 4)) * 1.2);
+ const volumeBase = 1_200_000 + (seed % 80) * 45_000;
+ const volume = Math.round(volumeBase * (1 + Math.abs(wave) / 4 + (index % 9) / 18));
+
+ candles.push({
+ date: date.toISOString().slice(0, 10),
+ open: round(open, 2),
+ high: round(high, 2),
+ low: round(low, 2),
+ close: round(close, 2),
+ volume
+ });
+ }
+
+ return candles;
+ }
+
+ async fundamentals(stock: WatchStock): Promise {
+ const seed = symbolSeed(stock);
+ const baseRevenue = 1_000_000_000 + (seed % 900) * 4_500_000;
+ return Array.from({ length: 8 }, (_, index) => {
+ const quarter = 1 + ((4 - (index % 4)) % 4);
+ const year = 2026 - Math.floor((index + (quarter === 4 ? 0 : 1)) / 4);
+ const growth = 1 - index * 0.035 + Math.sin((seed + index) / 3) * 0.025;
+ const margin = 0.12 + (seed % 18) / 100 + Math.sin(index / 2) * 0.018;
+ const revenue = Math.round(baseRevenue * growth);
+ const netIncome = Math.round(revenue * margin);
+
+ return {
+ period: `${year}Q${quarter}`,
+ reportDate: `${year}-${String(quarter * 3).padStart(2, "0")}-28`,
+ revenue,
+ netIncome,
+ eps: round(netIncome / (650_000_000 + (seed % 40) * 10_000_000), 2),
+ grossMargin: round(0.34 + (seed % 16) / 100 + Math.sin(index) * 0.015, 4),
+ operatingCashFlow: Math.round(netIncome * (1.05 + (index % 3) * 0.08)),
+ debtToAsset: round(0.24 + (seed % 24) / 100, 4)
+ };
+ });
+ }
+
+ async relatedData(stock: WatchStock): Promise {
+ const seed = symbolSeed(stock);
+ const snapshot = await this.snapshot(stock);
+ const candles = await this.candles(stock);
+ const recent = candles.slice(-60);
+ const high60 = Math.max(...recent.map((candle) => candle.high));
+ const low20 = Math.min(...recent.slice(-20).map((candle) => candle.low));
+ const high20 = Math.max(...recent.slice(-20).map((candle) => candle.high));
+ const last = candles[candles.length - 1];
+ const first60 = recent[0];
+ const avgVolume20 = Math.round(candles.slice(-20).reduce((sum, candle) => sum + candle.volume, 0) / 20);
+
+ return {
+ sector: stock.market === "US" ? "Technology" : stock.market === "HK" ? "Internet & Consumer" : "Large-cap Consumption",
+ industryRank: 1 + (seed % 30),
+ beta: round(0.75 + (seed % 70) / 100, 2),
+ avgVolume20,
+ volumeRatio: round(last.volume / avgVolume20, 2),
+ turnoverRate: round(0.006 + (seed % 34) / 1000, 4),
+ volatility20: round(0.018 + (seed % 28) / 1000, 4),
+ drawdownFromHigh: round((high60 - last.close) / high60, 4),
+ relativeStrength60: round((last.close - first60.close) / first60.close - ((seed % 9) - 3) / 100, 4),
+ support: round(low20, 2),
+ resistance: round(high20, 2),
+ capitalFlow: round(((seed % 41) - 20) / 100, 4),
+ sentimentScore: round(0.42 + (seed % 36) / 100, 4),
+ newsHeat: round(snapshot.news.length ? 0.52 + (seed % 24) / 100 : 0.22 + (seed % 22) / 100, 4),
+ riskFlags: seed % 5 === 0 ? ["成交量放大但趋势未确认"] : seed % 7 === 0 ? ["估值波动需要等待财报确认"] : [],
+ marketCap: Math.round(snapshot.price * (700_000_000 + (seed % 80) * 12_000_000)),
+ enterpriseValue: Math.round(snapshot.price * (760_000_000 + (seed % 95) * 14_000_000)),
+ sharesOutstanding: 700_000_000 + (seed % 80) * 12_000_000,
+ freeFloat: round(0.55 + (seed % 35) / 100, 4),
+ priceToBook: round(1.2 + (seed % 42) / 10, 2),
+ priceToSales: round(0.8 + (seed % 50) / 10, 2),
+ pegRatio: round(0.7 + (seed % 32) / 10, 2),
+ evToEbitda: round(7 + (seed % 28), 2),
+ dividendYield: round((seed % 45) / 1000, 4),
+ freeCashFlowYield: round(0.015 + (seed % 50) / 1000, 4),
+ grossMarginTtm: round(0.32 + (seed % 28) / 100, 4),
+ netMarginTtm: round(0.08 + (seed % 20) / 100, 4),
+ roeTtm: round(0.06 + (seed % 25) / 100, 4),
+ roaTtm: round(0.03 + (seed % 13) / 100, 4),
+ debtToEquity: round(0.15 + (seed % 80) / 100, 4),
+ currentRatio: round(0.9 + (seed % 22) / 10, 2),
+ revenueCagr3y: round(((seed % 24) - 4) / 100, 4),
+ epsGrowthTtm: round(((seed % 30) - 8) / 100, 4),
+ analystRating: seed % 3 === 0 ? "Buy" : seed % 3 === 1 ? "Hold" : "Neutral",
+ analystTargetPrice: round(last.close * (1.04 + (seed % 18) / 100), 2),
+ institutionalOwnership: round(0.22 + (seed % 65) / 100, 4),
+ insiderOwnership: round((seed % 18) / 100, 4),
+ shortInterestRatio: round(0.5 + (seed % 45) / 10, 2),
+ indexMembership: stock.market === "US" ? ["Demo large-cap universe"] : [],
+ recentNews: snapshot.news,
+ socialMentions: [],
+ socialDiscussionCount: 0,
+ webSentimentLabel: snapshot.news.length ? "中性" : undefined,
+ asOf: new Date().toISOString().slice(0, 10),
+ sources: [{ name: "demo provider", asOf: new Date().toISOString().slice(0, 10) }]
+ };
+ }
+
+ async quantAnalysis(input: QuantAnalysisInput): Promise {
+ return buildRuleBasedQuantAnalysis(input);
+ }
+}
+
+export function createProvider(apiKey: string): MarketDataProvider {
+ if (apiKey.trim()) {
+ return new IpadsResponsesProvider(apiKey.trim());
+ }
+ return new DemoMarketDataProvider();
+}
+
+function parseResponsesJson(payload: unknown, schema: z.ZodType, context: string): T {
+ const response = responsesOutputSchema.parse(payload);
+ const text = response.output_text ?? response.output?.flatMap((item) => item.content ?? []).find((content) => content.text)?.text;
+ if (!text) {
+ throw new Error(`IPADS Responses payload did not include output text for ${context}`);
+ }
+ return schema.parse(JSON.parse(stripJsonFence(text)));
+}
+
+function buildRuleBasedQuantAnalysis(input: QuantAnalysisInput, fallbackReason?: string): QuantAnalysis {
+ const fallbackPrice = positiveNumber(input.snapshot.price);
+ const last = input.candles[input.candles.length - 1] ?? {
+ date: "",
+ open: fallbackPrice,
+ high: fallbackPrice,
+ low: fallbackPrice,
+ close: fallbackPrice,
+ volume: 0
+ };
+ const latest = input.indicators[input.indicators.length - 1] ?? { date: last.date };
+ const previous = input.candles[Math.max(0, input.candles.length - 21)] ?? last;
+ const volumeRatio = input.relatedData.volumeRatio ?? 1;
+ const volatility20 = input.relatedData.volatility20 ?? estimateVolatility(input.candles.slice(-21)) ?? 0.03;
+ const sentimentScore = input.relatedData.sentimentScore ?? scoreSentimentLabel(input.relatedData.webSentimentLabel);
+ const capitalFlow = input.relatedData.capitalFlow ?? 0;
+ const relativeStrength60 = input.relatedData.relativeStrength60 ?? 0;
+ const drawdownFromHigh = input.relatedData.drawdownFromHigh ?? 0.15;
+ const newsHeat = input.relatedData.newsHeat ?? Math.min(1, ((input.relatedData.recentNews?.length ?? 0) + (input.relatedData.socialMentions?.length ?? 0)) / 12);
+ const recentReturn = safeReturn(last.close, previous.close);
+ const momentum = clampScore(50 + (recentReturn ?? 0) * 220 + (latest.ma20 && last.close > latest.ma20 ? 12 : input.candles.length ? -8 : 0));
+ const pe = input.snapshot.pe;
+ const valuation = isFiniteNumber(pe)
+ ? clampScore(62 - Math.max(0, pe - 18) * 1.4 + (input.snapshot.forwardPe && input.snapshot.forwardPe < pe ? 8 : 0))
+ : input.relatedData.analystTargetPrice && last.close > 0
+ ? clampScore(50 + (safeReturn(input.relatedData.analystTargetPrice, last.close, 0) ?? 0) * 80)
+ : 45;
+ const growth = clampScore(50 + (input.snapshot.revenueGrowth ?? 0) * 260 + (input.snapshot.profitGrowth ?? 0) * 320);
+ const liquidity = clampScore(55 + (volumeRatio - 1) * 18 - volatility20 * 220);
+ const sentiment = clampScore(40 + sentimentScore * 35 + capitalFlow * 18 + relativeStrength60 * 120 + newsHeat * 8);
+ const riskPenalty = drawdownFromHigh * 70 + input.relatedData.riskFlags.length * 6 + (input.candles.length < 252 ? 7 : 0);
+ const score = clampScore(momentum * 0.24 + valuation * 0.2 + growth * 0.22 + liquidity * 0.16 + sentiment * 0.18 - riskPenalty);
+ const action = score >= 68 ? "buy_watch" : score <= 42 ? "avoid" : "hold";
+ const tradePlan = buildTradePlan(action, last.close, input.relatedData, volatility20);
+ const marketContext = [
+ input.relatedData.webSentimentLabel ? `网页/社交舆情标签:${input.relatedData.webSentimentLabel}` : "网页/社交舆情数据不足,未作为强信号。",
+ `近端新闻 ${input.relatedData.recentNews?.length ?? 0} 条,社交讨论 ${input.relatedData.socialDiscussionCount ?? input.relatedData.socialMentions?.length ?? 0} 条。`,
+ input.relatedData.analystTargetPrice ? `分析师目标价 ${input.relatedData.analystTargetPrice}。` : "缺少可验证分析师目标价。"
+ ];
+
+ return {
+ action,
+ score: round(score, 0),
+ confidence: round(Math.min(0.86, 0.48 + Math.abs(score - 50) / 100 - (fallbackReason ? 0.08 : 0)), 2),
+ horizon: "1-3个月",
+ summary:
+ action === "buy_watch"
+ ? `趋势、估值/目标价或舆情组合达到买入观察阈值;参考卖出价 ${formatPrice(tradePlan.targetPrice)},预估收益 ${formatPct(tradePlan.expectedReturnPct)}。`
+ : action === "avoid"
+ ? `量化信号偏弱或风险惩罚较高,短期应回避/降低仓位;跌破 ${formatPrice(tradePlan.stopLoss)} 后风险进一步放大。`
+ : `优势信号不足,维持观察;只有重新站上 ${formatPrice(tradePlan.targetPrice)} 附近并放量才考虑提高权重。`,
+ factors: [
+ { name: "趋势动量", score: round(momentum, 0), weight: 0.24, evidence: latest.ma20 ? `收盘价${last.close > latest.ma20 ? "高于" : "低于"}MA20,60日相对强度 ${formatPct(relativeStrength60)}。` : "均线数据不足。" },
+ { name: "估值与目标价", score: round(valuation, 0), weight: 0.2, evidence: `PE ${input.snapshot.pe ?? "-"},Forward PE ${input.snapshot.forwardPe ?? "-"},分析师目标价 ${input.relatedData.analystTargetPrice ?? "-"}。` },
+ { name: "成长质量", score: round(growth, 0), weight: 0.22, evidence: `收入增速 ${formatPct(input.snapshot.revenueGrowth)},利润增速 ${formatPct(input.snapshot.profitGrowth)},财报期数 ${input.fundamentals.length}。` },
+ { name: "流动性与波动", score: round(liquidity, 0), weight: 0.16, evidence: `量比 ${volumeRatio},20日波动 ${formatPct(volatility20)}。` },
+ { name: "资金与舆情", score: round(sentiment, 0), weight: 0.18, evidence: `资金流 ${capitalFlow},舆情分 ${sentimentScore},新闻热度 ${newsHeat}。` }
+ ],
+ tradePlan,
+ marketContext,
+ riskNotes: [
+ ...(fallbackReason ? [`远端 agent 未返回有效分析,已使用本地量化规则兜底:${fallbackReason}`] : []),
+ input.candles.length === 0
+ ? "数据源未返回历史K线,技术面已忽略。"
+ : input.candles.length < 2000
+ ? `K线真实历史只有 ${input.candles.length} 根,可能是上市时间较短或数据源覆盖有限。`
+ : "",
+ input.fundamentals.length ? "" : "缺少可验证财报数据,基本面权重已降低。",
+ `距离60日高点回撤 ${formatPct(drawdownFromHigh)}。`,
+ ...input.relatedData.riskFlags,
+ "该结论是量化监控输出,不构成个性化投资建议。"
+ ].filter(Boolean)
+ };
+}
+
+function buildTradePlan(action: QuantAnalysis["action"], close: number, relatedData: RelatedStockData, volatility20: number): NonNullable {
+ if (!isFiniteNumber(close) || close <= 0) {
+ return {
+ direction: action === "avoid" ? "avoid" : "wait",
+ invalidation: "数据源未返回有效价格,无法生成价格触发条件。",
+ rationale: "当前缺少有效价格和历史K线,交易计划只保留观察结论;需要等待行情源恢复后再计算参考买入、卖出和止损价。"
+ };
+ }
+ const support = relatedData.support && relatedData.support > 0 ? relatedData.support : close * (1 - Math.max(0.04, volatility20 * 2.2));
+ const resistance = relatedData.resistance && relatedData.resistance > close ? relatedData.resistance : close * (1 + Math.max(0.06, volatility20 * 2.8));
+ const analystTarget = relatedData.analystTargetPrice && relatedData.analystTargetPrice > close ? relatedData.analystTargetPrice : undefined;
+ const targetPrice = round(Math.max(resistance, analystTarget ? Math.min(analystTarget, close * 1.35) : resistance), 2);
+ const stopLoss = round(Math.min(support, close * (1 - Math.max(0.035, volatility20 * 1.7))), 2);
+ const expectedReturnPct = round((targetPrice - close) / close, 4);
+ if (action === "buy_watch") {
+ return {
+ direction: "long",
+ entryPrice: round(close, 2),
+ targetPrice,
+ stopLoss,
+ expectedReturnPct,
+ invalidation: `跌破 ${stopLoss} 且无法快速收回,或放量跌破关键支撑。`,
+ rationale: `买入后优先参考 ${targetPrice} 附近开始分批止盈;该价位综合了近期压力位、分析师目标价和当前波动区间。`
+ };
+ }
+ if (action === "avoid") {
+ return {
+ direction: "avoid",
+ entryPrice: round(close, 2),
+ targetPrice: round(resistance, 2),
+ stopLoss,
+ expectedReturnPct,
+ invalidation: `重新站上 ${round(resistance, 2)} 且量能改善前不提高仓位。`,
+ rationale: `当前风险收益比不足,短期更适合等待趋势修复;已有仓位可把 ${stopLoss} 作为风险控制参考。`
+ };
+ }
+ return {
+ direction: "wait",
+ entryPrice: round(close, 2),
+ targetPrice: round(resistance, 2),
+ stopLoss,
+ expectedReturnPct: round((resistance - close) / close, 4),
+ invalidation: `跌破 ${stopLoss} 表示观察假设失效。`,
+ rationale: `当前不是明确买点,等待突破 ${round(resistance, 2)} 或回踩支撑企稳后再评估。`
+ };
+}
+
+function estimateVolatility(candles: PriceCandle[]): number | undefined {
+ if (candles.length < 3) {
+ return undefined;
+ }
+ const returns = candles.slice(1).map((candle, index) => (candle.close - candles[index].close) / candles[index].close);
+ const average = returns.reduce((sum, value) => sum + value, 0) / returns.length;
+ const variance = returns.reduce((sum, value) => sum + (value - average) ** 2, 0) / returns.length;
+ return round(Math.sqrt(variance), 4);
+}
+
+function safeReturn(current: number | undefined, previous: number | undefined, fallback?: number): number | undefined {
+ if (!isFiniteNumber(current) || !isFiniteNumber(previous) || previous <= 0) {
+ return fallback;
+ }
+ return (current - previous) / previous;
+}
+
+function positiveNumber(value: number | undefined): number {
+ return isFiniteNumber(value) && value > 0 ? value : 0;
+}
+
+function scoreSentimentLabel(label: string | undefined): number {
+ if (!label) {
+ return 0.5;
+ }
+ const normalized = label.toLowerCase();
+ if (normalized.includes("bull") || normalized.includes("正") || normalized.includes("积极")) {
+ return 0.68;
+ }
+ if (normalized.includes("bear") || normalized.includes("负") || normalized.includes("谨慎")) {
+ return 0.35;
+ }
+ return 0.5;
+}
+
+async function fetchNasdaqCandles(stock: WatchStock, fetchImpl: FetchLike, startDate?: string): Promise {
+ if (stock.market !== "US") {
+ throw new Error(`Nasdaq historical candle source currently supports US symbols only: ${stock.display}`);
+ }
+ const to = new Date().toISOString().slice(0, 10);
+ const from = startDate ?? new Date(Date.UTC(new Date().getUTCFullYear() - 10, new Date().getUTCMonth(), new Date().getUTCDate())).toISOString().slice(0, 10);
+ for (const assetClass of NASDAQ_ASSET_CLASSES) {
+ const payload = nasdaqHistoricalSchema.parse(
+ await fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/historical?assetclass=${assetClass}&fromdate=${from}&todate=${to}&limit=9999`)
+ );
+ const rows = payload.data?.tradesTable?.rows ?? [];
+ const candles = rows.map(parseNasdaqRow).filter((candle): candle is PriceCandle => candle !== undefined);
+ if (candles.length) {
+ return candles.sort((left, right) => left.date.localeCompare(right.date));
+ }
+ }
+ return [];
+}
+
+async function fetchNasdaqSnapshot(stock: WatchStock, fetchImpl: FetchLike): Promise {
+ if (stock.market !== "US") {
+ throw new Error(`Nasdaq snapshot source currently supports US symbols only: ${stock.display}`);
+ }
+ for (const assetClass of NASDAQ_ASSET_CLASSES) {
+ const [info, summary] = await Promise.all([
+ fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/info?assetclass=${assetClass}`),
+ fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/summary?assetclass=${assetClass}`)
+ ]);
+ const parsedInfo = nasdaqInfoSchema.parse(info);
+ const parsedSummary = nasdaqSummarySchema.parse(summary);
+ const price = parseNasdaqNumber(parsedInfo.data?.primaryData?.lastSalePrice ?? "");
+ if (!isFiniteNumber(price) || price <= 0) {
+ continue;
+ }
+ const path = assetClass === "etf" ? "etf" : "stocks";
+ return {
+ price,
+ currency: "USD",
+ forwardPe: undefined,
+ peers: [],
+ news: (parsedInfo.data?.notifications ?? []).slice(0, 5).map((notification) => ({
+ title: notification.headline,
+ url: `https://www.nasdaq.com/market-activity/${path}/` + encodeURIComponent(stock.symbol.toLowerCase()),
+ source: "Nasdaq"
+ })),
+ asOf: parsedInfo.data?.primaryData?.lastTradeTimestamp ?? new Date().toISOString(),
+ sources: [
+ { name: `Nasdaq ${assetClass} quote info`, url: "https://api.nasdaq.com/api/quote/info", asOf: parsedInfo.data?.primaryData?.lastTradeTimestamp },
+ { name: `Nasdaq ${assetClass} summary`, url: "https://api.nasdaq.com/api/quote/summary", asOf: new Date().toISOString().slice(0, 10) }
+ ]
+ };
+ }
+ throw new Error(`Nasdaq snapshot did not include a valid positive price for ${stock.display}`);
+}
+
+async function fetchNasdaqRelatedData(stock: WatchStock, fetchImpl: FetchLike): Promise {
+ if (stock.market !== "US") {
+ throw new Error(`Nasdaq research data source currently supports US symbols only: ${stock.display}`);
+ }
+ const summaryPayload = await fetchNasdaqSummaryWithAssetClass(stock, fetchImpl);
+ const [summary, analyst, holdings, recentNews, stocktwitsMentions, redditMentions] = await Promise.all([
+ Promise.resolve(summaryPayload.payload),
+ fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/analyst/${encodeURIComponent(stock.symbol)}/targetprice`).catch(() => undefined),
+ fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/company/${encodeURIComponent(stock.symbol)}/institutional-holdings`).catch(() => undefined),
+ fetchYahooFinanceNews(stock, fetchImpl).catch(() => []),
+ fetchStocktwitsMentions(stock, fetchImpl).catch(() => []),
+ fetchRedditMentions(stock, fetchImpl).catch(() => [])
+ ]);
+ const summaryData = nasdaqSummarySchema.parse(summary).data?.summaryData ?? {};
+ const analystData = analyst ? nasdaqAnalystSchema.parse(analyst).data?.consensusOverview : undefined;
+ const holdingsData = holdings ? nasdaqHoldingsSchema.parse(holdings).data?.ownershipSummary : undefined;
+ const highLow = parseRange(summaryData.FiftTwoWeekHighLow?.value);
+ const todayRange = parseRange(summaryData.TodayHighLow?.value);
+ const targetPrice = parseOptionalNumber(summaryData.OneYrTarget?.value) ?? analystData?.priceTarget;
+ const shareVolume = parseOptionalNumber(summaryData.ShareVolume?.value);
+ const avgVolume = parseOptionalNumber(summaryData.AverageVolume?.value);
+
+ const socialMentions = [...stocktwitsMentions, ...redditMentions].slice(0, 12);
+ const webSentimentLabel = inferWebSentimentLabel(stocktwitsMentions, recentNews);
+
+ return {
+ sector: stringifyOptional(summaryData.Sector?.value),
+ avgVolume20: avgVolume,
+ volumeRatio: shareVolume && avgVolume ? round(shareVolume / avgVolume, 4) : undefined,
+ support: todayRange?.low,
+ resistance: todayRange?.high,
+ marketCap: parseOptionalNumber(summaryData.MarketCap?.value),
+ analystRating: analystConsensusLabel(analystData),
+ analystTargetPrice: targetPrice,
+ institutionalOwnership: parsePercent(holdingsData?.SharesOutstandingPCT?.value),
+ sharesOutstanding: parseSharesOutstanding(holdingsData?.ShareoutstandingTotal?.value),
+ drawdownFromHigh: calculateDrawdown(highLow?.high, parseOptionalNumber(summaryData.PreviousClose?.value)),
+ recentNews: recentNews.slice(0, 10),
+ socialMentions,
+ socialDiscussionCount: socialMentions.length,
+ webSentimentLabel,
+ riskFlags: [],
+ sources: [
+ { name: `Nasdaq ${summaryPayload.assetClass} summary`, url: "https://api.nasdaq.com/api/quote/summary", asOf: new Date().toISOString().slice(0, 10) },
+ { name: "Nasdaq analyst targetprice", url: "https://api.nasdaq.com/api/analyst/targetprice", asOf: new Date().toISOString().slice(0, 10) },
+ { name: "Nasdaq institutional holdings", url: "https://api.nasdaq.com/api/company/institutional-holdings", asOf: new Date().toISOString().slice(0, 10) },
+ { name: "Yahoo Finance RSS", url: "https://feeds.finance.yahoo.com/rss/2.0/headline", asOf: new Date().toISOString().slice(0, 10) },
+ { name: "Stocktwits public stream", url: "https://api.stocktwits.com/api/2/streams/symbol", asOf: new Date().toISOString().slice(0, 10) },
+ { name: "Reddit public search", url: "https://www.reddit.com/search.json", asOf: new Date().toISOString().slice(0, 10) }
+ ],
+ asOf: new Date().toISOString().slice(0, 10)
+ };
+}
+
+async function fetchNasdaqSummaryWithAssetClass(stock: WatchStock, fetchImpl: FetchLike): Promise<{ assetClass: NasdaqAssetClass; payload: unknown }> {
+ let fallback: { assetClass: NasdaqAssetClass; payload: unknown } | undefined;
+ for (const assetClass of NASDAQ_ASSET_CLASSES) {
+ const payload = await fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/summary?assetclass=${assetClass}`);
+ const summaryData = nasdaqSummarySchema.parse(payload).data?.summaryData ?? {};
+ fallback ??= { assetClass, payload };
+ if (Object.keys(summaryData).length) {
+ return { assetClass, payload };
+ }
+ }
+ return fallback ?? { assetClass: "stocks", payload: {} };
+}
+
+async function fetchSinaChinaSnapshot(stock: WatchStock, fetchImpl: FetchLike): Promise {
+ const quote = await fetchSinaChinaQuote(stock, fetchImpl);
+ return {
+ price: quote.price,
+ currency: "CNY",
+ peers: [],
+ news: [],
+ asOf: quote.asOf,
+ sources: [{ name: "Sina CN quote", url: "https://hq.sinajs.cn/list=", asOf: quote.asOf }]
+ };
+}
+
+async function fetchSinaChinaCandles(stock: WatchStock, fetchImpl: FetchLike, startDate?: string): Promise {
+ const symbol = sinaChinaSymbol(stock);
+ const text = await fetchText(
+ fetchImpl,
+ `https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol=${symbol}&scale=240&ma=no&datalen=2600`
+ );
+ const rows = JSON.parse(text) as SinaKlineRow[] | null;
+ const start = startDate ? new Date(`${startDate}T00:00:00.000Z`).getTime() : Date.UTC(new Date().getUTCFullYear() - 10, new Date().getUTCMonth(), new Date().getUTCDate());
+ return (rows ?? [])
+ .map(parseSinaKlineRow)
+ .filter((candle): candle is PriceCandle => candle !== undefined)
+ .filter((candle) => new Date(`${candle.date}T00:00:00.000Z`).getTime() >= start)
+ .sort((left, right) => left.date.localeCompare(right.date));
+}
+
+async function fetchSinaChinaRelatedData(stock: WatchStock, fetchImpl: FetchLike): Promise {
+ const [quote, candles] = await Promise.all([
+ fetchSinaChinaQuote(stock, fetchImpl),
+ fetchSinaChinaCandles(stock, fetchImpl).catch(() => [])
+ ]);
+ const recent = candles.slice(-60);
+ const last = candles[candles.length - 1];
+ const first60 = recent[0];
+ const high60 = recent.length ? Math.max(...recent.map((candle) => candle.high)) : undefined;
+ const low20 = candles.length ? Math.min(...candles.slice(-20).map((candle) => candle.low)) : undefined;
+ const high20 = candles.length ? Math.max(...candles.slice(-20).map((candle) => candle.high)) : undefined;
+ const avgVolume20 = candles.length ? Math.round(candles.slice(-20).reduce((sum, candle) => sum + candle.volume, 0) / Math.min(20, candles.length)) : undefined;
+ const volatility20 = estimateVolatility(candles.slice(-21));
+
+ return {
+ sector: "China ETF / A-share",
+ avgVolume20,
+ volumeRatio: avgVolume20 && quote.volume ? round(quote.volume / avgVolume20, 4) : undefined,
+ volatility20,
+ drawdownFromHigh: high60 && last ? round((high60 - last.close) / high60, 4) : undefined,
+ relativeStrength60: first60 && last ? round((last.close - first60.close) / first60.close, 4) : undefined,
+ support: low20,
+ resistance: high20,
+ marketCap: quote.marketCap,
+ riskFlags: [],
+ asOf: quote.asOf,
+ sources: [
+ { name: "Sina CN quote", url: "https://hq.sinajs.cn/list=", asOf: quote.asOf },
+ { name: "Sina CN kline", url: "https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData", asOf: quote.asOf }
+ ]
+ };
+}
+
+const nasdaqHistoricalSchema = z.object({
+ data: z
+ .object({
+ tradesTable: z
+ .object({
+ rows: z
+ .array(
+ z.object({
+ date: z.string(),
+ close: z.string(),
+ volume: z.string(),
+ open: z.string(),
+ high: z.string(),
+ low: z.string()
+ })
+ )
+ .optional()
+ })
+ .optional()
+ })
+ .nullable()
+ .optional()
+});
+
+const nasdaqInfoSchema = z.object({
+ data: z
+ .object({
+ primaryData: z
+ .object({
+ lastSalePrice: z.string().optional(),
+ lastTradeTimestamp: z.string().optional()
+ })
+ .optional(),
+ notifications: z.array(z.object({ headline: z.string() })).optional()
+ })
+ .nullable()
+ .optional()
+});
+
+const nasdaqSummarySchema = z.object({
+ data: z
+ .object({
+ summaryData: z.record(z.string(), z.object({ label: z.string().optional(), value: z.union([z.string(), z.number()]).optional() })).optional()
+ })
+ .nullable()
+ .optional()
+});
+
+const nasdaqAnalystSchema = z.object({
+ data: z
+ .object({
+ consensusOverview: z
+ .object({
+ lowPriceTarget: z.number().optional(),
+ highPriceTarget: z.number().optional(),
+ priceTarget: z.number().optional(),
+ buy: z.number().optional(),
+ sell: z.number().optional(),
+ hold: z.number().optional()
+ })
+ .optional()
+ })
+ .nullable()
+ .optional()
+});
+
+const nasdaqHoldingsSchema = z.object({
+ data: z
+ .object({
+ ownershipSummary: z
+ .object({
+ SharesOutstandingPCT: z.object({ value: z.string().optional() }).optional(),
+ ShareoutstandingTotal: z.object({ value: z.string().optional() }).optional()
+ })
+ .optional()
+ })
+ .nullable()
+ .optional()
+});
+
+const stocktwitsSchema = z.object({
+ messages: z
+ .array(
+ z.object({
+ id: z.number().optional(),
+ body: z.string().optional(),
+ created_at: z.string().optional(),
+ entities: z
+ .object({
+ sentiment: z.object({ basic: z.string().optional() }).nullable().optional()
+ })
+ .optional(),
+ user: z.object({ username: z.string().optional() }).optional()
+ })
+ )
+ .default([])
+});
+
+const redditSchema = z.object({
+ data: z
+ .object({
+ children: z
+ .array(
+ z.object({
+ data: z.object({
+ title: z.string().optional(),
+ permalink: z.string().optional(),
+ created_utc: z.number().optional(),
+ subreddit: z.string().optional()
+ })
+ })
+ )
+ .default([])
+ })
+ .optional()
+});
+
+async function fetchNasdaqJson(fetchImpl: FetchLike, url: string): Promise {
+ const response = await fetchImpl(url, {
+ headers: {
+ Accept: "application/json, text/plain, */*",
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
+ Origin: "https://www.nasdaq.com",
+ Referer: "https://www.nasdaq.com/"
+ },
+ signal: AbortSignal.timeout(15_000)
+ });
+ if (!response.ok) {
+ throw new Error(`Nasdaq request failed: ${response.status}`);
+ }
+ return response.json();
+}
+
+async function fetchText(fetchImpl: FetchLike, url: string, headers: Record = {}): Promise {
+ const response = await fetchImpl(url, {
+ headers: {
+ Accept: "text/html,application/xml,text/xml,*/*",
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
+ ...headers
+ },
+ signal: AbortSignal.timeout(15_000)
+ });
+ if (!response.ok) {
+ throw new Error(`Text request failed: ${response.status}`);
+ }
+ return response.text();
+}
+
+async function fetchGbkText(fetchImpl: FetchLike, url: string, headers: Record = {}): Promise {
+ const response = await fetchImpl(url, {
+ headers: {
+ Accept: "text/plain,*/*",
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36",
+ Referer: "https://finance.sina.com.cn/",
+ ...headers
+ },
+ signal: AbortSignal.timeout(15_000)
+ });
+ if (!response.ok) {
+ throw new Error(`GBK text request failed: ${response.status}`);
+ }
+ return new TextDecoder("gb18030").decode(await response.arrayBuffer());
+}
+
+async function fetchSinaChinaQuote(stock: WatchStock, fetchImpl: FetchLike): Promise<{ price: number; previousClose?: number; high?: number; low?: number; volume?: number; amount?: number; marketCap?: number; asOf: string }> {
+ const symbol = sinaChinaSymbol(stock);
+ const text = await fetchGbkText(fetchImpl, `https://hq.sinajs.cn/list=${symbol}`);
+ const match = text.match(/="([^"]*)"/);
+ const fields = match?.[1]?.split(",") ?? [];
+ const price = parseOptionalNumber(fields[3]);
+ if (!isFiniteNumber(price)) {
+ throw new Error(`Sina CN quote did not include a valid price for ${stock.display}`);
+ }
+ const date = fields[30];
+ const time = fields[31];
+ const amount = parseOptionalNumber(fields[9]);
+ return {
+ price,
+ previousClose: parseOptionalNumber(fields[2]),
+ high: parseOptionalNumber(fields[4]),
+ low: parseOptionalNumber(fields[5]),
+ volume: parseOptionalNumber(fields[8]),
+ amount,
+ asOf: date && time ? `${date} ${time}` : new Date().toISOString(),
+ };
+}
+
+async function fetchYahooFinanceNews(stock: WatchStock, fetchImpl: FetchLike): Promise {
+ const xml = await fetchText(
+ fetchImpl,
+ `https://feeds.finance.yahoo.com/rss/2.0/headline?s=${encodeURIComponent(stock.symbol)}®ion=US&lang=en-US`
+ );
+ return parseRssItems(xml).slice(0, 10).map((item) => ({
+ ...item,
+ source: "Yahoo Finance"
+ }));
+}
+
+async function fetchStocktwitsMentions(stock: WatchStock, fetchImpl: FetchLike): Promise {
+ const payload = stocktwitsSchema.parse(
+ await fetchNasdaqJson(fetchImpl, `https://api.stocktwits.com/api/2/streams/symbol/${encodeURIComponent(stock.symbol)}.json`)
+ );
+ return payload.messages.slice(0, 10).flatMap((message) => {
+ if (!message.body) {
+ return [];
+ }
+ return [
+ {
+ title: message.body.slice(0, 180),
+ url: message.id ? `https://stocktwits.com/symbol/${encodeURIComponent(stock.symbol)}/message/${message.id}` : `https://stocktwits.com/symbol/${encodeURIComponent(stock.symbol)}`,
+ source: message.entities?.sentiment?.basic ? `Stocktwits ${message.entities.sentiment.basic}` : "Stocktwits",
+ publishedAt: message.created_at
+ }
+ ];
+ });
+}
+
+async function fetchRedditMentions(stock: WatchStock, fetchImpl: FetchLike): Promise {
+ const payload = redditSchema.parse(
+ await fetchNasdaqJson(
+ fetchImpl,
+ `https://www.reddit.com/search.json?q=${encodeURIComponent(`${stock.symbol} stock`)}&sort=new&t=week&limit=8`
+ )
+ );
+ return (payload.data?.children ?? []).flatMap((item) => {
+ const title = item.data.title;
+ if (!title) {
+ return [];
+ }
+ return [
+ {
+ title,
+ url: item.data.permalink ? `https://www.reddit.com${item.data.permalink}` : "https://www.reddit.com/search/",
+ source: item.data.subreddit ? `Reddit r/${item.data.subreddit}` : "Reddit",
+ publishedAt: item.data.created_utc ? new Date(item.data.created_utc * 1000).toISOString() : undefined
+ }
+ ];
+ });
+}
+
+function parseRssItems(xml: string): RelatedNews[] {
+ const matches = Array.from(xml.matchAll(/- /gi));
+ return matches.flatMap((match) => {
+ const item = match[0];
+ const title = decodeXml(readXmlTag(item, "title"));
+ const link = decodeXml(readXmlTag(item, "link"));
+ if (!title || !link) {
+ return [];
+ }
+ return [
+ {
+ title,
+ url: link,
+ publishedAt: decodeXml(readXmlTag(item, "pubDate")) || undefined
+ }
+ ];
+ });
+}
+
+function readXmlTag(xml: string, tag: string): string {
+ const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
+ return match?.[1]?.replace(/^$/i, "$1").trim() ?? "";
+}
+
+function decodeXml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, "\"")
+ .replace(/'/g, "'");
+}
+
+function inferWebSentimentLabel(socialMentions: RelatedNews[], news: RelatedNews[]): string | undefined {
+ const text = [...socialMentions, ...news].map((item) => `${item.source ?? ""} ${item.title}`.toLowerCase()).join(" ");
+ if (!text) {
+ return undefined;
+ }
+ const bullish = (text.match(/bullish|buy|breakout|upgrade|beat|surge|rally|看多|买入/g) ?? []).length;
+ const bearish = (text.match(/bearish|sell|downgrade|miss|drop|lawsuit|risk|看空|卖出/g) ?? []).length;
+ if (bullish > bearish + 1) {
+ return "偏积极";
+ }
+ if (bearish > bullish + 1) {
+ return "偏谨慎";
+ }
+ return "中性";
+}
+
+function parseNasdaqRow(row: NasdaqHistoricalRow): PriceCandle | undefined {
+ const date = parseNasdaqDate(row.date);
+ const open = parseNasdaqNumber(row.open);
+ const high = parseNasdaqNumber(row.high);
+ const low = parseNasdaqNumber(row.low);
+ const close = parseNasdaqNumber(row.close);
+ const volume = parseNasdaqNumber(row.volume);
+ if (!date || !isFiniteNumber(open) || !isFiniteNumber(high) || !isFiniteNumber(low) || !isFiniteNumber(close) || !isFiniteNumber(volume)) {
+ return undefined;
+ }
+ return {
+ date,
+ open: round(open, 4),
+ high: round(high, 4),
+ low: round(low, 4),
+ close: round(close, 4),
+ volume: Math.round(volume)
+ };
+}
+
+function parseSinaKlineRow(row: SinaKlineRow): PriceCandle | undefined {
+ const open = parseOptionalNumber(row.open);
+ const high = parseOptionalNumber(row.high);
+ const low = parseOptionalNumber(row.low);
+ const close = parseOptionalNumber(row.close);
+ const volume = parseOptionalNumber(row.volume);
+ if (!row.day || !isFiniteNumber(open) || !isFiniteNumber(high) || !isFiniteNumber(low) || !isFiniteNumber(close) || !isFiniteNumber(volume)) {
+ return undefined;
+ }
+ return {
+ date: row.day,
+ open: round(open, 4),
+ high: round(high, 4),
+ low: round(low, 4),
+ close: round(close, 4),
+ volume: Math.round(volume)
+ };
+}
+
+function sinaChinaSymbol(stock: WatchStock): string {
+ return `${stock.display.endsWith(".SS") ? "sh" : "sz"}${stock.symbol}`;
+}
+
+function parseNasdaqDate(value: string): string | undefined {
+ const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
+ if (!match) {
+ return undefined;
+ }
+ const [, month, day, year] = match;
+ return `${year}-${month}-${day}`;
+}
+
+function parseNasdaqNumber(value: string | number): number | undefined {
+ const number = typeof value === "number" ? value : Number(value.replace(/[$,]/g, ""));
+ return Number.isFinite(number) ? number : undefined;
+}
+
+function parseOptionalNumber(value: string | number | undefined): number | undefined {
+ return value !== undefined && value !== "" ? parseNasdaqNumber(value) : undefined;
+}
+
+function stringifyOptional(value: string | number | undefined): string | undefined {
+ return value === undefined || value === "" ? undefined : String(value);
+}
+
+function parsePercent(value: string | number | undefined): number | undefined {
+ if (!value) {
+ return undefined;
+ }
+ const number = typeof value === "number" ? value : Number(value.replace(/[%,$,]/g, ""));
+ return Number.isFinite(number) ? round(number / 100, 4) : undefined;
+}
+
+function parseRange(value: string | number | undefined): { low: number; high: number } | undefined {
+ if (typeof value !== "string" || !value) {
+ return undefined;
+ }
+ const parts = value.split(/[-/]/).map((part) => parseNasdaqNumber(part.trim())).filter(isFiniteNumber);
+ if (parts.length < 2) {
+ return undefined;
+ }
+ return { low: Math.min(parts[0], parts[1]), high: Math.max(parts[0], parts[1]) };
+}
+
+interface AnalystConsensus {
+ buy?: number;
+ hold?: number;
+ sell?: number;
+}
+
+function analystConsensusLabel(consensus: AnalystConsensus | undefined): string | undefined {
+ if (!consensus) {
+ return undefined;
+ }
+ const buy = consensus.buy ?? 0;
+ const hold = consensus.hold ?? 0;
+ const sell = consensus.sell ?? 0;
+ if (buy > hold && buy > sell) {
+ return "Buy";
+ }
+ if (sell > buy && sell > hold) {
+ return "Sell";
+ }
+ return "Hold";
+}
+
+function parseSharesOutstanding(value: string | undefined): number | undefined {
+ const sharesMillions = parseOptionalNumber(value);
+ return sharesMillions === undefined ? undefined : sharesMillions * 1_000_000;
+}
+
+function calculateDrawdown(high: number | undefined, previousClose: number | undefined): number | undefined {
+ return high && previousClose ? round((high - previousClose) / high, 4) : undefined;
+}
+
+function isFiniteNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function stripJsonFence(text: string): string {
+ const trimmed = text.trim();
+ const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
+ return fenced?.[1] ?? trimmed;
+}
+
+function peerDisplay(stock: WatchStock, index: number): string {
+ if (stock.market === "HK") {
+ return `${String(9000 + index).padStart(5, "0")}.HK`;
+ }
+ if (stock.market === "CN") {
+ return `${600000 + index}.SS`;
+ }
+ return index === 1 ? "MSFT" : "GOOGL";
+}
+
+function symbolSeed(stock: WatchStock): number {
+ return stock.display.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0);
+}
+
+function round(value: number, digits: number): number {
+ const factor = 10 ** digits;
+ return Math.round(value * factor) / factor;
+}
+
+function clampScore(value: number): number {
+ return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 50;
+}
+
+function formatPct(value?: number): string {
+ return value === undefined ? "-" : `${round(value * 100, 2)}%`;
+}
+
+function formatPrice(value?: number): string {
+ return value === undefined ? "-" : String(round(value, 2));
+}
diff --git a/web/src/agent/report.ts b/web/src/agent/report.ts
new file mode 100644
index 0000000..f006236
--- /dev/null
+++ b/web/src/agent/report.ts
@@ -0,0 +1,144 @@
+import type {
+ DailyReport,
+ DailyReportItem,
+ EarningsSignal,
+ Market,
+ MarketDataProvider,
+ Recommendation,
+ StockSnapshot,
+ ValuationSignal,
+ WatchStock
+} from "../shared/types";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export async function generateDailyReport(
+ watchlist: WatchStock[],
+ provider: MarketDataProvider,
+ now = new Date()
+): Promise
{
+ const items = await Promise.all(
+ watchlist.map(async (stock) => buildReportItem(stock, await provider.snapshot(stock), now))
+ );
+
+ return {
+ generatedAt: now.toISOString(),
+ summary: buildSummary(items),
+ items: items.sort((left, right) => rankItem(right) - rankItem(left))
+ };
+}
+
+function buildReportItem(stock: WatchStock, snapshot: StockSnapshot, now: Date): DailyReportItem {
+ const earnings = analyzeEarnings(snapshot, now);
+ const valuation = analyzeValuation(snapshot);
+ const recommendation = recommend(snapshot, valuation, earnings);
+
+ return {
+ stock,
+ snapshot,
+ earnings,
+ valuation,
+ recommendation,
+ news: snapshot.news
+ };
+}
+
+function analyzeEarnings(snapshot: StockSnapshot, now: Date): EarningsSignal {
+ if (!snapshot.nextEarningsDate) {
+ return { status: "none" };
+ }
+
+ const date = new Date(`${snapshot.nextEarningsDate}T00:00:00.000Z`);
+ const start = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+ const daysUntil = Math.ceil((date.getTime() - start) / DAY_MS);
+
+ return {
+ status: daysUntil >= 0 && daysUntil <= 14 ? "soon" : "scheduled",
+ nextDate: snapshot.nextEarningsDate,
+ daysUntil
+ };
+}
+
+function analyzeValuation(snapshot: StockSnapshot): ValuationSignal {
+ const stockPe = snapshot.pe;
+ const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0);
+ if (!Number.isFinite(stockPe) || stockPe === undefined || stockPe <= 0 || peerPes.length === 0) {
+ return { label: "unknown" };
+ }
+
+ const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2);
+ const spreadPct = round((stockPe - peerAveragePe) / peerAveragePe, 4);
+ const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair";
+
+ return {
+ label,
+ stockPe,
+ peerAveragePe,
+ spreadPct
+ };
+}
+
+function recommend(snapshot: StockSnapshot, valuation: ValuationSignal, earnings: EarningsSignal): Recommendation {
+ const revenueGrowth = snapshot.revenueGrowth ?? 0;
+ const profitGrowth = snapshot.profitGrowth ?? 0;
+ const reasons: string[] = [];
+
+ if (valuation.label === "discount") {
+ reasons.push("估值低于可比股票均值");
+ }
+ if (valuation.label === "premium") {
+ reasons.push("估值高于可比股票均值");
+ }
+ if (revenueGrowth > 0) {
+ reasons.push("收入仍在增长");
+ }
+ if (profitGrowth > 0) {
+ reasons.push("利润仍在增长");
+ }
+ if (earnings.status === "soon") {
+ reasons.push("未来 14 天内有财报事件");
+ }
+
+ if (valuation.label === "discount" && revenueGrowth > 0 && profitGrowth >= 0) {
+ return { action: "buy_watch", confidence: 0.72, reasons };
+ }
+
+ if (valuation.label === "premium" && revenueGrowth < 0 && profitGrowth < 0) {
+ return { action: "avoid", confidence: 0.68, reasons };
+ }
+
+ return {
+ action: "hold",
+ confidence: earnings.status === "soon" ? 0.58 : 0.52,
+ reasons: reasons.length ? reasons : ["等待更明确的基本面或估值信号"]
+ };
+}
+
+function buildSummary(items: DailyReportItem[]): string {
+ const counts = new Map([
+ ["CN", 0],
+ ["HK", 0],
+ ["US", 0]
+ ]);
+ for (const item of items) {
+ counts.set(item.stock.market, (counts.get(item.stock.market) ?? 0) + 1);
+ }
+
+ const soon = items.filter((item) => item.earnings.status === "soon").length;
+ const discounts = items.filter((item) => item.valuation.label === "discount").length;
+ return `CN: ${counts.get("CN")}; HK: ${counts.get("HK")}; US: ${counts.get("US")}; earnings soon: ${soon}; valuation discounts: ${discounts}`;
+}
+
+function rankItem(item: DailyReportItem): number {
+ return (
+ (item.earnings.status === "soon" ? 4 : 0) +
+ (item.valuation.label === "discount" ? 3 : 0) +
+ (item.recommendation.action === "buy_watch" ? 2 : 0) +
+ item.news.length
+ );
+}
+
+function round(value: number, digits: number): number {
+ const factor = 10 ** digits;
+ return Math.round(value * factor) / factor;
+}
diff --git a/web/src/agent/stockDataCache.ts b/web/src/agent/stockDataCache.ts
new file mode 100644
index 0000000..77c8f20
--- /dev/null
+++ b/web/src/agent/stockDataCache.ts
@@ -0,0 +1,380 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { calculateIndicators } from "./indicators";
+import type {
+ ChartPeriod,
+ FinancialStatement,
+ MarketDataProvider,
+ PriceCandle,
+ QuantAnalysis,
+ RelatedStockData,
+ StockChartData,
+ StockFundamentalsData,
+ StockQuantAnalysis,
+ StockRelatedData,
+ StockSnapshot,
+ WatchStock
+} from "../shared/types";
+
+interface CachedEntry {
+ cachedAt: string;
+ data: T;
+}
+
+interface StockCacheFile {
+ snapshot?: CachedEntry;
+ candles?: CachedEntry;
+ candlesWindowYears?: number;
+ candlesProviderVersion?: number;
+ fundamentals?: CachedEntry;
+ relatedData?: CachedEntry;
+ quantAnalysis?: CachedEntry & { version?: number };
+}
+
+const SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000;
+const CANDLES_TTL_MS = 24 * 60 * 60 * 1000;
+const FUNDAMENTALS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
+const RELATED_TTL_MS = 24 * 60 * 60 * 1000;
+const ANALYSIS_VERSION = 4;
+const CANDLES_WINDOW_YEARS = 10;
+const CANDLES_PROVIDER_VERSION = 2;
+
+export class StockDataCache {
+ constructor(
+ private readonly cacheDir: string,
+ private readonly provider: MarketDataProvider,
+ private readonly now: () => Date = () => new Date()
+ ) {}
+
+ async snapshot(stock: WatchStock, force = false): Promise {
+ const cache = await this.read(stock);
+ if (!force && cache.snapshot && !isExpired(cache.snapshot.cachedAt, SNAPSHOT_TTL_MS, this.now())) {
+ return cache.snapshot.data;
+ }
+
+ const next = await this.provider.snapshot(stock);
+ await this.write(stock, { ...cache, snapshot: { cachedAt: this.now().toISOString(), data: next } });
+ return next;
+ }
+
+ async chart(stock: WatchStock, period: ChartPeriod = "D", force = false): Promise {
+ const cache = await this.read(stock);
+ const existing = cache.candles;
+ let entry: CachedEntry;
+ try {
+ if (!existing || force || cache.candlesWindowYears !== CANDLES_WINDOW_YEARS || cache.candlesProviderVersion !== CANDLES_PROVIDER_VERSION) {
+ entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) };
+ } else if (isExpired(existing.cachedAt, CANDLES_TTL_MS, this.now())) {
+ entry = existing;
+ void this.refreshCandles(stock, existing);
+ } else {
+ entry = existing;
+ }
+ } catch (error) {
+ if (existing) {
+ entry = existing;
+ } else {
+ throw error;
+ }
+ }
+ if (entry !== cache.candles) {
+ await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
+ }
+
+ const candles = aggregateCandles(entry.data, period);
+ return {
+ stock,
+ period,
+ candles,
+ indicators: calculateIndicators(candles),
+ cachedAt: entry.cachedAt
+ };
+ }
+
+ private async refreshCandles(stock: WatchStock, existing: CachedEntry): Promise {
+ try {
+ const cache = await this.read(stock);
+ if (cache.candles?.cachedAt !== existing.cachedAt) {
+ return;
+ }
+ const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date);
+ const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock);
+ const entry = {
+ cachedAt: this.now().toISOString(),
+ data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data
+ };
+ await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
+ } catch {
+ // Keep stale cache when the upstream market data source is unavailable.
+ }
+ }
+
+ async refreshChart(stock: WatchStock, period: ChartPeriod = "D"): Promise {
+ const cache = await this.read(stock);
+ const existing = cache.candles;
+ let entry: CachedEntry;
+ if (existing && cache.candlesWindowYears === CANDLES_WINDOW_YEARS && cache.candlesProviderVersion === CANDLES_PROVIDER_VERSION) {
+ const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date);
+ const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock);
+ entry = {
+ cachedAt: this.now().toISOString(),
+ data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data
+ };
+ } else {
+ entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) };
+ }
+ await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
+
+ const candles = aggregateCandles(entry.data, period);
+ return {
+ stock,
+ period,
+ candles,
+ indicators: calculateIndicators(candles),
+ cachedAt: entry.cachedAt
+ };
+ }
+
+ async fundamentals(stock: WatchStock, force = false): Promise {
+ const cache = await this.read(stock);
+ let entry: CachedEntry;
+ try {
+ entry =
+ !force && cache.fundamentals
+ ? cache.fundamentals
+ : { cachedAt: this.now().toISOString(), data: await this.provider.fundamentals(stock) };
+ } catch {
+ entry = cache.fundamentals ?? { cachedAt: this.now().toISOString(), data: [] };
+ }
+ if (entry !== cache.fundamentals) {
+ await this.write(stock, { ...cache, fundamentals: entry });
+ }
+
+ return {
+ stock,
+ statements: entry.data,
+ cachedAt: entry.cachedAt
+ };
+ }
+
+ async relatedData(stock: WatchStock, force = false): Promise {
+ const cache = await this.read(stock);
+ let entry: CachedEntry;
+ try {
+ entry =
+ !force && cache.relatedData && hasResearchSchema(cache.relatedData.data)
+ ? cache.relatedData
+ : { cachedAt: this.now().toISOString(), data: await this.provider.relatedData(stock) };
+ } catch {
+ entry = cache.relatedData ?? {
+ cachedAt: this.now().toISOString(),
+ data: {
+ riskFlags: ["相关市场数据源暂不可用。"],
+ sources: []
+ }
+ };
+ }
+ if (entry !== cache.relatedData) {
+ await this.write(stock, { ...cache, relatedData: entry });
+ }
+
+ return {
+ stock,
+ data: entry.data,
+ cachedAt: entry.cachedAt
+ };
+ }
+
+ async quantAnalysis(stock: WatchStock, force = false): Promise {
+ const cache = await this.read(stock);
+ const hasTodayAnalysis =
+ cache.quantAnalysis?.version === ANALYSIS_VERSION && isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now());
+ if (!force && cache.quantAnalysis?.version === ANALYSIS_VERSION) {
+ return {
+ stock,
+ analysis: cache.quantAnalysis.data,
+ generatedAt: cache.quantAnalysis.cachedAt,
+ cachedAt: cache.quantAnalysis.cachedAt
+ };
+ }
+
+ const refreshSourceData = force || !hasTodayAnalysis;
+ const snapshot = await this.snapshot(stock, refreshSourceData);
+ const chart = await this.chart(stock, "D", refreshSourceData);
+ const fundamentals = await this.fundamentals(stock, refreshSourceData).catch(() => ({ stock, statements: [], cachedAt: this.now().toISOString() }));
+ const related = await this.relatedData(stock, refreshSourceData).catch(() => ({
+ stock,
+ data: { riskFlags: ["相关市场数据源暂不可用。"], sources: [] },
+ cachedAt: this.now().toISOString()
+ }));
+ const generatedAt = this.now().toISOString();
+ const analysis = await this.provider.quantAnalysis({
+ stock,
+ snapshot,
+ candles: chart.candles,
+ indicators: chart.indicators,
+ fundamentals: fundamentals.statements,
+ relatedData: related.data
+ });
+ const entry = { cachedAt: generatedAt, data: analysis, version: ANALYSIS_VERSION };
+ await this.write(stock, { ...(await this.read(stock)), quantAnalysis: entry });
+
+ return {
+ stock,
+ analysis,
+ generatedAt,
+ cachedAt: entry.cachedAt
+ };
+ }
+
+ async analysisNeedsRefresh(stock: WatchStock): Promise {
+ const cache = await this.read(stock);
+ return cache.quantAnalysis?.version !== ANALYSIS_VERSION || !isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now());
+ }
+
+ async hasAnalysis(stock: WatchStock): Promise {
+ const cache = await this.read(stock);
+ return cache.quantAnalysis?.version === ANALYSIS_VERSION;
+ }
+
+ placeholderAnalysis(stock: WatchStock): StockQuantAnalysis {
+ const generatedAt = this.now().toISOString();
+ return {
+ stock,
+ generatedAt,
+ cachedAt: generatedAt,
+ analysis: {
+ action: "hold",
+ score: 50,
+ confidence: 0.1,
+ horizon: "后台生成中",
+ summary: "本地暂无 Agent 分析缓存,已在后台生成最新量化分析。当前先展示缓存数据,避免切换自选股时阻塞界面。",
+ factors: [
+ { name: "缓存状态", score: 50, weight: 1, evidence: "等待后台 Agent 分析写入本地缓存。" },
+ { name: "技术面", score: 50, weight: 0, evidence: "K线和指标可先独立查看。" },
+ { name: "风险控制", score: 50, weight: 0, evidence: "完整交易计划将在后台分析完成后更新。" }
+ ],
+ tradePlan: {
+ direction: "wait",
+ rationale: "后台分析完成前不生成买入、卖出或止损价。"
+ },
+ marketContext: ["Agent 分析正在后台生成。"],
+ riskNotes: ["该占位结果只表示后台任务状态,不代表实际投资判断。"]
+ }
+ };
+ }
+
+ private async read(stock: WatchStock): Promise {
+ try {
+ return JSON.parse(await readFile(this.pathFor(stock), "utf8")) as StockCacheFile;
+ } catch (error) {
+ if (isMissingFile(error)) {
+ return {};
+ }
+ throw error;
+ }
+ }
+
+ private async write(stock: WatchStock, cache: StockCacheFile): Promise {
+ await mkdir(this.cacheDir, { recursive: true });
+ await writeFile(this.pathFor(stock), `${JSON.stringify(cache, null, 2)}\n`, "utf8");
+ }
+
+ private pathFor(stock: WatchStock): string {
+ return join(this.cacheDir, `${stock.display.replace(/[^A-Z0-9]/g, "_")}.json`);
+ }
+}
+
+function isExpired(cachedAt: string, ttlMs: number, now: Date): boolean {
+ const cachedTime = new Date(cachedAt).getTime();
+ return !Number.isFinite(cachedTime) || now.getTime() - cachedTime > ttlMs;
+}
+
+function isMissingFile(error: unknown): boolean {
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
+}
+
+function mergeCandles(existing: PriceCandle[], incoming: PriceCandle[]): PriceCandle[] {
+ const byDate = new Map();
+ for (const candle of existing) {
+ byDate.set(candle.date, candle);
+ }
+ for (const candle of incoming) {
+ byDate.set(candle.date, candle);
+ }
+ return Array.from(byDate.values()).sort((left, right) => left.date.localeCompare(right.date));
+}
+
+function previousCalendarDate(date: string | undefined): string | undefined {
+ if (!date) {
+ return undefined;
+ }
+ const value = new Date(`${date}T00:00:00.000Z`);
+ if (!Number.isFinite(value.getTime())) {
+ return undefined;
+ }
+ value.setUTCDate(value.getUTCDate() - 7);
+ return value.toISOString().slice(0, 10);
+}
+
+function hasResearchSchema(data: RelatedStockData): boolean {
+ return Array.isArray(data.sources) && data.sources.some((source) => source.name.startsWith("Nasdaq") || source.name.startsWith("Sina"));
+}
+
+function isSameShanghaiDate(cachedAt: string, now: Date): boolean {
+ const cachedTime = new Date(cachedAt);
+ if (!Number.isFinite(cachedTime.getTime())) {
+ return false;
+ }
+ return shanghaiDate(cachedTime) === shanghaiDate(now);
+}
+
+function shanghaiDate(date: Date): string {
+ return new Intl.DateTimeFormat("en-CA", {
+ timeZone: "Asia/Shanghai",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit"
+ }).format(date);
+}
+
+function aggregateCandles(candles: PriceCandle[], period: ChartPeriod): PriceCandle[] {
+ if (period === "D") {
+ return candles;
+ }
+
+ const grouped = new Map();
+ for (const candle of candles) {
+ const key = periodKey(candle.date, period);
+ const existing = grouped.get(key);
+ if (!existing) {
+ grouped.set(key, { ...candle });
+ continue;
+ }
+ existing.high = Math.max(existing.high, candle.high);
+ existing.low = Math.min(existing.low, candle.low);
+ existing.close = candle.close;
+ existing.volume += candle.volume;
+ existing.date = candle.date;
+ }
+ return Array.from(grouped.values());
+}
+
+function periodKey(date: string, period: ChartPeriod): string {
+ if (period === "M") {
+ return date.slice(0, 7);
+ }
+ if (period === "Y") {
+ return date.slice(0, 4);
+ }
+ return isoWeekKey(date);
+}
+
+function isoWeekKey(date: string): string {
+ const value = new Date(`${date}T00:00:00.000Z`);
+ const day = value.getUTCDay() || 7;
+ value.setUTCDate(value.getUTCDate() + 4 - day);
+ const yearStart = new Date(Date.UTC(value.getUTCFullYear(), 0, 1));
+ const week = Math.ceil(((value.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
+ return `${value.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
+}
diff --git a/web/src/agent/symbols.ts b/web/src/agent/symbols.ts
new file mode 100644
index 0000000..4ecc0f6
--- /dev/null
+++ b/web/src/agent/symbols.ts
@@ -0,0 +1,52 @@
+import type { Market, NormalizedSymbol } from "../shared/types";
+
+const CN_SUFFIXES = new Set(["SS", "SH", "SZ"]);
+
+export function normalizeSymbol(input: string): NormalizedSymbol {
+ const raw = input.trim().toUpperCase();
+ if (!raw) {
+ throw new Error("Stock symbol is required");
+ }
+
+ const suffixMatch = raw.match(/^([A-Z0-9]+)\.([A-Z]{2})$/);
+ if (suffixMatch) {
+ const [, base, suffix] = suffixMatch;
+ if (CN_SUFFIXES.has(suffix)) {
+ const exchange = inferChinaExchange(base);
+ return {
+ symbol: base,
+ market: "CN",
+ display: `${base}.${exchange}`
+ };
+ }
+ if (suffix === "HK" && /^\d{4,5}$/.test(base)) {
+ return { symbol: base.padStart(5, "0"), market: "HK", display: `${base.padStart(5, "0")}.HK` };
+ }
+ if (suffix === "US" && /^[A-Z]{1,6}$/.test(base)) {
+ return { symbol: base, market: "US", display: base };
+ }
+ }
+
+ if (/^\d{6}$/.test(raw)) {
+ return { symbol: raw, market: "CN", display: `${raw}.${inferChinaExchange(raw)}` };
+ }
+
+ if (/^\d{4,5}$/.test(raw)) {
+ const symbol = raw.padStart(5, "0");
+ return { symbol, market: "HK", display: `${symbol}.HK` };
+ }
+
+ if (/^[A-Z]{1,6}$/.test(raw)) {
+ return { symbol: raw, market: "US", display: raw };
+ }
+
+ throw new Error("Unsupported stock symbol");
+}
+
+function inferChinaExchange(symbol: string): "SS" | "SZ" {
+ return symbol.startsWith("5") || symbol.startsWith("6") || symbol.startsWith("9") ? "SS" : "SZ";
+}
+
+export function marketLabel(market: Market): string {
+ return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
+}
diff --git a/web/src/agent/watchlistStore.ts b/web/src/agent/watchlistStore.ts
new file mode 100644
index 0000000..b79ece0
--- /dev/null
+++ b/web/src/agent/watchlistStore.ts
@@ -0,0 +1,96 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname } from "node:path";
+import { normalizeSymbol } from "./symbols";
+import type { WatchStock } from "../shared/types";
+
+export class WatchlistStore {
+ constructor(private readonly path: string) {}
+
+ async list(): Promise {
+ return this.read();
+ }
+
+ async add(symbolInput: string, name?: string, tags: string[] = []): Promise {
+ const normalized = normalizeSymbol(symbolInput);
+ const stocks = await this.read();
+ const existing = stocks.find((stock) => stock.display === normalized.display);
+ if (existing) {
+ return existing;
+ }
+
+ const stock: WatchStock = {
+ ...normalized,
+ id: normalized.display,
+ name: name?.trim() || undefined,
+ tags: normalizeTags(tags),
+ addedAt: new Date().toISOString()
+ };
+ await this.write([...stocks, stock]);
+ return stock;
+ }
+
+ async updateTags(symbolInput: string, tags: string[]): Promise {
+ const normalized = normalizeSymbol(symbolInput);
+ const stocks = await this.read();
+ const stock = stocks.find((item) => item.display === normalized.display);
+ if (!stock) {
+ throw new Error("Stock is not in watchlist");
+ }
+
+ const updated = { ...stock, tags: normalizeTags(tags) };
+ await this.write(stocks.map((item) => (item.display === normalized.display ? updated : item)));
+ return updated;
+ }
+
+ async remove(symbolInput: string): Promise {
+ const normalized = normalizeSymbol(symbolInput);
+ const stocks = await this.read();
+ await this.write(stocks.filter((stock) => stock.display !== normalized.display));
+ }
+
+ private async read(): Promise {
+ try {
+ const content = await readFile(this.path, "utf8");
+ const parsed = JSON.parse(content) as WatchStock[];
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+ const byDisplay = new Map();
+ for (const stock of parsed) {
+ const normalized = normalizeSymbol(stock.display ?? stock.symbol);
+ byDisplay.set(normalized.display, {
+ ...stock,
+ ...normalized,
+ id: normalized.display,
+ tags: normalizeTags(stock.tags ?? [])
+ });
+ }
+ return Array.from(byDisplay.values());
+ } catch (error) {
+ if (isMissingFile(error)) {
+ return [];
+ }
+ throw error;
+ }
+ }
+
+ private async write(stocks: WatchStock[]): Promise {
+ await mkdir(dirname(this.path), { recursive: true });
+ await writeFile(this.path, `${JSON.stringify(stocks, null, 2)}\n`, "utf8");
+ }
+}
+
+function isMissingFile(error: unknown): boolean {
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
+}
+
+function normalizeTags(tags: string[]): string[] {
+ return Array.from(
+ new Set(
+ tags
+ .map((tag) => tag.trim())
+ .filter(Boolean)
+ .map((tag) => tag.slice(0, 24))
+ )
+ ).slice(0, 8);
+}
diff --git a/web/src/client/LivePage.tsx b/web/src/client/LivePage.tsx
new file mode 100644
index 0000000..01f05e2
--- /dev/null
+++ b/web/src/client/LivePage.tsx
@@ -0,0 +1,686 @@
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import {
+ ColorType,
+ CrosshairMode,
+ LineSeries,
+ createChart,
+ type IChartApi,
+ type LineData,
+ type Time,
+} from "lightweight-charts";
+import { ArrowDownRight, ArrowUpRight, RefreshCw, Wallet } from "lucide-react";
+import {
+ calendarDaysSince,
+ currencyForMarket,
+ formatMoney,
+ formatNumber,
+ formatPct,
+ type EquityPoint,
+ type LiveStrategyState,
+ type StrategyCurrency,
+ type StrategyMarket,
+ type TradeLogEntry,
+} from "../shared/strategyTypes";
+
+// A strategy whose state was last updated more than this many calendar days
+// ago is flagged (covers a normal weekend plus a holiday).
+const LIVE_STALE_DAYS = 4;
+
+async function api(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, {
+ ...init,
+ headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
+ });
+ if (!response.ok) {
+ const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
+ throw new Error(body?.error ?? `Request failed: ${response.status}`);
+ }
+ return (await response.json()) as T;
+}
+
+type SortKey = "currentValue" | "unrealizedPnlPct" | "name" | "lastUpdated";
+
+export function LivePage() {
+ const [market, setMarket] = useState("us");
+ const [strategies, setStrategies] = useState([]);
+ const [selectedName, setSelectedName] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [sortKey, setSortKey] = useState("unrealizedPnlPct");
+ const [filterText, setFilterText] = useState("");
+
+ const refresh = (mk: StrategyMarket = market) => {
+ setLoading(true);
+ setError("");
+ api(`/api/strategies/live?market=${mk}`)
+ .then((res) => {
+ setStrategies(res);
+ if (res.length > 0) {
+ setSelectedName((prev) =>
+ prev && res.some((s) => s.name === prev) ? prev : res[0].name,
+ );
+ } else {
+ setSelectedName(null);
+ }
+ })
+ .catch((err) => setError(err instanceof Error ? err.message : "加载失败"))
+ .finally(() => setLoading(false));
+ };
+
+ useEffect(() => {
+ refresh(market);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [market]);
+
+ const sorted = useMemo(() => {
+ const list = [...strategies];
+ const ft = filterText.trim().toLowerCase();
+ const filtered = ft
+ ? list.filter(
+ (s) =>
+ s.name.toLowerCase().includes(ft) ||
+ s.label.toLowerCase().includes(ft),
+ )
+ : list;
+ filtered.sort((a, b) => {
+ switch (sortKey) {
+ case "currentValue":
+ return (b.currentValue ?? 0) - (a.currentValue ?? 0);
+ case "unrealizedPnlPct":
+ return (b.unrealizedPnlPct ?? 0) - (a.unrealizedPnlPct ?? 0);
+ case "name":
+ return a.label.localeCompare(b.label);
+ case "lastUpdated":
+ return (b.lastUpdated ?? "").localeCompare(a.lastUpdated ?? "");
+ }
+ });
+ return filtered;
+ }, [strategies, sortKey, filterText]);
+
+ const focused = useMemo(
+ () => sorted.find((s) => s.name === selectedName) ?? sorted[0],
+ [sorted, selectedName],
+ );
+
+ return (
+
+ {error &&
{error}
}
+
+
+
+ setMarket("us")}>美股
+ setMarket("cn")}>A股
+
+
setFilterText(e.target.value)}
+ />
+
+ 排序
+ setSortKey(e.target.value as SortKey)}
+ >
+ 盈亏%
+ 总资产
+ 名称
+ 更新时间
+
+
+
refresh()}
+ disabled={loading}
+ title="重新读取 live 状态(不会拉取新行情)"
+ type="button"
+ >
+
+ {loading ? "加载中" : "刷新"}
+
+
+
+
+
+
+
策略列表
+ {sorted.length} / {strategies.length}
+
+ {loading && sorted.length === 0 ? (
+ 加载中...
+ ) : sorted.length === 0 ? (
+ 暂无在线策略。运行 quant 的 trader.py auto 后再刷新。
+ ) : (
+
+ {sorted.map((s) => (
+
setSelectedName(s.name)}
+ title={`${s.label} (${s.name})`}
+ type="button"
+ >
+
+ {s.label}
+ {s.name}
+
+
+
{money(s.currentValue, currencyOf(s))}
+
= 0 ? "positive" : "negative"}`}
+ >
+ {s.unrealizedPnl >= 0 ? : }
+ {pct(s.unrealizedPnlPct)}
+
+
+
+ 持仓 {Object.keys(s.holdings).length} · 起 {s.startDate ?? "—"}
+
+ {isStale(s) && 滞后 }
+ {s.lastUpdated ?? "-"}
+
+
+
+ ))}
+
+ )}
+
+
+
+ {focused ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
+ const m = strategy;
+ const currency = currencyOf(m);
+ const staleDays = calendarDaysSince(m.lastUpdated);
+ const bench = benchmarkComparison(m);
+ return (
+ <>
+
+
+
+
{m.name}
+
{m.label}
+
+ {m.market.toUpperCase()} · {currency} · 初始 {money(m.initialCapital, currency)} · 起始 {m.startDate ?? "—"}
+ {" "}· 更新于 {m.lastUpdated ?? "-"} · 价格截至 {m.priceAsOf ?? "—"}
+ {isStale(m) && 已 {staleDays} 天未更新 }
+
+
+
= 0 ? "positive" : "negative"}`}>
+ {money(m.currentValue, currency)}
+
+ {m.unrealizedPnl >= 0 ? "+" : ""}
+ {money(m.unrealizedPnl, currency)} ({pct(m.unrealizedPnlPct)})
+
+
+
+
+
+
+
+
+
+ {m.metrics && }
+ {m.metrics && }
+ {m.metrics && }
+ {bench ? (
+ <>
+
+ 0}
+ negative={bench.excessReturn < 0}
+ />
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function currencyOf(s: LiveStrategyState): StrategyCurrency {
+ return s.currency ?? currencyForMarket(s.market);
+}
+
+function isStale(s: LiveStrategyState): boolean {
+ const days = calendarDaysSince(s.lastUpdated);
+ return days !== null && days > LIVE_STALE_DAYS;
+}
+
+// Strategy vs. benchmark over the benchmark's window. The benchmark is rebased
+// to initialCapital, so both returns share the same starting capital.
+function benchmarkComparison(s: LiveStrategyState) {
+ const curve = s.benchmark?.equityCurve ?? [];
+ const first = curve[0]?.value;
+ const last = curve.at(-1);
+ if (!last || !first || !s.initialCapital || s.equityCurve.length === 0) return null;
+ const strategyEnd = s.equityCurve.find((p) => p.date === last.date) ?? s.equityCurve.at(-1)!;
+ const benchmarkReturn = last.value / first - 1;
+ const strategyReturn = strategyEnd.value / s.initialCapital - 1;
+ if (!Number.isFinite(benchmarkReturn) || !Number.isFinite(strategyReturn)) return null;
+ return {
+ name: s.benchmark?.name ?? "基准",
+ benchmarkReturn,
+ excessReturn: strategyReturn - benchmarkReturn,
+ };
+}
+
+function SummaryCell({
+ label,
+ value,
+ positive,
+ negative,
+}: {
+ label: string;
+ value: string;
+ positive?: boolean;
+ negative?: boolean;
+}) {
+ const cls = positive ? "positive" : negative ? "negative" : "";
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+function LiveEquityChart({
+ equityCurve,
+ initialCapital,
+ currency,
+ benchmark,
+}: {
+ equityCurve: EquityPoint[];
+ initialCapital: number;
+ currency: StrategyCurrency;
+ benchmark?: { name: string; equityCurve: EquityPoint[] };
+}) {
+ const containerRef = useRef(null);
+ const chartRef = useRef(null);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const chart = createChart(containerRef.current, {
+ width: containerRef.current.clientWidth,
+ height: 280,
+ layout: {
+ background: { type: ColorType.Solid, color: "#0d1219" },
+ textColor: "#b0b0b0",
+ attributionLogo: false,
+ },
+ grid: {
+ vertLines: { color: "#1a2030" },
+ horzLines: { color: "#1a2030" },
+ },
+ crosshair: { mode: CrosshairMode.Normal },
+ timeScale: { borderColor: "#1d2530", timeVisible: false },
+ rightPriceScale: { borderColor: "#1d2530" },
+ localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
+ });
+ chartRef.current = chart;
+
+ if (benchmark && benchmark.equityCurve.length > 0) {
+ const benchSeries = chart.addSeries(LineSeries, {
+ color: "#7c8694",
+ lineWidth: 1,
+ lineStyle: 2,
+ title: benchmark.name,
+ crosshairMarkerVisible: false,
+ priceLineVisible: false,
+ });
+ benchSeries.setData(
+ benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })),
+ );
+ }
+
+ const eqSeries = chart.addSeries(LineSeries, {
+ color: "#4db6ff",
+ lineWidth: 2,
+ priceLineVisible: false,
+ });
+ const data: LineData[] = equityCurve.map((p) => ({
+ time: p.date as Time,
+ value: p.value,
+ }));
+ eqSeries.setData(data);
+
+ if (initialCapital && equityCurve.length > 0) {
+ eqSeries.createPriceLine({
+ price: initialCapital,
+ color: "#5e6a78",
+ lineWidth: 1,
+ lineStyle: 2,
+ axisLabelVisible: true,
+ title: "初始",
+ });
+ }
+
+ chart.timeScale().fitContent();
+
+ const ro = new ResizeObserver((entries) => {
+ const w = entries[0]?.contentRect.width;
+ if (w) chart.applyOptions({ width: w });
+ });
+ ro.observe(containerRef.current);
+
+ return () => {
+ ro.disconnect();
+ chart.remove();
+ chartRef.current = null;
+ };
+ }, [equityCurve, initialCapital, currency, benchmark]);
+
+ return (
+
+
+
+
Live Equity
+
资产曲线
+
+ {equityCurve.length} 天数据
+ {equityCurve.length > 0
+ ? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}`
+ : ""}
+ {benchmark ? ` · 虚线为 ${benchmark.name}(同期,按初始资金归一)` : " · 基准数据暂缺"}
+
+
+
+
+
+ );
+}
+
+const DEFAULT_PAGE_SIZE = 30;
+const PAGE_SIZE_OPTIONS = [30, 50, 100];
+
+type PaginationState = { page: number; pageSize: number };
+
+function usePagination(total: number, deps: unknown[] = []): [
+ PaginationState,
+ React.Dispatch>,
+ { totalPages: number; sliceStart: number; sliceEnd: number },
+] {
+ const [state, setState] = useState({
+ page: 1,
+ pageSize: DEFAULT_PAGE_SIZE,
+ });
+
+ // Reset to page 1 whenever the underlying data identity changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => setState((s) => ({ ...s, page: 1 })), deps);
+
+ const totalPages = Math.max(1, Math.ceil(total / state.pageSize));
+ const safePage = Math.min(state.page, totalPages);
+ const sliceStart = (safePage - 1) * state.pageSize;
+ const sliceEnd = Math.min(total, sliceStart + state.pageSize);
+ return [
+ { ...state, page: safePage },
+ setState,
+ { totalPages, sliceStart, sliceEnd },
+ ];
+}
+
+function PaginationBar({
+ page,
+ pageSize,
+ totalPages,
+ total,
+ sliceStart,
+ sliceEnd,
+ onPageChange,
+ onPageSizeChange,
+}: {
+ page: number;
+ pageSize: number;
+ totalPages: number;
+ total: number;
+ sliceStart: number;
+ sliceEnd: number;
+ onPageChange: (page: number) => void;
+ onPageSizeChange: (size: number) => void;
+}) {
+ if (total === 0) return null;
+ return (
+
+
+ {sliceStart + 1}–{sliceEnd} / {total}
+
+
+ onPageChange(1)}
+ disabled={page <= 1}
+ >
+ «
+
+ onPageChange(page - 1)}
+ disabled={page <= 1}
+ >
+ ‹
+
+
+ {page} / {totalPages}
+
+ onPageChange(page + 1)}
+ disabled={page >= totalPages}
+ >
+ ›
+
+ onPageChange(totalPages)}
+ disabled={page >= totalPages}
+ >
+ »
+
+ onPageSizeChange(Number(e.target.value))}
+ >
+ {PAGE_SIZE_OPTIONS.map((sz) => (
+
+ {sz} / 页
+
+ ))}
+
+
+
+ );
+}
+
+function HoldingsPanel({
+ holdings,
+ cash,
+ currentValue,
+ currency,
+}: {
+ holdings: LiveStrategyState["holdingsDetail"];
+ cash: number;
+ currentValue: number;
+ currency: StrategyCurrency;
+}) {
+ const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
+ usePagination(holdings.length, [holdings]);
+ const visible = holdings.slice(sliceStart, sliceEnd);
+ const cashWeight = currentValue ? cash / currentValue : 0;
+
+ return (
+
+
+
当前持仓
+ {holdings.length} 只
+
+ {holdings.length === 0 ? (
+
当前空仓,全部为现金。
+ ) : (
+ <>
+
+
+
+ 标的
+ 持股
+ 最新价
+ 市值
+ 占组合
+
+
+
+ {visible.map((h) => {
+ const weight = currentValue ? (h.marketValue ?? 0) / currentValue : 0;
+ return (
+
+
+ {h.ticker}
+
+ {h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+
+ {money(h.lastPrice, currency, 2)}
+ {h.priceStale && (
+ 价格未更新
+ )}
+
+ {money(h.marketValue, currency)}
+ {pct(weight)}
+
+ );
+ })}
+
+
+
+
+ 现金
+
+ -
+ -
+ {money(cash, currency)}
+ {pct(cashWeight)}
+
+
+
+
setPage((s) => ({ ...s, page: p }))}
+ onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
+ />
+ >
+ )}
+
+ );
+}
+
+function TradeLogPanel({ log, currency }: { log: TradeLogEntry[]; currency: StrategyCurrency }) {
+ // Render newest-first; the trader logs trades chronologically.
+ const reversed = useMemo(() => [...log].reverse(), [log]);
+ const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
+ usePagination(reversed.length, [log]);
+ const visible = reversed.slice(sliceStart, sliceEnd);
+
+ return (
+
+
+
交易历史
+ {log.length} 笔
+
+ {log.length === 0 ? (
+
尚无交易记录。
+ ) : (
+ <>
+
+
+
+ 日期
+ 方向
+ 标的
+ 股数
+ 成交价
+ 金额
+ 手续费
+
+
+
+ {visible.map((t, i) => (
+
+ {t.date}
+
+
+ {t.action === "BUY" ? "买入" : "卖出"}
+
+
+
+ {t.ticker}
+
+ {t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+ {money(t.price, currency, 2)}
+ {money(t.value, currency)}
+ {money(t.commission, currency, 2)}
+
+ ))}
+
+
+
setPage((s) => ({ ...s, page: p }))}
+ onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
+ />
+ >
+ )}
+
+ );
+}
+
+const money = formatMoney;
+const pct = formatPct;
diff --git a/web/src/client/StrategyPage.tsx b/web/src/client/StrategyPage.tsx
new file mode 100644
index 0000000..1961505
--- /dev/null
+++ b/web/src/client/StrategyPage.tsx
@@ -0,0 +1,984 @@
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import {
+ ColorType,
+ CrosshairMode,
+ LineSeries,
+ createChart,
+ type IChartApi,
+ type ISeriesApi,
+ type LineData,
+ type Time,
+} from "lightweight-charts";
+import { ArrowDown, ArrowUp, BarChart3, Trophy, TrendingDown, TrendingUp } from "lucide-react";
+import {
+ calendarDaysSince,
+ currencyForMarket,
+ formatMoney,
+ formatNumber,
+ formatPct,
+ type BacktestResult,
+ type PrecomputedIndex,
+ type PrecomputedScenarioInfo,
+ type StrategyInfo,
+ type StrategyMarket,
+ type StrategyMetrics,
+ type StrategyResult,
+} from "../shared/strategyTypes";
+
+const STRATEGY_COLORS = [
+ "#4db6ff", "#f3c969", "#c084fc", "#26a69a", "#ef5350",
+ "#ff9800", "#8bc34a", "#e91e63", "#00bcd4", "#cddc39",
+];
+
+const MAX_CHART_SELECTION = 10;
+// Precomputed data older than this (calendar days) is flagged as stale.
+const STALE_DATA_DAYS = 7;
+
+type RankMetric =
+ | "annualizedReturn"
+ | "sharpeRatio"
+ | "sortinoRatio"
+ | "calmarRatio"
+ | "maxDrawdown"
+ | "winRate"
+ | "rebalanceWinRate"
+ | "totalReturn";
+
+const RANK_METRICS: Array<{ key: RankMetric; label: string; better: "high" | "low" }> = [
+ { key: "sharpeRatio", label: "Sharpe", better: "high" },
+ { key: "annualizedReturn", label: "年化", better: "high" },
+ { key: "totalReturn", label: "总收益", better: "high" },
+ { key: "calmarRatio", label: "Calmar", better: "high" },
+ { key: "sortinoRatio", label: "Sortino", better: "high" },
+ // maxDrawdown is negative: closer to zero (higher) is better.
+ { key: "maxDrawdown", label: "MaxDD", better: "high" },
+ { key: "winRate", label: "日胜率", better: "high" },
+ { key: "rebalanceWinRate", label: "再平衡胜率", better: "high" },
+];
+
+async function api(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, {
+ ...init,
+ headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
+ });
+ if (!response.ok) {
+ const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
+ throw new Error(body?.error ?? `Request failed: ${response.status}`);
+ }
+ return (await response.json()) as T;
+}
+
+export function StrategyPage() {
+ const [strategies, setStrategies] = useState([]);
+ const [selected, setSelected] = useState>(new Set());
+ const [market, setMarket] = useState("us");
+ const [scenarioList, setScenarioList] = useState([]);
+ const [scenarioId, setScenarioId] = useState(null);
+ const [precomputed, setPrecomputed] = useState();
+ const [precomputedError, setPrecomputedError] = useState("");
+ const [error, setError] = useState("");
+ const [focusStrategy, setFocusStrategy] = useState(null);
+ const [categoryFilter, setCategoryFilter] = useState("all");
+ const [rankMetric, setRankMetric] = useState("sharpeRatio");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api("/api/strategies")
+ .then(setStrategies)
+ .catch((err) => setError(err.message));
+ }, []);
+
+ useEffect(() => {
+ api("/api/strategies/precomputed/index")
+ .then((idx) => {
+ setScenarioList(idx.scenarios);
+ if (idx.scenarios.length > 0 && !scenarioId) {
+ setScenarioId(idx.scenarios[0].id);
+ }
+ })
+ .catch((err) => setPrecomputedError(err instanceof Error ? err.message : "无法加载场景列表"));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Prefer the scenario the server says it served over the requested one.
+ const servedScenarioId = precomputed?.scenarioId ?? scenarioId;
+ const currentScenario = useMemo(
+ () => scenarioList.find((s) => s.id === servedScenarioId) ?? null,
+ [scenarioList, servedScenarioId],
+ );
+
+ useEffect(() => {
+ if (!scenarioId) return;
+ setLoading(true);
+ setPrecomputedError("");
+ setPrecomputed(undefined);
+ api(`/api/strategies/precomputed?market=${market}&scenario=${encodeURIComponent(scenarioId)}`)
+ .then((res) => {
+ setPrecomputed(res);
+ const ranked = rankBy(res.strategies, rankMetric).filter((s) => !s.ruined);
+ const top = new Set(ranked.slice(0, 5).map((s) => s.name));
+ setSelected(top);
+ if (ranked.length > 0) setFocusStrategy(ranked[0].name);
+ })
+ .catch((err) => {
+ setPrecomputedError(err instanceof Error ? err.message : "无法加载预计算结果");
+ })
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [market, scenarioId]);
+
+ const categoryByName = useMemo(() => {
+ const map = new Map();
+ for (const s of strategies) map.set(s.name, s.category);
+ return map;
+ }, [strategies]);
+
+ const categories = useMemo(() => {
+ if (!precomputed) return ["all"];
+ const seen = new Set();
+ for (const s of precomputed.strategies) {
+ seen.add(categoryByName.get(s.name) ?? "other");
+ }
+ return ["all", ...Array.from(seen).sort()];
+ }, [precomputed, categoryByName]);
+
+ const filteredStrategies = useMemo(() => {
+ if (!precomputed) return [];
+ if (categoryFilter === "all") return precomputed.strategies;
+ return precomputed.strategies.filter(
+ (s) => (categoryByName.get(s.name) ?? "other") === categoryFilter,
+ );
+ }, [precomputed, categoryFilter, categoryByName]);
+
+ const rankedStrategies = useMemo(
+ () => rankBy(filteredStrategies, rankMetric),
+ [filteredStrategies, rankMetric],
+ );
+
+ const rankIndex = useMemo(() => {
+ const map = new Map();
+ rankedStrategies.forEach((s, i) => map.set(s.name, i + 1));
+ return map;
+ }, [rankedStrategies]);
+
+ const chartResult: BacktestResult | undefined = useMemo(() => {
+ if (!precomputed) return undefined;
+ return {
+ ...precomputed,
+ strategies: rankedStrategies.filter((s) => selected.has(s.name)),
+ };
+ }, [precomputed, rankedStrategies, selected]);
+
+ function toggleStrategy(name: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(name)) {
+ next.delete(name);
+ } else {
+ if (next.size >= MAX_CHART_SELECTION) return prev;
+ next.add(name);
+ }
+ return next;
+ });
+ setFocusStrategy(name);
+ }
+
+ function selectTopN(metric: RankMetric, n: number) {
+ if (!precomputed) return;
+ const ranked = rankBy(filteredStrategies, metric).filter((s) => !s.ruined);
+ const top = new Set(ranked.slice(0, n).map((s) => s.name));
+ setSelected(top);
+ if (ranked.length > 0) setFocusStrategy(ranked[0].name);
+ setRankMetric(metric);
+ }
+
+ function clearSelection() {
+ setSelected(new Set());
+ }
+
+ const meta = precomputed?.meta;
+ const computedAt = precomputed?.computedAt ? new Date(precomputed.computedAt) : null;
+ const dataAgeDays = calendarDaysSince(meta?.endDate);
+ const dataStale = dataAgeDays !== null && dataAgeDays > STALE_DATA_DAYS;
+
+ return (
+
+ {error &&
{error}
}
+ {precomputedError &&
{precomputedError}
}
+ {dataStale && (
+
+ 预计算数据截至 {meta?.endDate},已滞后 {dataAgeDays} 天。在 quant 项目重新运行 precompute_backtest.py 以更新。
+
+ )}
+
+
+ {/* Left: Strategy selector */}
+
+
+
策略选择
+ {selected.size} / {filteredStrategies.length}
+
+
+
+
+
市场
+
+ setMarket("us")}>美股
+ setMarket("cn")}>A股
+
+
+ {scenarioList.length > 0 && (
+
+
投入方式
+
+ {scenarioList.map((sc) => (
+ setScenarioId(sc.id)}
+ type="button"
+ title={scenarioDescription(sc, market)}
+ >
+ {scenarioLabel(sc, market)}
+ {summarizeScenario(sc, market)}
+
+ ))}
+
+
+ )}
+ {meta && (
+
+ {meta.years} 年 · 离线预计算 · 数据截至 {meta.endDate ?? "—"}
+ {computedAt ? ` · 计算于 ${computedAt.toLocaleDateString()}` : ""}
+ {dataStale && 数据滞后 {dataAgeDays} 天 }
+
+ )}
+
+
+
+
快速选取
+
+ selectTopN("sharpeRatio", 5)}>
+ Top 5 Sharpe
+
+ selectTopN("annualizedReturn", 5)}>
+ Top 5 年化
+
+ 清空
+
+
+
+ {categories.length > 1 && (
+
+ {categories.map((cat) => (
+ setCategoryFilter(cat)}
+ >
+ {categoryLabel(cat)}
+
+ ))}
+
+ )}
+
+
+ {loading &&
加载预计算结果中...
}
+ {!loading && rankedStrategies.length === 0 && (
+
当前筛选下暂无策略
+ )}
+ {rankedStrategies.map((s, i) => {
+ const isSelected = selected.has(s.name);
+ const colorIdx = chartResult?.strategies.findIndex((x) => x.name === s.name) ?? -1;
+ const color = colorIdx >= 0 ? STRATEGY_COLORS[colorIdx % STRATEGY_COLORS.length] : undefined;
+ return (
+
+ #{i + 1}
+ toggleStrategy(s.name)}
+ />
+
+
+ {displayStrategyName(s)}
+ {s.ruined && }
+
+
+ {pct(s.metrics.annualizedReturn)}
+
+
+ {num(s.metrics.sharpeRatio)}
+
+
+ );
+ })}
+
+
+
+ {/* Right: Results */}
+
+ {chartResult && precomputed ? (
+ <>
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
{loading ? "正在加载预计算回测结果..." : "暂无预计算回测结果"}
+
在 quant 项目运行 precompute_backtest.py 后即可查看
+
+ )}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Ranking Leaderboard
+// ---------------------------------------------------------------------------
+
+function RankingLeaderboard({
+ strategies,
+ selected,
+ onToggle,
+ onFocus,
+ rankMetric,
+ onChangeMetric,
+ categoryByName,
+ rankIndex,
+ timeWeighted,
+}: {
+ strategies: StrategyResult[];
+ selected: Set;
+ onToggle: (name: string) => void;
+ onFocus: (name: string) => void;
+ rankMetric: RankMetric;
+ onChangeMetric: (metric: RankMetric) => void;
+ categoryByName: Map;
+ rankIndex: Map;
+ timeWeighted: boolean;
+}) {
+ return (
+
+
+
+
Leaderboard
+
策略排名
+
+ 点击行加入/移除对比;列头切换排序指标
+ {timeWeighted ? ";收益/风险指标为时间加权 (TWR),不含注资影响" : ""}
+
+
+
{strategies.length} 个策略
+
+
+
+
+
+ #
+ 策略
+ 类别
+ {RANK_METRICS.map((m) => {
+ const active = rankMetric === m.key;
+ return (
+ onChangeMetric(m.key)}
+ title={`按 ${m.label} 排序`}
+ >
+ {m.label}
+ {active && (m.better === "high" ? : )}
+
+ );
+ })}
+
+
+
+ {strategies.map((s) => {
+ const isSelected = selected.has(s.name);
+ const rank = rankIndex.get(s.name) ?? 0;
+ return (
+ {
+ onToggle(s.name);
+ onFocus(s.name);
+ }}
+ >
+
+ {rank}
+
+
+ {displayStrategyName(s)}
+ {s.ruined && }
+ {s.name}
+
+
+
+ {categoryLabel(categoryByName.get(s.name) ?? "other")}
+
+
+ {num(s.metrics.sharpeRatio)}
+
+ {pct(s.metrics.annualizedReturn)}
+
+
+ {pct(s.metrics.totalReturn)}
+
+ {num(s.metrics.calmarRatio)}
+ {num(s.metrics.sortinoRatio)}
+ {pct(s.metrics.maxDrawdown)}
+ {pct(s.metrics.winRate)}
+ {pct(s.metrics.rebalanceWinRate)}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Equity Curve Chart
+// ---------------------------------------------------------------------------
+
+function EquityCurveChart({
+ result,
+ focusStrategy,
+ onFocus,
+ scenario,
+}: {
+ result: BacktestResult;
+ focusStrategy: string | null;
+ onFocus: (name: string) => void;
+ scenario: PrecomputedScenarioInfo | null;
+}) {
+ const containerRef = useRef(null);
+ const chartRef = useRef(null);
+ const seriesMapRef = useRef>>(new Map());
+ const currency = currencyForMarket(result.meta.market);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const chart = createChart(containerRef.current, {
+ width: containerRef.current.clientWidth,
+ height: 480,
+ layout: {
+ background: { type: ColorType.Solid, color: "#0d1219" },
+ textColor: "#b0b0b0",
+ attributionLogo: false,
+ },
+ grid: {
+ vertLines: { color: "#1a2030" },
+ horzLines: { color: "#1a2030" },
+ },
+ crosshair: { mode: CrosshairMode.Normal },
+ timeScale: { borderColor: "#1d2530", timeVisible: false },
+ rightPriceScale: { borderColor: "#1d2530" },
+ });
+ chartRef.current = chart;
+
+ const observer = new ResizeObserver((entries) => {
+ const width = entries[0]?.contentRect.width;
+ if (width) chart.applyOptions({ width });
+ });
+ observer.observe(containerRef.current);
+
+ return () => {
+ observer.disconnect();
+ chart.remove();
+ chartRef.current = null;
+ seriesMapRef.current.clear();
+ };
+ }, []);
+
+ useEffect(() => {
+ const chart = chartRef.current;
+ if (!chart) return;
+
+ chart.applyOptions({
+ localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
+ });
+ for (const series of seriesMapRef.current.values()) {
+ chart.removeSeries(series);
+ }
+ seriesMapRef.current.clear();
+
+ result.strategies.forEach((strat, i) => {
+ const color = STRATEGY_COLORS[i % STRATEGY_COLORS.length];
+ const lineWidth = focusStrategy === strat.name ? 3 : 1.6;
+ const series = chart.addSeries(LineSeries, {
+ color,
+ lineWidth: lineWidth as 1 | 2 | 3 | 4,
+ crosshairMarkerVisible: true,
+ priceLineVisible: false,
+ });
+ const data: LineData[] = strat.equityCurve.map((p) => ({
+ time: p.date as Time,
+ value: p.value,
+ }));
+ series.setData(data);
+ seriesMapRef.current.set(strat.name, series);
+ });
+
+ if (result.benchmark.equityCurve.length > 0) {
+ const benchSeries = chart.addSeries(LineSeries, {
+ color: "#7c8694",
+ lineWidth: 1,
+ lineStyle: 2,
+ title: result.benchmark.name,
+ crosshairMarkerVisible: false,
+ priceLineVisible: false,
+ });
+ benchSeries.setData(
+ result.benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })),
+ );
+ seriesMapRef.current.set("__benchmark__", benchSeries);
+ }
+
+ chart.timeScale().fitContent();
+ }, [result, focusStrategy, currency]);
+
+ return (
+
+
+
+
Equity Curves
+
资产曲线对比
+
+
+ {result.meta.market.toUpperCase()}
+ ·
+ {result.meta.years} 年
+ ·
+ {formatMoney(result.meta.initialCapital, currency)} 初始
+ {scenario?.injectionSchedule && (
+ <>
+ ·
+ {summarizeScenario(scenario, result.meta.market)}
+ >
+ )}
+
+
+
+ {result.strategies.length === 0 && (
+ 从左侧选取策略加入对比
+ )}
+ {result.strategies.map((strat, i) => (
+ onFocus(strat.name)}
+ type="button"
+ >
+
+ {displayStrategyName(strat)}
+
+ ))}
+ {result.benchmark.equityCurve.length ? (
+
+
+ {result.benchmark.name}
+
+ ) : null}
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Metrics Panel
+// ---------------------------------------------------------------------------
+
+function MetricsPanel({
+ result,
+ focusStrategy,
+ onFocus,
+}: {
+ result: BacktestResult;
+ focusStrategy: string | null;
+ onFocus: (name: string) => void;
+}) {
+ const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
+ const currency = currencyForMarket(result.meta.market);
+ // With cash injections the metrics are time-weighted; the money view comes
+ // from capitalFlows (never recomputed from the money curve here).
+ const timeWeighted = hasInjections(result);
+ const twr = timeWeighted ? " (TWR)" : "";
+ const showFlows = timeWeighted && result.strategies.some((s) => s.capitalFlows);
+ const flows = focused?.capitalFlows;
+ return (
+
+
+
核心指标
+ {focused && (
+
+ {displayStrategyName(focused)}
+ {focused.ruined && }
+
+ )}
+
+ {focused ? (
+ <>
+ {timeWeighted && flows && (
+ <>
+
资金视角(含注资)
+
+
+
+ 0}
+ negative={flows.profit < 0}
+ />
+
+
+ >
+ )}
+ {timeWeighted &&
收益/风险指标为时间加权 (TWR),剔除注资影响
}
+
+
+
+
+
+
+
+
+
+
+
+
+ 终值 {formatMoney(flows?.finalValue ?? focused.equityCurve.at(-1)?.value, currency)}
+ 最大回撤 {pct(focused.metrics.maxDrawdown)}
+ 年化{twr} {pct(focused.metrics.annualizedReturn)}
+ 交易 {focused.trades.length.toLocaleString()} 天
+
+ >
+ ) : (
+
从排名表选取至少一个策略查看核心指标
+ )}
+
+ {result.strategies.length > 1 && (
+
+
+
+
+ 策略
+ {showFlows && (
+ <>
+ 投入本金
+ 期末市值
+ 盈亏
+ 本金收益率
+ >
+ )}
+ 年化{twr}
+ Sharpe
+ MaxDD
+ 日胜率
+ 再平衡胜率
+
+
+
+ {result.strategies.map((s) => (
+ onFocus(s.name)}>
+
+ {displayStrategyName(s)}
+ {s.ruined && }
+ {s.name}
+
+ {showFlows && (
+ <>
+ {formatMoney(s.capitalFlows?.invested, currency)}
+ {formatMoney(s.capitalFlows?.finalValue, currency)}
+ {formatMoney(s.capitalFlows?.profit, currency)}
+ {pct(s.capitalFlows?.returnOnInvested)}
+ >
+ )}
+ {pct(s.metrics.annualizedReturn)}
+ {num(s.metrics.sharpeRatio)}
+ {pct(s.metrics.maxDrawdown)}
+ {pct(s.metrics.winRate)}
+ {pct(s.metrics.rebalanceWinRate)}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+function MetricCell({ label, value, positive, negative }: { label: string; value: string; positive?: boolean; negative?: boolean }) {
+ const cls = positive ? "positive" : negative ? "negative" : "";
+ return (
+
+ {value}
+ {label}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Drawdown Info
+// ---------------------------------------------------------------------------
+
+function DrawdownInfo({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
+ const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
+ if (!focused) {
+ return (
+
+ );
+ }
+ const topDrawdowns = [...focused.drawdowns].sort((a, b) => a.depth - b.depth).slice(0, 5);
+ return (
+
+
+
重大回撤
+ {displayStrategyName(focused)}
+
+ {topDrawdowns.length === 0 ? (
+
无显著回撤期间 (阈值 -5%)
+ ) : (
+
+
+
+ 开始
+ 结束
+ 深度
+ 恢复
+
+
+
+ {topDrawdowns.map((dd, i) => (
+
+ {dd.start}
+ {dd.end}
+ {pct(dd.depth)}
+ {dd.recoveryDate ?? "未恢复"}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Return Heatmap
+// ---------------------------------------------------------------------------
+
+function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
+ const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
+ if (!focused) return null;
+ const { monthlyReturns, yearlyReturns } = focused;
+ const years = Object.keys(yearlyReturns).sort();
+ const months = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"];
+ const monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+
+ return (
+
+
+
收益热力图{hasInjections(result) ? " (TWR)" : ""}
+ {displayStrategyName(focused)}
+
+
+
+
+
+ Year
+ {monthLabels.map((m) => {m} )}
+ YTD
+
+
+
+ {years.map((year) => (
+
+ {year}
+ {months.map((month) => {
+ const key = `${year}-${month}`;
+ const val = finiteOrUndefined(monthlyReturns[key]);
+ return (
+
+ {val !== undefined ? `${(val * 100).toFixed(1)}` : ""}
+
+ );
+ })}
+
+ {pct(yearlyReturns[year])}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function rankBy(strategies: StrategyResult[], metric: RankMetric): StrategyResult[] {
+ const better = RANK_METRICS.find((m) => m.key === metric)?.better ?? "high";
+ const dir = better === "high" ? -1 : 1;
+ const get = (s: StrategyResult) => {
+ const v = s.metrics[metric as keyof StrategyMetrics];
+ return typeof v === "number" && Number.isFinite(v) ? v : (better === "high" ? -Infinity : Infinity);
+ };
+ // Ruined strategies always rank last so they never take a "best" slot.
+ return [...strategies].sort(
+ (a, b) => Number(!!a.ruined) - Number(!!b.ruined) || dir * (get(a) - get(b)),
+ );
+}
+
+function hasInjections(result: BacktestResult): boolean {
+ return !!result.meta.injectionSchedule;
+}
+
+// Scenario capital in the given market's currency. Older files only carry the
+// base (US) `capital`, which then applies to every market.
+function scenarioCapital(sc: PrecomputedScenarioInfo, market: StrategyMarket): number {
+ return sc.capitalByMarket?.[market] ?? sc.capital;
+}
+
+function summarizeScenario(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
+ const currency = currencyForMarket(market);
+ const capital = scenarioCapital(sc, market);
+ const fmt = (v: number) => formatMoney(v, currency);
+ if (!sc.injectionSchedule) return `${fmt(capital)} 一次性`;
+ const inj = sc.injectionSchedule;
+ // The index lists the injection amount in base capital; scale to this market.
+ const amount = sc.capital ? (inj.amount * capital) / sc.capital : inj.amount;
+ if (inj.frequency === "yearly_months" && inj.months?.length) {
+ const monthLabel = inj.months.join("/");
+ return `${fmt(capital)} 起 · 每年 ${monthLabel} 月注入 ${fmt(amount)}`;
+ }
+ const freqLabel = inj.frequency === "monthly" ? "每月" : inj.frequency === "quarterly" ? "每季" : "每年";
+ return `${fmt(capital)} 起 · ${freqLabel}注入 ${fmt(amount)}`;
+}
+
+// Older precomputed files baked USD amounts into scenario copy; don't show
+// that copy for non-US markets.
+function hasUsdCopy(text: string | undefined): boolean {
+ return !!text && /\$|美元|USD/.test(text);
+}
+
+function scenarioLabel(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
+ if (market === "us" || !hasUsdCopy(sc.label)) return sc.label;
+ return sc.injectionSchedule ? "起始资金 + 定期追加" : "起始资金一次性投入";
+}
+
+function scenarioDescription(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
+ if (sc.description && (market === "us" || !hasUsdCopy(sc.description))) return sc.description;
+ return summarizeScenario(sc, market);
+}
+
+function categoryLabel(cat: string): string {
+ if (cat === "all") return "全部";
+ if (cat === "stock_picker") return "选股";
+ if (cat === "factor_combo") return "因子";
+ if (cat === "tactical_allocation") return "配置";
+ if (cat === "other") return "其他";
+ return cat;
+}
+
+const pct = formatPct;
+const num = formatNumber;
+
+function finiteOrUndefined(val: number | null | undefined): number | undefined {
+ return typeof val === "number" && Number.isFinite(val) ? val : undefined;
+}
+
+function isPositive(val: number | null | undefined): boolean {
+ return (finiteOrUndefined(val) ?? 0) > 0;
+}
+
+function isNegative(val: number | null | undefined): boolean {
+ return (finiteOrUndefined(val) ?? 0) < 0;
+}
+
+function isAbove(val: number | null | undefined, threshold: number): boolean {
+ const v = finiteOrUndefined(val);
+ return v !== undefined && v > threshold;
+}
+
+function signClass(val: number | null | undefined): string {
+ const v = finiteOrUndefined(val);
+ if (v === undefined) return "";
+ return v >= 0 ? "positive" : "negative";
+}
+
+function RuinedBadge() {
+ return (
+
+ 爆仓/不可行
+
+ );
+}
+
+function displayStrategyName(strategy: Pick): string {
+ const label = typeof strategy.label === "string" ? strategy.label.trim() : "";
+ return label || strategy.name;
+}
+
+function heatColor(val: number | undefined): string {
+ if (val === undefined) return "transparent";
+ if (val > 0) {
+ const intensity = Math.min(val / 0.15, 1);
+ return `rgba(38, 166, 154, ${0.15 + intensity * 0.6})`;
+ }
+ const intensity = Math.min(Math.abs(val) / 0.15, 1);
+ return `rgba(239, 83, 80, ${0.15 + intensity * 0.6})`;
+}
diff --git a/web/src/client/main.tsx b/web/src/client/main.tsx
new file mode 100644
index 0000000..2c5b3cf
--- /dev/null
+++ b/web/src/client/main.tsx
@@ -0,0 +1,1248 @@
+import React, { useEffect, useMemo, useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import {
+ CandlestickSeries,
+ ColorType,
+ CrosshairMode,
+ HistogramSeries,
+ LineSeries,
+ createChart,
+ type CandlestickData,
+ type HistogramData,
+ type IChartApi,
+ type LineData,
+ type LogicalRange,
+ type MouseEventParams,
+ type Time
+} from "lightweight-charts";
+import {
+ Activity,
+ BarChart3,
+ Bell,
+ Database,
+ Filter,
+ Pencil,
+ Plus,
+ RefreshCw,
+ ShieldAlert,
+ Tag,
+ Trash2,
+ TrendingUp,
+ X,
+ ZoomIn,
+ ZoomOut
+} from "lucide-react";
+import type {
+ ChartPeriod,
+ DailyReport,
+ StockChartData,
+ StockFundamentalsData,
+ StockQuantAnalysis,
+ StockRelatedData,
+ WatchStock
+} from "../shared/types";
+import "./styles.css";
+import { StrategyPage } from "./StrategyPage";
+import { LivePage } from "./LivePage";
+
+type TagFilter = "all" | "untagged" | string;
+type DetailKey = "chart" | "fundamentals" | "related" | "analysis";
+type LoadStatus = "idle" | "loading" | "done" | "error";
+type MaKey = "ma5" | "ma20" | "ma60" | "ma120" | "ma200";
+const PERIODS: ChartPeriod[] = ["D", "W", "M", "Y"];
+const MA_OPTIONS: Array<{ key: MaKey; label: string; color: string }> = [
+ { key: "ma5", label: "MA5", color: "#f3c969" },
+ { key: "ma20", label: "MA20", color: "#4db6ff" },
+ { key: "ma60", label: "MA60", color: "#c084fc" },
+ { key: "ma120", label: "MA120", color: "#f59e0b" },
+ { key: "ma200", label: "MA200", color: "#94a3b8" }
+];
+const EMPTY_DETAIL_STATUS: Record = {
+ chart: "idle",
+ fundamentals: "idle",
+ related: "idle",
+ analysis: "idle"
+};
+
+function App() {
+ const [pageTab, setPageTab] = useState<"watchlist" | "strategies" | "live">("watchlist");
+ const [watchlist, setWatchlist] = useState([]);
+ const [report, setReport] = useState();
+ const [chartData, setChartData] = useState();
+ const [fundamentals, setFundamentals] = useState();
+ const [relatedData, setRelatedData] = useState();
+ const [quantAnalysis, setQuantAnalysis] = useState();
+ const [symbol, setSymbol] = useState("");
+ const [name, setName] = useState("");
+ const [selectedId, setSelectedId] = useState("");
+ const [selectedTag, setSelectedTag] = useState("all");
+ const [chartPeriod, setChartPeriod] = useState("D");
+ const [zoomCount, setZoomCount] = useState(110);
+ const [tagDraft, setTagDraft] = useState("");
+ const [editingTagId, setEditingTagId] = useState(null);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailStatus, setDetailStatus] = useState>(EMPTY_DETAIL_STATUS);
+ const detailRequestRef = useRef(0);
+
+ useEffect(() => {
+ void loadAll();
+ }, []);
+
+ const selectedStock = useMemo(() => watchlist.find((stock) => stock.id === selectedId) ?? watchlist[0], [selectedId, watchlist]);
+
+ useEffect(() => {
+ if (!selectedStock) {
+ setChartData(undefined);
+ setFundamentals(undefined);
+ setRelatedData(undefined);
+ setQuantAnalysis(undefined);
+ return;
+ }
+ setSelectedId(selectedStock.id);
+ setChartData(undefined);
+ setFundamentals(undefined);
+ setRelatedData(undefined);
+ setQuantAnalysis(undefined);
+ void loadStockDetails(selectedStock.display);
+ }, [selectedStock?.id]);
+
+ const marketCounts = useMemo(() => {
+ return {
+ CN: watchlist.filter((stock) => stock.market === "CN").length,
+ HK: watchlist.filter((stock) => stock.market === "HK").length,
+ US: watchlist.filter((stock) => stock.market === "US").length
+ };
+ }, [watchlist]);
+
+ const allTags = useMemo(() => Array.from(new Set(watchlist.flatMap((stock) => stock.tags))).sort(), [watchlist]);
+ const filteredWatchlist = useMemo(() => {
+ if (selectedTag === "all") {
+ return watchlist;
+ }
+ if (selectedTag === "untagged") {
+ return watchlist.filter((stock) => stock.tags.length === 0);
+ }
+ return watchlist.filter((stock) => stock.tags.includes(selectedTag));
+ }, [selectedTag, watchlist]);
+
+ const selectedReportItem = useMemo(() => {
+ return report?.items.find((item) => item.stock.id === selectedStock?.id);
+ }, [report, selectedStock?.id]);
+
+ async function loadAll() {
+ setLoading(true);
+ setError("");
+ try {
+ const [nextWatchlist, nextReport] = await Promise.all([
+ api("/api/watchlist"),
+ api("/api/report/daily")
+ ]);
+ setWatchlist(nextWatchlist);
+ setReport(nextReport);
+ setSelectedId((current) => current || nextWatchlist[0]?.id || "");
+ } catch (nextError) {
+ setError(errorMessage(nextError));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function loadStockDetails(display: string, refresh = false) {
+ const requestId = ++detailRequestRef.current;
+ setDetailLoading(true);
+ setDetailStatus({ chart: "loading", fundamentals: "loading", related: "loading", analysis: "loading" });
+ setError("");
+ try {
+ const suffix = refresh ? "?refresh=1" : "";
+ const separator = refresh ? "&" : "?";
+ const failures: string[] = [];
+ const tasks = [
+ trackDetail("chart", api(`/api/stocks/${encodeURIComponent(display)}/chart${suffix}${separator}period=${chartPeriod}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setChartData(value);
+ }
+ })
+ .catch(() => {
+ failures.push("K线");
+ if (!refresh && requestId === detailRequestRef.current) {
+ setChartData(undefined);
+ }
+ }),
+ trackDetail("fundamentals", api(`/api/stocks/${encodeURIComponent(display)}/fundamentals${suffix}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setFundamentals(value);
+ }
+ })
+ .catch(() => failures.push("财报")),
+ trackDetail("related", api(`/api/stocks/${encodeURIComponent(display)}/related${suffix}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setRelatedData(value);
+ }
+ })
+ .catch(() => failures.push("研究数据")),
+ trackDetail("analysis", api(`/api/stocks/${encodeURIComponent(display)}/analysis${suffix}`), requestId)
+ .then((value) => {
+ if (requestId === detailRequestRef.current) {
+ setQuantAnalysis(value);
+ }
+ })
+ .catch(() => failures.push("Agent分析"))
+ ];
+ await Promise.allSettled(tasks);
+ if (requestId !== detailRequestRef.current) {
+ return;
+ }
+ if (failures.length) {
+ setError(`部分实时数据源未返回有效数据:${failures.join("、")}。未使用模拟数据替代。`);
+ }
+ } catch (nextError) {
+ if (requestId === detailRequestRef.current) {
+ setError(errorMessage(nextError));
+ }
+ } finally {
+ if (requestId === detailRequestRef.current) {
+ setDetailLoading(false);
+ }
+ }
+ }
+
+ async function loadChart(display: string, period: ChartPeriod) {
+ const requestId = ++detailRequestRef.current;
+ setDetailLoading(true);
+ setDetailStatus((current) => ({ ...current, chart: "loading" }));
+ setError("");
+ try {
+ const nextChart = await api(`/api/stocks/${encodeURIComponent(display)}/chart?period=${period}`);
+ if (requestId !== detailRequestRef.current) {
+ return;
+ }
+ setChartData(nextChart);
+ setDetailStatus((current) => ({ ...current, chart: "done" }));
+ } catch (nextError) {
+ if (requestId === detailRequestRef.current) {
+ setDetailStatus((current) => ({ ...current, chart: "error" }));
+ setError(`K线数据源未返回有效 ${period} 周期数据,已保留现有缓存且未使用模拟数据替代。`);
+ }
+ } finally {
+ if (requestId === detailRequestRef.current) {
+ setDetailLoading(false);
+ }
+ }
+ }
+
+ async function trackDetail(key: DetailKey, promise: Promise, requestId: number): Promise {
+ try {
+ const result = await promise;
+ if (requestId === detailRequestRef.current) {
+ setDetailStatus((current) => ({ ...current, [key]: "done" }));
+ }
+ return result;
+ } catch (nextError) {
+ if (requestId === detailRequestRef.current) {
+ setDetailStatus((current) => ({ ...current, [key]: "error" }));
+ }
+ throw nextError;
+ }
+ }
+
+ async function addStock(event: React.FormEvent) {
+ event.preventDefault();
+ setError("");
+ try {
+ const added = await api("/api/watchlist", {
+ method: "POST",
+ body: JSON.stringify({ symbol, name, tags: [] })
+ });
+ setSymbol("");
+ setName("");
+ setSelectedId(added.id);
+ await loadAll();
+ } catch (nextError) {
+ setError(errorMessage(nextError));
+ }
+ }
+
+ async function removeStock(display: string) {
+ await api(`/api/watchlist/${encodeURIComponent(display)}`, { method: "DELETE" });
+ await loadAll();
+ }
+
+ async function saveTags(stock: WatchStock, tagText: string) {
+ const updated = await api(`/api/watchlist/${encodeURIComponent(stock.display)}/tags`, {
+ method: "PUT",
+ body: JSON.stringify({ tags: parseTagText(tagText) })
+ });
+ setWatchlist((stocks) => stocks.map((item) => (item.id === updated.id ? updated : item)));
+ }
+
+ function beginEditTags(stock: WatchStock) {
+ setEditingTagId(stock.id);
+ setTagDraft(stock.tags.join(", "));
+ }
+
+ function cancelEditTags() {
+ setEditingTagId(null);
+ setTagDraft("");
+ }
+
+ async function commitEditTags(stock: WatchStock) {
+ await saveTags(stock, tagDraft);
+ setEditingTagId(null);
+ setTagDraft("");
+ }
+
+ async function refreshReport() {
+ setLoading(true);
+ setError("");
+ try {
+ setReport(await api("/api/report/daily/refresh", { method: "POST" }));
+ if (selectedStock) {
+ await loadStockDetails(selectedStock.display, true);
+ }
+ } catch (nextError) {
+ setError(errorMessage(nextError));
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function chooseTag(tag: TagFilter) {
+ setSelectedTag(tag);
+ const next = tag === "all" ? watchlist[0] : tag === "untagged" ? watchlist.find((stock) => stock.tags.length === 0) : watchlist.find((stock) => stock.tags.includes(tag));
+ if (next) {
+ setSelectedId(next.id);
+ }
+ }
+
+ function changePeriod(period: ChartPeriod) {
+ setChartPeriod(period);
+ setZoomCount(defaultZoomCount(period));
+ if (selectedStock) {
+ void loadChart(selectedStock.display, period);
+ }
+ }
+
+ function zoomIn() {
+ setZoomCount((count) => Math.max(24, Math.round(count * 0.72)));
+ }
+
+ function zoomOut() {
+ setZoomCount((count) => Math.min(260, Math.round(count * 1.38)));
+ }
+
+ return (
+
+
+
+
Personal Stock Monitor
+
策略实盘与量化监控
+
+
+ setPageTab("watchlist")}>自选股
+ setPageTab("strategies")}>策略回测
+ setPageTab("live")}>在线监测
+
+
+
+ {loading ? "加载中" : "刷新"}
+
+
+
+ {error ? {error}
: null}
+
+ {pageTab === "live" ? (
+
+ ) : pageTab === "strategies" ? (
+
+ ) : (
+ <>
+
+
+
+ } label="自选股" value={watchlist.length} />
+ } label="A股 / 港股 / 美股" value={`${marketCounts.CN} / ${marketCounts.HK} / ${marketCounts.US}`} />
+ } label="标签" value={allTags.length} />
+ } label="本地缓存" value="K线 / 财报 / 分析" />
+
+
+
+
+
+
自选股
+ {filteredWatchlist.length} / {watchlist.length}
+
+
+
+
+
按标签筛选
+
+ chooseTag("all")}>
+
+ 全部
+
+ chooseTag("untagged")}>
+ 未标记
+
+ {allTags.map((tagName) => (
+ chooseTag(tagName)}>
+ {tagName}
+
+ ))}
+
+
+
+
+ {filteredWatchlist.map((stock) => {
+ const isSelected = selectedStock?.id === stock.id;
+ const isEditing = editingTagId === stock.id;
+ return (
+
+
setSelectedId(stock.id)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setSelectedId(stock.id);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+ {stock.display}
+ {stock.name || marketName(stock.market)}
+
+
+
{
+ event.stopPropagation();
+ if (isEditing) {
+ cancelEditTags();
+ } else {
+ beginEditTags(stock);
+ }
+ }}
+ >
+ {isEditing ? : }
+
+
{
+ event.stopPropagation();
+ if (editingTagId === stock.id) cancelEditTags();
+ void removeStock(stock.display);
+ }}
+ >
+
+
+
+
+
+
+
+ {isEditing ? (
+
+ setTagDraft(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void commitEditTags(stock);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancelEditTags();
+ }
+ }}
+ placeholder="例如: core, AI, dividend"
+ />
+ void commitEditTags(stock)}
+ >
+ 保存
+
+
+ ) : null}
+
+ );
+ })}
+ {filteredWatchlist.length === 0 ?
当前标签下暂无自选股
: null}
+
+
+
+
+
+
+
+
K Line
+
{selectedStock ? `${selectedStock.display} ${selectedStock.name ?? ""}` : "暂无自选股"}
+
+
+
+ {detailLoading ? "加载中" : chartData ? `缓存 ${new Date(chartData.cachedAt).toLocaleString()}` : "无缓存"}
+
+
+
+
+ {PERIODS.map((period) => (
+ changePeriod(period)}>
+ {period}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
财报与估值
+
{selectedReportItem?.recommendation.reasons.join(" · ") ?? report?.summary ?? "暂无日报"}
+
+
{fundamentals ? `缓存 ${new Date(fundamentals.cachedAt).toLocaleString()}` : ""}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+function KLineChart({ data, snapshot, zoomCount }: { data?: StockChartData; snapshot?: DailyReport["items"][number]["snapshot"]; zoomCount: number }) {
+ const visibleCount = Math.min(data?.candles.length ?? zoomCount, zoomCount);
+ const candles = data?.candles ?? [];
+ const indicators = data?.indicators ?? [];
+ const chartRootRef = useRef(null);
+ const [selectedDate, setSelectedDate] = useState();
+ const [visibleMas, setVisibleMas] = useState>({
+ ma5: true,
+ ma20: true,
+ ma60: true,
+ ma120: false,
+ ma200: false
+ });
+
+ useEffect(() => {
+ setSelectedDate(undefined);
+ }, [data?.stock.id, data?.period]);
+
+ useEffect(() => {
+ const container = chartRootRef.current;
+ if (!container || !candles.length) {
+ return;
+ }
+
+ const chart = createTerminalChart(container);
+ const candleSeries = chart.addSeries(CandlestickSeries, {
+ upColor: "#ef5350",
+ downColor: "#26a69a",
+ borderUpColor: "#ef5350",
+ borderDownColor: "#26a69a",
+ wickUpColor: "#ef5350",
+ wickDownColor: "#26a69a",
+ priceLineColor: "#d5dee9"
+ });
+ const maSeries = MA_OPTIONS.flatMap((option) => {
+ if (!visibleMas[option.key]) {
+ return [];
+ }
+ return [
+ {
+ option,
+ series: chart.addSeries(LineSeries, lineOptions(option.color))
+ }
+ ];
+ });
+ const volumeSeries = chart.addSeries(
+ HistogramSeries,
+ {
+ priceFormat: { type: "volume" },
+ priceScaleId: "volume",
+ lastValueVisible: false,
+ priceLineVisible: false
+ },
+ 1
+ );
+ const macdSeries = chart.addSeries(
+ HistogramSeries,
+ {
+ priceScaleId: "macd",
+ lastValueVisible: false,
+ priceLineVisible: false
+ },
+ 2
+ );
+ const macdLine = chart.addSeries(LineSeries, lineOptions("#f3c969", "DIF"), 2);
+ const signalLine = chart.addSeries(LineSeries, lineOptions("#4db6ff", "DEA"), 2);
+
+ const candleData = candles.map(toCandleData);
+ candleSeries.setData(candleData);
+ for (const item of maSeries) {
+ item.series.setData(indicators.map((point) => toLineData(point.date, point[item.option.key])).filter(isLineData));
+ }
+ volumeSeries.setData(candles.map((candle) => toVolumeData(candle)));
+ macdSeries.setData(indicators.map(toMacdHistogram).filter(isHistogramData));
+ macdLine.setData(indicators.map((point) => toLineData(point.date, point.macd)).filter(isLineData));
+ signalLine.setData(indicators.map((point) => toLineData(point.date, point.macdSignal)).filter(isLineData));
+
+ chart.priceScale("right").applyOptions({ borderColor: "#1e2938", scaleMargins: { top: 0.08, bottom: 0.05 } });
+ chart.priceScale("volume", 1).applyOptions({ borderVisible: false });
+ chart.priceScale("macd", 2).applyOptions({ borderVisible: false });
+ const setLatestRange = () => {
+ chart.timeScale().setVisibleLogicalRange({ from: Math.max(0, candles.length - visibleCount), to: candles.length - 1 });
+ };
+ setLatestRange();
+
+ let correctingRange = false;
+ const clampRightEdge = (range: LogicalRange | null) => {
+ if (!range || correctingRange) {
+ return;
+ }
+ const lastIndex = candles.length - 1;
+ const rangeTo = Number(range.to);
+ const rangeFrom = Number(range.from);
+ if (rangeTo > lastIndex) {
+ const width = rangeTo - rangeFrom;
+ correctingRange = true;
+ chart.timeScale().setVisibleLogicalRange({ from: Math.max(0, lastIndex - width), to: lastIndex });
+ correctingRange = false;
+ }
+ };
+ chart.timeScale().subscribeVisibleLogicalRangeChange(clampRightEdge);
+
+ const handleClick = (param: MouseEventParams) => {
+ const date = timeToDate(param.time);
+ if (date) {
+ setSelectedDate(date);
+ }
+ };
+ chart.subscribeClick(handleClick);
+
+ const resize = new ResizeObserver(([entry]) => {
+ chart.applyOptions({ width: Math.round(entry.contentRect.width) });
+ });
+ resize.observe(container);
+
+ return () => {
+ chart.unsubscribeClick(handleClick);
+ chart.timeScale().unsubscribeVisibleLogicalRangeChange(clampRightEdge);
+ resize.disconnect();
+ chart.remove();
+ };
+ }, [candles, indicators, visibleCount, visibleMas]);
+
+ if (!candles.length) {
+ return 数据源暂无历史 K 线;已忽略技术图表,不使用模拟数据
;
+ }
+
+ const last = candles[candles.length - 1];
+ const previous = candles[candles.length - 2];
+ const lastIndicator = indicators[indicators.length - 1];
+ const macdHistogram = lastIndicator?.macdHistogram;
+ const selectedIndex = selectedDate ? candles.findIndex((candle) => candle.date === selectedDate) : -1;
+ const selected = selectedIndex >= 0 ? candles[selectedIndex] : undefined;
+ const selectedPrevious = selectedIndex > 0 ? candles[selectedIndex - 1] : undefined;
+ const selectedIndicator = selectedDate ? indicators.find((point) => point.date === selectedDate) : undefined;
+ const dayChange = previous?.close ? (last.close - previous.close) / previous.close : undefined;
+ const sessionChange = last.open ? (last.close - last.open) / last.open : undefined;
+
+ return (
+
+
+ {last.close.toFixed(2)}
+ {formatPercent(dayChange)}
+ {previous ? `${formatSigned(last.close - previous.close)} vs 前收` : "前收 -"}
+ 开收 {formatPercent(sessionChange)}
+ O {last.open.toFixed(2)}
+ H {last.high.toFixed(2)}
+ L {last.low.toFixed(2)}
+ V {compactNumber(last.volume)}
+ RSI {formatOptional(lastIndicator?.rsi14)}
+ MACD {formatOptional(macdHistogram)}
+ PE {snapshot?.pe ?? "-"}
+ {data?.period ?? "D"} · {candles.length}
+
+
+ {MA_OPTIONS.map((option) => (
+ setVisibleMas((current) => ({ ...current, [option.key]: !current[option.key] }))}
+ type="button"
+ >
+
+ {option.label} {formatOptional(lastIndicator?.[option.key])}
+
+ ))}
+
+
+
+
+ );
+}
+
+function DayDetailPanel({
+ candle,
+ previousCandle,
+ indicator,
+ period
+}: {
+ candle?: StockChartData["candles"][number];
+ previousCandle?: StockChartData["candles"][number];
+ indicator?: StockChartData["indicators"][number];
+ period: ChartPeriod;
+}) {
+ if (!candle) {
+ return {period === "D" ? "点击任意日 K 查看当日完整数据" : "点击任意 K 线查看该周期完整数据"}
;
+ }
+
+ const change = previousCandle?.close ? (candle.close - previousCandle.close) / previousCandle.close : undefined;
+ const sessionChange = candle.open ? (candle.close - candle.open) / candle.open : undefined;
+ const amplitude = candle.low ? (candle.high - candle.low) / candle.low : undefined;
+ return (
+
+
+ {candle.date}
+ {formatPercent(change)} vs 前收
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function StatusBar({ status }: { status: Record }) {
+ const items: Array<[DetailKey, string]> = [
+ ["chart", "K线"],
+ ["fundamentals", "财报"],
+ ["related", "研究数据"],
+ ["analysis", "Agent分析"]
+ ];
+ if (items.every(([key]) => status[key] === "idle")) {
+ return null;
+ }
+ return (
+
+ {items.map(([key, label]) => (
+
+ {label}: {statusLabel(status[key])}
+
+ ))}
+
+ );
+}
+
+function createTerminalChart(container: HTMLDivElement): IChartApi {
+ return createChart(container, {
+ width: container.clientWidth,
+ height: 420,
+ autoSize: true,
+ layout: {
+ background: { type: ColorType.Solid, color: "#070b11" },
+ textColor: "#9aa4b2",
+ attributionLogo: false,
+ panes: {
+ separatorColor: "#1b2431",
+ separatorHoverColor: "#2e79c8",
+ enableResize: true
+ }
+ },
+ grid: {
+ vertLines: { color: "#111a26" },
+ horzLines: { color: "#111a26" }
+ },
+ crosshair: {
+ mode: CrosshairMode.Normal,
+ vertLine: { color: "#46627f", labelBackgroundColor: "#1c2a3a" },
+ horzLine: { color: "#46627f", labelBackgroundColor: "#1c2a3a" }
+ },
+ rightPriceScale: {
+ borderColor: "#1e2938"
+ },
+ timeScale: {
+ borderColor: "#1e2938",
+ timeVisible: true,
+ secondsVisible: false,
+ rightOffset: 0,
+ barSpacing: 9,
+ fixRightEdge: true,
+ lockVisibleTimeRangeOnResize: true
+ },
+ localization: {
+ priceFormatter: (value: number) => value.toFixed(value >= 100 ? 2 : 3)
+ },
+ handleScale: {
+ axisPressedMouseMove: true,
+ mouseWheel: true,
+ pinch: true
+ },
+ handleScroll: {
+ mouseWheel: true,
+ pressedMouseMove: true,
+ horzTouchDrag: true,
+ vertTouchDrag: false
+ }
+ });
+}
+
+function timeToDate(time?: Time) {
+ if (!time) {
+ return undefined;
+ }
+ if (typeof time === "string") {
+ return time;
+ }
+ if (typeof time === "number") {
+ return new Date(time * 1000).toISOString().slice(0, 10);
+ }
+ return `${time.year}-${String(time.month).padStart(2, "0")}-${String(time.day).padStart(2, "0")}`;
+}
+
+function lineOptions(color: string, title = "") {
+ return {
+ color,
+ ...(title ? { title } : {}),
+ lineWidth: 2 as const,
+ lastValueVisible: false,
+ priceLineVisible: false
+ };
+}
+
+function toCandleData(candle: StockChartData["candles"][number]): CandlestickData {
+ return {
+ time: candle.date,
+ open: candle.open,
+ high: candle.high,
+ low: candle.low,
+ close: candle.close
+ };
+}
+
+function toVolumeData(candle: StockChartData["candles"][number]): HistogramData {
+ return {
+ time: candle.date,
+ value: candle.volume,
+ color: candle.close >= candle.open ? "rgba(239, 83, 80, 0.46)" : "rgba(38, 166, 154, 0.46)"
+ };
+}
+
+function toMacdHistogram(point: StockChartData["indicators"][number]): HistogramData | undefined {
+ if (!isNumber(point.macdHistogram)) {
+ return undefined;
+ }
+ return {
+ time: point.date,
+ value: point.macdHistogram,
+ color: point.macdHistogram >= 0 ? "rgba(239, 83, 80, 0.58)" : "rgba(38, 166, 154, 0.58)"
+ };
+}
+
+function toLineData(date: string, value: number | undefined): LineData | undefined {
+ return isNumber(value) ? { time: date, value } : undefined;
+}
+
+function isLineData(value: LineData | undefined): value is LineData {
+ return value !== undefined;
+}
+
+function isHistogramData(value: HistogramData | undefined): value is HistogramData {
+ return value !== undefined;
+}
+
+function FundamentalsTable({ data, item }: { data?: StockFundamentalsData; item?: DailyReport["items"][number] }) {
+ const statements = data?.statements ?? [];
+ if (!statements.length) {
+ return 暂无财报缓存
;
+ }
+ return (
+ <>
+
+ {item?.snapshot.price ?? "-"} {item?.snapshot.currency ?? ""}
+ PE {item?.snapshot.pe ?? "-"}
+ {valuationLabel(item?.valuation.label)}
+
+
+
期间
+
营收
+
净利润
+
EPS
+
毛利率
+ {statements.map((statement) => (
+
+ {statement.period}
+ {compactNumber(statement.revenue)}
+ {compactNumber(statement.netIncome)}
+ {formatOptional(statement.eps)}
+ {formatPercent(statement.grossMargin)}
+
+ ))}
+
+ >
+ );
+}
+
+function RelatedDataPanel({ data }: { data?: StockRelatedData }) {
+ const context = data?.data;
+ return (
+
+
+
+
相关市场数据
+
{data ? `缓存 ${new Date(data.cachedAt).toLocaleString()}` : "暂无缓存"}
+
+
+
+ {context ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {context.recentNews?.length ?
: null}
+ {context.socialMentions?.length ?
: null}
+ {context.riskFlags.length ?
{context.riskFlags.join(" · ")}
: null}
+
+ ) : (
+
暂无相关市场数据
+ )}
+
+ );
+}
+
+function DataCoveragePanel({
+ chartData,
+ fundamentals,
+ relatedData,
+ analysis
+}: {
+ chartData?: StockChartData;
+ fundamentals?: StockFundamentalsData;
+ relatedData?: StockRelatedData;
+ analysis?: StockQuantAnalysis;
+}) {
+ const firstCandle = chartData?.candles[0];
+ const lastCandle = chartData?.candles[chartData.candles.length - 1];
+ return (
+
+
+
+
数据覆盖
+
打开自选股时,若当天没有分析,会用这些缓存数据重新调用 agent。
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function QuantAnalysisPanel({ data }: { data?: StockQuantAnalysis }) {
+ const analysis = data?.analysis;
+ return (
+
+
+
+
Agent 量化分析
+
{data ? `生成 ${new Date(data.generatedAt).toLocaleString()}` : "等待分析"}
+
+
+
+ {analysis ? (
+ <>
+
+ {formatOptional(analysis.score)}
+ {actionLabel(analysis.action)}
+ 置信度 {isNumber(analysis.confidence) ? (analysis.confidence * 100).toFixed(0) : "-"}% · {analysis.horizon}
+
+
{analysis.summary}
+ {analysis.tradePlan ?
: null}
+ {analysis.marketContext?.length ? (
+
+ {analysis.marketContext.map((item) => (
+ {item}
+ ))}
+
+ ) : null}
+
+ {analysis.factors.map((factor) => (
+
+
+ {factor.name}
+ {factor.evidence}
+
+
{isNumber(factor.score) ? factor.score.toFixed(0) : "-"}
+
+ ))}
+
+
+ {analysis.riskNotes.map((note) => (
+ {note}
+ ))}
+
+ >
+ ) : (
+
暂无量化分析
+ )}
+
+ );
+}
+
+function TradePlanPanel({ plan }: { plan: NonNullable }) {
+ return (
+
+
+
+
+
+
+
{plan.rationale}
+ {plan.invalidation ?
{plan.invalidation} : null}
+
+ );
+}
+
+function MentionList({ title, items }: { title: string; items: NonNullable }) {
+ return (
+
+ );
+}
+
+function Signal({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function Metric({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
+ return (
+
+ {icon}
+ {label}
+ {value}
+
+ );
+}
+
+function TagList({ tags }: { tags: string[] }) {
+ if (!tags.length) {
+ return 无标签 ;
+ }
+ return (
+
+ {tags.map((tagName) => (
+ {tagName}
+ ))}
+
+ );
+}
+
+async function api(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, {
+ ...init,
+ headers: {
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {})
+ }
+ });
+ if (!response.ok) {
+ const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
+ throw new Error(body?.error ?? `Request failed: ${response.status}`);
+ }
+ if (response.status === 204) {
+ return undefined as T;
+ }
+ return (await response.json()) as T;
+}
+
+function parseTagText(value: string): string[] {
+ return value.split(/[,,]/).map((tagName) => tagName.trim()).filter(Boolean);
+}
+
+function marketName(market: string) {
+ return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
+}
+
+function valuationLabel(label?: string) {
+ return label === "discount" ? "估值折价" : label === "premium" ? "估值溢价" : label === "fair" ? "估值合理" : "估值未知";
+}
+
+function actionLabel(action: string) {
+ return action === "buy_watch" ? "买入观察" : action === "avoid" ? "回避" : "持有";
+}
+
+function tradeDirectionLabel(direction: string) {
+ return direction === "long" ? "做多观察" : direction === "reduce" ? "减仓" : direction === "avoid" ? "回避" : "等待";
+}
+
+function statusLabel(status: LoadStatus) {
+ return status === "loading" ? "进行中" : status === "done" ? "完成" : status === "error" ? "失败" : "等待";
+}
+
+function defaultZoomCount(period: ChartPeriod) {
+ return period === "D" ? 110 : period === "W" ? 96 : period === "M" ? 72 : 36;
+}
+
+function compactNumber(value?: number) {
+ if (!isNumber(value)) {
+ return "-";
+ }
+ if (Math.abs(value) >= 1_000_000_000) {
+ return `${(value / 1_000_000_000).toFixed(2)}B`;
+ }
+ if (Math.abs(value) >= 1_000_000) {
+ return `${(value / 1_000_000).toFixed(1)}M`;
+ }
+ return value.toLocaleString();
+}
+
+function formatPercent(value?: number) {
+ return isNumber(value) ? `${(value * 100).toFixed(2)}%` : "-";
+}
+
+function formatSigned(value?: number) {
+ if (!isNumber(value)) {
+ return "-";
+ }
+ return `${value >= 0 ? "+" : ""}${value.toFixed(2)}`;
+}
+
+function changeClass(value?: number) {
+ return isNumber(value) && value >= 0 ? "up" : "down";
+}
+
+function formatOptional(value?: number) {
+ return isNumber(value) ? value.toFixed(2) : "-";
+}
+
+function isNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function errorMessage(error: unknown) {
+ return error instanceof Error ? error.message : "Unknown error";
+}
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/web/src/client/styles.css b/web/src/client/styles.css
new file mode 100644
index 0000000..9a1b36c
--- /dev/null
+++ b/web/src/client/styles.css
@@ -0,0 +1,2505 @@
+:root {
+ color: #e7edf5;
+ background: #080a0e;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ background:
+ linear-gradient(180deg, rgba(23, 28, 36, 0.95), rgba(8, 10, 14, 1) 360px),
+ #080a0e;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+.shell {
+ width: min(1680px, calc(100% - 28px));
+ margin: 0 auto;
+ padding: 18px 0 28px;
+}
+
+.topbar,
+.metrics,
+.workspace,
+.panelHeader,
+.stockForm,
+.watchlist,
+.infoGrid,
+.insightGrid,
+.quoteLine,
+.configRows,
+.fundTable {
+ display: grid;
+ gap: 12px;
+}
+
+.topbar {
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+ min-height: 58px;
+ padding: 6px 0 16px;
+ margin-bottom: 22px;
+ border-bottom: 1px solid #1d2530;
+}
+
+.eyebrow {
+ margin: 0 0 3px;
+ color: #7f8da0;
+ font-size: 11px;
+ font-weight: 850;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+p {
+ margin: 0;
+}
+
+h1 {
+ color: #f4f7fb;
+ font-size: clamp(24px, 2.8vw, 38px);
+ line-height: 1.06;
+ letter-spacing: 0;
+}
+
+h2 {
+ color: #f0f4fa;
+ font-size: 15px;
+ letter-spacing: 0;
+}
+
+.iconText,
+.iconButton,
+.ghostIcon,
+.smallButton,
+.tagChip,
+.toolButton,
+.segmented button {
+ border-radius: 6px;
+ min-height: 36px;
+}
+
+.iconText,
+.smallButton {
+ border: 1px solid #2c74b8;
+ background: #155f9f;
+ color: #fff;
+ font-weight: 850;
+}
+
+.iconText {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 38px;
+ padding: 0 14px;
+}
+
+.iconText:disabled {
+ opacity: 0.65;
+}
+
+.smallButton {
+ padding: 0 11px;
+}
+
+.iconButton,
+.ghostIcon {
+ width: 40px;
+ height: 40px;
+ display: inline-grid;
+ place-items: center;
+}
+
+.iconButton {
+ border: 1px solid #208c6c;
+ background: #14785e;
+ color: #fff;
+}
+
+.ghostIcon {
+ border: 1px solid #283241;
+ background: #111821;
+ color: #91a0b3;
+}
+
+.alert {
+ border: 1px solid #7a2b34;
+ background: #251015;
+ color: #ff9aa5;
+ padding: 11px 13px;
+ margin-bottom: 14px;
+ border-radius: 6px;
+}
+
+.alert.warning {
+ border-color: #6b5320;
+ background: #21180c;
+ color: #e6c071;
+}
+
+/* Inline pills: stale data (amber) and ruined backtest (red). */
+.staleBadge,
+.ruinedBadge {
+ display: inline-block;
+ margin: 0 6px;
+ padding: 1px 6px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 800;
+ white-space: nowrap;
+ vertical-align: middle;
+}
+
+.staleBadge {
+ color: #e6c071;
+ background: #21180c;
+ border: 1px solid #6b5320;
+}
+
+.ruinedBadge {
+ color: #ff99a1;
+ background: #2a1117;
+ border: 1px solid #7a2b34;
+}
+
+.statusBar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: -4px 0 14px;
+}
+
+.loadState {
+ border: 1px solid #202936;
+ border-radius: 999px;
+ background: #0d1219;
+ color: #91a0b3;
+ padding: 6px 10px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.loadState.loading {
+ border-color: #2e79c8;
+ color: #9dd3ff;
+ background: #102235;
+}
+
+.loadState.done {
+ border-color: #17654e;
+ color: #74e4bd;
+ background: #10281f;
+}
+
+.loadState.error {
+ border-color: #7a2b34;
+ color: #ff99a1;
+ background: #2a1117;
+}
+
+.metrics {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ margin-bottom: 14px;
+}
+
+.metric,
+.panel {
+ background: #10151d;
+ border: 1px solid #202936;
+ border-radius: 8px;
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
+}
+
+.metric {
+ min-height: 78px;
+ padding: 13px 14px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto auto;
+ gap: 5px 10px;
+ align-content: center;
+}
+
+.metric svg {
+ grid-row: 1 / 3;
+ color: #d5a44b;
+}
+
+.metric span,
+.panelHeader p,
+.panelHeader span,
+.empty,
+.hint,
+.watchItem span,
+.configRows span,
+.cacheMeta {
+ color: #8390a2;
+ font-size: 12px;
+}
+
+.metric strong {
+ color: #f4f7fb;
+ font-size: 18px;
+ line-height: 1.1;
+}
+
+.workspace {
+ grid-template-columns: 330px minmax(0, 1fr);
+ align-items: start;
+}
+
+.sidebar,
+.detailStack {
+ min-width: 0;
+}
+
+.panel {
+ padding: 14px;
+}
+
+.sidebar {
+ position: sticky;
+ top: 12px;
+}
+
+.panelHeader {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ margin-bottom: 12px;
+}
+
+.chartHeader {
+ align-items: center;
+ margin-bottom: 10px;
+}
+
+.cacheMeta {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.sidebarSectionLabel {
+ font-size: 11px;
+ color: #7f8da0;
+ font-weight: 800;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.sidebarHint {
+ margin: 0;
+ font-size: 11px;
+ color: #6f7c8f;
+ line-height: 1.4;
+}
+
+.stockForm {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 14px;
+ padding: 10px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+}
+
+.stockFormRow {
+ display: flex;
+ gap: 6px;
+ min-width: 0;
+}
+
+.stockFormRow > .ghostInput {
+ flex: 1 1 0;
+ min-width: 0;
+ width: 0;
+}
+
+.stockFormSubmitRow .ghostButton {
+ flex-shrink: 0;
+}
+
+.stockFormSubmit {
+ justify-content: center;
+}
+
+.sidebarSection {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 10px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+ margin-bottom: 14px;
+ min-width: 0;
+}
+
+.tagFilterSection .tagFilter {
+ margin-bottom: 0;
+}
+
+input {
+ width: 100%;
+ min-height: 40px;
+ border: 1px solid #283241;
+ border-radius: 6px;
+ padding: 0 11px;
+ color: #e7edf5;
+ background: #0b1017;
+}
+
+input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ min-height: 16px;
+ padding: 0;
+ margin: 0;
+ flex: 0 0 auto;
+}
+
+input::placeholder {
+ color: #536173;
+}
+
+input:focus {
+ outline: 2px solid rgba(46, 121, 200, 0.45);
+ border-color: #2e79c8;
+}
+
+.tagFilter {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-bottom: 12px;
+}
+
+.tagChip {
+ border: 1px solid #283241;
+ background: #0d131b;
+ color: #a8b3c2;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 0 9px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.tagChip.active {
+ border-color: #2e79c8;
+ background: #112943;
+ color: #d5ebff;
+}
+
+.watchlist {
+ gap: 6px;
+}
+
+.watchItem {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ text-align: left;
+ border: 1px solid #1d2530;
+ background: #0c1118;
+ border-radius: 7px;
+ padding: 9px 10px;
+ transition: border-color 0.12s, background 0.12s;
+ min-width: 0;
+}
+
+.watchItem:hover {
+ border-color: #2c3a4f;
+ background: #0d141d;
+}
+
+.watchItem.selected {
+ border-color: #2c74b8;
+ background: rgba(77, 182, 255, 0.07);
+}
+
+.watchItem.editing {
+ border-color: #2c74b8;
+}
+
+.watchItemHead {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 8px;
+ cursor: pointer;
+ min-width: 0;
+}
+
+.watchItemHead:focus-visible {
+ outline: 2px solid rgba(46, 121, 200, 0.45);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.watchItemTitle {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.watchItemTitle strong {
+ color: #f3f6fb;
+ font-size: 13px;
+ font-weight: 800;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.watchItemTitle span {
+ color: #8390a2;
+ font-size: 11px;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.watchItemActions {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.watchItemTags {
+ min-width: 0;
+}
+
+.watchItemTagEdit {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.watchItemTagEdit .ghostInput {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
+.ghostIconButton {
+ width: 26px;
+ height: 26px;
+ display: inline-grid;
+ place-items: center;
+ border: 1px solid #1d2530;
+ background: transparent;
+ color: #7f8da0;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+
+.ghostIconButton:hover {
+ border-color: #2c74b8;
+ background: #11202f;
+ color: #cfe6ff;
+}
+
+.ghostIconButton.danger:hover {
+ border-color: #7a2b34;
+ background: #2a1117;
+ color: #ff9aa5;
+}
+
+.ghostButtonPrimary {
+ border-color: #2c74b8;
+ background: #112943;
+ color: #cfe6ff;
+}
+
+.ghostButtonPrimary:hover:not(:disabled) {
+ border-color: #4d97e0;
+ background: #163556;
+ color: #ffffff;
+}
+
+.tagList {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+}
+
+.tagList em {
+ border-radius: 999px;
+ background: #10281f;
+ color: #7bdcbf;
+ font-size: 11px;
+ font-style: normal;
+ font-weight: 850;
+ padding: 2px 7px;
+}
+
+.tagList.muted {
+ color: #617083;
+}
+
+.detailStack {
+ display: grid;
+ gap: 14px;
+}
+
+.chartPanel {
+ background: #070b11;
+ border-color: #1a2230;
+ padding: 14px;
+}
+
+.chartPanel .eyebrow,
+.chartPanel .cacheMeta,
+.chartPanel .panelHeader span {
+ color: #7f8da0;
+}
+
+.chartControls {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 5px;
+ margin: -4px 0 8px;
+}
+
+.segmented {
+ display: inline-grid;
+ grid-template-columns: repeat(4, 28px);
+ gap: 2px;
+ border: 1px solid #222c3a;
+ border-radius: 6px;
+ padding: 2px;
+ background: #080d13;
+}
+
+.segmented button,
+.toolButton {
+ border: 0;
+ background: #111923;
+ color: #9caabc;
+ font-size: 11px;
+ font-weight: 850;
+ min-height: 26px;
+}
+
+.segmented button.active {
+ background: #d5a44b;
+ color: #0b0f15;
+}
+
+.toolButton {
+ width: 28px;
+ display: inline-grid;
+ place-items: center;
+ border-radius: 6px;
+}
+
+.chartWrap {
+ display: grid;
+ gap: 10px;
+}
+
+.tickerTape {
+ min-height: 36px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 7px;
+ color: #a8b3c2;
+ font-size: 12px;
+}
+
+.tickerTape strong {
+ color: #f7fafc;
+ font-size: 24px;
+ line-height: 1;
+ margin-right: 2px;
+}
+
+.tickerTape span {
+ border: 1px solid #1d2632;
+ border-radius: 6px;
+ background: #0d131b;
+ padding: 5px 8px;
+ font-weight: 850;
+}
+
+.tickerTape .up {
+ color: #ff6b72;
+ border-color: rgba(239, 83, 80, 0.3);
+ background: rgba(239, 83, 80, 0.1);
+}
+
+.tickerTape .down {
+ color: #3bd6aa;
+ border-color: rgba(38, 166, 154, 0.3);
+ background: rgba(38, 166, 154, 0.1);
+}
+
+.maToggleBar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ margin-top: -2px;
+}
+
+.maToggleBar button {
+ min-height: 27px;
+ border: 1px solid #1d2632;
+ border-radius: 6px;
+ background: #0d131b;
+ color: #8f9caf;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px;
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.maToggleBar button.active {
+ border-color: #334256;
+ color: #f0f5fb;
+ background: #121b26;
+}
+
+.maToggleBar i {
+ width: 14px;
+ height: 2px;
+ border-radius: 999px;
+ opacity: 0.45;
+}
+
+.maToggleBar button.active i {
+ opacity: 1;
+}
+
+.libraryChart {
+ width: 100%;
+ min-height: 390px;
+ overflow: hidden;
+ border: 1px solid #1a2230;
+ border-radius: 7px;
+ background: #070b11;
+}
+
+.chartEmpty {
+ min-height: 340px;
+ display: grid;
+ place-items: center;
+ color: #7f8da0;
+ border: 1px solid #1a2230;
+ border-radius: 7px;
+ background: #070b11;
+}
+
+.dayDetail {
+ border: 1px solid #1a2230;
+ border-radius: 7px;
+ background: #0a0f16;
+ padding: 9px;
+ display: grid;
+ gap: 8px;
+}
+
+.dayDetail.empty {
+ min-height: 42px;
+ place-items: center;
+ color: #7f8da0;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.dayDetailTitle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.dayDetailTitle strong {
+ color: #f0f5fb;
+ font-size: 13px;
+}
+
+.dayDetailTitle span {
+ border-radius: 999px;
+ padding: 3px 8px;
+ font-size: 11px;
+ font-weight: 850;
+}
+
+.dayDetailTitle .up {
+ color: #ff737a;
+ background: rgba(239, 83, 80, 0.12);
+}
+
+.dayDetailTitle .down {
+ color: #4be2b8;
+ background: rgba(38, 166, 154, 0.12);
+}
+
+.dayDetailGrid {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(86px, 1fr));
+ gap: 6px;
+ overflow-x: auto;
+}
+
+.dayDetailGrid .signal {
+ padding: 7px 8px;
+ gap: 2px;
+}
+
+.dayDetailGrid .signal span {
+ font-size: 10px;
+}
+
+.dayDetailGrid .signal strong {
+ font-size: 12px;
+}
+
+.infoGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.insightGrid {
+ grid-template-columns: minmax(360px, 0.95fr) minmax(0, 1.25fr);
+}
+
+.hint {
+ margin-top: 8px;
+}
+
+.configRows {
+ grid-template-columns: 92px minmax(0, 1fr);
+ gap: 8px 12px;
+ align-items: baseline;
+}
+
+.configRows strong {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: #e7edf5;
+ font-size: 13px;
+}
+
+.report {
+ min-width: 0;
+}
+
+.quoteLine {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ margin-bottom: 12px;
+}
+
+.quoteLine span {
+ border: 1px solid #202936;
+ border-radius: 7px;
+ background: #0d1219;
+ min-height: 34px;
+ display: grid;
+ place-items: center;
+ color: #dfe7f1;
+ font-size: 12px;
+ font-weight: 850;
+ text-align: center;
+ padding: 0 8px;
+}
+
+.fundTable {
+ grid-template-columns: 0.7fr repeat(4, minmax(0, 1fr));
+ gap: 0;
+ overflow: hidden;
+ border: 1px solid #202936;
+ border-radius: 7px;
+}
+
+.fundTable > * {
+ min-height: 36px;
+ display: grid;
+ align-items: center;
+ padding: 0 10px;
+ border-bottom: 1px solid #202936;
+ color: #cfd8e5;
+ font-size: 12px;
+}
+
+.fundTable > *:nth-last-child(-n + 5) {
+ border-bottom: 0;
+}
+
+.fundHead {
+ background: #121923;
+ color: #8d9aad;
+ font-weight: 850;
+}
+
+.fundTable strong {
+ color: #f2f6fb;
+ font-size: 12px;
+}
+
+.relatedGrid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 9px;
+}
+
+.coverageRows {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 9px;
+}
+
+.signal {
+ min-width: 0;
+ border: 1px solid #202936;
+ border-radius: 7px;
+ background: #0d1219;
+ padding: 10px;
+ display: grid;
+ gap: 4px;
+}
+
+.signal span {
+ color: #8390a2;
+ font-size: 11px;
+}
+
+.signal strong {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: #f0f5fb;
+ font-size: 13px;
+}
+
+.riskFlags {
+ grid-column: 1 / -1;
+ border: 1px solid #61451d;
+ border-radius: 7px;
+ background: #21180c;
+ color: #e3ba72;
+ padding: 10px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.mentionList {
+ grid-column: 1 / -1;
+ border: 1px solid #202936;
+ border-radius: 7px;
+ background: #0d1219;
+ padding: 10px;
+ display: grid;
+ gap: 7px;
+}
+
+.mentionList > strong {
+ color: #f0f5fb;
+ font-size: 12px;
+}
+
+.mentionList a {
+ color: #aebbd0;
+ font-size: 12px;
+ line-height: 1.4;
+ text-decoration: none;
+}
+
+.mentionList a:hover {
+ color: #d5a44b;
+}
+
+.mentionList a span {
+ color: #6f7f94;
+ font-size: 10px;
+ font-weight: 850;
+ margin-right: 6px;
+ text-transform: uppercase;
+}
+
+.analysisPanel {
+ min-width: 0;
+}
+
+.analysisScore {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.analysisScore strong {
+ color: #f5f8fc;
+ font-size: 36px;
+ line-height: 1;
+}
+
+.analysisScore em {
+ color: #8390a2;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 800;
+}
+
+.analysisSummary {
+ color: #c8d2df;
+ font-size: 13px;
+ line-height: 1.55;
+ margin-bottom: 12px;
+}
+
+.tradePlan {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 8px;
+ border: 1px solid #273246;
+ border-radius: 7px;
+ background: #0a1018;
+ padding: 10px;
+ margin-bottom: 12px;
+}
+
+.tradePlan p,
+.tradePlan em {
+ grid-column: 1 / -1;
+ color: #c8d2df;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.tradePlan em {
+ color: #e6c071;
+ font-style: normal;
+}
+
+.marketContext {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-bottom: 12px;
+}
+
+.marketContext span {
+ border: 1px solid #273246;
+ border-radius: 999px;
+ background: #0d1219;
+ color: #aebbd0;
+ padding: 5px 9px;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.factorList {
+ display: grid;
+ gap: 8px;
+}
+
+.factorRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 42px;
+ gap: 10px;
+ align-items: center;
+ border-top: 1px solid #202936;
+ padding-top: 8px;
+}
+
+.factorRow div {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.factorRow strong {
+ color: #eef4fb;
+ font-size: 12px;
+}
+
+.factorRow span {
+ color: #8390a2;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.factorRow b {
+ border-radius: 7px;
+ background: #112943;
+ color: #9dd3ff;
+ min-height: 30px;
+ display: grid;
+ place-items: center;
+}
+
+.riskNotes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-top: 12px;
+}
+
+.riskNotes span {
+ border: 1px solid #202936;
+ border-radius: 999px;
+ background: #0d1219;
+ color: #9aa8ba;
+ padding: 5px 9px;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.badge {
+ border-radius: 999px;
+ padding: 5px 10px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.buy_watch {
+ color: #74e4bd;
+ background: #10281f;
+}
+
+.hold {
+ color: #e6c071;
+ background: #21180c;
+}
+
+.avoid {
+ color: #ff99a1;
+ background: #2a1117;
+}
+
+@media (max-width: 1180px) {
+ .workspace,
+ .infoGrid,
+ .insightGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: static;
+ }
+
+ .relatedGrid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .coverageRows {
+ grid-template-columns: 1fr;
+ }
+
+ .tradePlan {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 760px) {
+ .shell {
+ width: min(100% - 16px, 720px);
+ padding-top: 14px;
+ }
+
+ .topbar,
+ .metrics,
+ .quoteLine {
+ grid-template-columns: 1fr;
+ }
+
+ .stockForm {
+ grid-template-columns: 1fr auto;
+ }
+
+ .stockForm input,
+ .stockForm input:first-child,
+ .stockForm input:nth-child(3) {
+ grid-column: 1 / -1;
+ }
+
+ .stockForm .iconButton {
+ width: 100%;
+ grid-column: 1 / -1;
+ }
+
+ .cacheMeta {
+ white-space: normal;
+ justify-content: start;
+ }
+
+ .chartControls {
+ justify-content: start;
+ }
+
+ .libraryChart {
+ min-height: 360px;
+ }
+
+ .dayDetailGrid {
+ grid-template-columns: repeat(12, minmax(82px, 1fr));
+ }
+
+ .relatedGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .tradePlan {
+ grid-template-columns: 1fr;
+ }
+
+ .fundTable {
+ grid-template-columns: repeat(5, minmax(86px, 1fr));
+ overflow-x: auto;
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ Tab navigation
+--------------------------------------------------------------------------- */
+
+.topbarTabs {
+ display: flex;
+ gap: 4px;
+ background: #131820;
+ border-radius: 8px;
+ padding: 3px;
+}
+
+.tabButton {
+ background: transparent;
+ border: none;
+ color: #7f8da0;
+ font-size: 13px;
+ font-weight: 600;
+ padding: 6px 16px;
+ border-radius: 6px;
+ transition: all 0.15s;
+}
+
+.tabButton.active {
+ background: #1d2530;
+ color: #f0f4fa;
+}
+
+.tabButton:hover:not(.active) {
+ color: #b0b8c4;
+}
+
+/* ---------------------------------------------------------------------------
+ Strategy Page
+--------------------------------------------------------------------------- */
+
+.strategyPage {
+ padding-top: 8px;
+}
+
+.strategyLayout {
+ display: grid;
+ grid-template-columns: minmax(340px, 360px) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.strategySelector {
+ position: sticky;
+ top: 16px;
+ max-height: calc(100vh - 100px);
+ overflow-y: auto;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px !important;
+}
+
+.strategyConfig {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.configRow {
+ display: grid;
+ grid-template-columns: 82px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+}
+
+.configRow label {
+ font-size: 12px;
+ color: #7f8da0;
+ white-space: nowrap;
+}
+
+.configRow input[type="number"],
+.configRow select {
+ width: 100%;
+ min-width: 0;
+ min-height: 34px;
+ background: #131820;
+ border: 1px solid #1d2530;
+ color: #e7edf5;
+ border-radius: 5px;
+ padding: 5px 8px;
+ font-size: 13px;
+}
+
+.configRow .segmented.small {
+ width: 100%;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.configRow .segmented.small button {
+ min-height: 28px;
+ padding: 4px 8px;
+ font-size: 12px;
+}
+
+.precomputedHeader {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #1d2530;
+}
+
+.precomputedHeaderRow {
+ display: grid;
+ grid-template-columns: 82px minmax(0, 1fr);
+ align-items: center;
+ gap: 8px;
+}
+
+.precomputedHeaderRow label {
+ font-size: 12px;
+ color: #7f8da0;
+}
+
+.precomputedHint {
+ margin: 0;
+ font-size: 11px;
+ color: #6f7c8f;
+ font-weight: 700;
+ letter-spacing: 0;
+}
+
+.scenarioPicker {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.scenarioPickerLabel {
+ font-size: 11px;
+ color: #7f8da0;
+ font-weight: 800;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.scenarioOptions {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.scenarioOption {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 6px;
+ padding: 8px 10px;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+}
+
+.scenarioOption strong {
+ color: #e7edf5;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.scenarioOption span {
+ color: #7f8da0;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.scenarioOption:hover {
+ border-color: #2c3a4f;
+}
+
+.scenarioOption.active {
+ border-color: #2c74b8;
+ background: rgba(77, 182, 255, 0.07);
+}
+
+.scenarioOption.active strong {
+ color: #cfe6ff;
+}
+
+.quickPicks {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.quickPicksLabel {
+ font-size: 11px;
+ color: #7f8da0;
+ font-weight: 800;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+
+.quickPicksRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.quickPickButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: #14304a;
+ border: 1px solid #1d4571;
+ color: #cfe6ff;
+ border-radius: 5px;
+ padding: 5px 9px;
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.12s;
+}
+
+.quickPickButton:hover {
+ background: #1c3f5e;
+}
+
+.quickPickButton.ghost {
+ background: transparent;
+ border-color: #283241;
+ color: #91a0b3;
+}
+
+.quickPickButton.ghost:hover {
+ background: #131820;
+}
+
+.strategyList {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ max-height: 460px;
+ overflow-y: auto;
+ border-top: 1px solid #1d2530;
+ padding-top: 6px;
+}
+
+.strategyItem {
+ display: grid;
+ grid-template-columns: 26px 16px 10px minmax(0, 1fr) auto auto;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 6px;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 12px;
+ color: #b0b8c4;
+ transition: background 0.12s;
+}
+
+.strategyItem:hover {
+ background: #1a2030;
+}
+
+.strategyItem.selected {
+ background: #1a2a3d;
+ color: #f0f4fa;
+}
+
+.strategyItem input[type="checkbox"] {
+ accent-color: #4db6ff;
+ margin: 0;
+ width: 14px;
+ height: 14px;
+}
+
+.strategyRank {
+ font-size: 10px;
+ font-weight: 800;
+ color: #6f7c8f;
+ text-align: center;
+ letter-spacing: 0;
+}
+
+.strategyRank[data-top="1"] {
+ color: #f3c969;
+}
+
+.strategyDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ border: 1px solid #2a3344;
+}
+
+.strategyLabel {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.strategyMetric {
+ font-size: 11px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+}
+
+.strategyMetric.positive {
+ color: #26a69a;
+}
+
+.strategyMetric.negative {
+ color: #ef5350;
+}
+
+.strategyMetricMuted {
+ font-size: 10px;
+ font-weight: 700;
+ color: #7f8da0;
+ font-variant-numeric: tabular-nums;
+ min-width: 28px;
+ text-align: right;
+}
+
+.strategyEmpty {
+ color: #7f8da0;
+ font-size: 12px;
+ line-height: 1.45;
+ padding: 10px 6px;
+}
+
+.strategyResults {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.legendEmpty {
+ color: #6f7c8f;
+ font-size: 12px;
+ font-style: italic;
+}
+
+/* Ranking leaderboard */
+.rankingPanel {
+ padding: 14px !important;
+}
+
+.rankingWrap {
+ overflow-x: auto;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+ background: #0c1118;
+ margin-top: 6px;
+}
+
+.rankingTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.rankingTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 8px 10px;
+ border-bottom: 1px solid #1d2530;
+ background: #0c1118;
+ white-space: nowrap;
+ position: sticky;
+ top: 0;
+}
+
+.rankingTable th.sortable {
+ cursor: pointer;
+ user-select: none;
+}
+
+.rankingTable th.sortable:hover {
+ color: #cfe6ff;
+}
+
+.rankingTable th.sortable.active {
+ color: #4db6ff;
+ display: inline;
+}
+
+.rankingTable th.sortable svg {
+ margin-left: 4px;
+ vertical-align: middle;
+}
+
+.rankingTable td {
+ padding: 7px 10px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+ white-space: nowrap;
+}
+
+.rankingTable tbody tr {
+ cursor: pointer;
+}
+
+.rankingTable tbody tr:hover td {
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.rankingTable tbody tr.selected td {
+ background: rgba(77, 182, 255, 0.06);
+ color: #f0f4fa;
+}
+
+.rankingTable td.positive {
+ color: #26a69a;
+}
+
+.rankingTable td.negative {
+ color: #ef5350;
+}
+
+.rankingTable td:nth-child(2) strong {
+ display: block;
+ color: #eef4fb;
+}
+
+.rankSub {
+ display: block;
+ margin-top: 2px;
+ color: #6f7c8f;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.rankCol {
+ width: 50px;
+ text-align: center;
+}
+
+.rankBadge {
+ display: inline-block;
+ min-width: 26px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: #131820;
+ color: #b0b8c4;
+ font-size: 11px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.rankBadge.top {
+ background: rgba(243, 201, 105, 0.12);
+ color: #f3c969;
+ border: 1px solid rgba(243, 201, 105, 0.3);
+}
+
+.categoryChip {
+ display: inline-block;
+ padding: 2px 8px;
+ background: #131820;
+ border: 1px solid #1d2530;
+ border-radius: 999px;
+ font-size: 10px;
+ color: #91a0b3;
+ font-weight: 700;
+}
+
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ min-height: 400px;
+ color: #7f8da0;
+ text-align: center;
+}
+
+.emptyState svg {
+ opacity: 0.4;
+}
+
+/* Equity chart */
+.equityChartContainer {
+ width: 100%;
+ min-height: 480px;
+}
+
+.equityHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+ margin-bottom: 6px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #161e2a;
+}
+
+.equityTitle {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.equityTitle h2 {
+ font-size: 16px;
+ line-height: 1.2;
+}
+
+.equityMeta {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ color: #8390a2;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.equityMetaItem {
+ padding: 3px 8px;
+ border: 1px solid #1d2530;
+ border-radius: 4px;
+ background: #0c1118;
+ color: #cfd8e3;
+}
+
+.equityMetaSep {
+ color: #4a5566;
+ font-weight: 800;
+}
+
+.legendRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ background: transparent;
+ border: 1px solid #2a2a4a;
+ border-radius: 4px;
+ padding: 3px 8px;
+ font-size: 11px;
+ color: #b0b8c4;
+ cursor: pointer;
+ transition: all 0.12s;
+}
+
+.legendItem.focused {
+ border-color: #4db6ff;
+ color: #f0f4fa;
+ background: rgba(77, 182, 255, 0.08);
+}
+
+.legendItem.hidden {
+ color: #5e6a78;
+ opacity: 0.62;
+}
+
+.legendItem.hidden .legendDot {
+ background: transparent !important;
+ border: 1px solid #5e6a78;
+}
+
+.benchmarkLegend {
+ color: #8d98a8;
+ cursor: default;
+}
+
+.benchmarkLegend .legendDot {
+ border-radius: 2px;
+ background: #777;
+}
+
+.legendDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+/* Metrics panel */
+.metricsPanel {
+ padding: 14px !important;
+}
+
+.metricsGrid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.focusSummary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: -4px 0 12px;
+}
+
+.focusSummary span {
+ min-height: 30px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 1px solid #222c3a;
+ border-radius: 6px;
+ background: #0d131b;
+ color: #b8c3d1;
+ padding: 0 10px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.metricCell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 8px 4px;
+ background: #131820;
+ border-radius: 6px;
+}
+
+.metricValue {
+ font-size: 16px;
+ font-weight: 700;
+ color: #f0f4fa;
+}
+
+.metricCell.positive .metricValue {
+ color: #26a69a;
+}
+
+.metricCell.negative .metricValue {
+ color: #ef5350;
+}
+
+.metricLabel {
+ font-size: 11px;
+ color: #7f8da0;
+}
+
+/* Comparison table */
+.comparisonWrap {
+ overflow-x: auto;
+ border: 1px solid #1d2530;
+ border-radius: 7px;
+ background: #0c1118;
+}
+
+.comparisonTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.comparisonTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 8px 10px;
+ border-bottom: 1px solid #1d2530;
+ white-space: nowrap;
+}
+
+.comparisonTable td {
+ padding: 9px 10px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+ vertical-align: middle;
+ white-space: nowrap;
+}
+
+.comparisonTable tbody tr {
+ cursor: pointer;
+}
+
+.comparisonTable tbody tr:hover td {
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.comparisonTable td:first-child {
+ min-width: 220px;
+ white-space: normal;
+}
+
+.comparisonTable td:first-child strong,
+.comparisonTable td:first-child span {
+ display: block;
+}
+
+.comparisonTable td:first-child strong {
+ color: #eef4fb;
+ font-size: 12px;
+}
+
+.comparisonTable td:first-child span {
+ margin-top: 2px;
+ color: #6f7c8f;
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.comparisonTable tr.focused td {
+ color: #f0f4fa;
+ background: rgba(77, 182, 255, 0.08);
+}
+
+.comparisonTable .positive {
+ color: #26a69a;
+}
+
+.comparisonTable .negative {
+ color: #ef5350;
+}
+
+/* Drawdown panel */
+.drawdownPanel {
+ padding: 14px !important;
+}
+
+.drawdownTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.drawdownTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 5px 8px;
+ border-bottom: 1px solid #1d2530;
+}
+
+.drawdownTable td {
+ padding: 5px 8px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+}
+
+.drawdownTable .negative {
+ color: #ef5350;
+}
+
+/* Return heatmap */
+.heatmapPanel {
+ padding: 14px !important;
+}
+
+.heatmapContainer {
+ overflow-x: auto;
+}
+
+.heatmapTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+ min-width: 700px;
+}
+
+.heatmapTable th {
+ text-align: center;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 4px 2px;
+ border-bottom: 1px solid #1d2530;
+}
+
+.heatmapTable td {
+ text-align: center;
+ padding: 4px 2px;
+ color: #e7edf5;
+}
+
+.heatmapTable .yearLabel {
+ font-weight: 600;
+ color: #b0b8c4;
+ text-align: left;
+ padding-left: 6px;
+}
+
+.heatCell {
+ border-radius: 3px;
+ font-size: 10px;
+ min-width: 42px;
+}
+
+.yearReturn {
+ font-weight: 700;
+ font-size: 12px;
+}
+
+.yearReturn.positive {
+ color: #26a69a;
+}
+
+.yearReturn.negative {
+ color: #ef5350;
+}
+
+/* ---------------------------------------------------------------------------
+ Live Page
+--------------------------------------------------------------------------- */
+
+.livePage {
+ padding-top: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.liveTopbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+}
+
+.ghostInput {
+ flex: 1 1 200px;
+ min-width: 160px;
+ min-height: 32px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ color: #e7edf5;
+ border-radius: 6px;
+ padding: 4px 10px;
+ font-size: 12px;
+ transition: border-color 0.12s, background 0.12s;
+}
+
+.ghostInput::placeholder {
+ color: #5e6a78;
+}
+
+.ghostInput:hover {
+ border-color: #2c3a4f;
+}
+
+.ghostInput:focus {
+ border-color: #2c74b8;
+ outline: none;
+ background: #0d141d;
+}
+
+.ghostSelectLabel {
+ display: inline-flex;
+ align-items: stretch;
+ gap: 0;
+ border: 1px solid #1d2530;
+ border-radius: 6px;
+ background: #0c1118;
+ overflow: hidden;
+ min-height: 32px;
+}
+
+.ghostSelectLabel > span {
+ display: inline-flex;
+ align-items: center;
+ padding: 0 8px;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0;
+ color: #7f8da0;
+ background: #0a0f15;
+ border-right: 1px solid #1d2530;
+ text-transform: uppercase;
+}
+
+.ghostSelect {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ background: transparent;
+ background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12' fill='none' stroke='%237f8da0' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 10px center;
+ background-size: 10px;
+ border: none;
+ color: #cfd8e3;
+ padding: 0 26px 0 10px;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ outline: none;
+}
+
+.ghostSelectLabel:hover {
+ border-color: #2c3a4f;
+}
+
+.ghostSelectLabel:focus-within {
+ border-color: #2c74b8;
+}
+
+.ghostSelect option {
+ background: #0c1118;
+ color: #e7edf5;
+}
+
+/* Standalone select without the label chip (e.g. pagination size). */
+.ghostSelectCompact {
+ min-height: 28px;
+ background: #0c1118;
+ background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12' fill='none' stroke='%237f8da0' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ background-size: 10px;
+ border: 1px solid #1d2530;
+ border-radius: 5px;
+ padding: 0 22px 0 8px;
+}
+
+.ghostSelectCompact:hover {
+ border-color: #2c3a4f;
+}
+
+.ghostButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 32px;
+ padding: 0 14px;
+ border: 1px solid #1d2530;
+ background: #0c1118;
+ color: #cfd8e3;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+
+.ghostButton:hover:not(:disabled) {
+ border-color: #2c74b8;
+ background: #11202f;
+ color: #e7edf5;
+}
+
+.ghostButton:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+.ghostButton.loading svg {
+ animation: ghostSpin 1s linear infinite;
+}
+
+@keyframes ghostSpin {
+ to { transform: rotate(360deg); }
+}
+
+.liveLayout {
+ display: grid;
+ grid-template-columns: minmax(320px, 360px) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.liveSidebar {
+ position: sticky;
+ top: 16px;
+ max-height: calc(100vh - 120px);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px !important;
+}
+
+.liveList {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.liveListItem {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 10px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ border-radius: 6px;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.liveListItem:hover {
+ border-color: #2c3a4f;
+}
+
+.liveListItem.selected {
+ border-color: #2c74b8;
+ background: rgba(77, 182, 255, 0.07);
+}
+
+.liveListTitle {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.liveListTitle strong {
+ color: #eef4fb;
+ font-size: 13px;
+ font-weight: 800;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+ max-width: 100%;
+}
+
+.liveListName {
+ color: #6f7c8f;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: block;
+ max-width: 100%;
+}
+
+.liveListBody {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-variant-numeric: tabular-nums;
+}
+
+.liveListValue {
+ font-size: 14px;
+ font-weight: 800;
+ color: #e7edf5;
+}
+
+.liveListPnl {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.liveListPnl.positive {
+ color: #26a69a;
+}
+
+.liveListPnl.negative {
+ color: #ef5350;
+}
+
+.liveListMeta {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ font-size: 10px;
+ color: #6f7c8f;
+ font-weight: 700;
+}
+
+.liveDetail {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.liveSummary {
+ padding: 14px !important;
+}
+
+.liveSummaryHead {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.liveTotalPnl {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 2px;
+ font-variant-numeric: tabular-nums;
+}
+
+.liveTotalPnl strong {
+ font-size: 22px;
+ font-weight: 800;
+ color: #e7edf5;
+}
+
+.liveTotalPnl span {
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.liveTotalPnl.positive strong,
+.liveTotalPnl.positive span {
+ color: #26a69a;
+}
+
+.liveTotalPnl.negative strong,
+.liveTotalPnl.negative span {
+ color: #ef5350;
+}
+
+.liveSummaryGrid {
+ margin-top: 14px;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+}
+
+.summaryCell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 8px 4px;
+ background: #131820;
+ border-radius: 6px;
+}
+
+.summaryValue {
+ font-size: 15px;
+ font-weight: 700;
+ color: #f0f4fa;
+ font-variant-numeric: tabular-nums;
+}
+
+.summaryCell.positive .summaryValue {
+ color: #26a69a;
+}
+
+.summaryCell.negative .summaryValue {
+ color: #ef5350;
+}
+
+.summaryLabel {
+ font-size: 11px;
+ color: #7f8da0;
+}
+
+.liveEquityChart {
+ width: 100%;
+ min-height: 280px;
+}
+
+.holdingsPanel {
+ padding: 14px !important;
+}
+
+.holdingsTable,
+.tradeLogTable {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.holdingsTable th,
+.tradeLogTable th {
+ text-align: left;
+ color: #7f8da0;
+ font-weight: 600;
+ padding: 7px 10px;
+ border-bottom: 1px solid #1d2530;
+ white-space: nowrap;
+}
+
+.holdingsTable td,
+.tradeLogTable td {
+ padding: 7px 10px;
+ border-bottom: 1px solid #131820;
+ color: #b0b8c4;
+ white-space: nowrap;
+}
+
+.holdingsTable td:first-child strong,
+.tradeLogTable td:first-child strong {
+ color: #eef4fb;
+}
+
+.holdingsCashRow td {
+ background: rgba(255, 255, 255, 0.02);
+ color: #7f8da0;
+}
+
+.tradeAction {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.tradeAction.buy {
+ background: rgba(38, 166, 154, 0.15);
+ color: #26a69a;
+ border: 1px solid rgba(38, 166, 154, 0.3);
+}
+
+.tradeAction.sell {
+ background: rgba(239, 83, 80, 0.15);
+ color: #ef5350;
+ border: 1px solid rgba(239, 83, 80, 0.3);
+}
+
+.tradeLogPanel {
+ padding: 14px !important;
+}
+
+.paginationBar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 10px;
+ padding-top: 8px;
+ border-top: 1px solid #1d2530;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+
+.paginationRange {
+ color: #7f8da0;
+ font-weight: 700;
+}
+
+.paginationControls {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.paginationButton {
+ min-width: 28px;
+ min-height: 28px;
+ padding: 0 8px;
+ background: #0c1118;
+ border: 1px solid #1d2530;
+ color: #cfd8e3;
+ border-radius: 5px;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s, color 0.12s;
+}
+
+.paginationButton:hover:not(:disabled) {
+ border-color: #2c74b8;
+ background: #11202f;
+ color: #e7edf5;
+}
+
+.paginationButton:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.paginationPage {
+ padding: 0 10px;
+ color: #b0b8c4;
+ font-weight: 800;
+ letter-spacing: 0;
+}
+
+/* Live page responsive */
+@media (max-width: 960px) {
+ .liveLayout {
+ grid-template-columns: 1fr;
+ }
+ .liveSidebar {
+ position: static;
+ max-height: none;
+ }
+ .liveSummaryGrid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Strategy page responsive */
+@media (max-width: 960px) {
+ .strategyLayout {
+ grid-template-columns: 1fr;
+ }
+
+ .strategySelector {
+ position: static;
+ max-height: none;
+ }
+
+ .metricsGrid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
diff --git a/web/src/server/index.ts b/web/src/server/index.ts
new file mode 100644
index 0000000..b83701e
--- /dev/null
+++ b/web/src/server/index.ts
@@ -0,0 +1,352 @@
+import "dotenv/config";
+import cors from "cors";
+import express from "express";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { generateDailyReport } from "../agent/report";
+import { createProvider } from "../agent/provider";
+import { normalizeSymbol } from "../agent/symbols";
+import { StockDataCache } from "../agent/stockDataCache";
+import { WatchlistStore } from "../agent/watchlistStore";
+import { loadIpadsConfig } from "./ipadsConfig";
+import type { ChartPeriod, WatchStock } from "../shared/types";
+import type { BacktestConfig } from "../shared/strategyTypes";
+import { listStrategies, runBacktest, getCachedResult, getLiveStrategies } from "./strategyService";
+
+const port = Number(process.env.PORT ?? 8787);
+const host = process.env.HOST ?? "127.0.0.1";
+const dataDir = process.env.STOCK_AGENT_DATA_DIR ?? join(process.cwd(), "data");
+
+const watchlistStore = new WatchlistStore(join(dataDir, "watchlist.json"));
+const dataProvider = createProvider(loadIpadsConfig().apiKey);
+const stockDataCache = new StockDataCache(join(dataDir, "cache"), dataProvider);
+const app = express();
+
+let cachedReportUpdatedAt = 0;
+let cachedReport: Awaited> | undefined;
+
+app.use(cors());
+app.use(express.json());
+
+app.get("/api/health", async (_request, response) => {
+ const config = loadIpadsConfig();
+ response.json({
+ ok: true,
+ providerConfigured: config.apiKeyConfigured,
+ baseUrl: config.baseUrl,
+ wireApi: config.wireApi,
+ scheduler: "24h"
+ });
+});
+
+app.get("/api/watchlist", async (_request, response) => {
+ response.json(await watchlistStore.list());
+});
+
+app.post("/api/watchlist", async (request, response, next) => {
+ try {
+ response.status(201).json(await watchlistStore.add(String(request.body?.symbol ?? ""), request.body?.name, parseTags(request.body?.tags)));
+ cachedReport = undefined;
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.put("/api/watchlist/:symbol/tags", async (request, response, next) => {
+ try {
+ const updated = await watchlistStore.updateTags(request.params.symbol, parseTags(request.body?.tags));
+ cachedReport = undefined;
+ response.json(updated);
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.delete("/api/watchlist/:symbol", async (request, response, next) => {
+ try {
+ await watchlistStore.remove(request.params.symbol);
+ cachedReport = undefined;
+ response.status(204).end();
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/stocks/:symbol/chart", async (request, response, next) => {
+ try {
+ const stock = await findWatchStock(request.params.symbol);
+ response.json(
+ request.query.refresh === "1"
+ ? await stockDataCache.refreshChart(stock, parsePeriod(request.query.period))
+ : await stockDataCache.chart(stock, parsePeriod(request.query.period))
+ );
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/stocks/:symbol/fundamentals", async (request, response, next) => {
+ try {
+ response.json(await stockDataCache.fundamentals(await findWatchStock(request.params.symbol), request.query.refresh === "1"));
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/stocks/:symbol/related", async (request, response, next) => {
+ try {
+ response.json(await stockDataCache.relatedData(await findWatchStock(request.params.symbol), request.query.refresh === "1"));
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/stocks/:symbol/analysis", async (request, response, next) => {
+ try {
+ const stock = await findWatchStock(request.params.symbol);
+ if (request.query.refresh !== "1" && (await stockDataCache.hasAnalysis(stock))) {
+ const analysis = await stockDataCache.quantAnalysis(stock);
+ if (await stockDataCache.analysisNeedsRefresh(stock)) {
+ void stockDataCache.quantAnalysis(stock, true).catch((error) => {
+ console.error(`background analysis refresh failed for ${stock.display}`, error);
+ });
+ }
+ response.json(analysis);
+ return;
+ }
+ if (request.query.refresh !== "1") {
+ void stockDataCache.quantAnalysis(stock, true).catch((error) => {
+ console.error(`background analysis generation failed for ${stock.display}`, error);
+ });
+ response.json(stockDataCache.placeholderAnalysis(stock));
+ return;
+ }
+ response.json(await stockDataCache.quantAnalysis(stock, request.query.refresh === "1"));
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/report/daily", async (_request, response, next) => {
+ try {
+ response.json(await getDailyReport());
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.post("/api/report/daily/refresh", async (_request, response, next) => {
+ try {
+ cachedReport = undefined;
+ response.json(await getDailyReport(true));
+ } catch (error) {
+ next(error);
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Strategy / Quant endpoints
+// ---------------------------------------------------------------------------
+
+app.get("/api/strategies", async (_request, response, next) => {
+ try {
+ response.json(await listStrategies());
+ } catch (error) {
+ next(error);
+ }
+});
+
+function loadPrecomputedFile() {
+ const precomputedPath = join(dataDir, "precomputed", "backtest_results.json");
+ if (!existsSync(precomputedPath)) {
+ throw new Error("Pre-computed results not available. Run precompute_backtest.py first.");
+ }
+ return JSON.parse(readFileSync(precomputedPath, "utf-8"));
+}
+
+// Newer files expose `scenarios`; legacy files expose `markets` at the top level.
+function resolveScenarios(raw: { scenarios?: Record; markets?: Record }) {
+ if (raw.scenarios && typeof raw.scenarios === "object") {
+ return raw.scenarios as Record;
+ injectionSchedule: unknown;
+ markets: Record;
+ }>;
+ }
+ // Backward compat — wrap legacy single-scenario file as one anonymous scenario.
+ if (raw.markets) {
+ return {
+ legacy: {
+ id: "legacy",
+ label: "默认",
+ capital: 100000,
+ injectionSchedule: null,
+ markets: raw.markets,
+ },
+ } as Record;
+ injectionSchedule: unknown;
+ markets: Record;
+ }>;
+ }
+ throw new Error("Pre-computed file is missing both `scenarios` and `markets`.");
+}
+
+app.get("/api/strategies/precomputed/index", (_request, response, next) => {
+ try {
+ const raw = loadPrecomputedFile();
+ const scenarios = resolveScenarios(raw);
+ const list = Object.values(scenarios).map((sc) => ({
+ id: sc.id,
+ label: sc.label,
+ description: sc.description,
+ capital: sc.capital,
+ capitalByMarket: sc.capitalByMarket,
+ injectionSchedule: sc.injectionSchedule ?? null,
+ availableMarkets: Object.keys(sc.markets ?? {}),
+ }));
+ response.json({ computedAt: raw.computedAt, scenarios: list });
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/strategies/precomputed", (_request, response, next) => {
+ try {
+ const market = String(_request.query.market ?? "us");
+ const requestedScenario = _request.query.scenario ? String(_request.query.scenario) : undefined;
+ const raw = loadPrecomputedFile();
+ const scenarios = resolveScenarios(raw);
+ const scenarioIds = Object.keys(scenarios);
+ // An explicit but unknown scenario is a client error; silently serving
+ // another scenario would mislabel the results.
+ if (requestedScenario && !Object.hasOwn(scenarios, requestedScenario)) {
+ throw new Error(`Unknown pre-computed scenario: ${requestedScenario}`);
+ }
+ const scenarioId = requestedScenario ?? scenarioIds[0];
+ const scenario = scenarios[scenarioId];
+ if (!scenario) {
+ throw new Error("No pre-computed scenarios available.");
+ }
+ const marketResult = scenario.markets?.[market];
+ if (!marketResult) {
+ throw new Error(`No pre-computed results for market: ${market} in scenario ${scenarioId}`);
+ }
+ response.json({
+ id: `precomputed_${scenarioId}_${market}`,
+ scenarioId,
+ computedAt: raw.computedAt,
+ ...(marketResult as object),
+ });
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.post("/api/strategies/backtest", async (request, response, next) => {
+ try {
+ const config = request.body as BacktestConfig;
+ if (!config.strategies?.length) throw new Error("strategies is required");
+ if (!config.market) throw new Error("market is required");
+ const result = await runBacktest({
+ strategies: config.strategies,
+ market: config.market,
+ years: config.years ?? 10,
+ initialCapital: config.initialCapital ?? 100000,
+ fixedFee: config.fixedFee ?? 2.0,
+ txCost: config.txCost ?? 0.001,
+ injectionSchedule: config.injectionSchedule,
+ });
+ response.json(result);
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/strategies/results/:id", async (request, response, next) => {
+ try {
+ const result = await getCachedResult(request.params.id);
+ if (!result) throw new Error("Result not found");
+ response.json(result);
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.get("/api/strategies/live", async (request, response, next) => {
+ try {
+ const market = String(request.query.market ?? "us");
+ const strategy = request.query.strategy ? String(request.query.strategy) : undefined;
+ response.json(await getLiveStrategies(market, strategy));
+ } catch (error) {
+ next(error);
+ }
+});
+
+app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
+ const message = error instanceof Error ? error.message : "Unknown server error";
+ response.status(400).json({ error: message });
+});
+
+setInterval(() => {
+ void getDailyReport().catch((error) => {
+ console.error("daily report refresh failed", error);
+ });
+}, 24 * 60 * 60 * 1000).unref();
+
+app.listen(port, host, () => {
+ console.log(`stock agent api listening at http://${host}:${port}`);
+});
+
+async function getDailyReport(forceSnapshots = false) {
+ const now = Date.now();
+ if (!forceSnapshots && cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
+ return cachedReport;
+ }
+
+ const watchlist = await watchlistStore.list();
+ cachedReport = await generateDailyReport(
+ watchlist,
+ {
+ snapshot: (stock) => stockDataCache.snapshot(stock, forceSnapshots),
+ candles: (stock) => dataProvider.candles(stock),
+ fundamentals: (stock) => dataProvider.fundamentals(stock),
+ relatedData: (stock) => dataProvider.relatedData(stock),
+ quantAnalysis: (input) => dataProvider.quantAnalysis(input)
+ },
+ new Date()
+ );
+ cachedReportUpdatedAt = now;
+ return cachedReport;
+}
+
+async function findWatchStock(symbolInput: string): Promise {
+ const normalized = normalizeSymbol(symbolInput);
+ const stock = (await watchlistStore.list()).find((item) => item.display === normalized.display);
+ if (!stock) {
+ throw new Error("Stock is not in watchlist");
+ }
+ return stock;
+}
+
+function parseTags(value: unknown): string[] {
+ if (Array.isArray(value)) {
+ return value.map(String);
+ }
+ if (typeof value === "string") {
+ return value.split(",");
+ }
+ return [];
+}
+
+function parsePeriod(value: unknown): ChartPeriod {
+ return value === "W" || value === "M" || value === "Y" ? value : "D";
+}
diff --git a/web/src/server/ipadsConfig.ts b/web/src/server/ipadsConfig.ts
new file mode 100644
index 0000000..807d8e1
--- /dev/null
+++ b/web/src/server/ipadsConfig.ts
@@ -0,0 +1,18 @@
+import { IPADS_BASE_URL, IPADS_WIRE_API } from "../agent/provider";
+
+export interface IpadsConfig {
+ baseUrl: typeof IPADS_BASE_URL;
+ wireApi: typeof IPADS_WIRE_API;
+ apiKey: string;
+ apiKeyConfigured: boolean;
+}
+
+export function loadIpadsConfig(env: NodeJS.ProcessEnv = process.env): IpadsConfig {
+ const apiKey = (env.IPADS_API_KEY ?? "").trim();
+ return {
+ baseUrl: IPADS_BASE_URL,
+ wireApi: IPADS_WIRE_API,
+ apiKey,
+ apiKeyConfigured: Boolean(apiKey)
+ };
+}
diff --git a/web/src/server/strategyService.ts b/web/src/server/strategyService.ts
new file mode 100644
index 0000000..bc206e3
--- /dev/null
+++ b/web/src/server/strategyService.ts
@@ -0,0 +1,178 @@
+import { spawn } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync } from "node:fs";
+import { join, resolve } from "node:path";
+import type { BacktestConfig, BacktestResult, LiveStrategyState, StrategyInfo } from "../shared/strategyTypes.js";
+
+const QUANT_DIR = resolve(process.env.QUANT_PROJECT_DIR || join(process.cwd(), "../quant"));
+const CACHE_DIR = join(process.env.STOCK_AGENT_DATA_DIR || join(process.cwd(), "data"), "strategy_cache");
+const TIMEOUT_MS = Number(process.env.QUANT_BACKTEST_TIMEOUT_MS) || 180_000;
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+const MAX_CACHE_FILES = 50;
+
+function ensureCacheDir(): void {
+ if (!existsSync(CACHE_DIR)) {
+ mkdirSync(CACHE_DIR, { recursive: true });
+ }
+}
+
+function configHash(config: BacktestConfig): string {
+ const serialized = stableStringify(config);
+ return createHash("md5").update(serialized).digest("hex").slice(0, 12);
+}
+
+function stableStringify(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
+ }
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value)
+ .sort()
+ .map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}`)
+ .join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function runBridge(args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = spawn("uv", ["run", "python", "bridge.py", ...args], {
+ cwd: QUANT_DIR,
+ env: { ...process.env, PYTHONUNBUFFERED: "1" },
+ });
+
+ const chunks: Buffer[] = [];
+ let stderr = "";
+ let exitCode: number | null = null;
+ let stdoutEnded = false;
+ let exited = false;
+
+ function tryFinish() {
+ if (!stdoutEnded || !exited) return;
+ clearTimeout(timer);
+ const stdout = Buffer.concat(chunks).toString("utf-8");
+ if (exitCode !== 0) {
+ reject(new Error(`bridge.py exited with code ${exitCode}: ${stderr.slice(-1000)}`));
+ } else {
+ try {
+ resolve(JSON.parse(stdout));
+ } catch {
+ reject(new Error(`Failed to parse bridge output (${stdout.length} bytes): ${stdout.slice(0, 200)}`));
+ }
+ }
+ }
+
+ proc.stdout.on("data", (chunk: Buffer) => { chunks.push(chunk); });
+ proc.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
+ proc.stdout.on("end", () => { stdoutEnded = true; tryFinish(); });
+ proc.on("close", (code) => { exitCode = code; exited = true; tryFinish(); });
+
+ const timer = setTimeout(() => {
+ proc.kill("SIGTERM");
+ reject(new Error(`Bridge timeout after ${TIMEOUT_MS}ms. stderr: ${stderr.slice(-500)}`));
+ }, TIMEOUT_MS);
+
+ proc.on("error", (err) => {
+ clearTimeout(timer);
+ reject(new Error(`Failed to spawn bridge.py: ${err.message}`));
+ });
+ });
+}
+
+function evictOldCache(): void {
+ ensureCacheDir();
+ const files = readdirSync(CACHE_DIR)
+ .filter((f) => f.endsWith(".json"))
+ .map((f) => ({ name: f, mtime: statSync(join(CACHE_DIR, f)).mtimeMs }))
+ .sort((a, b) => b.mtime - a.mtime);
+
+ while (files.length > MAX_CACHE_FILES) {
+ const old = files.pop()!;
+ unlinkSync(join(CACHE_DIR, old.name));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+let cachedStrategies: { data: StrategyInfo[]; fetchedAt: number } | null = null;
+
+export async function listStrategies(): Promise {
+ if (cachedStrategies && Date.now() - cachedStrategies.fetchedAt < CACHE_TTL_MS) {
+ return cachedStrategies.data;
+ }
+ const data = (await runBridge(["list"])) as StrategyInfo[];
+ cachedStrategies = { data, fetchedAt: Date.now() };
+ return data;
+}
+
+export async function runBacktest(config: BacktestConfig): Promise {
+ const id = configHash(config);
+ ensureCacheDir();
+
+ // Check cache
+ const cachePath = join(CACHE_DIR, `${id}.json`);
+ if (existsSync(cachePath)) {
+ const stat = statSync(cachePath);
+ if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
+ return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult);
+ }
+ }
+
+ // Run backtest
+ const args = [
+ "backtest",
+ "--strategies", config.strategies.join(","),
+ "--market", config.market,
+ "--years", String(config.years),
+ "--capital", String(config.initialCapital),
+ "--fixed-fee", String(config.fixedFee),
+ "--tx-cost", String(config.txCost),
+ ];
+
+ if (config.injectionSchedule) {
+ args.push("--injection", JSON.stringify(config.injectionSchedule));
+ }
+
+ const raw = await runBridge(args);
+
+ const result = normalizeBacktestResult({
+ id,
+ ...(raw as Omit),
+ computedAt: new Date().toISOString(),
+ });
+
+ // Cache result
+ writeFileSync(cachePath, `${JSON.stringify(result)}\n`);
+ evictOldCache();
+
+ return result;
+}
+
+export async function getCachedResult(id: string): Promise {
+ ensureCacheDir();
+ const cachePath = join(CACHE_DIR, `${id}.json`);
+ if (!existsSync(cachePath)) return null;
+ return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult);
+}
+
+export async function getLiveStrategies(market: string, strategy?: string): Promise {
+ const args = ["live", "--market", market];
+ if (strategy) args.push("--strategy", strategy);
+ return (await runBridge(args)) as LiveStrategyState[];
+}
+
+function normalizeBacktestResult(result: BacktestResult): BacktestResult {
+ return {
+ ...result,
+ strategies: result.strategies.map((strategy) => ({
+ ...strategy,
+ label: typeof strategy.label === "string" && strategy.label.trim() ? strategy.label : strategy.name
+ })),
+ benchmark: {
+ name: result.benchmark?.name || "Benchmark",
+ equityCurve: result.benchmark?.equityCurve ?? []
+ }
+ };
+}
diff --git a/web/src/shared/strategyTypes.ts b/web/src/shared/strategyTypes.ts
new file mode 100644
index 0000000..acd3ba6
--- /dev/null
+++ b/web/src/shared/strategyTypes.ts
@@ -0,0 +1,232 @@
+export type StrategyMarket = "us" | "cn";
+export type StrategyCurrency = "USD" | "CNY";
+export type StrategyCategory = "stock_picker" | "tactical_allocation" | "factor_combo" | "other";
+
+export interface StrategyParam {
+ type: "number" | "string" | "select";
+ default: number | string;
+ options?: string[];
+ min?: number;
+ max?: number;
+}
+
+export interface StrategyInfo {
+ name: string;
+ label: string;
+ category: StrategyCategory;
+ params: Record;
+ markets: StrategyMarket[];
+}
+
+export interface InjectionSchedule {
+ amount: number;
+ frequency: "yearly" | "quarterly" | "monthly" | "yearly_months";
+ startYear: number;
+ months?: number[];
+}
+
+export interface PrecomputedScenarioInfo {
+ id: string;
+ label: string;
+ description?: string;
+ capital: number;
+ // Per-market starting capital in that market's currency. Absent in older
+ // precomputed files, where `capital` applies to every market.
+ capitalByMarket?: Partial>;
+ injectionSchedule: InjectionSchedule | null;
+ availableMarkets: StrategyMarket[];
+}
+
+export interface PrecomputedIndex {
+ computedAt: string;
+ scenarios: PrecomputedScenarioInfo[];
+}
+
+export interface BacktestConfig {
+ strategies: string[];
+ market: StrategyMarket;
+ years: number;
+ initialCapital: number;
+ fixedFee: number;
+ txCost: number;
+ injectionSchedule?: InjectionSchedule;
+}
+
+export interface EquityPoint {
+ date: string;
+ value: number;
+}
+
+export interface DrawdownPeriod {
+ start: string;
+ end: string;
+ depth: number;
+ recoveryDate: string | null;
+}
+
+export interface TradeEvent {
+ date: string;
+ buys: number;
+ sells: number;
+ buyTickers: string[];
+ sellTickers: string[];
+}
+
+export interface InjectionEvent {
+ date: string;
+ amount: number;
+}
+
+// Backtest metrics are time-weighted (computed on the no-injection curve).
+// The bridge serializes NaN as null, so every field may be null.
+export interface StrategyMetrics {
+ totalReturn: number | null;
+ annualizedReturn: number | null;
+ annualizedVolatility: number | null;
+ sharpeRatio: number | null;
+ sortinoRatio: number | null;
+ // Negative fraction; closer to zero is better.
+ maxDrawdown: number | null;
+ calmarRatio: number | null;
+ winRate: number | null;
+ // Fraction of inter-rebalance periods with positive return. Null for
+ // strategies with fewer than two rebalances in the window (buy-and-hold).
+ rebalanceWinRate: number | null;
+}
+
+// Money view of a run with cash injections: what was put in vs. what it is worth.
+export interface CapitalFlows {
+ invested: number;
+ finalValue: number;
+ profit: number;
+ returnOnInvested: number | null;
+}
+
+export interface StrategyResult {
+ name: string;
+ label: string;
+ // Money curve (includes injections when a schedule exists).
+ equityCurve: EquityPoint[];
+ // Same strategy without injections; metrics are computed on this curve.
+ baseEquityCurve: EquityPoint[] | null;
+ metrics: StrategyMetrics;
+ capitalFlows?: CapitalFlows;
+ // Equity hit zero at some point; the result is not investable.
+ ruined?: boolean;
+ drawdowns: DrawdownPeriod[];
+ trades: TradeEvent[];
+ injections: InjectionEvent[];
+ monthlyReturns: Record;
+ yearlyReturns: Record;
+}
+
+export interface BacktestMeta {
+ market: StrategyMarket;
+ years: number;
+ initialCapital: number;
+ startDate: string | null;
+ endDate: string | null;
+ txCost: number;
+ fixedFee: number;
+ injectionSchedule: InjectionSchedule | null;
+}
+
+export interface BacktestResult {
+ id: string;
+ // Set by the precomputed endpoint to the scenario actually served.
+ scenarioId?: string;
+ meta: BacktestMeta;
+ strategies: StrategyResult[];
+ benchmark: { name: string; equityCurve: EquityPoint[] };
+ computedAt: string;
+}
+
+export interface HoldingDetail {
+ ticker: string;
+ shares: number;
+ lastPrice: number | null;
+ marketValue: number | null;
+ // Latest panel price missing; lastPrice is the last recorded trade price.
+ priceStale?: boolean;
+}
+
+export interface TradeLogEntry {
+ date: string;
+ action: "BUY" | "SELL";
+ ticker: string;
+ shares: number;
+ price: number;
+ value: number;
+ commission: number;
+}
+
+export interface LiveStrategyState {
+ name: string;
+ label: string;
+ category: StrategyCategory | string;
+ market: StrategyMarket;
+ currency?: StrategyCurrency;
+ initialCapital: number;
+ // First live session; strategies may start on different dates.
+ startDate?: string | null;
+ currentValue: number;
+ cash: number;
+ marketValue: number;
+ unrealizedPnl: number;
+ unrealizedPnlPct: number;
+ holdings: Record;
+ holdingsDetail: HoldingDetail[];
+ priceAsOf: string | null;
+ // Starts with an anchor point at initialCapital one day before startDate.
+ equityCurve: EquityPoint[];
+ // Market benchmark rebased to initialCapital over the strategy's sessions.
+ // Empty when the price panel lacks the benchmark.
+ benchmark?: { name: string; equityCurve: EquityPoint[] };
+ metrics: StrategyMetrics | null;
+ drawdowns: DrawdownPeriod[];
+ trades: TradeEvent[];
+ tradeLog: TradeLogEntry[];
+ monthlyReturns: Record;
+ yearlyReturns: Record;
+ lastUpdated: string | null;
+}
+
+// ---------------------------------------------------------------------------
+// Display helpers shared by the strategy pages
+// ---------------------------------------------------------------------------
+
+const MARKET_CURRENCY: Record = { us: "USD", cn: "CNY" };
+
+export function currencyForMarket(market: string | undefined): StrategyCurrency {
+ return MARKET_CURRENCY[market as StrategyMarket] ?? "USD";
+}
+
+export function formatMoney(value: number | null | undefined, currency: StrategyCurrency, fractionDigits = 0): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) return "—";
+ return value.toLocaleString(undefined, {
+ style: "currency",
+ currency,
+ currencyDisplay: "narrowSymbol",
+ minimumFractionDigits: fractionDigits,
+ maximumFractionDigits: fractionDigits,
+ });
+}
+
+export function formatPct(value: number | null | undefined, fractionDigits = 1): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) return "—";
+ return `${(value * 100).toFixed(fractionDigits)}%`;
+}
+
+export function formatNumber(value: number | null | undefined, fractionDigits = 2): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) return "—";
+ return value.toFixed(fractionDigits);
+}
+
+// Whole calendar days from a YYYY-MM-DD date to today (local time); null if unparseable.
+export function calendarDaysSince(date: string | null | undefined, now: Date = new Date()): number | null {
+ const match = typeof date === "string" ? /^(\d{4})-(\d{2})-(\d{2})/.exec(date) : null;
+ if (!match) return null;
+ const then = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ return Math.round((today.getTime() - then.getTime()) / 86_400_000);
+}
diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts
new file mode 100644
index 0000000..42cbf69
--- /dev/null
+++ b/web/src/shared/types.ts
@@ -0,0 +1,238 @@
+export type Market = "CN" | "HK" | "US";
+
+export type RecommendationAction = "buy_watch" | "hold" | "avoid";
+export type ChartPeriod = "D" | "W" | "M" | "Y";
+
+export interface NormalizedSymbol {
+ symbol: string;
+ market: Market;
+ display: string;
+}
+
+export interface WatchStock extends NormalizedSymbol {
+ id: string;
+ name?: string;
+ tags: string[];
+ addedAt: string;
+}
+
+export interface RelatedNews {
+ title: string;
+ url: string;
+ source?: string;
+ publishedAt?: string;
+}
+
+export interface DataSourceRef {
+ name: string;
+ url?: string;
+ asOf?: string;
+}
+
+export interface PeerValuation {
+ display: string;
+ pe: number;
+}
+
+export interface StockSnapshot {
+ price: number;
+ currency: string;
+ pe?: number;
+ forwardPe?: number;
+ revenueGrowth?: number;
+ profitGrowth?: number;
+ nextEarningsDate?: string;
+ peers: PeerValuation[];
+ news: RelatedNews[];
+ asOf?: string;
+ sources?: DataSourceRef[];
+}
+
+export interface MarketDataProvider {
+ snapshot(stock: WatchStock): Promise;
+ candles(stock: WatchStock, startDate?: string): Promise;
+ fundamentals(stock: WatchStock): Promise;
+ relatedData(stock: WatchStock): Promise;
+ quantAnalysis(input: QuantAnalysisInput): Promise;
+}
+
+export interface PriceCandle {
+ date: string;
+ open: number;
+ high: number;
+ low: number;
+ close: number;
+ volume: number;
+}
+
+export interface IndicatorPoint {
+ date: string;
+ ma5?: number;
+ ma20?: number;
+ ma60?: number;
+ ma120?: number;
+ ma200?: number;
+ rsi14?: number;
+ macd?: number;
+ macdSignal?: number;
+ macdHistogram?: number;
+}
+
+export interface StockChartData {
+ stock: WatchStock;
+ period: ChartPeriod;
+ candles: PriceCandle[];
+ indicators: IndicatorPoint[];
+ cachedAt: string;
+}
+
+export interface FinancialStatement {
+ period: string;
+ reportDate?: string;
+ revenue: number;
+ netIncome: number;
+ eps?: number;
+ grossMargin?: number;
+ operatingCashFlow?: number;
+ debtToAsset?: number;
+}
+
+export interface StockFundamentalsData {
+ stock: WatchStock;
+ statements: FinancialStatement[];
+ cachedAt: string;
+}
+
+export interface RelatedStockData {
+ sector?: string;
+ industryRank?: number;
+ beta?: number;
+ avgVolume20?: number;
+ volumeRatio?: number;
+ turnoverRate?: number;
+ volatility20?: number;
+ drawdownFromHigh?: number;
+ relativeStrength60?: number;
+ support?: number;
+ resistance?: number;
+ capitalFlow?: number;
+ sentimentScore?: number;
+ newsHeat?: number;
+ marketCap?: number;
+ enterpriseValue?: number;
+ sharesOutstanding?: number;
+ freeFloat?: number;
+ priceToBook?: number;
+ priceToSales?: number;
+ pegRatio?: number;
+ evToEbitda?: number;
+ dividendYield?: number;
+ freeCashFlowYield?: number;
+ grossMarginTtm?: number;
+ netMarginTtm?: number;
+ roeTtm?: number;
+ roaTtm?: number;
+ debtToEquity?: number;
+ currentRatio?: number;
+ revenueCagr3y?: number;
+ epsGrowthTtm?: number;
+ analystRating?: string;
+ analystTargetPrice?: number;
+ institutionalOwnership?: number;
+ insiderOwnership?: number;
+ shortInterestRatio?: number;
+ nextExDividendDate?: string;
+ indexMembership?: string[];
+ recentNews?: RelatedNews[];
+ socialMentions?: RelatedNews[];
+ socialDiscussionCount?: number;
+ webSentimentLabel?: string;
+ riskFlags: string[];
+ asOf?: string;
+ sources: DataSourceRef[];
+}
+
+export interface StockRelatedData {
+ stock: WatchStock;
+ data: RelatedStockData;
+ cachedAt: string;
+}
+
+export interface QuantFactor {
+ name: string;
+ score: number;
+ weight: number;
+ evidence: string;
+}
+
+export interface QuantAnalysis {
+ action: RecommendationAction;
+ score: number;
+ confidence: number;
+ horizon: string;
+ summary: string;
+ factors: QuantFactor[];
+ tradePlan?: QuantTradePlan;
+ marketContext?: string[];
+ riskNotes: string[];
+}
+
+export interface QuantTradePlan {
+ direction: "long" | "wait" | "reduce" | "avoid";
+ entryPrice?: number;
+ targetPrice?: number;
+ stopLoss?: number;
+ expectedReturnPct?: number;
+ invalidation?: string;
+ rationale: string;
+}
+
+export interface StockQuantAnalysis {
+ stock: WatchStock;
+ analysis: QuantAnalysis;
+ generatedAt: string;
+ cachedAt: string;
+}
+
+export interface QuantAnalysisInput {
+ stock: WatchStock;
+ snapshot: StockSnapshot;
+ candles: PriceCandle[];
+ indicators: IndicatorPoint[];
+ fundamentals: FinancialStatement[];
+ relatedData: RelatedStockData;
+}
+
+export interface EarningsSignal {
+ status: "none" | "soon" | "scheduled";
+ nextDate?: string;
+ daysUntil?: number;
+}
+
+export interface ValuationSignal {
+ label: "discount" | "fair" | "premium" | "unknown";
+ stockPe?: number;
+ peerAveragePe?: number;
+ spreadPct?: number;
+}
+
+export interface Recommendation {
+ action: RecommendationAction;
+ confidence: number;
+ reasons: string[];
+}
+
+export interface DailyReportItem {
+ stock: WatchStock;
+ snapshot: StockSnapshot;
+ earnings: EarningsSignal;
+ valuation: ValuationSignal;
+ recommendation: Recommendation;
+ news: RelatedNews[];
+}
+
+export interface DailyReport {
+ generatedAt: string;
+ summary: string;
+ items: DailyReportItem[];
+}
diff --git a/web/tests/provider.test.ts b/web/tests/provider.test.ts
new file mode 100644
index 0000000..93a8066
--- /dev/null
+++ b/web/tests/provider.test.ts
@@ -0,0 +1,217 @@
+import { describe, expect, it, vi } from "vitest";
+import { DemoMarketDataProvider, IPADS_BASE_URL, IPADS_WIRE_API, IpadsResponsesProvider } from "../src/agent/provider";
+import { loadIpadsConfig } from "../src/server/ipadsConfig";
+import type { WatchStock } from "../src/shared/types";
+
+const stock: WatchStock = {
+ id: "AAPL",
+ symbol: "AAPL",
+ display: "AAPL",
+ market: "US",
+ name: "Apple",
+ tags: ["core"],
+ addedAt: "2026-05-11T00:00:00.000Z"
+};
+
+describe("IpadsResponsesProvider", () => {
+ it("uses the fixed IPADS Responses API endpoint for agent quant analysis", async () => {
+ const fetchImpl = vi.fn(async () => {
+ return new Response(
+ JSON.stringify({
+ output: [
+ {
+ type: "message",
+ role: "assistant",
+ content: [
+ {
+ type: "output_text",
+ text: JSON.stringify({
+ action: "buy_watch",
+ score: 72,
+ confidence: 0.68,
+ horizon: "1-3 months",
+ summary: "Trend and valuation are supportive.",
+ factors: [
+ { name: "trend", score: 75, weight: 0.4, evidence: "above MA20" },
+ { name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" },
+ { name: "risk", score: 70, weight: 0.3, evidence: "controlled" }
+ ],
+ riskNotes: []
+ })
+ }
+ ]
+ }
+ ]
+ }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+ });
+ const provider = new IpadsResponsesProvider("secret", fetchImpl);
+
+ const analysis = await provider.quantAnalysis({
+ stock,
+ snapshot: { price: 100, currency: "USD", pe: 20, peers: [], news: [] },
+ candles: [{ date: "2026-05-11", open: 100, high: 102, low: 99, close: 101, volume: 1_000_000 }],
+ indicators: [{ date: "2026-05-11", ma20: 98 }],
+ fundamentals: [],
+ relatedData: { riskFlags: [], sources: [] }
+ });
+
+ expect(fetchImpl).toHaveBeenCalledWith(
+ `${IPADS_BASE_URL}/${IPADS_WIRE_API}`,
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: "Bearer secret",
+ "Content-Type": "application/json"
+ }),
+ body: expect.stringContaining("\"model\":\"gpt-5.5\"")
+ })
+ );
+ const calls = fetchImpl.mock.calls as unknown as [string, RequestInit][];
+ const requestBody = JSON.parse(String(calls[0]?.[1].body));
+ expect(requestBody.input).toContain("\"symbol\":\"AAPL\"");
+ expect(typeof requestBody.input).toBe("string");
+ expect(requestBody).not.toHaveProperty("metadata");
+ expect(analysis.action).toBe("buy_watch");
+ });
+
+ it("falls back to finite local analysis when remote agent cannot analyze empty market data", async () => {
+ const fetchImpl = vi.fn(async () => {
+ return new Response(
+ JSON.stringify({
+ output_text: JSON.stringify({
+ action: "hold",
+ score: 50,
+ confidence: 0.4,
+ horizon: "1-3个月",
+ summary: "invalid",
+ factors: [
+ { name: "trend", score: 50, weight: 1, evidence: "empty" },
+ { name: "valuation", score: 50, weight: 0, evidence: "empty" },
+ { name: "risk", score: 50, weight: 0, evidence: "empty" }
+ ],
+ tradePlan: {
+ direction: "wait",
+ entryPrice: null,
+ targetPrice: null,
+ stopLoss: null,
+ expectedReturnPct: null,
+ rationale: "invalid null prices"
+ },
+ riskNotes: []
+ })
+ }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ );
+ });
+ const provider = new IpadsResponsesProvider("secret", fetchImpl);
+
+ const analysis = await provider.quantAnalysis({
+ stock: { ...stock, symbol: "DRAM", display: "DRAM", id: "DRAM" },
+ snapshot: { price: 0, currency: "USD", peers: [], news: [] },
+ candles: [],
+ indicators: [],
+ fundamentals: [],
+ relatedData: { riskFlags: [], sources: [] }
+ });
+
+ expect(Number.isFinite(analysis.score)).toBe(true);
+ expect(Number.isFinite(analysis.confidence)).toBe(true);
+ expect(analysis.factors.every((factor) => Number.isFinite(factor.score))).toBe(true);
+ expect(analysis.tradePlan?.entryPrice).toBeUndefined();
+ });
+});
+
+describe("loadIpadsConfig", () => {
+ it("reads IPADS_API_KEY from an env object and keeps fixed URL and wire API", () => {
+ expect(loadIpadsConfig({ IPADS_API_KEY: "secret" })).toEqual({
+ baseUrl: IPADS_BASE_URL,
+ wireApi: IPADS_WIRE_API,
+ apiKey: "secret",
+ apiKeyConfigured: true
+ });
+ });
+
+ it("reports an unconfigured API key when IPADS_API_KEY is blank", () => {
+ expect(loadIpadsConfig({ IPADS_API_KEY: " " })).toEqual({
+ baseUrl: IPADS_BASE_URL,
+ wireApi: IPADS_WIRE_API,
+ apiKey: "",
+ apiKeyConfigured: false
+ });
+ });
+});
+
+describe("DemoMarketDataProvider", () => {
+ it("returns deterministic snapshots when no provider URL is configured", async () => {
+ const provider = new DemoMarketDataProvider();
+
+ await expect(provider.snapshot(stock)).resolves.toEqual(
+ expect.objectContaining({
+ currency: "USD",
+ peers: expect.any(Array),
+ news: expect.any(Array)
+ })
+ );
+ });
+
+ it("returns chart candles and financial statements for local cached views", async () => {
+ const provider = new DemoMarketDataProvider();
+
+ await expect(provider.candles(stock)).resolves.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ date: expect.any(String),
+ open: expect.any(Number),
+ high: expect.any(Number),
+ low: expect.any(Number),
+ close: expect.any(Number),
+ volume: expect.any(Number)
+ })
+ ])
+ );
+ await expect(provider.fundamentals(stock)).resolves.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ period: expect.any(String),
+ revenue: expect.any(Number),
+ netIncome: expect.any(Number)
+ })
+ ])
+ );
+ });
+
+ it("returns related data and a structured quantitative analysis", async () => {
+ const provider = new DemoMarketDataProvider();
+ const candles = await provider.candles(stock);
+ const relatedData = await provider.relatedData(stock);
+
+ expect(relatedData).toEqual(
+ expect.objectContaining({
+ sector: expect.any(String),
+ avgVolume20: expect.any(Number),
+ sentimentScore: expect.any(Number)
+ })
+ );
+ await expect(
+ provider.quantAnalysis({
+ stock,
+ snapshot: await provider.snapshot(stock),
+ candles,
+ indicators: [],
+ fundamentals: await provider.fundamentals(stock),
+ relatedData
+ })
+ ).resolves.toEqual(
+ expect.objectContaining({
+ action: expect.stringMatching(/buy_watch|hold|avoid/),
+ score: expect.any(Number),
+ factors: expect.any(Array),
+ tradePlan: expect.objectContaining({
+ rationale: expect.any(String)
+ })
+ })
+ );
+ });
+});
diff --git a/web/tests/report.test.ts b/web/tests/report.test.ts
new file mode 100644
index 0000000..6388a8e
--- /dev/null
+++ b/web/tests/report.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "vitest";
+import { generateDailyReport } from "../src/agent/report";
+import type { MarketDataProvider, WatchStock } from "../src/shared/types";
+
+const watchlist: WatchStock[] = [
+ {
+ id: "AAPL",
+ symbol: "AAPL",
+ display: "AAPL",
+ market: "US",
+ name: "Apple",
+ tags: ["core"],
+ addedAt: "2026-05-11T00:00:00.000Z"
+ },
+ {
+ id: "00700.HK",
+ symbol: "00700",
+ display: "00700.HK",
+ market: "HK",
+ name: "Tencent",
+ tags: ["internet"],
+ addedAt: "2026-05-11T00:00:00.000Z"
+ }
+];
+
+const provider: MarketDataProvider = {
+ async snapshot(stock) {
+ if (stock.display === "AAPL") {
+ return {
+ price: 100,
+ currency: "USD",
+ pe: 12,
+ forwardPe: 11,
+ revenueGrowth: 0.08,
+ profitGrowth: 0.05,
+ nextEarningsDate: "2026-05-13",
+ peers: [
+ { display: "MSFT", pe: 28 },
+ { display: "GOOGL", pe: 24 }
+ ],
+ news: [{ title: "Apple supplier raises shipment guidance", url: "https://example.test/aapl" }]
+ };
+ }
+
+ return {
+ price: 380,
+ currency: "HKD",
+ pe: 22,
+ forwardPe: 20,
+ revenueGrowth: 0.02,
+ profitGrowth: -0.01,
+ nextEarningsDate: "2026-06-30",
+ peers: [{ display: "9988.HK", pe: 18 }],
+ news: []
+ };
+ },
+ async candles() {
+ return [];
+ },
+ async fundamentals() {
+ return [];
+ },
+ async relatedData() {
+ return {
+ sector: "Technology",
+ avgVolume20: 1_000_000,
+ volumeRatio: 1,
+ volatility20: 0.02,
+ drawdownFromHigh: 0.1,
+ relativeStrength60: 0.04,
+ support: 90,
+ resistance: 120,
+ capitalFlow: 0.1,
+ sentimentScore: 0.6,
+ newsHeat: 0.5,
+ riskFlags: [],
+ sources: []
+ };
+ },
+ async quantAnalysis() {
+ return {
+ action: "hold",
+ score: 55,
+ confidence: 0.5,
+ horizon: "1-3 months",
+ summary: "Hold",
+ factors: [
+ { name: "trend", score: 55, weight: 1, evidence: "neutral" },
+ { name: "valuation", score: 55, weight: 0, evidence: "neutral" },
+ { name: "risk", score: 55, weight: 0, evidence: "neutral" }
+ ],
+ riskNotes: []
+ };
+ }
+};
+
+describe("generateDailyReport", () => {
+ it("ranks earnings, related news, valuation mismatch, and buy advice across markets", async () => {
+ const report = await generateDailyReport(watchlist, provider, new Date("2026-05-11T00:00:00.000Z"));
+
+ expect(report.generatedAt).toBe("2026-05-11T00:00:00.000Z");
+ expect(report.items).toHaveLength(2);
+ expect(report.summary).toContain("US: 1");
+ expect(report.summary).toContain("HK: 1");
+
+ const apple = report.items.find((item) => item.stock.display === "AAPL");
+ expect(apple?.earnings.status).toBe("soon");
+ expect(apple?.valuation.label).toBe("discount");
+ expect(apple?.recommendation.action).toBe("buy_watch");
+ expect(apple?.news[0]?.title).toContain("supplier");
+
+ const tencent = report.items.find((item) => item.stock.display === "00700.HK");
+ expect(tencent?.valuation.label).toBe("premium");
+ expect(tencent?.recommendation.action).toBe("hold");
+ });
+});
diff --git a/web/tests/stockDataCache.test.ts b/web/tests/stockDataCache.test.ts
new file mode 100644
index 0000000..94684f5
--- /dev/null
+++ b/web/tests/stockDataCache.test.ts
@@ -0,0 +1,179 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { StockDataCache } from "../src/agent/stockDataCache";
+import type { MarketDataProvider, WatchStock } from "../src/shared/types";
+
+let tempDir: string | undefined;
+
+afterEach(async () => {
+ if (tempDir) {
+ await rm(tempDir, { force: true, recursive: true });
+ tempDir = undefined;
+ }
+});
+
+const stock: WatchStock = {
+ id: "AAPL",
+ symbol: "AAPL",
+ display: "AAPL",
+ market: "US",
+ name: "Apple",
+ tags: ["core"],
+ addedAt: "2026-05-11T00:00:00.000Z"
+};
+
+describe("StockDataCache", () => {
+ it("persists chart and fundamentals data and reuses the local cache", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
+ const provider: MarketDataProvider = {
+ snapshot: vi.fn(async () => ({
+ price: 100,
+ currency: "USD",
+ pe: 20,
+ peers: [],
+ news: []
+ })),
+ candles: vi.fn(async () =>
+ Array.from({ length: 1010 }, (_, index) => ({
+ date: new Date(Date.UTC(2022, 0, 3 + index)).toISOString().slice(0, 10),
+ open: 100 + index,
+ high: 102 + index,
+ low: 99 + index,
+ close: 101 + index,
+ volume: 1_000_000 + index
+ }))
+ ),
+ fundamentals: vi.fn(async () => [
+ {
+ period: "2026Q1",
+ revenue: 1_000_000,
+ netIncome: 120_000
+ }
+ ]),
+ relatedData: vi.fn(async () => ({
+ sector: "Technology",
+ avgVolume20: 1_000_000,
+ volumeRatio: 1.1,
+ volatility20: 0.02,
+ drawdownFromHigh: 0.08,
+ relativeStrength60: 0.05,
+ support: 98,
+ resistance: 112,
+ capitalFlow: 0.1,
+ sentimentScore: 0.6,
+ newsHeat: 0.5,
+ riskFlags: [],
+ sources: []
+ })),
+ quantAnalysis: vi.fn(async () => ({
+ action: "buy_watch" as const,
+ score: 72,
+ confidence: 0.7,
+ horizon: "1-3 months",
+ summary: "Positive",
+ factors: [
+ { name: "trend", score: 70, weight: 0.4, evidence: "above average" },
+ { name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" },
+ { name: "sentiment", score: 74, weight: 0.3, evidence: "supportive" }
+ ],
+ riskNotes: []
+ }))
+ };
+ const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-11T00:00:00.000Z"));
+
+ await cache.chart(stock);
+ await cache.chart(stock);
+ await cache.fundamentals(stock);
+ await cache.fundamentals(stock);
+
+ expect(provider.candles).toHaveBeenCalledTimes(1);
+ expect(provider.fundamentals).toHaveBeenCalledTimes(1);
+ await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 }));
+ await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 }));
+ expect(provider.snapshot).toHaveBeenCalledTimes(1);
+ await expect(cache.chart(stock, "W")).resolves.toEqual(expect.objectContaining({ period: "W" }));
+ await expect(cache.relatedData(stock)).resolves.toEqual(expect.objectContaining({ data: expect.objectContaining({ sector: "Technology" }) }));
+ await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) }));
+ await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) }));
+ expect(provider.quantAnalysis).toHaveBeenCalledTimes(1);
+ });
+
+ it("accepts real short listing history instead of requiring a full ten years", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
+ const provider: MarketDataProvider = {
+ snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })),
+ candles: vi.fn(async () =>
+ Array.from({ length: 225 }, (_, index) => ({
+ date: new Date(Date.UTC(2025, 5, 18 + index)).toISOString().slice(0, 10),
+ open: 18 + index / 100,
+ high: 19 + index / 100,
+ low: 17 + index / 100,
+ close: 18.5 + index / 100,
+ volume: 800_000 + index
+ }))
+ ),
+ fundamentals: vi.fn(async () => []),
+ relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })),
+ quantAnalysis: vi.fn(async () => ({
+ action: "hold" as const,
+ score: 58,
+ confidence: 0.6,
+ horizon: "1-3个月",
+ summary: "等待确认",
+ factors: [
+ { name: "趋势", score: 58, weight: 0.4, evidence: "短历史可用" },
+ { name: "估值", score: 50, weight: 0.3, evidence: "数据不足" },
+ { name: "风险", score: 55, weight: 0.3, evidence: "控制仓位" }
+ ],
+ riskNotes: []
+ }))
+ };
+ const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z"));
+
+ await expect(cache.chart({ ...stock, id: "SLDE", symbol: "SLDE", display: "SLDE" })).resolves.toEqual(
+ expect.objectContaining({
+ candles: expect.arrayContaining([expect.objectContaining({ date: expect.any(String) })])
+ })
+ );
+ expect(provider.candles).toHaveBeenCalledTimes(1);
+ });
+
+ it("caches an empty historical result without treating it as an error", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
+ const provider: MarketDataProvider = {
+ snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })),
+ candles: vi.fn(async () => []),
+ fundamentals: vi.fn(async () => []),
+ relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })),
+ quantAnalysis: vi.fn(async () => ({
+ action: "hold" as const,
+ score: 50,
+ confidence: 0.4,
+ horizon: "1-3个月",
+ summary: "无K线数据,忽略技术面。",
+ factors: [
+ { name: "趋势", score: 50, weight: 0.4, evidence: "无历史数据" },
+ { name: "估值", score: 50, weight: 0.3, evidence: "数据不足" },
+ { name: "风险", score: 50, weight: 0.3, evidence: "等待数据" }
+ ],
+ riskNotes: []
+ }))
+ };
+ const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z"));
+
+ await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual(
+ expect.objectContaining({
+ candles: [],
+ indicators: []
+ })
+ );
+ await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual(
+ expect.objectContaining({
+ candles: []
+ })
+ );
+ expect(provider.candles).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/web/tests/symbols.test.ts b/web/tests/symbols.test.ts
new file mode 100644
index 0000000..1acfe9c
--- /dev/null
+++ b/web/tests/symbols.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+import { normalizeSymbol } from "../src/agent/symbols";
+
+describe("normalizeSymbol", () => {
+ it.each([
+ ["600519", { symbol: "600519", market: "CN", display: "600519.SS" }],
+ ["000001.SZ", { symbol: "000001", market: "CN", display: "000001.SZ" }],
+ ["512480", { symbol: "512480", market: "CN", display: "512480.SS" }],
+ ["512480.SZ", { symbol: "512480", market: "CN", display: "512480.SS" }],
+ ["00700.hk", { symbol: "00700", market: "HK", display: "00700.HK" }],
+ ["AAPL", { symbol: "AAPL", market: "US", display: "AAPL" }],
+ ["msft.us", { symbol: "MSFT", market: "US", display: "MSFT" }]
+ ])("normalizes %s", (input, expected) => {
+ expect(normalizeSymbol(input)).toEqual(expected);
+ });
+
+ it("rejects empty and unsupported symbols", () => {
+ expect(() => normalizeSymbol("")).toThrow("Stock symbol is required");
+ expect(() => normalizeSymbol("!!!")).toThrow("Unsupported stock symbol");
+ });
+});
diff --git a/web/tests/watchlistStore.test.ts b/web/tests/watchlistStore.test.ts
new file mode 100644
index 0000000..27075ce
--- /dev/null
+++ b/web/tests/watchlistStore.test.ts
@@ -0,0 +1,63 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { WatchlistStore } from "../src/agent/watchlistStore";
+
+let tempDir: string | undefined;
+
+afterEach(async () => {
+ if (tempDir) {
+ await rm(tempDir, { force: true, recursive: true });
+ tempDir = undefined;
+ }
+});
+
+describe("WatchlistStore", () => {
+ it("adds normalized stocks once and persists them", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
+ const path = join(tempDir, "watchlist.json");
+ const store = new WatchlistStore(path);
+
+ await store.add("aapl", "Apple", ["Core", " AI ", "Core"]);
+ await store.add("AAPL", "Duplicate");
+ await store.add("00700.HK", "Tencent");
+
+ expect(await store.list()).toEqual([
+ expect.objectContaining({ display: "AAPL", market: "US", name: "Apple", tags: ["Core", "AI"] }),
+ expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent", tags: [] })
+ ]);
+
+ const reloaded = new WatchlistStore(path);
+ expect(await reloaded.list()).toHaveLength(2);
+ });
+
+ it("removes stocks by any supported symbol form", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
+ const store = new WatchlistStore(join(tempDir, "watchlist.json"));
+
+ await store.add("600519", "Kweichow Moutai");
+ await store.remove("600519.SS");
+
+ expect(await store.list()).toEqual([]);
+ });
+
+ it("updates tags for a persisted stock", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
+ const store = new WatchlistStore(join(tempDir, "watchlist.json"));
+
+ await store.add("MSFT", "Microsoft");
+ await store.updateTags("MSFT.US", ["cloud", "quality"]);
+
+ expect((await store.list())[0]).toEqual(expect.objectContaining({ tags: ["cloud", "quality"] }));
+ });
+
+ it("canonicalizes persisted China ETF exchange suffixes", async () => {
+ tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
+ const store = new WatchlistStore(join(tempDir, "watchlist.json"));
+
+ await store.add("512480.SZ", "Semiconductor ETF");
+
+ expect((await store.list())[0]).toEqual(expect.objectContaining({ display: "512480.SS", id: "512480.SS" }));
+ });
+});
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..5d4f200
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src", "tests", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..94502a6
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+const host = process.env.HOST ?? "127.0.0.1";
+const clientPort = Number(process.env.CLIENT_PORT ?? 5173);
+const apiPort = Number(process.env.PORT ?? 8787);
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host,
+ port: clientPort,
+ allowedHosts: ["gahow-pc.ipads-lab.se.sjtu.edu.cn"],
+ proxy: {
+ "/api": `http://127.0.0.1:${apiPort}`
+ }
+ }
+});