From c87f37974eb3fd648e71ac04133b6e7e625d362f Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Mon, 11 May 2026 21:10:55 +0800 Subject: [PATCH 1/8] feat: bootstrap personal stock agent --- web/.env.example | 1 + web/.gitignore | 6 + web/README.md | 46 + web/index.html | 12 + web/package-lock.json | 3475 ++++++++++++++++++++++++++++++ web/package.json | 35 + web/src/agent/provider.ts | 143 ++ web/src/agent/report.ts | 143 ++ web/src/agent/symbols.ts | 51 + web/src/agent/watchlistStore.ts | 58 + web/src/client/main.tsx | 250 +++ web/src/client/styles.css | 332 +++ web/src/server/index.ts | 98 + web/src/server/ipadsConfig.ts | 18 + web/src/shared/types.ts | 77 + web/tests/provider.test.ts | 99 + web/tests/report.test.ts | 76 + web/tests/symbols.test.ts | 19 + web/tests/watchlistStore.test.ts | 44 + web/tsconfig.json | 20 + web/vite.config.ts | 11 + 21 files changed, 5014 insertions(+) create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/agent/provider.ts create mode 100644 web/src/agent/report.ts create mode 100644 web/src/agent/symbols.ts create mode 100644 web/src/agent/watchlistStore.ts create mode 100644 web/src/client/main.tsx create mode 100644 web/src/client/styles.css create mode 100644 web/src/server/index.ts create mode 100644 web/src/server/ipadsConfig.ts create mode 100644 web/src/shared/types.ts create mode 100644 web/tests/provider.test.ts create mode 100644 web/tests/report.test.ts create mode 100644 web/tests/symbols.test.ts create mode 100644 web/tests/watchlistStore.test.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts 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..7dd17be --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +data/ +.env +.DS_Store +*.log diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..3a69b83 --- /dev/null +++ b/web/README.md @@ -0,0 +1,46 @@ +# 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 + +## 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. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f9a89ba --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Personal Stock Agent + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..9ed180f --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3475 @@ +{ + "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", + "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/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/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..4261199 --- /dev/null +++ b/web/package.json @@ -0,0 +1,35 @@ +{ + "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 --host 127.0.0.1", + "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", + "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/src/agent/provider.ts b/web/src/agent/provider.ts new file mode 100644 index 0000000..3413880 --- /dev/null +++ b/web/src/agent/provider.ts @@ -0,0 +1,143 @@ +import { z } from "zod"; +import type { MarketDataProvider, StockSnapshot, WatchStock } from "../shared/types"; + +type FetchLike = typeof fetch; + +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 snapshotSchema = z.object({ + price: z.number(), + currency: z.string().min(1), + pe: z.number(), + 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([]) +}); + +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 { + 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" + }, + body: JSON.stringify({ + model: IPADS_MODEL, + input: buildSnapshotPrompt(stock) + }) + }); + + if (!response.ok) { + throw new Error(`IPADS Responses request failed: ${response.status}`); + } + + return parseResponsesSnapshot(await response.json()); + } +} + +function buildSnapshotPrompt(stock: WatchStock): string { + return [ + "Return only a valid JSON object for a stock monitoring snapshot.", + "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"}]}', + "All numeric fields must be numbers, not null. If precise market data is unavailable, provide a conservative approximate monitoring snapshot and keep news empty.", + `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}` + ].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)}` + } + ] + }; + } +} + +export function createProvider(apiKey: string): MarketDataProvider { + if (apiKey.trim()) { + return new IpadsResponsesProvider(apiKey.trim()); + } + return new DemoMarketDataProvider(); +} + +function parseResponsesSnapshot(payload: unknown): StockSnapshot { + 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"); + } + return snapshotSchema.parse(JSON.parse(stripJsonFence(text))); +} + +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 round(value: number, digits: number): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} diff --git a/web/src/agent/report.ts b/web/src/agent/report.ts new file mode 100644 index 0000000..08ca580 --- /dev/null +++ b/web/src/agent/report.ts @@ -0,0 +1,143 @@ +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 peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0); + if (!Number.isFinite(snapshot.pe) || snapshot.pe <= 0 || peerPes.length === 0) { + return { label: "unknown" }; + } + + const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2); + const spreadPct = round((snapshot.pe - peerAveragePe) / peerAveragePe, 4); + const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair"; + + return { + label, + stockPe: snapshot.pe, + 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/symbols.ts b/web/src/agent/symbols.ts new file mode 100644 index 0000000..9e54c20 --- /dev/null +++ b/web/src/agent/symbols.ts @@ -0,0 +1,51 @@ +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)) { + return { + symbol: base, + market: "CN", + display: `${base}.${suffix === "SH" ? "SS" : suffix}` + }; + } + 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("6") ? "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..50472c9 --- /dev/null +++ b/web/src/agent/watchlistStore.ts @@ -0,0 +1,58 @@ +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): 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, + addedAt: new Date().toISOString() + }; + await this.write([...stocks, stock]); + return stock; + } + + 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[]; + return Array.isArray(parsed) ? parsed : []; + } 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"; +} diff --git a/web/src/client/main.tsx b/web/src/client/main.tsx new file mode 100644 index 0000000..7a3d129 --- /dev/null +++ b/web/src/client/main.tsx @@ -0,0 +1,250 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { Activity, Bell, CircleDollarSign, Plus, RefreshCw, Server, Trash2 } from "lucide-react"; +import type { DailyReport, WatchStock } from "../shared/types"; +import "./styles.css"; + +interface HealthStatus { + providerConfigured: boolean; + baseUrl: string; + wireApi: string; + scheduler: string; +} + +function App() { + const [watchlist, setWatchlist] = useState([]); + const [report, setReport] = useState(); + const [health, setHealth] = useState({ + providerConfigured: false, + baseUrl: "", + wireApi: "responses", + scheduler: "24h" + }); + const [symbol, setSymbol] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + void loadAll(); + }, []); + + 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]); + + async function loadAll() { + setLoading(true); + setError(""); + try { + const [nextWatchlist, nextReport, nextHealth] = await Promise.all([ + api("/api/watchlist"), + api("/api/report/daily"), + api("/api/health") + ]); + setWatchlist(nextWatchlist); + setReport(nextReport); + setHealth(nextHealth); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setLoading(false); + } + } + + async function addStock(event: React.FormEvent) { + event.preventDefault(); + setError(""); + try { + await api("/api/watchlist", { + method: "POST", + body: JSON.stringify({ symbol, name }) + }); + setSymbol(""); + setName(""); + await loadAll(); + } catch (nextError) { + setError(errorMessage(nextError)); + } + } + + async function removeStock(display: string) { + await api(`/api/watchlist/${encodeURIComponent(display)}`, { method: "DELETE" }); + await loadAll(); + } + + async function refreshReport() { + setLoading(true); + try { + setReport(await api("/api/report/daily/refresh", { method: "POST" })); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Personal Stock Agent

+

统一自选股监控

+
+ +
+ + {error ?
{error}
: null} + +
+ } label="自选股" value={watchlist.length} /> + } label="A股 / 港股 / 美股" value={`${marketCounts.CN} / ${marketCounts.HK} / ${marketCounts.US}`} /> + } label="IPADS_API_KEY" value={health.providerConfigured ? "已配置" : "未配置"} /> + } label="wire API" value={health.wireApi} /> +
+ +
+
+
+

自选股

+
+
+ setSymbol(event.target.value)} placeholder="AAPL / 00700.HK / 600519" /> + setName(event.target.value)} placeholder="名称" /> + +
+
+ {watchlist.map((stock) => ( +
+
+ {stock.display} + {stock.name || marketName(stock.market)} +
+ +
+ ))} + {watchlist.length === 0 ?

暂无自选股

: null} +
+
+ +
+
+

IPADS 接口

+
+
+ base_url + {health.baseUrl || "-"} + env_key + IPADS_API_KEY + 自动监控 + {health.scheduler} +
+
+
+ +
+
+
+

今日监控日报

+

{report?.summary ?? "暂无日报"}

+
+ {report ? new Date(report.generatedAt).toLocaleString() : ""} +
+
+ {report?.items.map((item) => ( +
+
+
+ {item.stock.display} + {item.stock.name || marketName(item.stock.market)} +
+ {actionLabel(item.recommendation.action)} +
+
+ {item.snapshot.price} {item.snapshot.currency} + PE {item.snapshot.pe} + {valuationLabel(item.valuation.label)} +
+

{item.recommendation.reasons.join(" · ")}

+
+ 财报: {item.earnings.nextDate ?? "未披露"} + 可比均值: {item.valuation.peerAveragePe ?? "-"} + 相关股票: {item.snapshot.peers.map((peer) => peer.display).join(", ") || "-"} +
+ {item.news[0] ? ( + + {item.news[0].title} + + ) : null} +
+ ))} +
+
+
+ ); +} + +function Metric({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) { + return ( +
+ {icon} + {label} + {value} +
+ ); +} + +function Badge({ tone, children }: { tone: "buy_watch" | "hold" | "avoid"; children: React.ReactNode }) { + return {children}; +} + +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 marketName(market: string) { + return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股"; +} + +function actionLabel(action: string) { + return action === "buy_watch" ? "买入观察" : action === "avoid" ? "回避" : "持有"; +} + +function valuationLabel(label: string) { + return label === "discount" ? "估值折价" : label === "premium" ? "估值溢价" : label === "fair" ? "估值合理" : "估值未知"; +} + +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..6d10014 --- /dev/null +++ b/web/src/client/styles.css @@ -0,0 +1,332 @@ +:root { + color: #18202c; + background: #eef1f4; + 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; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 28px 0 40px; +} + +.topbar, +.layout, +.metrics, +.reportGrid, +.itemTitle, +.panelHeader, +.watchItem, +.quoteLine { + display: grid; + gap: 16px; +} + +.topbar { + grid-template-columns: 1fr auto; + align-items: center; + margin-bottom: 22px; +} + +.eyebrow { + margin: 0 0 4px; + color: #596579; + font-size: 13px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: clamp(30px, 4vw, 48px); + line-height: 1.05; + letter-spacing: 0; +} + +h2 { + font-size: 18px; + letter-spacing: 0; +} + +.iconText { + border: 0; + border-radius: 8px; + background: #184c8f; + color: #fff; + min-height: 42px; + padding: 0 16px; + font-weight: 700; +} + +.iconText { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.iconButton, +.ghostIcon { + width: 42px; + height: 42px; + display: inline-grid; + place-items: center; + border-radius: 8px; +} + +.iconButton { + border: 0; + background: #1f7a5c; + color: #fff; +} + +.ghostIcon { + border: 1px solid #d6dce5; + background: #fff; + color: #596579; +} + +.alert { + border-left: 4px solid #bd3b3b; + background: #fff; + color: #8f2020; + padding: 12px 14px; + margin-bottom: 18px; + border-radius: 8px; +} + +.metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 18px; +} + +.metric, +.panel, +.report, +.reportItem { + background: #fff; + border: 1px solid #dfe4ea; + border-radius: 8px; +} + +.metric { + min-height: 104px; + padding: 18px; + display: grid; + align-content: space-between; +} + +.metric svg { + color: #b45f24; +} + +.metric span { + color: #667085; + font-size: 13px; +} + +.metric strong { + font-size: 24px; +} + +.layout { + grid-template-columns: 1.15fr 0.85fr; + margin-bottom: 18px; +} + +.panel, +.report { + padding: 18px; +} + +.panelHeader { + grid-template-columns: 1fr auto; + align-items: start; + margin-bottom: 16px; +} + +.panelHeader p, +.panelHeader span, +.reason, +.empty, +.detailRows, +.watchItem span { + color: #667085; + font-size: 13px; +} + +.stockForm { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 10px; + margin-bottom: 14px; +} + +.configRows { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 10px 12px; + align-items: baseline; +} + +.configRows span { + color: #465366; + font-size: 13px; +} + +.configRows strong { + min-width: 0; + overflow-wrap: anywhere; + font-size: 14px; +} + +input { + width: 100%; + min-height: 42px; + border: 1px solid #cad2dd; + border-radius: 8px; + padding: 0 12px; + color: #18202c; + background: #f9fafb; +} + +input:focus { + outline: 2px solid #8bb8e8; + border-color: #184c8f; +} + +.watchlist { + display: grid; + gap: 10px; +} + +.watchItem { + grid-template-columns: 1fr auto; + align-items: center; + padding: 10px 0; + border-top: 1px solid #edf0f3; +} + +.watchItem div, +.itemTitle div { + display: grid; + gap: 3px; +} + +.reportGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.reportItem { + padding: 16px; +} + +.itemTitle { + grid-template-columns: 1fr auto; + align-items: center; + margin-bottom: 14px; +} + +.badge { + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + font-weight: 800; +} + +.buy_watch { + color: #0f5c43; + background: #dff4eb; +} + +.hold { + color: #684513; + background: #f8ead2; +} + +.avoid { + color: #8f2020; + background: #f7dddd; +} + +.quoteLine { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 10px; +} + +.quoteLine span { + border-radius: 8px; + background: #f2f5f8; + min-height: 34px; + display: grid; + place-items: center; + font-size: 13px; + font-weight: 700; +} + +.reason { + min-height: 38px; + line-height: 1.45; +} + +.detailRows { + display: grid; + gap: 6px; + margin: 12px 0; +} + +.news { + display: block; + color: #184c8f; + font-size: 13px; + font-weight: 700; + text-decoration: none; + overflow-wrap: anywhere; +} + +@media (max-width: 860px) { + .shell { + width: min(100% - 20px, 720px); + padding-top: 18px; + } + + .topbar, + .layout, + .reportGrid, + .metrics { + grid-template-columns: 1fr; + } + + .stockForm { + grid-template-columns: 1fr auto; + } + + .stockForm input:first-child { + grid-column: 1 / -1; + } +} diff --git a/web/src/server/index.ts b/web/src/server/index.ts new file mode 100644 index 0000000..b19e554 --- /dev/null +++ b/web/src/server/index.ts @@ -0,0 +1,98 @@ +import "dotenv/config"; +import cors from "cors"; +import express from "express"; +import { join } from "node:path"; +import { generateDailyReport } from "../agent/report"; +import { createProvider } from "../agent/provider"; +import { WatchlistStore } from "../agent/watchlistStore"; +import { loadIpadsConfig } from "./ipadsConfig"; + +const port = Number(process.env.PORT ?? 8787); +const dataDir = process.env.STOCK_AGENT_DATA_DIR ?? join(process.cwd(), "data"); + +const watchlistStore = new WatchlistStore(join(dataDir, "watchlist.json")); +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)); + cachedReport = undefined; + } 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/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()); + } 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, "127.0.0.1", () => { + console.log(`stock agent api listening at http://127.0.0.1:${port}`); +}); + +async function getDailyReport() { + const now = Date.now(); + if (cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) { + return cachedReport; + } + + const watchlist = await watchlistStore.list(); + cachedReport = await generateDailyReport(watchlist, createProvider(loadIpadsConfig().apiKey), new Date()); + cachedReportUpdatedAt = now; + return cachedReport; +} 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/shared/types.ts b/web/src/shared/types.ts new file mode 100644 index 0000000..e4239fc --- /dev/null +++ b/web/src/shared/types.ts @@ -0,0 +1,77 @@ +export type Market = "CN" | "HK" | "US"; + +export type RecommendationAction = "buy_watch" | "hold" | "avoid"; + +export interface NormalizedSymbol { + symbol: string; + market: Market; + display: string; +} + +export interface WatchStock extends NormalizedSymbol { + id: string; + name?: string; + addedAt: string; +} + +export interface RelatedNews { + title: string; + url: string; + source?: string; + publishedAt?: 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[]; +} + +export interface MarketDataProvider { + snapshot(stock: WatchStock): Promise; +} + +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..771b3eb --- /dev/null +++ b/web/tests/provider.test.ts @@ -0,0 +1,99 @@ +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", + addedAt: "2026-05-11T00:00:00.000Z" +}; + +describe("IpadsResponsesProvider", () => { + it("uses the fixed IPADS Responses API endpoint with IPADS_API_KEY", async () => { + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + output: [ + { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: JSON.stringify({ + price: 100, + currency: "USD", + pe: 20, + forwardPe: 18, + peers: [{ display: "MSFT", pe: 25 }], + news: [{ title: "Apple earnings preview", url: "https://example.test/news" }] + }) + } + ] + } + ] + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + const provider = new IpadsResponsesProvider("secret", fetchImpl); + + const snapshot = await provider.snapshot(stock); + + 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(snapshot.news[0]?.title).toBe("Apple earnings preview"); + }); +}); + +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) + }) + ); + }); +}); diff --git a/web/tests/report.test.ts b/web/tests/report.test.ts new file mode 100644 index 0000000..fe85fcf --- /dev/null +++ b/web/tests/report.test.ts @@ -0,0 +1,76 @@ +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", + addedAt: "2026-05-11T00:00:00.000Z" + }, + { + id: "00700.HK", + symbol: "00700", + display: "00700.HK", + market: "HK", + name: "Tencent", + 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: [] + }; + } +}; + +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/symbols.test.ts b/web/tests/symbols.test.ts new file mode 100644 index 0000000..a0d2932 --- /dev/null +++ b/web/tests/symbols.test.ts @@ -0,0 +1,19 @@ +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" }], + ["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..41df6e8 --- /dev/null +++ b/web/tests/watchlistStore.test.ts @@ -0,0 +1,44 @@ +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"); + await store.add("AAPL", "Duplicate"); + await store.add("00700.HK", "Tencent"); + + expect(await store.list()).toEqual([ + expect.objectContaining({ display: "AAPL", market: "US", name: "Apple" }), + expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent" }) + ]); + + 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([]); + }); +}); 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..4c77a46 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + "/api": "http://127.0.0.1:8787" + } + } +}); From 30f6cd84a894f5f9dcb7ccfd6c90b04603227d4a Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 19 May 2026 22:59:32 +0800 Subject: [PATCH 2/8] chore: add dev start scripts for server and vite Co-Authored-By: Claude Opus 4.7 --- web/scripts/start-server.sh | 11 +++++++++++ web/scripts/start-vite.sh | 9 +++++++++ 2 files changed, 20 insertions(+) create mode 100755 web/scripts/start-server.sh create mode 100755 web/scripts/start-vite.sh diff --git a/web/scripts/start-server.sh b/web/scripts/start-server.sh new file mode 100755 index 0000000..97815a2 --- /dev/null +++ b/web/scripts/start-server.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" +export HOST="127.0.0.1" +export PORT="${PORT:-8787}" + +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..657cbd0 --- /dev/null +++ b/web/scripts/start-vite.sh @@ -0,0 +1,9 @@ +#!/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" + +exec npx vite --host 0.0.0.0 --port 5174 From a4a2cb2ccfebbe206327ae74f694181767ea1ef5 Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 19 May 2026 23:20:42 +0800 Subject: [PATCH 3/8] feat(data): extend market data provider with charts, fundamentals, and quant analysis Adds a stock data cache layer and indicator helpers (MA/RSI/MACD), expands the MarketDataProvider interface with candles, fundamentals, related-data, and quant analysis, and threads tags + source attribution through the shared types. Updates affected agent tests. Co-Authored-By: Claude Opus 4.7 --- web/src/agent/indicators.ts | 119 ++++ web/src/agent/provider.ts | 1121 +++++++++++++++++++++++++++++- web/src/agent/report.ts | 7 +- web/src/agent/stockDataCache.ts | 380 ++++++++++ web/src/agent/symbols.ts | 5 +- web/src/agent/watchlistStore.ts | 42 +- web/src/shared/types.ts | 163 ++++- web/tests/provider.test.ts | 136 +++- web/tests/report.test.ts | 40 ++ web/tests/stockDataCache.test.ts | 179 +++++ web/tests/symbols.test.ts | 2 + web/tests/watchlistStore.test.ts | 25 +- 12 files changed, 2187 insertions(+), 32 deletions(-) create mode 100644 web/src/agent/indicators.ts create mode 100644 web/src/agent/stockDataCache.ts create mode 100644 web/tests/stockDataCache.test.ts 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 index 3413880..2ea8939 100644 --- a/web/src/agent/provider.ts +++ b/web/src/agent/provider.ts @@ -1,22 +1,146 @@ import { z } from "zod"; -import type { MarketDataProvider, StockSnapshot, WatchStock } from "../shared/types"; +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(), + 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([]) + 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({ @@ -44,6 +168,41 @@ export class IpadsResponsesProvider implements MarketDataProvider { ) {} 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: { @@ -51,9 +210,10 @@ export class IpadsResponsesProvider implements MarketDataProvider { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(60_000), body: JSON.stringify({ model: IPADS_MODEL, - input: buildSnapshotPrompt(stock) + input: prompt }) }); @@ -61,20 +221,67 @@ export class IpadsResponsesProvider implements MarketDataProvider { throw new Error(`IPADS Responses request failed: ${response.status}`); } - return parseResponsesSnapshot(await response.json()); + return parseResponsesJson(await response.json(), schema, context); } } function buildSnapshotPrompt(stock: WatchStock): string { return [ - "Return only a valid JSON object for a stock monitoring snapshot.", + "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"}]}', - "All numeric fields must be numbers, not null. If precise market data is unavailable, provide a conservative approximate monitoring snapshot and keep news empty.", + '{"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); @@ -100,9 +307,136 @@ export class DemoMarketDataProvider implements MarketDataProvider { 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 { @@ -112,13 +446,760 @@ export function createProvider(apiKey: string): MarketDataProvider { return new DemoMarketDataProvider(); } -function parseResponsesSnapshot(payload: unknown): StockSnapshot { +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"); + throw new Error(`IPADS Responses payload did not include output text for ${context}`); } - return snapshotSchema.parse(JSON.parse(stripJsonFence(text))); + 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 { @@ -137,7 +1218,23 @@ function peerDisplay(stock: WatchStock, index: number): string { 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 index 08ca580..f006236 100644 --- a/web/src/agent/report.ts +++ b/web/src/agent/report.ts @@ -60,18 +60,19 @@ function analyzeEarnings(snapshot: StockSnapshot, now: Date): EarningsSignal { } 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(snapshot.pe) || snapshot.pe <= 0 || peerPes.length === 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((snapshot.pe - peerAveragePe) / peerAveragePe, 4); + const spreadPct = round((stockPe - peerAveragePe) / peerAveragePe, 4); const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair"; return { label, - stockPe: snapshot.pe, + stockPe, peerAveragePe, spreadPct }; 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 index 9e54c20..4ecc0f6 100644 --- a/web/src/agent/symbols.ts +++ b/web/src/agent/symbols.ts @@ -12,10 +12,11 @@ export function normalizeSymbol(input: string): NormalizedSymbol { if (suffixMatch) { const [, base, suffix] = suffixMatch; if (CN_SUFFIXES.has(suffix)) { + const exchange = inferChinaExchange(base); return { symbol: base, market: "CN", - display: `${base}.${suffix === "SH" ? "SS" : suffix}` + display: `${base}.${exchange}` }; } if (suffix === "HK" && /^\d{4,5}$/.test(base)) { @@ -43,7 +44,7 @@ export function normalizeSymbol(input: string): NormalizedSymbol { } function inferChinaExchange(symbol: string): "SS" | "SZ" { - return symbol.startsWith("6") ? "SS" : "SZ"; + return symbol.startsWith("5") || symbol.startsWith("6") || symbol.startsWith("9") ? "SS" : "SZ"; } export function marketLabel(market: Market): string { diff --git a/web/src/agent/watchlistStore.ts b/web/src/agent/watchlistStore.ts index 50472c9..b79ece0 100644 --- a/web/src/agent/watchlistStore.ts +++ b/web/src/agent/watchlistStore.ts @@ -10,7 +10,7 @@ export class WatchlistStore { return this.read(); } - async add(symbolInput: string, name?: string): Promise { + 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); @@ -22,12 +22,26 @@ export class WatchlistStore { ...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(); @@ -38,7 +52,20 @@ export class WatchlistStore { try { const content = await readFile(this.path, "utf8"); const parsed = JSON.parse(content) as WatchStock[]; - return Array.isArray(parsed) ? parsed : []; + 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 []; @@ -56,3 +83,14 @@ export class WatchlistStore { 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/shared/types.ts b/web/src/shared/types.ts index e4239fc..42cbf69 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -1,6 +1,7 @@ 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; @@ -11,6 +12,7 @@ export interface NormalizedSymbol { export interface WatchStock extends NormalizedSymbol { id: string; name?: string; + tags: string[]; addedAt: string; } @@ -21,6 +23,12 @@ export interface RelatedNews { publishedAt?: string; } +export interface DataSourceRef { + name: string; + url?: string; + asOf?: string; +} + export interface PeerValuation { display: string; pe: number; @@ -29,17 +37,170 @@ export interface PeerValuation { export interface StockSnapshot { price: number; currency: string; - pe: number; + 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 { diff --git a/web/tests/provider.test.ts b/web/tests/provider.test.ts index 771b3eb..93a8066 100644 --- a/web/tests/provider.test.ts +++ b/web/tests/provider.test.ts @@ -9,11 +9,12 @@ const stock: WatchStock = { 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 with IPADS_API_KEY", async () => { + it("uses the fixed IPADS Responses API endpoint for agent quant analysis", async () => { const fetchImpl = vi.fn(async () => { return new Response( JSON.stringify({ @@ -25,12 +26,17 @@ describe("IpadsResponsesProvider", () => { { type: "output_text", text: JSON.stringify({ - price: 100, - currency: "USD", - pe: 20, - forwardPe: 18, - peers: [{ display: "MSFT", pe: 25 }], - news: [{ title: "Apple earnings preview", url: "https://example.test/news" }] + 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: [] }) } ] @@ -42,7 +48,14 @@ describe("IpadsResponsesProvider", () => { }); const provider = new IpadsResponsesProvider("secret", fetchImpl); - const snapshot = await provider.snapshot(stock); + 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}`, @@ -60,7 +73,53 @@ describe("IpadsResponsesProvider", () => { expect(requestBody.input).toContain("\"symbol\":\"AAPL\""); expect(typeof requestBody.input).toBe("string"); expect(requestBody).not.toHaveProperty("metadata"); - expect(snapshot.news[0]?.title).toBe("Apple earnings preview"); + 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(); }); }); @@ -96,4 +155,63 @@ describe("DemoMarketDataProvider", () => { }) ); }); + + 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 index fe85fcf..6388a8e 100644 --- a/web/tests/report.test.ts +++ b/web/tests/report.test.ts @@ -9,6 +9,7 @@ const watchlist: WatchStock[] = [ display: "AAPL", market: "US", name: "Apple", + tags: ["core"], addedAt: "2026-05-11T00:00:00.000Z" }, { @@ -17,6 +18,7 @@ const watchlist: WatchStock[] = [ display: "00700.HK", market: "HK", name: "Tencent", + tags: ["internet"], addedAt: "2026-05-11T00:00:00.000Z" } ]; @@ -51,6 +53,44 @@ const provider: MarketDataProvider = { 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: [] + }; } }; 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 index a0d2932..1acfe9c 100644 --- a/web/tests/symbols.test.ts +++ b/web/tests/symbols.test.ts @@ -5,6 +5,8 @@ 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" }] diff --git a/web/tests/watchlistStore.test.ts b/web/tests/watchlistStore.test.ts index 41df6e8..27075ce 100644 --- a/web/tests/watchlistStore.test.ts +++ b/web/tests/watchlistStore.test.ts @@ -19,13 +19,13 @@ describe("WatchlistStore", () => { const path = join(tempDir, "watchlist.json"); const store = new WatchlistStore(path); - await store.add("aapl", "Apple"); + 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" }), - expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent" }) + 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); @@ -41,4 +41,23 @@ describe("WatchlistStore", () => { 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" })); + }); }); From f54041646fa3f38bd08ee28d2076d89917ba11ab Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 19 May 2026 23:20:50 +0800 Subject: [PATCH 4/8] feat(strategy): add backtest strategy module Introduces strategyTypes shared schema and a server-side strategyService that lists strategies, runs backtests, and caches results. Co-Authored-By: Claude Opus 4.7 --- web/src/server/strategyService.ts | 178 ++++++++++++++++++++++++++++++ web/src/shared/strategyTypes.ts | 161 +++++++++++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 web/src/server/strategyService.ts create mode 100644 web/src/shared/strategyTypes.ts 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..455625a --- /dev/null +++ b/web/src/shared/strategyTypes.ts @@ -0,0 +1,161 @@ +export type StrategyMarket = "us" | "cn"; +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; + 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; +} + +export interface StrategyMetrics { + totalReturn: number; + annualizedReturn: number; + annualizedVolatility: number; + sharpeRatio: number; + sortinoRatio: number; + maxDrawdown: number; + calmarRatio: number; + winRate: number; + // 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; +} + +export interface StrategyResult { + name: string; + label: string; + equityCurve: EquityPoint[]; + baseEquityCurve: EquityPoint[] | null; + metrics: StrategyMetrics; + 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; + meta: BacktestMeta; + strategies: StrategyResult[]; + benchmark: { name: string; equityCurve: EquityPoint[] }; + computedAt: string; +} + +export interface HoldingDetail { + ticker: string; + shares: number; + lastPrice: number | null; + marketValue: number | null; +} + +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; + initialCapital: number; + currentValue: number; + cash: number; + marketValue: number; + unrealizedPnl: number; + unrealizedPnlPct: number; + holdings: Record; + holdingsDetail: HoldingDetail[]; + priceAsOf: string | null; + equityCurve: EquityPoint[]; + metrics: StrategyMetrics | null; + drawdowns: DrawdownPeriod[]; + trades: TradeEvent[]; + tradeLog: TradeLogEntry[]; + monthlyReturns: Record; + yearlyReturns: Record; + lastUpdated: string | null; +} From 5716267309ce1a021041bc970b7651ae2e65c50b Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Tue, 19 May 2026 23:21:03 +0800 Subject: [PATCH 5/8] feat(ui): split client into Live and Strategy pages and wire new routes Rebuilds the client into Live (watchlist with charts, fundamentals, news, quant analysis) and Strategy (backtest runner / precomputed results) pages, adds the corresponding /api/stocks/:symbol/chart|fundamentals|related|quant and /api/strategies endpoints, and updates project tooling. Co-Authored-By: Claude Opus 4.7 --- web/README.md | 24 + web/index.html | 2 +- web/package-lock.json | 16 + web/package.json | 3 +- web/src/client/LivePage.tsx | 606 ++++++++ web/src/client/StrategyPage.tsx | 850 +++++++++++ web/src/client/main.tsx | 1204 +++++++++++++-- web/src/client/styles.css | 2507 ++++++++++++++++++++++++++++--- web/src/server/index.ts | 262 +++- web/vite.config.ts | 9 +- 10 files changed, 5187 insertions(+), 296 deletions(-) create mode 100644 web/src/client/LivePage.tsx create mode 100644 web/src/client/StrategyPage.tsx diff --git a/web/README.md b/web/README.md index 3a69b83..5ebc818 100644 --- a/web/README.md +++ b/web/README.md @@ -13,6 +13,14 @@ 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 配置文件: @@ -44,3 +52,19 @@ The response output text must be JSON matching the stock snapshot schema used by - 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 index f9a89ba..7c00985 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Personal Stock Agent + Personal Stock Monitor
diff --git a/web/package-lock.json b/web/package-lock.json index 9ed180f..65b6e16 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "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", @@ -2041,6 +2042,12 @@ "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", @@ -2332,6 +2339,15 @@ "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", diff --git a/web/package.json b/web/package.json index 4261199..d055849 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"", "dev:server": "tsx watch src/server/index.ts", - "dev:client": "vite --host 127.0.0.1", + "dev:client": "vite", "test": "vitest run", "build": "tsc --noEmit && vite build" }, @@ -15,6 +15,7 @@ "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", diff --git a/web/src/client/LivePage.tsx b/web/src/client/LivePage.tsx new file mode 100644 index 0000000..c44d7af --- /dev/null +++ b/web/src/client/LivePage.tsx @@ -0,0 +1,606 @@ +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 type { + LiveStrategyState, + StrategyMarket, + TradeLogEntry, +} from "../shared/strategyTypes"; + +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}
} + +
+
+ + +
+ setFilterText(e.target.value)} + /> + + +
+ +
+ + +
+ {focused ? ( + + ) : ( +
+ +

未选择策略

+
+ )} +
+
+
+ ); +} + +function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) { + const m = strategy; + return ( + <> +
+
+
+

{m.name}

+

{m.label}

+

+ {m.market.toUpperCase()} · 初始 {money(m.initialCapital)} · 更新于 {m.lastUpdated ?? "-"} + {m.priceAsOf && m.priceAsOf !== m.lastUpdated ? ` · 价格 ${m.priceAsOf}` : ""} +

+
+
= 0 ? "positive" : "negative"}`}> + {money(m.currentValue)} + + {m.unrealizedPnl >= 0 ? "+" : ""} + {money(m.unrealizedPnl)} ({pct(m.unrealizedPnlPct)}) + +
+
+
+ + + + + + {m.metrics && } + {m.metrics && } + {m.metrics && } +
+
+ + + + + + + + ); +} + +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, +}: { + equityCurve: { date: string; value: number }[]; + initialCapital: number; +}) { + 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" }, + }); + chartRef.current = chart; + + const eqSeries = chart.addSeries(LineSeries, { + color: "#4db6ff", + lineWidth: 2, + priceLineVisible: false, + }); + const data: LineData