first commit
This commit is contained in:
commit
e3a721a055
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
119
README.md
Normal file
119
README.md
Normal file
@ -0,0 +1,119 @@
|
||||
# 比特币马丁格尔策略回测模拟器
|
||||
|
||||
一个基于 Vue 3 + TypeScript 开发的加密货币投资策略回测工具,专门用于模拟马丁格尔(Martingale)策略在比特币合约交易中的表现。
|
||||
|
||||
## 🚀 功能特色
|
||||
|
||||
- **实时K线图显示**:支持自定义时间周期的K线图展示
|
||||
- **马丁格尔策略模拟**:完整实现马丁格尔加仓策略算法
|
||||
- **合约交易支持**:支持3倍杠杆合约交易模拟
|
||||
- **风险管理**:包含强平机制和维持保证金计算
|
||||
- **详细数据统计**:总资产、收益率、完成周期、爆仓次数等指标
|
||||
- **历史数据导入**:支持CSV格式的历史价格数据导入
|
||||
- **收益对比图表**:策略收益vs基准收益的可视化对比
|
||||
|
||||
## 📊 策略参数
|
||||
|
||||
- **杠杆倍数**:3倍
|
||||
- **跌幅加仓**:价格下跌1.87%时加仓
|
||||
- **止盈目标**:单周期止盈3%
|
||||
- **最大加仓**:最多6次加仓
|
||||
- **初始比例**:1/1.32
|
||||
- **资金管理**:单周期最大投入为账户余额的44%
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
- **前端框架**:Vue 3 (Composition API)
|
||||
- **开发语言**:TypeScript
|
||||
- **构建工具**:Vite
|
||||
- **图表库**:ECharts
|
||||
- **样式预处理**:SCSS
|
||||
- **数据处理**:PapaParse (CSV解析)
|
||||
|
||||
## 📦 安装与运行
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- npm 或 yarn 或 bun
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm install
|
||||
|
||||
# 使用 yarn
|
||||
yarn install
|
||||
|
||||
# 使用 bun
|
||||
bun install
|
||||
```
|
||||
|
||||
### 开发模式运行
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm run dev
|
||||
|
||||
# 使用 yarn
|
||||
yarn dev
|
||||
|
||||
# 使用 bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
# 使用 npm
|
||||
npm run build
|
||||
|
||||
# 使用 yarn
|
||||
yarn build
|
||||
|
||||
# 使用 bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
## 📋 使用说明
|
||||
|
||||
1. **准备数据**:准备CSV格式的比特币历史价格数据,包含时间、开盘价、最高价、最低价、收盘价、成交量等字段
|
||||
|
||||
2. **上传文件**:在页面中点击"选择CSV文件"按钮,上传历史数据文件
|
||||
|
||||
3. **开始模拟**:文件上传后会自动开始回测模拟,实时显示交易过程
|
||||
|
||||
4. **查看结果**:
|
||||
- 实时K线图显示价格走势和交易订单
|
||||
- 下方信息面板显示关键指标
|
||||
- 模拟完成后显示收益对比图表
|
||||
|
||||
5. **暂停/继续**:点击顶部标题可以暂停或继续模拟
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Vue组件
|
||||
│ ├── KLineChart.vue # K线图组件
|
||||
│ ├── CashChart.vue # 收益对比图组件
|
||||
│ └── ...
|
||||
├── martingale.ts # 马丁格尔策略核心算法
|
||||
├── analysis.ts # 数据分析工具
|
||||
├── App.vue # 主应用组件
|
||||
├── main.ts # 应用入口
|
||||
└── style.css # 全局样式
|
||||
```
|
||||
|
||||
## ⚠️ 风险提示
|
||||
|
||||
- 本项目仅用于教育和研究目的,展示马丁格尔策略的运作机制
|
||||
- 不构成任何投资建议,实际交易存在重大风险
|
||||
- 马丁格尔策略在极端市场条件下可能导致重大损失
|
||||
- 参与加密货币交易时,请遵守当地法律法规
|
||||
|
||||
## 📊 数据指标说明
|
||||
|
||||
- **总资产**:当前账户总价值(现金+持仓市值)
|
||||
- **总收益**:相对于初始资金的收益金额
|
||||
- **收益率**:收益占初始资金的百分比
|
||||
- **浮动收益**:当前持仓的未实现盈亏
|
||||
- **已完成周期**:完成止盈或强平的交易周期数
|
||||
- **已爆仓次数**:触发强平的次数
|
||||
279
bun.lock
Normal file
279
bun.lock
Normal file
@ -0,0 +1,279 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "btc-simulator",
|
||||
"dependencies": {
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"echarts": "^5.6.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"vue": "^3.5.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"sass-embedded": "^1.86.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0",
|
||||
"vue-tsc": "^2.2.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.27.7", "", { "dependencies": { "@babel/types": "^7.27.7" }, "bin": "./bin/babel-parser.js" }, "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.27.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.5.2", "", {}, "sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.5", "", { "os": "android", "cpu": "arm64" }, "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.5", "", { "os": "android", "cpu": "x64" }, "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.5", "", { "os": "linux", "cpu": "arm" }, "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.5", "", { "os": "none", "cpu": "x64" }, "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.44.1", "", { "os": "android", "cpu": "arm" }, "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.44.1", "", { "os": "android", "cpu": "arm64" }, "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.44.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.44.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.44.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.44.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.44.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.44.1", "", { "os": "linux", "cpu": "arm" }, "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.44.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.44.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g=="],
|
||||
|
||||
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.44.1", "", { "os": "linux", "cpu": "none" }, "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew=="],
|
||||
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.44.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.44.1", "", { "os": "linux", "cpu": "none" }, "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.44.1", "", { "os": "linux", "cpu": "none" }, "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.44.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.44.1", "", { "os": "linux", "cpu": "x64" }, "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.44.1", "", { "os": "linux", "cpu": "x64" }, "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.44.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.44.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.1", "", { "os": "win32", "cpu": "x64" }, "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="],
|
||||
|
||||
"@types/papaparse": ["@types/papaparse@5.3.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@2.4.15", "", { "dependencies": { "@volar/language-core": "2.4.15", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.17", "", { "dependencies": { "@babel/parser": "^7.27.5", "@vue/shared": "3.5.17", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.17", "", { "dependencies": { "@vue/compiler-core": "3.5.17", "@vue/shared": "3.5.17" } }, "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.17", "", { "dependencies": { "@babel/parser": "^7.27.5", "@vue/compiler-core": "3.5.17", "@vue/compiler-dom": "3.5.17", "@vue/compiler-ssr": "3.5.17", "@vue/shared": "3.5.17", "estree-walker": "^2.0.2", "magic-string": "^0.30.17", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/shared": "3.5.17" } }, "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ=="],
|
||||
|
||||
"@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@2.2.10", "", { "dependencies": { "@volar/language-core": "~2.4.11", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.17", "", { "dependencies": { "@vue/shared": "3.5.17" } }, "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.17", "", { "dependencies": { "@vue/reactivity": "3.5.17", "@vue/shared": "3.5.17" } }, "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.17", "", { "dependencies": { "@vue/reactivity": "3.5.17", "@vue/runtime-core": "3.5.17", "@vue/shared": "3.5.17", "csstype": "^3.1.3" } }, "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.17", "", { "dependencies": { "@vue/compiler-ssr": "3.5.17", "@vue/shared": "3.5.17" }, "peerDependencies": { "vue": "3.5.17" } }, "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.17", "", {}, "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg=="],
|
||||
|
||||
"@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
|
||||
|
||||
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"buffer-builder": ["buffer-builder@0.2.0", "", {}, "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg=="],
|
||||
|
||||
"colorjs.io": ["colorjs.io@0.5.2", "", {}, "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||
|
||||
"echarts": ["echarts@5.6.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "5.6.1" } }, "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"immutable": ["immutable@5.1.3", "", {}, "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"rollup": ["rollup@4.44.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.44.1", "@rollup/rollup-android-arm64": "4.44.1", "@rollup/rollup-darwin-arm64": "4.44.1", "@rollup/rollup-darwin-x64": "4.44.1", "@rollup/rollup-freebsd-arm64": "4.44.1", "@rollup/rollup-freebsd-x64": "4.44.1", "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", "@rollup/rollup-linux-arm-musleabihf": "4.44.1", "@rollup/rollup-linux-arm64-gnu": "4.44.1", "@rollup/rollup-linux-arm64-musl": "4.44.1", "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-musl": "4.44.1", "@rollup/rollup-linux-s390x-gnu": "4.44.1", "@rollup/rollup-linux-x64-gnu": "4.44.1", "@rollup/rollup-linux-x64-musl": "4.44.1", "@rollup/rollup-win32-arm64-msvc": "4.44.1", "@rollup/rollup-win32-ia32-msvc": "4.44.1", "@rollup/rollup-win32-x64-msvc": "4.44.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"sass-embedded": ["sass-embedded@1.89.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", "colorjs.io": "^0.5.0", "immutable": "^5.0.2", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", "varint": "^6.0.0" }, "optionalDependencies": { "sass-embedded-android-arm": "1.89.2", "sass-embedded-android-arm64": "1.89.2", "sass-embedded-android-riscv64": "1.89.2", "sass-embedded-android-x64": "1.89.2", "sass-embedded-darwin-arm64": "1.89.2", "sass-embedded-darwin-x64": "1.89.2", "sass-embedded-linux-arm": "1.89.2", "sass-embedded-linux-arm64": "1.89.2", "sass-embedded-linux-musl-arm": "1.89.2", "sass-embedded-linux-musl-arm64": "1.89.2", "sass-embedded-linux-musl-riscv64": "1.89.2", "sass-embedded-linux-musl-x64": "1.89.2", "sass-embedded-linux-riscv64": "1.89.2", "sass-embedded-linux-x64": "1.89.2", "sass-embedded-win32-arm64": "1.89.2", "sass-embedded-win32-x64": "1.89.2" }, "bin": { "sass": "dist/bin/sass.js" } }, "sha512-Ack2K8rc57kCFcYlf3HXpZEJFNUX8xd8DILldksREmYXQkRHI879yy8q4mRDJgrojkySMZqmmmW1NxrFxMsYaA=="],
|
||||
|
||||
"sass-embedded-android-arm": ["sass-embedded-android-arm@1.89.2", "", { "os": "android", "cpu": "arm" }, "sha512-oHAPTboBHRZlDBhyRB6dvDKh4KvFs+DZibDHXbkSI6dBZxMTT+Yb2ivocHnctVGucKTLQeT7+OM5DjWHyynL/A=="],
|
||||
|
||||
"sass-embedded-android-arm64": ["sass-embedded-android-arm64@1.89.2", "", { "os": "android", "cpu": "arm64" }, "sha512-+pq7a7AUpItNyPu61sRlP6G2A8pSPpyazASb+8AK2pVlFayCSPAEgpwpCE9A2/Xj86xJZeMizzKUHxM2CBCUxA=="],
|
||||
|
||||
"sass-embedded-android-riscv64": ["sass-embedded-android-riscv64@1.89.2", "", { "os": "android", "cpu": "none" }, "sha512-HfJJWp/S6XSYvlGAqNdakeEMPOdhBkj2s2lN6SHnON54rahKem+z9pUbCriUJfM65Z90lakdGuOfidY61R9TYg=="],
|
||||
|
||||
"sass-embedded-android-x64": ["sass-embedded-android-x64@1.89.2", "", { "os": "android", "cpu": "x64" }, "sha512-BGPzq53VH5z5HN8de6jfMqJjnRe1E6sfnCWFd4pK+CAiuM7iw5Fx6BQZu3ikfI1l2GY0y6pRXzsVLdp/j4EKEA=="],
|
||||
|
||||
"sass-embedded-darwin-arm64": ["sass-embedded-darwin-arm64@1.89.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UCm3RL/tzMpG7DsubARsvGUNXC5pgfQvP+RRFJo9XPIi6elopY5B6H4m9dRYDpHA+scjVthdiDwkPYr9+S/KGw=="],
|
||||
|
||||
"sass-embedded-darwin-x64": ["sass-embedded-darwin-x64@1.89.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-D9WxtDY5VYtMApXRuhQK9VkPHB8R79NIIR6xxVlN2MIdEid/TZWi1MHNweieETXhWGrKhRKglwnHxxyKdJYMnA=="],
|
||||
|
||||
"sass-embedded-linux-arm": ["sass-embedded-linux-arm@1.89.2", "", { "os": "linux", "cpu": "arm" }, "sha512-leP0t5U4r95dc90o8TCWfxNXwMAsQhpWxTkdtySDpngoqtTy3miMd7EYNYd1znI0FN1CBaUvbdCMbnbPwygDlA=="],
|
||||
|
||||
"sass-embedded-linux-arm64": ["sass-embedded-linux-arm64@1.89.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-2N4WW5LLsbtrWUJ7iTpjvhajGIbmDR18ZzYRywHdMLpfdPApuHPMDF5CYzHbS+LLx2UAx7CFKBnj5LLjY6eFgQ=="],
|
||||
|
||||
"sass-embedded-linux-musl-arm": ["sass-embedded-linux-musl-arm@1.89.2", "", { "os": "linux", "cpu": "arm" }, "sha512-Z6gG2FiVEEdxYHRi2sS5VIYBmp17351bWtOCUZ/thBM66+e70yiN6Eyqjz80DjL8haRUegNQgy9ZJqsLAAmr9g=="],
|
||||
|
||||
"sass-embedded-linux-musl-arm64": ["sass-embedded-linux-musl-arm64@1.89.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-nTyuaBX6U1A/cG7WJh0pKD1gY8hbg1m2SnzsyoFG+exQ0lBX/lwTLHq3nyhF+0atv7YYhYKbmfz+sjPP8CZ9lw=="],
|
||||
|
||||
"sass-embedded-linux-musl-riscv64": ["sass-embedded-linux-musl-riscv64@1.89.2", "", { "os": "linux", "cpu": "none" }, "sha512-N6oul+qALO0SwGY8JW7H/Vs0oZIMrRMBM4GqX3AjM/6y8JsJRxkAwnfd0fDyK+aICMFarDqQonQNIx99gdTZqw=="],
|
||||
|
||||
"sass-embedded-linux-musl-x64": ["sass-embedded-linux-musl-x64@1.89.2", "", { "os": "linux", "cpu": "x64" }, "sha512-K+FmWcdj/uyP8GiG9foxOCPfb5OAZG0uSVq80DKgVSC0U44AdGjvAvVZkrgFEcZ6cCqlNC2JfYmslB5iqdL7tg=="],
|
||||
|
||||
"sass-embedded-linux-riscv64": ["sass-embedded-linux-riscv64@1.89.2", "", { "os": "linux", "cpu": "none" }, "sha512-g9nTbnD/3yhOaskeqeBQETbtfDQWRgsjHok6bn7DdAuwBsyrR3JlSFyqKc46pn9Xxd9SQQZU8AzM4IR+sY0A0w=="],
|
||||
|
||||
"sass-embedded-linux-x64": ["sass-embedded-linux-x64@1.89.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ax7dKvzncyQzIl4r7012KCMBvJzOz4uwSNoyoM5IV6y5I1f5hEwI25+U4WfuTqdkv42taCMgpjZbh9ERr6JVMQ=="],
|
||||
|
||||
"sass-embedded-win32-arm64": ["sass-embedded-win32-arm64@1.89.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-j96iJni50ZUsfD6tRxDQE2QSYQ2WrfHxeiyAXf41Kw0V4w5KYR/Sf6rCZQLMTUOHnD16qTMVpQi20LQSqf4WGg=="],
|
||||
|
||||
"sass-embedded-win32-x64": ["sass-embedded-win32-x64@1.89.2", "", { "os": "win32", "cpu": "x64" }, "sha512-cS2j5ljdkQsb4PaORiClaVYynE9OAPZG/XjbOMxpQmjRIf7UroY4PEIH+Waf+y47PfXFX9SyxhYuw2NIKGbEng=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"sync-child-process": ["sync-child-process@1.0.2", "", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
|
||||
|
||||
"sync-message-port": ["sync-message-port@1.1.3", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||
|
||||
"typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
|
||||
"varint": ["varint@6.0.0", "", {}, "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg=="],
|
||||
|
||||
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/compiler-sfc": "3.5.17", "@vue/runtime-dom": "3.5.17", "@vue/server-renderer": "3.5.17", "@vue/shared": "3.5.17" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@2.2.10", "", { "dependencies": { "@volar/typescript": "~2.4.11", "@vue/language-core": "2.2.10" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ=="],
|
||||
|
||||
"zrender": ["zrender@5.6.1", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag=="],
|
||||
}
|
||||
}
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "btc-simulator",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"echarts": "^5.6.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"sass-embedded": "^1.86.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0",
|
||||
"vue-tsc": "^2.2.4"
|
||||
}
|
||||
}
|
||||
723
src/App.vue
Normal file
723
src/App.vue
Normal file
@ -0,0 +1,723 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import KLineChart from './components/KLineChart.vue'; // 引入之前创建的K线图组件
|
||||
import type { OrderLine, MartingaleState } from './martingale';
|
||||
import { simulateMartingale } from './martingale';
|
||||
import CashChart from './components/CashChart.vue';
|
||||
|
||||
// 类型定义
|
||||
// 定义K线数据接口
|
||||
interface KLineData {
|
||||
time: number; // 时间
|
||||
open: number; // 开盘价
|
||||
high: number; // 最高价
|
||||
low: number; // 最低价
|
||||
close: number; // 收盘价
|
||||
volume: number; // 成交量
|
||||
}
|
||||
|
||||
// 响应式状态
|
||||
const klineData = ref<KLineData[]>([]);
|
||||
const fileName = ref('');
|
||||
const uploadStatus = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
// 响应式计算属性
|
||||
const chartWidth = computed(() => {
|
||||
return Math.min(window.innerWidth - 40, 1200);
|
||||
});
|
||||
|
||||
// 文件上传处理函数
|
||||
// K线数据类型
|
||||
interface KLineData {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
// K线配置参数
|
||||
interface KLineConfig {
|
||||
periodsPerKLine: number; // 每根K线包含多少个15分钟周期 (例如:16=4小时)
|
||||
maxDisplayedKLines: number; // 最大显示的K线数量
|
||||
}
|
||||
// 定义平均持仓成本接口
|
||||
interface Position {
|
||||
avgCost: number; // 平均持仓成本
|
||||
profitLoss?: number; // 浮动盈亏
|
||||
profitLossPercent?: number; // 浮动盈亏百分比
|
||||
}
|
||||
|
||||
const pause = ref(false)
|
||||
const orders = ref<OrderLine[]>([]);
|
||||
const position = ref<Position | null>(null);
|
||||
|
||||
const cashEachTime = 10000 // 初始资金/每次归零后追加的资金
|
||||
let initCash = cashEachTime
|
||||
const cash = ref(initCash)
|
||||
const cashHistory = ref<number[]>([])
|
||||
const btcHistory = ref<number[]>([])
|
||||
|
||||
const state = ref<MartingaleState>({ positions: [] })
|
||||
const endSimulation = ref(false)
|
||||
const tableData = {
|
||||
总资产: 0,
|
||||
总收益: 0,
|
||||
收益率: 0,
|
||||
浮动收益: 0,
|
||||
浮动收益率: 0,
|
||||
已完成周期: 0,
|
||||
持仓量: 0,
|
||||
持仓量USDT: 0,
|
||||
平均持仓成本: 0 as number | '-',
|
||||
已加仓次数: 0,
|
||||
已爆仓次数: 0,
|
||||
止盈价格: 0 as number | '-',
|
||||
}
|
||||
const dTD = ref(structuredClone(tableData))
|
||||
function updateTableData() {
|
||||
dTD.value = { ...tableData }
|
||||
}
|
||||
setInterval(updateTableData, 100);
|
||||
|
||||
const strategyIndex = 0
|
||||
async function handleFileUpload(event: Event, config: KLineConfig = { periodsPerKLine: 96, maxDisplayedKLines: 10000 }) {
|
||||
|
||||
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
|
||||
await wait(5000)
|
||||
if (!files || files.length === 0) {
|
||||
uploadStatus.value = '未选择文件';
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
fileName.value = file.name;
|
||||
|
||||
loading.value = true;
|
||||
uploadStatus.value = '正在解析文件...';
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
// 增量处理的状态变量
|
||||
const aggregatedMap = new Map<string, KLineData>();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
const content = e.target?.result as string;
|
||||
const lines = content.split('\n');
|
||||
const dataLines = lines.slice(1); // 跳过标题行
|
||||
|
||||
await wait(50)
|
||||
loading.value = false;
|
||||
uploadStatus.value = '开始处理数据...';
|
||||
|
||||
simulation: for (let i = 0; i < dataLines.length; i++) {
|
||||
/* await wait(100) */
|
||||
const line = dataLines[i];
|
||||
if (!line.trim()) continue;
|
||||
const dataPoint = parseDataLine(line);
|
||||
if (!dataPoint) continue;
|
||||
|
||||
if (i == 0) {
|
||||
orders.value = simulateMartingale(state.value, {
|
||||
...strategy[strategyIndex],
|
||||
maxInvestment: strategy[strategyIndex].maxInvestment(cash.value),
|
||||
currentPrice: dataPoint.open
|
||||
})
|
||||
}
|
||||
|
||||
updateAggregatedData(dataPoint, aggregatedMap, config.periodsPerKLine);
|
||||
|
||||
const sortedData = Array.from(aggregatedMap.values()).sort((a, b) => a.time - b.time);
|
||||
|
||||
klineData.value = sortedData.slice(
|
||||
Math.max(0, sortedData.length - config.maxDisplayedKLines)
|
||||
);
|
||||
|
||||
const updateTable = () => {
|
||||
if (state.value.positions.length > 0) {
|
||||
let totalInvestment = state.value.positions.reduce((acc, cur) => acc + cur.investment, 0)
|
||||
let totalAmount = state.value.positions.reduce((acc, cur) => acc + cur.amount, 0)
|
||||
let averageCost = totalInvestment / totalAmount * (strategy[strategyIndex].leverage ?? 1)
|
||||
let profitLoss = dataPoint.close * totalAmount / (strategy[strategyIndex].leverage ?? 1) - totalInvestment
|
||||
let profitLossPercent = (profitLoss / totalInvestment) * 100
|
||||
position.value = {
|
||||
avgCost: averageCost,
|
||||
profitLoss: profitLoss,
|
||||
profitLossPercent: profitLossPercent
|
||||
}
|
||||
tableData.总资产 = cash.value + dataPoint.close * totalAmount / (strategy[strategyIndex].leverage ?? 1)
|
||||
tableData.总收益 = tableData.总资产 - initCash
|
||||
tableData.收益率 = (tableData.总资产 - initCash) / initCash * 100
|
||||
tableData.浮动收益 = profitLoss
|
||||
tableData.浮动收益率 = profitLossPercent
|
||||
tableData.持仓量 = totalAmount
|
||||
tableData.持仓量USDT = totalAmount * dataPoint.close
|
||||
tableData.平均持仓成本 = averageCost
|
||||
tableData.已加仓次数 = state.value.positions.length
|
||||
cashHistory.value.push(tableData.总资产)
|
||||
btcHistory.value.push(dataPoint.close)
|
||||
} else {
|
||||
position.value = null
|
||||
tableData.总资产 = cash.value
|
||||
tableData.总收益 = tableData.总资产 - initCash
|
||||
tableData.收益率 = (tableData.总资产 - initCash) / initCash * 100
|
||||
tableData.浮动收益 = 0
|
||||
tableData.浮动收益率 = 0
|
||||
tableData.持仓量 = 0
|
||||
tableData.平均持仓成本 = '-'
|
||||
tableData.已加仓次数 = 0
|
||||
|
||||
cashHistory.value.push(tableData.总资产)
|
||||
btcHistory.value.push(dataPoint.close)
|
||||
}
|
||||
tableData.止盈价格 = orders.value.find(order => order.type === 'SELL')?.price ?? '-'
|
||||
}
|
||||
|
||||
for (const order of orders.value) {
|
||||
switch (order.type) {
|
||||
case 'BUY':
|
||||
if (order.price >= dataPoint.low) {
|
||||
// 直接使用预先计算好的investment值,而不是重新计算
|
||||
let dCash = -order.investment!;
|
||||
cash.value += dCash;
|
||||
state.value.positions.push({
|
||||
price: order.price,
|
||||
amount: order.amount,
|
||||
investment: order.investment!
|
||||
});
|
||||
console.log(`买入 ${order.amount} @ ${order.price} cash ${dCash} USDT`);
|
||||
}
|
||||
break;
|
||||
case 'SELL':
|
||||
if (order.price <= dataPoint.high) {
|
||||
// 计算总投资和总持仓量
|
||||
let totalInvestment = state.value.positions.reduce((acc, cur) => acc + cur.investment, 0);
|
||||
let totalAmount = state.value.positions.reduce((acc, cur) => acc + cur.amount, 0);
|
||||
|
||||
// 获取杠杆倍数
|
||||
let leverageFactor = strategy[strategyIndex].leverage ?? 1;
|
||||
|
||||
// 计算入场时的总仓位价值(含杠杆)
|
||||
let entryPositionValue = totalInvestment * leverageFactor;
|
||||
|
||||
// 计算出场时的总仓位价值
|
||||
let exitPositionValue = totalAmount * order.price;
|
||||
|
||||
// 计算利润/亏损(已经包含杠杆放大效应)
|
||||
let pnl = exitPositionValue - entryPositionValue;
|
||||
|
||||
// 最终返回的现金 = 原始投资 + 利润/亏损
|
||||
let dCash = totalInvestment + pnl;
|
||||
|
||||
cash.value += dCash;
|
||||
state.value.positions = [];
|
||||
tableData.已完成周期++;
|
||||
console.log(`卖出 ${totalAmount} @ ${order.price} cash ${dCash} USDT (PnL: ${pnl.toFixed(2)})`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'LIQUIDATION':
|
||||
if (order.price >= dataPoint.low) { // 价格低于强平线时触发
|
||||
// 计算总投资金额
|
||||
let totalInvestment = state.value.positions.reduce((acc, cur) => acc + cur.investment, 0);
|
||||
|
||||
// 强平时通常损失全部保证金
|
||||
let dCash = 0;
|
||||
|
||||
cash.value += dCash;
|
||||
state.value.positions = [];
|
||||
tableData.已完成周期++;
|
||||
tableData.已爆仓次数++;
|
||||
console.log(`强平 @ ${order.price} 损失 ${totalInvestment.toFixed(2)} USDT`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateTable()
|
||||
|
||||
if (cash.value <= 1 && position.value == null) {
|
||||
// 没钱了... 加仓!
|
||||
initCash += cashEachTime
|
||||
cash.value += cashEachTime
|
||||
}
|
||||
|
||||
orders.value = simulateMartingale(state.value, {
|
||||
...strategy[strategyIndex],
|
||||
maxInvestment: strategy[strategyIndex].maxInvestment(cash.value),
|
||||
currentPrice: dataPoint.close
|
||||
})
|
||||
|
||||
if (i % 8 == 0)
|
||||
do { await wait(1) }
|
||||
while (pause.value)
|
||||
|
||||
|
||||
}
|
||||
await wait(2000)
|
||||
endSimulation.value = true
|
||||
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
// 解析单行数据
|
||||
function parseDataLine(line: string): KLineData | null {
|
||||
const values = line.split(',');
|
||||
if (values.length < 5) return null; // 至少需要时间和OHLC值
|
||||
|
||||
// 解析时间和价格数据
|
||||
const openTime = values[0].trim();
|
||||
const open = parseFloat(values[1]);
|
||||
const high = parseFloat(values[2]);
|
||||
const low = parseFloat(values[3]);
|
||||
const close = parseFloat(values[4]);
|
||||
const volume = parseFloat(values[5] || '0');
|
||||
|
||||
// 验证解析出的数据是否合法
|
||||
if (isNaN(open) || isNaN(high) || isNaN(low) || isNaN(close)) {
|
||||
return null; // 跳过无效数据行
|
||||
}
|
||||
|
||||
// 返回解析结果
|
||||
return {
|
||||
time: new Date(openTime).getTime(),
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume
|
||||
};
|
||||
}
|
||||
|
||||
// 增量更新聚合数据 - 只处理一条新数据
|
||||
function updateAggregatedData(
|
||||
newItem: KLineData,
|
||||
aggregatedMap: Map<string, KLineData>,
|
||||
periodsPerKLine: number
|
||||
): void {
|
||||
const minutesPerPeriod = 15 * periodsPerKLine;
|
||||
|
||||
const date = new Date(newItem.time);
|
||||
|
||||
// 计算周期开始时间
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const day = date.getDate();
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
|
||||
// 计算当天的总分钟数
|
||||
const totalMinutesInDay = hours * 60 + minutes;
|
||||
|
||||
// 计算周期索引和周期开始的分钟数
|
||||
const periodIndex = Math.floor(totalMinutesInDay / minutesPerPeriod);
|
||||
const periodStartMinutes = periodIndex * minutesPerPeriod;
|
||||
const periodStartHours = Math.floor(periodStartMinutes / 60);
|
||||
const periodStartMins = periodStartMinutes % 60;
|
||||
|
||||
// 创建周期开始时间
|
||||
const periodStart = new Date(year, month, day, periodStartHours, periodStartMins, 0, 0);
|
||||
const periodKey = periodStart.getTime().toString();
|
||||
|
||||
if (!aggregatedMap.has(periodKey)) {
|
||||
// 新的周期
|
||||
aggregatedMap.set(periodKey, {
|
||||
time: periodStart.getTime(),
|
||||
open: newItem.open,
|
||||
high: newItem.high,
|
||||
low: newItem.low,
|
||||
close: newItem.close,
|
||||
volume: newItem.volume
|
||||
});
|
||||
} else {
|
||||
const periodData = aggregatedMap.get(periodKey)!;
|
||||
periodData.high = Math.max(periodData.high, newItem.high);
|
||||
periodData.low = Math.min(periodData.low, newItem.low);
|
||||
periodData.close = newItem.close;
|
||||
periodData.volume += newItem.volume;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtNum(num: number, decimals: number = 0, sign = true): string {
|
||||
const formattedNum = decimals > 0
|
||||
? num.toFixed(decimals)
|
||||
: Math.round(num).toString();
|
||||
|
||||
const parts = formattedNum.replace(/^-/, '').split('.');
|
||||
const integerPart = parts[0];
|
||||
const decimalPart = parts.length > 1 ? '.' + parts[1] : '';
|
||||
|
||||
const integerWithCommas = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
const withCommas = integerWithCommas + decimalPart;
|
||||
|
||||
if (parseFloat(formattedNum) > 0) {
|
||||
return sign ? `+${withCommas}` : withCommas;
|
||||
} else if (parseFloat(formattedNum) < 0) {
|
||||
return `-${withCommas}`;
|
||||
} else {
|
||||
return decimals > 0 ? `+${withCommas}` : "+0";
|
||||
}
|
||||
}
|
||||
|
||||
const strategy = [
|
||||
{
|
||||
name: '合约马丁格尔 稳健',
|
||||
// 杠杆
|
||||
leverage: 3,
|
||||
maintenanceMargin: .05,
|
||||
dropPercentage: 1.87, // 跌多少加仓
|
||||
takeProfitPercentage: 3, // 单周期止盈目标
|
||||
maxOrders: 6, // 最大加仓次数
|
||||
initialRatio: 1 / 1.32, // 初始加仓比例
|
||||
priceMultiplier: 1, // 加仓价差倍数
|
||||
amountMultiplier: 1.05, // 加仓金额倍数
|
||||
maxInvestment: (cash: number) => Math.min(cash, Math.max(cashEachTime, cash * .44))
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
const waterMark = "Douyin @feie9454"
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="watermark">
|
||||
<div class="item">{{ waterMark }}</div>
|
||||
<div class="item">{{ waterMark }}</div>
|
||||
<div class="item">{{ waterMark }}</div>
|
||||
<div class="item">{{ waterMark }}</div>
|
||||
|
||||
</div>
|
||||
<div class="header" @click="pause = !pause">
|
||||
<img src="./assets/btc20230419112752.webp" alt="">
|
||||
<span>BTC/USDT (Binance) - 合约马丁格尔策略 <span style="color: #f9a825;">3
|
||||
X</span></span>
|
||||
<!-- <span>BTC/USDT (Binance) - 现货马丁格尔策略 回测</span> -->
|
||||
</div>
|
||||
<div class="file-upload-container" v-if="!fileName">
|
||||
<label for="csv-upload" class="file-upload-label">
|
||||
<span>选择CSV文件</span>
|
||||
<span>{{ fileName }}</span>
|
||||
</label>
|
||||
<input type="file" id="csv-upload" accept=".csv"
|
||||
@change="handleFileUpload" class="file-upload-input" />
|
||||
|
||||
<div class="upload-status" v-if="uploadStatus">
|
||||
{{ uploadStatus }}
|
||||
</div>
|
||||
<h1> 2019年 <span :style="{ color: '#25a750' }">{{ cashEachTime }}
|
||||
USDT</span><br> 投资
|
||||
BTC 合约马丁格尔策略 <span style="color: #f9a825;">3 X</span> </h1>
|
||||
<!-- <h1>能跑赢现货吗?</h1> -->
|
||||
<!-- <h1> 2019年 <span :style="{ color: '#25a750' }">{{ cashEachTime }}
|
||||
USDT</span><span style="font-size: x-small;">(67788 RMB)</span><br> 投资
|
||||
BTC 现货马丁格尔策略 </h1>
|
||||
<h1>能跑赢现货吗?</h1> -->
|
||||
|
||||
<!-- <h2>每次<span :style="{ color: '#ca3f64' }">归零</span>,重新追加 <span
|
||||
:style="{ color: '#25a750' }">{{ cashEachTime }} USDT</span></h2> -->
|
||||
<h2>
|
||||
<span style="">单周期最小 /
|
||||
最大投入</span><br>
|
||||
<span :style="{ color: '#25a750' }">10000 USDT</span> / <span
|
||||
:style="{ color: '#25a750' }">账户余额44%</span>
|
||||
|
||||
</h2>
|
||||
|
||||
<h2>能稳定复利吗?</h2>
|
||||
<h3>截至:2025 年 3 月 30 日 8:00 UTC+8</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数</th>
|
||||
<th>数值</th>
|
||||
<th>参数</th>
|
||||
<th>数值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>跌多少加仓</td>
|
||||
<td>{{ strategy[strategyIndex].dropPercentage }} %</td>
|
||||
<td>初始加仓比例</td>
|
||||
<td>1/{{ (1 / strategy[strategyIndex].initialRatio).toFixed(2) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>单周期止盈目标</td>
|
||||
<td>{{ strategy[strategyIndex].takeProfitPercentage }} %</td>
|
||||
<td>加仓价差倍数</td>
|
||||
<td>{{ strategy[strategyIndex].priceMultiplier }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>最大加仓次数</td>
|
||||
<td>{{ strategy[strategyIndex].maxOrders }}</td>
|
||||
<td>加仓金额倍数</td>
|
||||
<td>{{ strategy[strategyIndex].amountMultiplier }}</td>
|
||||
</tr>
|
||||
<tr v-if="strategy[strategyIndex].leverage">
|
||||
<td colspan="2">杠杆</td>
|
||||
<td colspan="2" style="font-weight: bolder;"><span
|
||||
style="color: #f9a825;">{{ strategy[strategyIndex].leverage
|
||||
}}X</span>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h3>策略来源:欧易OKX 长期稳健投资</h3>
|
||||
<h4 style="margin-top: 32px;">
|
||||
仅为金融策略介绍与分析,不包含主观策略评价,<br>不构成任何投资建议。<br><br>参与加密货币交易时,请遵守当地法律法规。</h4>
|
||||
</div>
|
||||
|
||||
<div class="chart-container" v-if="klineData.length > 0 && !endSimulation">
|
||||
<KLineChart :data="klineData" :width="chartWidth" :height="600"
|
||||
:order-lines="orders" :position="position" />
|
||||
</div>
|
||||
<div class="chart-container" v-if="endSimulation">
|
||||
<CashChart :fund-amounts="cashHistory" :benchmark-amounts="btcHistory" />
|
||||
</div>
|
||||
<div class="info-panel" v-if="klineData.length > 0">
|
||||
<div class="item">
|
||||
<div class="title">总资产</div>
|
||||
<div class="value">{{ fmtNum(dTD.总资产, 2, false) }} USDT</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="title">总收益</div>
|
||||
<div class="value" :class="{ green: dTD.总收益 > 0, red: dTD.总收益 < 0 }">{{
|
||||
fmtNum(dTD.总收益, 2) }} USDT</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="title">收益率</div>
|
||||
<div class="value" :class="{ green: dTD.收益率 > 0, red: dTD.收益率 < 0 }">{{
|
||||
fmtNum(dTD.收益率, 2) }} %</div>
|
||||
</div>
|
||||
<!-- <div class="item">
|
||||
<div class="title">平均持仓成本</div>
|
||||
<div class="value">{{ dTD.平均持仓成本 == '-' ? '-' : fmtNum(dTD.平均持仓成本, 2,
|
||||
false)
|
||||
}} USDT</div>
|
||||
</div> -->
|
||||
<div class="item">
|
||||
<div class="title">持仓量</div>
|
||||
<div class="value">
|
||||
{{ fmtNum(dTD.持仓量USDT, 2, false) }} USDT</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="title">浮动收益</div>
|
||||
<div class="value" :class="{ green: dTD.浮动收益 > 0, red: dTD.浮动收益 < 0 }">
|
||||
{{ fmtNum(dTD.浮动收益, 2) }} USDT</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="title">浮动收益率</div>
|
||||
<div class="value"
|
||||
:class="{ green: dTD.浮动收益率 > 0, red: dTD.浮动收益率 < 0 }">{{
|
||||
fmtNum(dTD.浮动收益率, 2) }} %</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- <div class="item">
|
||||
<div class="title">持仓量</div>
|
||||
<div class="value">{{ dTD.持仓量.toFixed(5) }} BTC</div>
|
||||
</div> -->
|
||||
|
||||
<div class="item">
|
||||
<div class="title">已加仓次数</div>
|
||||
<div class="value">{{ dTD.已加仓次数 }}</div>
|
||||
</div>
|
||||
<!-- <div class="item">
|
||||
<div class="title">止盈价格</div>
|
||||
<div class="value">{{ dTD.止盈价格 == '-' ? '-' : fmtNum(dTD.止盈价格, 1, false)
|
||||
}} USDT</div>
|
||||
</div> -->
|
||||
<div class="item">
|
||||
<div class="title">已爆仓次数</div>
|
||||
<div class="value">{{ dTD.已爆仓次数 }}</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="title">已完成周期</div>
|
||||
<div class="value">{{ dTD.已完成周期 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.watermark {
|
||||
pointer-events: none;
|
||||
padding: 10vh 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #242424;
|
||||
opacity: .5;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
font-size: 4vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
.item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
transform: rotate(-30deg);
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #2e2e2e;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #242424;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #242424;
|
||||
}
|
||||
|
||||
.app {
|
||||
color: #f0f0f0;
|
||||
margin: 0 auto;
|
||||
padding: 72px;
|
||||
font-family: Arial, sans-serif;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
background-color: #0c0c0c;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
gap: 8px;
|
||||
font-weight: bold;
|
||||
font-size: large;
|
||||
|
||||
img {
|
||||
width: 30px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.file-upload-container {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.file-upload-label {
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.file-upload-label:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.file-upload-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
margin-top: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 7;
|
||||
}
|
||||
|
||||
.no-data-message,
|
||||
.loading-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
position: relative;
|
||||
flex: 2.5;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 0.7fr;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
padding: 16px 8px 24px;
|
||||
font-size: 18px;
|
||||
|
||||
.title {
|
||||
color: #909090;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #f0f0f0;
|
||||
font-weight: bold;
|
||||
|
||||
&.green {
|
||||
color: #25a750;
|
||||
}
|
||||
|
||||
&.red {
|
||||
color: #ca3f64;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
80
src/analysis.ts
Normal file
80
src/analysis.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 分析投资数据并计算关键指标
|
||||
* @param fundAmounts 资金量数组,每15分钟记录一次
|
||||
* @returns 投资分析结果
|
||||
*/
|
||||
export function analyzeInvestment(fundAmounts: number[]): {
|
||||
maxDrawdown: number; // 最大回撤 (百分比)
|
||||
maxDrawdownPeriod: [number, number]; // 最大回撤区间 [开始索引, 结束索引]
|
||||
volatility: number; // 波动率 (标准差)
|
||||
averageReturn: number; // 平均收益率 (百分比)
|
||||
cumulativeReturn: number; // 累计收益率 (百分比)
|
||||
winRate: number; // 盈利率 (百分比)
|
||||
sharpeRatio: number; // 夏普比率 (假设无风险利率为0)
|
||||
} {
|
||||
if (fundAmounts.length < 2) {
|
||||
throw new Error("需要至少两个数据点来计算投资指标");
|
||||
}
|
||||
|
||||
// 计算每个时间点的收益率
|
||||
const returns: number[] = [];
|
||||
for (let i = 1; i < fundAmounts.length; i++) {
|
||||
const returnRate = (fundAmounts[i] - fundAmounts[i - 1]) / fundAmounts[i - 1];
|
||||
returns.push(returnRate);
|
||||
}
|
||||
|
||||
// 计算最大回撤
|
||||
let maxDrawdown = 0;
|
||||
let peak = fundAmounts[0];
|
||||
let maxDrawdownStartIndex = 0;
|
||||
let maxDrawdownEndIndex = 0;
|
||||
let currentPeakIndex = 0;
|
||||
|
||||
for (let i = 1; i < fundAmounts.length; i++) {
|
||||
if (fundAmounts[i] > peak) {
|
||||
peak = fundAmounts[i];
|
||||
currentPeakIndex = i;
|
||||
}
|
||||
|
||||
const drawdown = (peak - fundAmounts[i]) / peak;
|
||||
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown;
|
||||
maxDrawdownStartIndex = currentPeakIndex;
|
||||
maxDrawdownEndIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算波动率 (收益率的标准差)
|
||||
const avgReturn = returns.reduce((sum, r) => sum + r, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, r) => sum + Math.pow(r - avgReturn, 2), 0) / returns.length;
|
||||
const volatility = Math.sqrt(variance);
|
||||
|
||||
// 计算累计收益率
|
||||
const cumulativeReturn = (fundAmounts[fundAmounts.length - 1] - fundAmounts[0]) / fundAmounts[0];
|
||||
|
||||
// 计算盈利率 (正收益率的比例)
|
||||
const winCount = returns.filter(r => r > 0).length;
|
||||
const winRate = (winCount / returns.length) * 100;
|
||||
|
||||
// 计算夏普比率 (假设无风险利率为5%)
|
||||
const riskFreeRate = 0.05; // 无风险利率5%
|
||||
const annualizedReturn = avgReturn * (250 * 16) - riskFreeRate; // 调整为年化收益率减去无风险利率
|
||||
const annualizedVolatility = volatility * Math.sqrt(250 * 16);
|
||||
const sharpeRatio = annualizedVolatility === 0 ? 0 : annualizedReturn / annualizedVolatility;
|
||||
|
||||
return {
|
||||
maxDrawdown: maxDrawdown * 100, // 转换为百分比
|
||||
maxDrawdownPeriod: [maxDrawdownStartIndex, maxDrawdownEndIndex],
|
||||
volatility: volatility * 100, // 转换为百分比
|
||||
averageReturn: avgReturn * 100, // 转换为百分比
|
||||
cumulativeReturn: cumulativeReturn * 100, // 转换为百分比
|
||||
winRate,
|
||||
sharpeRatio
|
||||
};
|
||||
}
|
||||
|
||||
// 使用示例:
|
||||
// const fundAmounts = [10000, 8844.4, 9200, 9100, 9500, 9300, 9200, 8800, 9000, 9400];
|
||||
// const analysis = analyzeInvestment(fundAmounts);
|
||||
// console.log(analysis);
|
||||
BIN
src/assets/btc20230419112752.webp
Normal file
BIN
src/assets/btc20230419112752.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
715
src/components/CashChart.vue
Normal file
715
src/components/CashChart.vue
Normal file
@ -0,0 +1,715 @@
|
||||
<template>
|
||||
<div class="chart-container" ref="chartContainer">
|
||||
<canvas ref="chartCanvas" @mousemove="handleMouseMove"
|
||||
@mouseleave="hideTooltip"></canvas>
|
||||
<div v-if="tooltip.show" class="tooltip" :style="{
|
||||
left: `${tooltip.x}px`,
|
||||
top: `${tooltip.y}px`,
|
||||
color: tooltip.color
|
||||
}">
|
||||
{{ tooltip.text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, reactive, computed } from 'vue';
|
||||
import { analyzeInvestment } from '../analysis';
|
||||
|
||||
// 定义组件属性
|
||||
interface Props {
|
||||
fundAmounts: number[]; // 资金量变化数组
|
||||
benchmarkAmounts: number[]; // 基准/目标标的净值历史
|
||||
title?: string; // 图表标题
|
||||
maxDataPoints?: number; // 最大显示数据点数,用于采样
|
||||
trendThreshold?: number; // 趋势识别阈值(百分比)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '资金量变化图',
|
||||
maxDataPoints: 4000, // 默认最多显示3000个数据点,超过则采样
|
||||
trendThreshold: 15.0, // 默认15%阈值,高于这个值才认为是趋势转
|
||||
});
|
||||
|
||||
// DOM引用
|
||||
const chartContainer = ref<HTMLDivElement | null>(null);
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// 提示框状态
|
||||
const tooltip = reactive({
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: '',
|
||||
color: '#ffffff',
|
||||
isBenchmark: false
|
||||
});
|
||||
|
||||
// 存储图表数据点用于交互
|
||||
const chartData = reactive({
|
||||
dataPoints: [] as { x: number, y: number, value: number, originalIndex: number, trendUp: boolean, isBenchmark?: boolean }[],
|
||||
margin: { top: 40, right: 30, bottom: 50, left: 80 }
|
||||
});
|
||||
|
||||
// 颜色配置
|
||||
const COLORS = {
|
||||
UP: '#25a750', // 上涨颜色
|
||||
DOWN: '#ca3f64', // 下跌颜色
|
||||
PRIMARY_TEXT: '#f0f0f0', // 主要文字颜色
|
||||
SECONDARY_TEXT: '#909090', // 次要文字颜色
|
||||
BENCHMARK: '#f9a825', // 基准/目标标的线颜色
|
||||
};
|
||||
|
||||
// 识别峰谷点和趋势
|
||||
const trendAnalysis = computed(() => {
|
||||
const data = props.fundAmounts;
|
||||
if (!data || data.length < 3) return { peaks: [], valleys: [], trendSegments: [] };
|
||||
|
||||
// 1. 识别峰和谷
|
||||
const peaks: number[] = [];
|
||||
const valleys: number[] = [];
|
||||
|
||||
// 第一个点作为起始点,还不确定是峰还是谷
|
||||
let lastExtreme = 0;
|
||||
let lastExtremeValue = data[0];
|
||||
let isCurrentlyUp = false; // 开始时不确定趋势
|
||||
|
||||
// 寻找所有可能的峰和谷
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const currentValue = data[i];
|
||||
const changePercent = Math.abs((currentValue - lastExtremeValue) / lastExtremeValue * 100);
|
||||
|
||||
// 如果价格变化超过阈值,确认为峰或谷
|
||||
if (changePercent >= props.trendThreshold) {
|
||||
if (currentValue > lastExtremeValue && !isCurrentlyUp) {
|
||||
// 从下跌转为上涨,确认一个谷
|
||||
valleys.push(lastExtreme);
|
||||
isCurrentlyUp = true;
|
||||
} else if (currentValue < lastExtremeValue && isCurrentlyUp) {
|
||||
// 从上涨转为下跌,确认一个峰
|
||||
peaks.push(lastExtreme);
|
||||
isCurrentlyUp = false;
|
||||
}
|
||||
|
||||
// 更新最后极值点
|
||||
lastExtreme = i;
|
||||
lastExtremeValue = currentValue;
|
||||
}
|
||||
}
|
||||
|
||||
// 最后一个点作为终点
|
||||
if (isCurrentlyUp) {
|
||||
peaks.push(data.length - 1);
|
||||
} else {
|
||||
valleys.push(data.length - 1);
|
||||
}
|
||||
|
||||
// 确保第一个点被处理
|
||||
if (peaks.length === 0 && valleys.length === 0) {
|
||||
// 如果没有找到任何峰或谷,则将第一个点设为谷,最后一个点设为峰
|
||||
valleys.push(0);
|
||||
peaks.push(data.length - 1);
|
||||
} else if (valleys[0] > peaks[0]) {
|
||||
// 确保序列以谷开始
|
||||
valleys.unshift(0);
|
||||
} else if (peaks.length === 0) {
|
||||
// 如果没有峰,将最后一个点设为峰
|
||||
peaks.push(data.length - 1);
|
||||
} else if (valleys.length === 0) {
|
||||
// 如果没有谷,将第一个点设为谷
|
||||
valleys.unshift(0);
|
||||
}
|
||||
|
||||
// 2. 构建趋势段
|
||||
const trendSegments: { start: number, end: number, isUp: boolean }[] = [];
|
||||
|
||||
// 合并所有峰和谷,按索引排序
|
||||
const extremes = [...peaks.map(p => ({ index: p, isPeak: true })),
|
||||
...valleys.map(v => ({ index: v, isPeak: false }))]
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
// 构建趋势段
|
||||
for (let i = 0; i < extremes.length - 1; i++) {
|
||||
trendSegments.push({
|
||||
start: extremes[i].index,
|
||||
end: extremes[i + 1].index,
|
||||
isUp: !extremes[i].isPeak // 从谷到峰是上涨,从峰到谷是下跌
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
peaks,
|
||||
valleys,
|
||||
trendSegments
|
||||
};
|
||||
});
|
||||
|
||||
// 计算采样后的数据
|
||||
const sampledData = computed(() => {
|
||||
const data = props.fundAmounts;
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
if (data.length <= props.maxDataPoints) {
|
||||
// 数据量不大,直接返回原始数据
|
||||
return data.map((value, index) => ({ value, originalIndex: index }));
|
||||
}
|
||||
|
||||
// 采样算法 - 使用Largest-Triangle-Three-Buckets (LTTB)算法的简化版本
|
||||
// 这个采样方法保留视觉上重要的点,比如峰值和谷值
|
||||
const result: { value: number, originalIndex: number }[] = [];
|
||||
const sampleSize = props.maxDataPoints;
|
||||
const bucketSize = data.length / sampleSize;
|
||||
|
||||
// 总是保留第一个点
|
||||
result.push({ value: data[0], originalIndex: 0 });
|
||||
|
||||
// 处理中间部分
|
||||
for (let i = 1; i < sampleSize - 1; i++) {
|
||||
const bucketStart = Math.floor(i * bucketSize);
|
||||
const bucketEnd = Math.floor((i + 1) * bucketSize);
|
||||
|
||||
// 在每个桶中找到最高和最低的点
|
||||
let maxVal = data[bucketStart];
|
||||
let minVal = data[bucketStart];
|
||||
let maxIdx = bucketStart;
|
||||
let minIdx = bucketStart;
|
||||
|
||||
for (let j = bucketStart; j < bucketEnd; j++) {
|
||||
if (data[j] > maxVal) {
|
||||
maxVal = data[j];
|
||||
maxIdx = j;
|
||||
}
|
||||
if (data[j] < minVal) {
|
||||
minVal = data[j];
|
||||
minIdx = j;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最极端的点(保留波动)
|
||||
if (maxIdx !== minIdx) {
|
||||
// 按索引顺序添加
|
||||
if (maxIdx < minIdx) {
|
||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||
result.push({ value: minVal, originalIndex: minIdx });
|
||||
} else {
|
||||
result.push({ value: minVal, originalIndex: minIdx });
|
||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||
}
|
||||
} else {
|
||||
// 如果最大值和最小值是同一个点,只添加一次
|
||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||
}
|
||||
}
|
||||
|
||||
// 总是保留最后一个点
|
||||
result.push({ value: data[data.length - 1], originalIndex: data.length - 1 });
|
||||
|
||||
// 按原始索引排序
|
||||
return result.sort((a, b) => a.originalIndex - b.originalIndex);
|
||||
});
|
||||
|
||||
// 计算基准数据的采样
|
||||
const sampledBenchmarkData = computed(() => {
|
||||
const data = props.benchmarkAmounts;
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
if (data.length <= props.maxDataPoints) {
|
||||
// 数据量不大,直接返回原始数据
|
||||
return data.map((value, index) => ({ value, originalIndex: index }));
|
||||
}
|
||||
|
||||
// 采样算法 - 使用相同的采样逻辑,确保与策略数据点对齐
|
||||
const result: { value: number, originalIndex: number }[] = [];
|
||||
const sampleSize = props.maxDataPoints;
|
||||
const bucketSize = data.length / sampleSize;
|
||||
|
||||
// 总是保留第一个点
|
||||
result.push({ value: data[0], originalIndex: 0 });
|
||||
|
||||
// 处理中间部分
|
||||
for (let i = 1; i < sampleSize - 1; i++) {
|
||||
const bucketStart = Math.floor(i * bucketSize);
|
||||
const bucketEnd = Math.floor((i + 1) * bucketSize);
|
||||
|
||||
// 在每个桶中找到最高和最低的点
|
||||
let maxVal = data[bucketStart];
|
||||
let minVal = data[bucketStart];
|
||||
let maxIdx = bucketStart;
|
||||
let minIdx = bucketStart;
|
||||
|
||||
for (let j = bucketStart; j < bucketEnd; j++) {
|
||||
if (data[j] > maxVal) {
|
||||
maxVal = data[j];
|
||||
maxIdx = j;
|
||||
}
|
||||
if (data[j] < minVal) {
|
||||
minVal = data[j];
|
||||
minIdx = j;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最极端的点(保留波动)
|
||||
if (maxIdx !== minIdx) {
|
||||
// 按索引顺序添加
|
||||
if (maxIdx < minIdx) {
|
||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||
result.push({ value: minVal, originalIndex: minIdx });
|
||||
} else {
|
||||
result.push({ value: minVal, originalIndex: minIdx });
|
||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||
}
|
||||
} else {
|
||||
// 如果最大值和最小值是同一个点,只添加一次
|
||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||
}
|
||||
}
|
||||
|
||||
// 总是保留最后一个点
|
||||
result.push({ value: data[data.length - 1], originalIndex: data.length - 1 });
|
||||
|
||||
// 按原始索引排序
|
||||
return result.sort((a, b) => a.originalIndex - b.originalIndex);
|
||||
});
|
||||
|
||||
// 处理鼠标移动显示提示框
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!chartCanvas.value) return;
|
||||
|
||||
const rect = chartCanvas.value.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
|
||||
// 二分查找找到最近的数据点
|
||||
let closestPoint = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const point of chartData.dataPoints) {
|
||||
const distance = Math.abs(mouseX - point.x);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
closestPoint = point;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestPoint && minDistance < 30) { // 在30px范围内显示提示
|
||||
const index = closestPoint.originalIndex;
|
||||
tooltip.show = true;
|
||||
tooltip.x = closestPoint.x + 10; // 稍微偏移点
|
||||
tooltip.y = closestPoint.y - 30; // 显示在点上方
|
||||
|
||||
// 查找同一索引位置的另一条线的点(如果有)
|
||||
|
||||
if (closestPoint.isBenchmark) {
|
||||
// 当前点是基准点
|
||||
const strategyValue = props.fundAmounts[index];
|
||||
tooltip.text = `基准: ${closestPoint.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}\n策略: ${strategyValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} (${Math.floor(index * 15)}分钟)`;
|
||||
tooltip.color = COLORS.BENCHMARK;
|
||||
tooltip.isBenchmark = true;
|
||||
} else {
|
||||
// 当前点是策略点,且有基准数据
|
||||
const benchmarkValue = props.benchmarkAmounts[index] || 0;
|
||||
tooltip.text = `策略: ${closestPoint.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}\n基准: ${benchmarkValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} (${Math.floor(index * 15)}分钟)`;
|
||||
tooltip.color = closestPoint.trendUp ? COLORS.UP : COLORS.DOWN;
|
||||
tooltip.isBenchmark = false;
|
||||
}
|
||||
} else {
|
||||
hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// 隐藏提示框
|
||||
const hideTooltip = () => {
|
||||
tooltip.show = false;
|
||||
};
|
||||
|
||||
// 绘制图表
|
||||
const drawChart = () => {
|
||||
const canvas = chartCanvas.value;
|
||||
const container = chartContainer.value;
|
||||
if (!canvas || !container || props.fundAmounts.length < 2) return;
|
||||
|
||||
// 设置canvas大小以匹配容器
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 清除画布和数据点
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
chartData.dataPoints = [];
|
||||
|
||||
// 设置尺寸和边距
|
||||
const margin = chartData.margin;
|
||||
const chartWidth = canvas.width - margin.left - margin.right;
|
||||
const chartHeight = canvas.height - margin.top - margin.bottom;
|
||||
|
||||
// 计算最小和最大值用于缩放 - 使用原始数据获取完整的值范围
|
||||
let minValue = props.fundAmounts.length > 0 ? props.fundAmounts[0] : 0;
|
||||
let maxValue = props.fundAmounts.length > 0 ? props.fundAmounts[0] : 0;
|
||||
|
||||
// 使用循环而不是 Math.min/max(...array) 以避免堆栈溢出
|
||||
for (let i = 1; i < props.fundAmounts.length; i++) {
|
||||
if (props.fundAmounts[i] < minValue) minValue = props.fundAmounts[i];
|
||||
if (props.fundAmounts[i] > maxValue) maxValue = props.fundAmounts[i];
|
||||
}
|
||||
|
||||
// 如果有基准数据,也考虑其最小和最大值
|
||||
for (let i = 0; i < props.benchmarkAmounts.length; i++) {
|
||||
if (props.benchmarkAmounts[i] < minValue) minValue = props.benchmarkAmounts[i];
|
||||
if (props.benchmarkAmounts[i] > maxValue) maxValue = props.benchmarkAmounts[i];
|
||||
}
|
||||
|
||||
|
||||
const valueRange = maxValue - minValue;
|
||||
|
||||
// 为Y轴添加一些填充(5%)
|
||||
const yPadding = valueRange * 0.05;
|
||||
const yMin = Math.max(minValue - yPadding, 0);
|
||||
const yMax = maxValue + yPadding;
|
||||
const effectiveRange = yMax - yMin;
|
||||
|
||||
// 比例尺函数 - 使用采样后的数据点的原始索引进行X轴缩放
|
||||
const scaleX = (originalIndex: number) =>
|
||||
margin.left + (originalIndex / (props.fundAmounts.length - 1)) * chartWidth;
|
||||
const scaleY = (value: number) =>
|
||||
margin.top + chartHeight - ((value - yMin) / effectiveRange) * chartHeight;
|
||||
|
||||
// 绘制水平网格线
|
||||
ctx.strokeStyle = 'rgba(144, 144, 144, 0.1)'; // 非常微妙的网格
|
||||
ctx.lineWidth = 0.5;
|
||||
|
||||
const yGridLines = 5; // 水平网格线数量
|
||||
for (let i = 0; i <= yGridLines; i++) {
|
||||
const y = margin.top + (i / yGridLines) * chartHeight;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin.left, y);
|
||||
ctx.lineTo(margin.left + chartWidth, y);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制Y轴标签
|
||||
const labelValue = yMax - (i / yGridLines) * effectiveRange;
|
||||
ctx.fillStyle = COLORS.SECONDARY_TEXT;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = '12px PingFang SC';
|
||||
ctx.fillText(labelValue.toLocaleString(undefined, { maximumFractionDigits: 2 }), margin.left - 10, y);
|
||||
}
|
||||
|
||||
// 绘制垂直网格线(表示时间)
|
||||
const numPoints = props.fundAmounts.length;
|
||||
const totalMinutes = (numPoints - 1) * 15;
|
||||
const hourMarkers = Math.min(Math.floor(totalMinutes / 60) + 1, 6); // 最多显示6个小时标记
|
||||
|
||||
for (let i = 0; i <= hourMarkers; i++) {
|
||||
// 计算每小时的位置
|
||||
const hourIndex = Math.floor((i / hourMarkers) * (numPoints - 1));
|
||||
const x = scaleX(hourIndex);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, margin.top);
|
||||
ctx.lineTo(x, margin.top + chartHeight);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制X轴标签(Day)
|
||||
const days = Math.floor((hourIndex * 15) / 60 / 24);
|
||||
const yr = Math.floor(days / 365);
|
||||
const timeLabel = (yr > 0 ? `${yr}Y` : '') + (days % 365 + 'D');
|
||||
|
||||
ctx.fillStyle = COLORS.SECONDARY_TEXT;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '12px PingFang SC';
|
||||
ctx.fillText(timeLabel, x, margin.top + chartHeight + 10);
|
||||
}
|
||||
|
||||
|
||||
// 绘制标题
|
||||
if (props.title) {
|
||||
ctx.fillStyle = COLORS.PRIMARY_TEXT;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = 'bold 16px PingFang SC';
|
||||
ctx.fillText(props.title, canvas.width / 2, 10);
|
||||
}
|
||||
|
||||
// 绘制图例(有基准数据)
|
||||
const legendX = canvas.width - margin.right - 130;
|
||||
const legendY = margin.top;
|
||||
|
||||
// 绘制策略线的图例
|
||||
ctx.fillStyle = COLORS.UP;
|
||||
ctx.fillRect(legendX, legendY, 15, 3);
|
||||
ctx.fillStyle = COLORS.PRIMARY_TEXT;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = '12px PingFang SC';
|
||||
ctx.fillText('策略', legendX + 20, legendY);
|
||||
|
||||
// 绘制基准线的图例
|
||||
ctx.fillStyle = COLORS.BENCHMARK;
|
||||
ctx.fillRect(legendX, legendY + 15, 15, 3);
|
||||
ctx.fillText('基准', legendX + 20, legendY + 15);
|
||||
|
||||
|
||||
// 获取趋势分析结果
|
||||
const { trendSegments } = trendAnalysis.value;
|
||||
const sampleData = sampledData.value;
|
||||
|
||||
// 为每个数据点确定它所属的趋势
|
||||
const pointTrends = new Map<number, boolean>(); // 原始索引 -> 是否上涨趋势
|
||||
|
||||
// 为每个趋势段中的每个点设置趋势方向
|
||||
for (const segment of trendSegments) {
|
||||
for (let i = segment.start; i <= segment.end; i++) {
|
||||
pointTrends.set(i, segment.isUp);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制趋势区间
|
||||
for (let i = 0; i < trendSegments.length; i++) {
|
||||
const segment = trendSegments[i];
|
||||
const isUp = segment.isUp;
|
||||
|
||||
// 获取该段内的所有采样点
|
||||
const segmentPoints = sampleData.filter(
|
||||
point => point.originalIndex >= segment.start && point.originalIndex <= segment.end
|
||||
);
|
||||
|
||||
if (segmentPoints.length < 2) continue;
|
||||
|
||||
// 开始新路径
|
||||
ctx.beginPath();
|
||||
|
||||
// 移动到第一个点
|
||||
const firstPoint = segmentPoints[0];
|
||||
const firstX = scaleX(firstPoint.originalIndex);
|
||||
const firstY = scaleY(firstPoint.value);
|
||||
ctx.moveTo(firstX, firstY);
|
||||
|
||||
// 存储第一个点用于交互
|
||||
chartData.dataPoints.push({
|
||||
x: firstX,
|
||||
y: firstY,
|
||||
value: firstPoint.value,
|
||||
originalIndex: firstPoint.originalIndex,
|
||||
trendUp: isUp
|
||||
});
|
||||
|
||||
// 绘制所有线段
|
||||
for (let j = 1; j < segmentPoints.length; j++) {
|
||||
const point = segmentPoints[j];
|
||||
const x = scaleX(point.originalIndex);
|
||||
const y = scaleY(point.value);
|
||||
|
||||
ctx.lineTo(x, y);
|
||||
|
||||
// 存储数据点用于交互
|
||||
chartData.dataPoints.push({
|
||||
x,
|
||||
y,
|
||||
value: point.value,
|
||||
originalIndex: point.originalIndex,
|
||||
trendUp: isUp
|
||||
});
|
||||
}
|
||||
if (trendSegments[i + 1]) {
|
||||
let point = sampleData.find(p => p.originalIndex >= trendSegments[i + 1].start);
|
||||
if (point) {
|
||||
const x = scaleX(point.originalIndex);
|
||||
const y = scaleY(point.value);
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
// 设置线条样式并绘制
|
||||
ctx.strokeStyle = isUp ? COLORS.UP : COLORS.DOWN;
|
||||
ctx.lineWidth = 1; // 略微加粗,便于区分
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 有基准数据,绘制基准线
|
||||
const benchmarkData = sampledBenchmarkData.value;
|
||||
|
||||
if (benchmarkData.length >= 2) {
|
||||
// 开始新路径
|
||||
ctx.beginPath();
|
||||
|
||||
// 移动到第一个点
|
||||
const firstPoint = benchmarkData[0];
|
||||
const firstX = scaleX(firstPoint.originalIndex);
|
||||
const firstY = scaleY(firstPoint.value);
|
||||
ctx.moveTo(firstX, firstY);
|
||||
|
||||
// 存储第一个点用于交互
|
||||
chartData.dataPoints.push({
|
||||
x: firstX,
|
||||
y: firstY,
|
||||
value: firstPoint.value,
|
||||
originalIndex: firstPoint.originalIndex,
|
||||
trendUp: true,
|
||||
isBenchmark: true
|
||||
});
|
||||
|
||||
// 绘制所有线段
|
||||
for (let j = 1; j < benchmarkData.length; j++) {
|
||||
const point = benchmarkData[j];
|
||||
const x = scaleX(point.originalIndex);
|
||||
const y = scaleY(point.value);
|
||||
|
||||
ctx.lineTo(x, y);
|
||||
|
||||
// 存储数据点用于交互
|
||||
chartData.dataPoints.push({
|
||||
x,
|
||||
y,
|
||||
value: point.value,
|
||||
originalIndex: point.originalIndex,
|
||||
trendUp: true,
|
||||
isBenchmark: true
|
||||
});
|
||||
}
|
||||
|
||||
// 设置线条样式并绘制
|
||||
ctx.strokeStyle = COLORS.BENCHMARK;
|
||||
ctx.lineWidth = 1; // 与策略线相同粗细
|
||||
ctx.setLineDash([5, 3]); // 虚线样式,以便区分
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]); // 重置线条样式
|
||||
}
|
||||
|
||||
|
||||
// 计算并绘制统计数据
|
||||
drawStatistics(ctx, canvas.width, margin);
|
||||
};
|
||||
|
||||
// 绘制关键统计信息
|
||||
const drawStatistics = (ctx: CanvasRenderingContext2D, width: number, margin: { top: number, right: number, bottom: number, left: number }) => {
|
||||
if (props.fundAmounts.length < 2) return;
|
||||
|
||||
// 计算百分比变化
|
||||
const initialValue = props.fundAmounts[0];
|
||||
const currentValue = props.fundAmounts[props.fundAmounts.length - 1];
|
||||
const percentChange = ((currentValue - initialValue) / initialValue) * 100;
|
||||
|
||||
// 计算最大回撤 - 优化方法以处理大数据集
|
||||
let maxDrawdown = 0;
|
||||
let peak = props.fundAmounts[0];
|
||||
let maxDrawdownStart = 0;
|
||||
let maxDrawdownEnd = 0;
|
||||
|
||||
for (let i = 1; i < props.fundAmounts.length; i++) {
|
||||
if (props.fundAmounts[i] > peak) {
|
||||
peak = props.fundAmounts[i];
|
||||
maxDrawdownStart = i;
|
||||
}
|
||||
|
||||
const drawdown = (peak - props.fundAmounts[i]) / peak * 100;
|
||||
if (drawdown > maxDrawdown) {
|
||||
maxDrawdown = drawdown;
|
||||
maxDrawdownEnd = i;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算年化收益率 (假设一年252个交易日,一天有24小时,每15分钟一个数据点)
|
||||
const totalMinutes = (props.fundAmounts.length - 1) * 15;
|
||||
const years = totalMinutes / (365 * 24 * 60);
|
||||
const annualizedReturn = (Math.pow((Math.max(currentValue, 0.01) / initialValue), (1 / years)) - 1) * 100;
|
||||
|
||||
// 绘制统计信息
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '14px PingFang SC';
|
||||
|
||||
// 當前值
|
||||
ctx.fillStyle = COLORS.PRIMARY_TEXT;
|
||||
ctx.fillText(`當前: ${currentValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}`, 200, margin.top);
|
||||
|
||||
// 百分比變化
|
||||
ctx.fillStyle = percentChange >= 0 ? COLORS.UP : COLORS.DOWN;
|
||||
ctx.fillText(`總收益: ${percentChange.toFixed(2)}%`, 200, margin.top + 20);
|
||||
|
||||
// 年化收益率
|
||||
ctx.fillStyle = annualizedReturn >= 0 ? COLORS.UP : COLORS.DOWN;
|
||||
ctx.fillText(`年化: ${annualizedReturn.toFixed(2)}%`, 200, margin.top + 40);
|
||||
|
||||
// 最大回撤
|
||||
ctx.fillStyle = COLORS.DOWN;
|
||||
ctx.fillText(`最大回撤: ${maxDrawdown.toFixed(2)}%`, 200, margin.top + 60);
|
||||
|
||||
let alz = analyzeInvestment(props.fundAmounts);
|
||||
let winRate = alz.winRate;
|
||||
ctx.fillStyle = winRate >= 50 ? COLORS.UP : COLORS.DOWN;
|
||||
ctx.fillText(`勝率: ${winRate.toFixed(2)}%`, 200, margin.top + 80);
|
||||
const initBenchmarkValue = props.benchmarkAmounts[0];
|
||||
const currentBenchmarkValue = props.benchmarkAmounts[props.benchmarkAmounts.length - 1];
|
||||
const benchmarkPercentChange = ((currentBenchmarkValue - initBenchmarkValue) / initBenchmarkValue) * 100;
|
||||
const delta = (percentChange - benchmarkPercentChange) / benchmarkPercentChange * 100;
|
||||
ctx.fillStyle = delta >= 0 ? COLORS.UP : COLORS.DOWN;
|
||||
ctx.fillText(`跑贏現貨: ${delta.toFixed(2)}%`, 200, margin.top + 100);
|
||||
};
|
||||
|
||||
// 组件挂载时初始化图表和监听器
|
||||
onMounted(() => {
|
||||
if (chartContainer.value) {
|
||||
// 创建 ResizeObserver 实例来监听容器大小变化
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
drawChart();
|
||||
});
|
||||
|
||||
// 观察容器元素
|
||||
resizeObserver.observe(chartContainer.value);
|
||||
|
||||
// 初始绘制
|
||||
drawChart();
|
||||
}
|
||||
});
|
||||
|
||||
// 当 props 改变时重绘图表
|
||||
watch(() => props.fundAmounts, drawChart, { deep: true });
|
||||
watch(() => props.benchmarkAmounts, drawChart, { deep: true });
|
||||
watch(() => props.trendThreshold, drawChart);
|
||||
watch(() => sampledData.value, drawChart, { deep: true });
|
||||
watch(() => sampledBenchmarkData.value, drawChart, { deep: true });
|
||||
watch(() => trendAnalysis.value, drawChart, { deep: true });
|
||||
|
||||
// 清理事件监听器和观察器
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver && chartContainer.value) {
|
||||
resizeObserver.unobserve(chartContainer.value);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
font-family: PingFang SC, sans-serif;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
/* 填充父容器 */
|
||||
height: 100%;
|
||||
/* 填充父容器 */
|
||||
min-height: 300px;
|
||||
/* 确保有最小高度 */
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
/* 消除底部小空隙 */
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
600
src/components/KLineChart.vue
Normal file
600
src/components/KLineChart.vue
Normal file
@ -0,0 +1,600 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive, computed, watch, onUnmounted } from 'vue';
|
||||
import type { OrderLine } from '../martingale';
|
||||
|
||||
// 定义K线数据接口
|
||||
interface KLineData {
|
||||
time: number; // 时间
|
||||
open: number; // 开盘价
|
||||
high: number; // 最高价
|
||||
low: number; // 最低价
|
||||
close: number; // 收盘价
|
||||
volume: number; // 成交量
|
||||
}
|
||||
|
||||
// 定义平均持仓成本接口
|
||||
interface Position {
|
||||
avgCost: number; // 平均持仓成本
|
||||
profitLoss?: number; // 浮动盈亏
|
||||
profitLossPercent?: number; // 浮动盈亏百分比
|
||||
}
|
||||
|
||||
// 定义组件属性
|
||||
interface Props {
|
||||
data: KLineData[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
responsive?: boolean; // 是否响应容器尺寸变化
|
||||
orderLines?: OrderLine[]; // 挂单线数组
|
||||
position: Position | null; // 持仓信息
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 800,
|
||||
height: 400,
|
||||
responsive: true,
|
||||
orderLines: () => [],
|
||||
position: undefined
|
||||
});
|
||||
|
||||
// DOM引用
|
||||
const chartContainer = ref<HTMLDivElement | null>(null);
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
// 图表状态
|
||||
const chartState = reactive({
|
||||
canvasWidth: 0,
|
||||
canvasHeight: 0,
|
||||
pixelRatio: 1,
|
||||
dataStartIndex: 0,
|
||||
visibleBars: 0,
|
||||
barWidth: 5,
|
||||
spacing: 1,
|
||||
paddingLeft: 68,
|
||||
paddingRight: 70, // 增加右侧padding以显示价格和盈亏
|
||||
paddingTop: 20,
|
||||
paddingBottom: 30,
|
||||
priceMax: 0,
|
||||
priceMin: 0,
|
||||
volumeMax: 0,
|
||||
priceScale: 0,
|
||||
volumeScale: 0,
|
||||
priceHeight: 0,
|
||||
volumeHeight: 0,
|
||||
mouseX: 0,
|
||||
mouseY: 0,
|
||||
selectedIndex: -1,
|
||||
});
|
||||
|
||||
// 鼠标悬停提示
|
||||
const tooltip = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
time: '',
|
||||
open: 0,
|
||||
high: 0,
|
||||
low: 0,
|
||||
close: 0,
|
||||
volume: 0,
|
||||
});
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
return {
|
||||
left: `${tooltip.x + 10}px`,
|
||||
top: `${tooltip.y + 10}px`
|
||||
};
|
||||
});
|
||||
|
||||
// 创建ResizeObserver实例
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// 初始化图表
|
||||
onMounted(() => {
|
||||
if (!chartCanvas.value || !chartContainer.value) return;
|
||||
|
||||
// 初始设置
|
||||
initChart();
|
||||
|
||||
// 如果开启响应式,设置ResizeObserver
|
||||
if (props.responsive) {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(chartContainer.value);
|
||||
}
|
||||
|
||||
// 添加事件监听
|
||||
chartCanvas.value.addEventListener('mousemove', handleMouseMove);
|
||||
chartCanvas.value.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
// 初始渲染
|
||||
render();
|
||||
});
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver && chartContainer.value) {
|
||||
resizeObserver.unobserve(chartContainer.value);
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
|
||||
if (chartCanvas.value) {
|
||||
chartCanvas.value.removeEventListener('mousemove', handleMouseMove);
|
||||
chartCanvas.value.removeEventListener('mouseleave', handleMouseLeave);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理容器大小变化
|
||||
function handleResize(entries: ResizeObserverEntry[]) {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === chartContainer.value) {
|
||||
const { width, height } = entry.contentRect;
|
||||
|
||||
// 更新画布尺寸
|
||||
updateChartSize(width, height);
|
||||
|
||||
// 重新渲染
|
||||
render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
function initChart() {
|
||||
if (!chartCanvas.value || !chartContainer.value) return;
|
||||
|
||||
// 设置画布大小
|
||||
chartState.pixelRatio = window.devicePixelRatio || 1;
|
||||
|
||||
// 如果是响应式,使用容器的尺寸;否则使用props中的尺寸
|
||||
if (props.responsive) {
|
||||
const containerRect = chartContainer.value.getBoundingClientRect();
|
||||
updateChartSize(containerRect.width, containerRect.height);
|
||||
} else {
|
||||
updateChartSize(props.width, props.height);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新图表尺寸
|
||||
function updateChartSize(width: number, height: number) {
|
||||
if (!chartCanvas.value) return;
|
||||
|
||||
// 更新状态
|
||||
chartState.canvasWidth = width;
|
||||
chartState.canvasHeight = height;
|
||||
|
||||
// 更新画布
|
||||
chartCanvas.value.width = chartState.canvasWidth * chartState.pixelRatio;
|
||||
chartCanvas.value.height = chartState.canvasHeight * chartState.pixelRatio;
|
||||
chartCanvas.value.style.width = `${chartState.canvasWidth}px`;
|
||||
chartCanvas.value.style.height = `${chartState.canvasHeight}px`;
|
||||
|
||||
// 重新计算可见K线数量
|
||||
const contentWidth = chartState.canvasWidth - chartState.paddingLeft - chartState.paddingRight;
|
||||
chartState.visibleBars = Math.floor(contentWidth / (chartState.barWidth + chartState.spacing));
|
||||
|
||||
// 设置高度分配
|
||||
chartState.priceHeight = chartState.canvasHeight * 0.7; // 价格区域占70%
|
||||
chartState.volumeHeight = chartState.canvasHeight - chartState.priceHeight - chartState.paddingBottom;
|
||||
}
|
||||
|
||||
// 监听数据变化,重新渲染
|
||||
watch(() => props.data, () => {
|
||||
render();
|
||||
}, { deep: true });
|
||||
|
||||
// 监听挂单线变化,重新渲染
|
||||
watch(() => props.orderLines, () => {
|
||||
render();
|
||||
}, { deep: true });
|
||||
|
||||
// 监听持仓信息变化,重新渲染
|
||||
watch(() => props.position, () => {
|
||||
render();
|
||||
}, { deep: true });
|
||||
|
||||
// 渲染图表
|
||||
function render() {
|
||||
if (!chartCanvas.value || props.data.length === 0) return;
|
||||
|
||||
const ctx = chartCanvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 清除画布
|
||||
ctx.resetTransform();
|
||||
ctx.clearRect(0, 0, chartCanvas.value.width, chartCanvas.value.height);
|
||||
ctx.scale(chartState.pixelRatio, chartState.pixelRatio);
|
||||
|
||||
// 确定显示的数据范围
|
||||
const dataLength = props.data.length;
|
||||
const endIndex = dataLength - 1;
|
||||
const startIndex = Math.max(0, endIndex - chartState.visibleBars + 1);
|
||||
chartState.dataStartIndex = startIndex;
|
||||
|
||||
const visibleData = props.data.slice(startIndex, endIndex + 1);
|
||||
if (visibleData.length === 0) return;
|
||||
|
||||
// 计算价格和成交量范围
|
||||
chartState.priceMax = Math.max(...visibleData.map(item => item.high));
|
||||
chartState.priceMin = Math.min(...visibleData.map(item => item.low));
|
||||
chartState.volumeMax = Math.max(...visibleData.map(item => item.volume));
|
||||
|
||||
// 考虑挂单价和持仓价格,确保它们也在视图范围内
|
||||
if (props.orderLines && props.orderLines.length > 0) {
|
||||
const orderPrices = props.orderLines.map(order => order.price);
|
||||
chartState.priceMax = Math.max(chartState.priceMax, ...orderPrices);
|
||||
chartState.priceMin = Math.min(chartState.priceMin, ...orderPrices);
|
||||
}
|
||||
|
||||
if (props.position && props.position.avgCost) {
|
||||
chartState.priceMax = Math.max(chartState.priceMax, props.position.avgCost);
|
||||
chartState.priceMin = Math.min(chartState.priceMin, props.position.avgCost);
|
||||
}
|
||||
|
||||
chartState.priceMax = Math.ceil(chartState.priceMax / 1000) * 1000;
|
||||
chartState.priceMin = Math.floor(chartState.priceMin / 1000) * 1000;
|
||||
|
||||
|
||||
// 增加一些边距,使图表不会紧贴边缘
|
||||
const pricePadding = (chartState.priceMax - chartState.priceMin) * 0.03;
|
||||
chartState.priceMax += pricePadding;
|
||||
chartState.priceMin -= pricePadding;
|
||||
|
||||
// 计算比例
|
||||
chartState.priceScale = chartState.priceHeight / (chartState.priceMax - chartState.priceMin);
|
||||
chartState.volumeScale = chartState.volumeHeight / chartState.volumeMax;
|
||||
|
||||
// 绘制背景
|
||||
drawBackground(ctx);
|
||||
|
||||
// 绘制价格网格和标签
|
||||
drawPriceGrid(ctx);
|
||||
|
||||
// 绘制时间网格和标签
|
||||
drawTimeGrid(ctx, visibleData);
|
||||
|
||||
// 绘制K线
|
||||
drawCandles(ctx, visibleData);
|
||||
|
||||
// 绘制成交量
|
||||
drawVolume(ctx, visibleData);
|
||||
|
||||
// 绘制挂单线
|
||||
if (props.orderLines && props.orderLines.length > 0) {
|
||||
drawOrderLines(ctx);
|
||||
}
|
||||
|
||||
// 绘制持仓成本线
|
||||
if (props.position && props.position.avgCost) {
|
||||
drawPositionLine(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制背景
|
||||
function drawBackground(ctx: CanvasRenderingContext2D) {
|
||||
|
||||
// 价格和成交量区域分隔线
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, chartState.priceHeight);
|
||||
ctx.lineTo(chartState.canvasWidth, chartState.priceHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制价格网格和标签
|
||||
function drawPriceGrid(ctx: CanvasRenderingContext2D) {
|
||||
const priceRange = chartState.priceMax - chartState.priceMin;
|
||||
const gridCount = 5;
|
||||
const gridStep = priceRange / gridCount;
|
||||
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#999';
|
||||
ctx.font = '11px PingFang SC';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
for (let i = 0; i <= gridCount; i++) {
|
||||
const price = chartState.priceMax - i * gridStep;
|
||||
const y = yForPrice(price);
|
||||
|
||||
// 网格线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartState.paddingLeft, y);
|
||||
ctx.lineTo(chartState.canvasWidth, y);
|
||||
ctx.stroke();
|
||||
|
||||
// 价格标签
|
||||
ctx.fillText(price.toFixed(2), chartState.paddingLeft - 5, y);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制时间网格和标签
|
||||
function drawTimeGrid(ctx: CanvasRenderingContext2D, data: KLineData[]) {
|
||||
const gridCount = Math.min(4, Math.ceil(data.length / 16));
|
||||
const step = data.length / gridCount;
|
||||
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#999';
|
||||
ctx.font = '12px PingFang SC';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
for (let i = 0; i < gridCount; i++) {
|
||||
const index = Math.floor((i + 1) * step - 1);
|
||||
const x = xForIndex(index);
|
||||
const time = new Date(data[index].time);
|
||||
const timeLabel = `${time.getFullYear()}/${time.getMonth() + 1}/${time.getDate()}`;
|
||||
|
||||
// 网格线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, chartState.canvasHeight - chartState.paddingBottom);
|
||||
ctx.stroke();
|
||||
|
||||
// 时间标签
|
||||
ctx.fillText(timeLabel, x, chartState.canvasHeight - chartState.paddingBottom + 5);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制K线
|
||||
function drawCandles(ctx: CanvasRenderingContext2D, data: KLineData[]) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const candle = data[i];
|
||||
const x = xForIndex(i);
|
||||
const bodyTop = yForPrice(Math.max(candle.open, candle.close));
|
||||
const bodyBottom = yForPrice(Math.min(candle.open, candle.close));
|
||||
const wickTop = yForPrice(candle.high);
|
||||
const wickBottom = yForPrice(candle.low);
|
||||
|
||||
// 是上涨还是下跌
|
||||
const isRising = candle.close > candle.open;
|
||||
ctx.fillStyle = isRising ? '#26A69A' : '#EF5350';
|
||||
ctx.strokeStyle = isRising ? '#26A69A' : '#EF5350';
|
||||
|
||||
// 绘制K线影线
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, wickTop);
|
||||
ctx.lineTo(x, wickBottom);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制K线实体
|
||||
const bodyWidth = chartState.barWidth;
|
||||
const bodyHeight = Math.max(1, bodyBottom - bodyTop);
|
||||
ctx.fillRect(x - bodyWidth / 2, bodyTop, bodyWidth, bodyHeight);
|
||||
|
||||
// 如果是选中的K线,绘制高亮边框
|
||||
if (i + chartState.dataStartIndex === chartState.selectedIndex) {
|
||||
ctx.strokeStyle = '#FFFF00';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x - bodyWidth / 2 - 2, wickTop - 2, bodyWidth + 4, wickBottom - wickTop + 4);
|
||||
ctx.lineWidth = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制成交量
|
||||
function drawVolume(ctx: CanvasRenderingContext2D, data: KLineData[]) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const candle = data[i];
|
||||
const x = xForIndex(i);
|
||||
const volumeHeight = candle.volume * chartState.volumeScale;
|
||||
const y = chartState.canvasHeight - chartState.paddingBottom - volumeHeight;
|
||||
|
||||
// 是上涨还是下跌
|
||||
const isRising = candle.close > candle.open;
|
||||
ctx.fillStyle = isRising ? 'rgba(38, 166, 154, 0.5)' : 'rgba(239, 83, 80, 0.5)';
|
||||
|
||||
// 绘制成交量柱
|
||||
const barWidth = chartState.barWidth;
|
||||
ctx.fillRect(x - barWidth / 2, y, barWidth, volumeHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制挂单线
|
||||
function drawOrderLines(ctx: CanvasRenderingContext2D) {
|
||||
if (!props.orderLines || props.orderLines.length === 0) return;
|
||||
|
||||
props.orderLines.forEach(order => {
|
||||
const y = yForPrice(order.price);
|
||||
|
||||
// 确保线不超出视口
|
||||
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
|
||||
switch (order.type) {
|
||||
case 'BUY':
|
||||
ctx.strokeStyle = ctx.fillStyle = '#26A69A';
|
||||
break;
|
||||
case 'SELL':
|
||||
ctx.strokeStyle = ctx.fillStyle = '#EF5350';
|
||||
break;
|
||||
case 'LIQUIDATION':
|
||||
ctx.strokeStyle = ctx.fillStyle = '#ffb117';
|
||||
break;
|
||||
}
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([5, 3]); // 设置虚线样式
|
||||
|
||||
// 绘制横线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartState.paddingLeft, y);
|
||||
ctx.lineTo(chartState.canvasWidth - chartState.paddingRight, y);
|
||||
ctx.stroke();
|
||||
|
||||
// 重置为实线
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 绘制价格标签
|
||||
ctx.font = '11px PingFang SC';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
ctx.fillText(order.price.toFixed(2), chartState.canvasWidth - chartState.paddingRight + 5, y);
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制持仓成本线
|
||||
function drawPositionLine(ctx: CanvasRenderingContext2D) {
|
||||
if (!props.position || !props.position.avgCost) return;
|
||||
|
||||
const y = yForPrice(props.position.avgCost);
|
||||
|
||||
// 确保线不超出视口
|
||||
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
|
||||
|
||||
// 设置线的样式 - 持仓线为绿色实线
|
||||
ctx.strokeStyle = '#26A69A';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([]); // 确保是实线
|
||||
|
||||
// 绘制横线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartState.paddingLeft, y);
|
||||
ctx.lineTo(chartState.canvasWidth - chartState.paddingRight, y);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制价格标签
|
||||
ctx.font = '11px PingFang SC';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = '#26A69A';
|
||||
ctx.fillText(props.position.avgCost.toFixed(2), chartState.canvasWidth - chartState.paddingRight + 5, y);
|
||||
|
||||
// 如果有浮动盈亏,在价格下方显示
|
||||
if (props.position.profitLoss !== undefined) {
|
||||
const plColor = props.position.profitLoss >= 0 ? '#26A69A' : '#EF5350';
|
||||
const plSign = props.position.profitLoss >= 0 ? '+' : '';
|
||||
const plText = `${plSign}${props.position.profitLoss.toFixed(2)}`;
|
||||
|
||||
ctx.fillStyle = plColor;
|
||||
ctx.font = '10px PingFang SC';
|
||||
ctx.fillText(plText, chartState.canvasWidth - chartState.paddingRight + 5, y + 12);
|
||||
|
||||
// 如果有百分比,继续在下方显示
|
||||
if (props.position.profitLossPercent !== undefined) {
|
||||
const plPercentSign = props.position.profitLossPercent >= 0 ? '+' : '';
|
||||
const plPercentText = `(${plPercentSign}${props.position.profitLossPercent.toFixed(2)}%)`;
|
||||
ctx.fillText(plPercentText, chartState.canvasWidth - chartState.paddingRight + 5, y + 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将价格转换为Y坐标
|
||||
function yForPrice(price: number): number {
|
||||
return chartState.paddingTop + (chartState.priceMax - price) * chartState.priceScale;
|
||||
}
|
||||
|
||||
// 将索引转换为X坐标
|
||||
function xForIndex(index: number): number {
|
||||
return chartState.paddingLeft + (index + 0.5) * (chartState.barWidth + chartState.spacing);
|
||||
}
|
||||
|
||||
// 将X坐标转换为索引
|
||||
function indexForX(x: number): number {
|
||||
const relativeX = x - chartState.paddingLeft;
|
||||
return Math.floor(relativeX / (chartState.barWidth + chartState.spacing));
|
||||
}
|
||||
|
||||
// 处理鼠标移动
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!chartCanvas.value) return;
|
||||
|
||||
const rect = chartCanvas.value.getBoundingClientRect();
|
||||
chartState.mouseX = e.clientX - rect.left;
|
||||
chartState.mouseY = e.clientY - rect.top;
|
||||
|
||||
// 如果鼠标在图表区域内
|
||||
if (chartState.mouseX >= chartState.paddingLeft &&
|
||||
chartState.mouseX <= chartState.canvasWidth - chartState.paddingRight &&
|
||||
chartState.mouseY >= chartState.paddingTop &&
|
||||
chartState.mouseY <= chartState.canvasHeight - chartState.paddingBottom) {
|
||||
|
||||
const index = indexForX(chartState.mouseX);
|
||||
const dataIndex = index + chartState.dataStartIndex;
|
||||
|
||||
if (dataIndex >= 0 && dataIndex < props.data.length) {
|
||||
chartState.selectedIndex = dataIndex;
|
||||
const candle = props.data[dataIndex];
|
||||
|
||||
// 更新提示信息
|
||||
tooltip.visible = true;
|
||||
tooltip.x = chartState.mouseX;
|
||||
tooltip.y = chartState.mouseY;
|
||||
tooltip.time = formatTime(candle.time);
|
||||
tooltip.open = candle.open;
|
||||
tooltip.high = candle.high;
|
||||
tooltip.low = candle.low;
|
||||
tooltip.close = candle.close;
|
||||
tooltip.volume = candle.volume;
|
||||
|
||||
// 重新渲染
|
||||
render();
|
||||
}
|
||||
} else {
|
||||
handleMouseLeave();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理鼠标离开
|
||||
function handleMouseLeave() {
|
||||
tooltip.visible = false;
|
||||
chartState.selectedIndex = -1;
|
||||
render();
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timestamp: number): string {
|
||||
let time = new Date(timestamp);
|
||||
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ${pad(time.getHours())}:00`;
|
||||
}
|
||||
|
||||
// 数字补零
|
||||
function pad(num: number): string {
|
||||
return num.toString().padStart(2, '0');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kline-chart-container" ref="chartContainer">
|
||||
<canvas ref="chartCanvas" class="chart-canvas"></canvas>
|
||||
<div v-if="tooltip.visible" class="tooltip" :style="tooltipStyle">
|
||||
<div>时间: {{ tooltip.time }}</div>
|
||||
<div>开盘: {{ tooltip.open }}</div>
|
||||
<div>最高: {{ tooltip.high }}</div>
|
||||
<div>最低: {{ tooltip.low }}</div>
|
||||
<div>收盘: {{ tooltip.close }}</div>
|
||||
<div>成交量: {{ tooltip.volume }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
.kline-chart-container {
|
||||
position: relative;
|
||||
border: 1px solid #333;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
336
src/components/KLineChartDS.vue
Normal file
336
src/components/KLineChartDS.vue
Normal file
@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div class="kline-container" ref="container">
|
||||
<canvas ref="canvas"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
|
||||
interface KLineData {
|
||||
time: number; // 时间
|
||||
open: number; // 开盘价
|
||||
high: number; // 最高价
|
||||
low: number; // 最低价
|
||||
close: number; // 收盘价
|
||||
volume: number; // 成交量
|
||||
}
|
||||
|
||||
interface Position {
|
||||
avgCost: number; // 平均持仓成本
|
||||
profitLoss?: number; // 浮动盈亏
|
||||
profitLossPercent?: number; // 浮动盈亏百分比
|
||||
}
|
||||
|
||||
interface OrderLine {
|
||||
type: 'BUY' | 'SELL'; // 订单类型
|
||||
price: number; // 价格
|
||||
amount: number; // 数量
|
||||
investment?: number; // 投资金额(仅BUY订单)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KLineData[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
responsive?: boolean; // 是否响应容器尺寸变化
|
||||
orderLines?: OrderLine[]; // 挂单线数组
|
||||
position: Position | null; // 持仓信息
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 800,
|
||||
height: 500,
|
||||
responsive: true,
|
||||
orderLines: () => [],
|
||||
})
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const ctx = computed(() => canvas.value?.getContext('2d'))
|
||||
const canvasWidth = ref(props.width)
|
||||
const canvasHeight = ref(props.height)
|
||||
|
||||
// 样式配置
|
||||
const theme = {
|
||||
background: 'transparent',
|
||||
gridColor: '#2A3042',
|
||||
textColor: '#A1A8BD',
|
||||
upColor: '#00B15D',
|
||||
downColor: '#FF5B5A',
|
||||
buyOrderColor: 'rgba(0, 177, 93, 0.5)',
|
||||
sellOrderColor: 'rgba(255, 91, 90, 0.5)',
|
||||
positionLineColor: '#FFA726',
|
||||
candleWidth: 8,
|
||||
candleGap: 2,
|
||||
padding: {
|
||||
top: 20,
|
||||
right: 40,
|
||||
bottom: 60,
|
||||
left: 60
|
||||
}
|
||||
}
|
||||
|
||||
// 计算价格范围
|
||||
const priceRange = computed(() => {
|
||||
if (props.data.length === 0) return { min: 0, max: 0 }
|
||||
|
||||
let min = Number.MAX_VALUE
|
||||
let max = Number.MIN_VALUE
|
||||
|
||||
props.data.forEach(item => {
|
||||
min = Math.min(min, item.low)
|
||||
max = Math.max(max, item.high)
|
||||
})
|
||||
|
||||
// 考虑订单线和持仓线
|
||||
props.orderLines.forEach(line => {
|
||||
min = Math.min(min, line.price)
|
||||
max = Math.max(max, line.price)
|
||||
})
|
||||
|
||||
if (props.position) {
|
||||
min = Math.min(min, props.position.avgCost)
|
||||
max = Math.max(max, props.position.avgCost)
|
||||
}
|
||||
|
||||
// 添加一些边距
|
||||
const padding = (max - min) * 0.1
|
||||
return {
|
||||
min: min - padding,
|
||||
max: max + padding
|
||||
}
|
||||
})
|
||||
|
||||
// 计算时间范围
|
||||
const timeRange = computed(() => {
|
||||
if (props.data.length === 0) return { min: 0, max: 0 }
|
||||
|
||||
const times = props.data.map(item => item.time)
|
||||
return {
|
||||
min: Math.min(...times),
|
||||
max: Math.max(...times)
|
||||
}
|
||||
})
|
||||
|
||||
// 坐标转换:价格到Y坐标
|
||||
const priceToY = (price: number) => {
|
||||
const range = priceRange.value
|
||||
const height = canvasHeight.value - theme.padding.top - theme.padding.bottom
|
||||
return theme.padding.top + height * (1 - (price - range.min) / (range.max - range.min))
|
||||
}
|
||||
|
||||
// 坐标转换:时间到X坐标
|
||||
const timeToX = (time: number) => {
|
||||
const range = timeRange.value
|
||||
const width = canvasWidth.value - theme.padding.left - theme.padding.right
|
||||
return theme.padding.left + width * (time - range.min) / (range.max - range.min)
|
||||
}
|
||||
|
||||
// 绘制网格
|
||||
const drawGrid = () => {
|
||||
if (!ctx.value) return
|
||||
|
||||
const cw = canvasWidth.value
|
||||
const ch = canvasHeight.value
|
||||
const padding = theme.padding
|
||||
|
||||
ctx.value.fillStyle = theme.background
|
||||
ctx.value.fillRect(0, 0, cw, ch)
|
||||
|
||||
// 网格线
|
||||
ctx.value.strokeStyle = theme.gridColor
|
||||
ctx.value.lineWidth = 1
|
||||
|
||||
// 水平网格线 (价格)
|
||||
const priceLevels = 5
|
||||
for (let i = 0; i <= priceLevels; i++) {
|
||||
const price = priceRange.value.min + (priceRange.value.max - priceRange.value.min) * (i / priceLevels)
|
||||
const y = priceToY(price)
|
||||
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(padding.left, y)
|
||||
ctx.value.lineTo(cw - padding.right, y)
|
||||
ctx.value.stroke()
|
||||
|
||||
// 价格标签
|
||||
ctx.value.fillStyle = theme.textColor
|
||||
ctx.value.font = '12px Arial'
|
||||
ctx.value.textAlign = 'right'
|
||||
ctx.value.fillText(price.toFixed(2), padding.left - 10, y + 4)
|
||||
}
|
||||
|
||||
// 垂直网格线 (时间)
|
||||
const timeLevels = Math.min(10, props.data.length)
|
||||
for (let i = 0; i <= timeLevels; i++) {
|
||||
const idx = Math.floor(props.data.length * (i / timeLevels) - 1)
|
||||
if (idx < 0) continue
|
||||
|
||||
const time = props.data[idx].time
|
||||
const x = timeToX(time)
|
||||
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(x, padding.top)
|
||||
ctx.value.lineTo(x, ch - padding.bottom)
|
||||
ctx.value.stroke()
|
||||
|
||||
// 时间标签
|
||||
const date = new Date(time)
|
||||
const timeStr = `${date.getMonth()+1}/${date.getDate()} ${date.getHours()}:${date.getMinutes()}`
|
||||
ctx.value.fillStyle = theme.textColor
|
||||
ctx.value.font = '12px Arial'
|
||||
ctx.value.textAlign = 'center'
|
||||
ctx.value.fillText(timeStr, x, ch - padding.bottom + 20)
|
||||
}
|
||||
|
||||
// 边框
|
||||
ctx.value.strokeStyle = theme.gridColor
|
||||
ctx.value.lineWidth = 1
|
||||
ctx.value.strokeRect(padding.left, padding.top, cw - padding.left - padding.right, ch - padding.top - padding.bottom)
|
||||
}
|
||||
|
||||
// 绘制K线
|
||||
const drawCandles = () => {
|
||||
if (!ctx.value || props.data.length === 0) return
|
||||
|
||||
const candleWidth = theme.candleWidth
|
||||
const halfCandleWidth = candleWidth / 2
|
||||
|
||||
props.data.forEach(item => {
|
||||
const x = timeToX(item.time)
|
||||
const yOpen = priceToY(item.open)
|
||||
const yClose = priceToY(item.close)
|
||||
const yHigh = priceToY(item.high)
|
||||
const yLow = priceToY(item.low)
|
||||
|
||||
const isUp = item.close >= item.open
|
||||
|
||||
// 绘制上下影线
|
||||
ctx.value!.strokeStyle = isUp ? theme.upColor : theme.downColor
|
||||
ctx.value!.lineWidth = 1
|
||||
ctx.value!.beginPath()
|
||||
ctx.value!.moveTo(x, yHigh)
|
||||
ctx.value!.lineTo(x, yLow)
|
||||
ctx.value!.stroke()
|
||||
|
||||
// 绘制实体
|
||||
ctx.value!.fillStyle = isUp ? theme.upColor : theme.downColor
|
||||
ctx.value!.fillRect(x - halfCandleWidth, Math.min(yOpen, yClose), candleWidth, Math.abs(yClose - yOpen))
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制挂单线
|
||||
const drawOrderLines = () => {
|
||||
if (!ctx.value || props.orderLines.length === 0) return
|
||||
|
||||
props.orderLines.forEach(line => {
|
||||
const y = priceToY(line.price)
|
||||
const cw = canvasWidth.value
|
||||
const padding = theme.padding
|
||||
|
||||
ctx.value!.strokeStyle = line.type === 'BUY' ? theme.buyOrderColor : theme.sellOrderColor
|
||||
ctx.value!.lineWidth = 1
|
||||
ctx.value!.setLineDash([5, 3])
|
||||
ctx.value!.beginPath()
|
||||
ctx.value!.moveTo(padding.left, y)
|
||||
ctx.value!.lineTo(cw - padding.right, y)
|
||||
ctx.value!.stroke()
|
||||
ctx.value!.setLineDash([])
|
||||
|
||||
// 标签
|
||||
ctx.value!.fillStyle = line.type === 'BUY' ? theme.upColor : theme.downColor
|
||||
ctx.value!.font = '12px Arial'
|
||||
ctx.value!.textAlign = 'right'
|
||||
ctx.value!.fillText(`${line.type} ${line.price.toFixed(2)}`, padding.left - 10, y - 5)
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制持仓成本线
|
||||
const drawPositionLine = () => {
|
||||
if (!ctx.value || !props.position) return
|
||||
|
||||
const y = priceToY(props.position.avgCost)
|
||||
const cw = canvasWidth.value
|
||||
const padding = theme.padding
|
||||
|
||||
ctx.value!.strokeStyle = theme.positionLineColor
|
||||
ctx.value!.lineWidth = 1
|
||||
ctx.value!.setLineDash([5, 3])
|
||||
ctx.value!.beginPath()
|
||||
ctx.value!.moveTo(padding.left, y)
|
||||
ctx.value!.lineTo(cw - padding.right, y)
|
||||
ctx.value!.stroke()
|
||||
ctx.value!.setLineDash([])
|
||||
|
||||
// 标签
|
||||
ctx.value!.fillStyle = theme.positionLineColor
|
||||
ctx.value!.font = '12px Arial'
|
||||
ctx.value!.textAlign = 'right'
|
||||
let label = `成本 ${props.position.avgCost.toFixed(2)}`
|
||||
if (props.position.profitLoss !== undefined) {
|
||||
label += ` (${props.position.profitLoss >= 0 ? '+' : ''}${props.position.profitLoss.toFixed(2)})`
|
||||
}
|
||||
ctx.value!.fillText(label, padding.left - 10, y - 5)
|
||||
}
|
||||
|
||||
// 绘制图表
|
||||
const drawChart = () => {
|
||||
if (!canvas.value || !ctx.value) return
|
||||
|
||||
// 设置canvas尺寸
|
||||
canvas.value.width = canvasWidth.value
|
||||
canvas.value.height = canvasHeight.value
|
||||
|
||||
drawGrid()
|
||||
drawCandles()
|
||||
drawOrderLines()
|
||||
drawPositionLine()
|
||||
}
|
||||
|
||||
// 处理窗口大小变化
|
||||
const handleResize = () => {
|
||||
if (!container.value || !props.responsive) return
|
||||
|
||||
const rect = container.value.getBoundingClientRect()
|
||||
canvasWidth.value = rect.width
|
||||
canvasHeight.value = rect.height
|
||||
|
||||
drawChart()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
if (props.responsive && container.value) {
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
}
|
||||
|
||||
drawChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (props.responsive) {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据变化
|
||||
watch(() => props.data, drawChart, { deep: true })
|
||||
watch(() => props.orderLines, drawChart, { deep: true })
|
||||
watch(() => props.position, drawChart, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kline-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: v-bind('theme.background');
|
||||
position: relative;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
454
src/components/KlineChartGM.vue
Normal file
454
src/components/KlineChartGM.vue
Normal file
@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div ref="chartContainerRef" class="kline-chart-container" :style="containerStyle">
|
||||
<canvas ref="canvasRef"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
|
||||
|
||||
// --- Type Definitions ---
|
||||
interface KLineData {
|
||||
time: number; // Timestamp (seconds or milliseconds)
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
avgCost: number;
|
||||
profitLoss?: number;
|
||||
profitLossPercent?: number;
|
||||
}
|
||||
|
||||
interface OrderLine {
|
||||
type: 'BUY' | 'SELL' | 'LIQUIDATION';
|
||||
price: number;
|
||||
amount: number;
|
||||
investment?: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KLineData[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
responsive?: boolean; // Default: true
|
||||
orderLines?: OrderLine[];
|
||||
position: Position | null;
|
||||
maxVisibleCandles?: number; // Max candles to display
|
||||
}
|
||||
|
||||
// --- Props ---
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
width: 600,
|
||||
height: 400,
|
||||
responsive: true,
|
||||
orderLines: () => [],
|
||||
position: null,
|
||||
maxVisibleCandles: 100,
|
||||
});
|
||||
|
||||
// --- Refs ---
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||
const chartContainerRef = ref<HTMLDivElement | null>(null);
|
||||
const ctxRef = ref<CanvasRenderingContext2D | null>(null);
|
||||
const canvasWidth = ref(props.width);
|
||||
const canvasHeight = ref(props.height);
|
||||
|
||||
// --- Constants ---
|
||||
const AXIS_TEXT_COLOR = '#CCCCCC';
|
||||
const GRID_COLOR = '#444444';
|
||||
const PRICE_UP_COLOR = '#26A69A';
|
||||
const PRICE_DOWN_COLOR = '#EF5350';
|
||||
const LIQUIDATION_COLOR = '#ffb117';
|
||||
const POSITION_COST_COLOR = '#42A5F5';
|
||||
const FONT = '10px Arial';
|
||||
const MARGIN_TOP = 20;
|
||||
const MARGIN_BOTTOM = 40;
|
||||
const MARGIN_LEFT = 10;
|
||||
const MARGIN_RIGHT = 60;
|
||||
const VOLUME_CHART_HEIGHT_RATIO = 0.2;
|
||||
const MAIN_VOLUME_GAP = 8;
|
||||
const CANDLE_WIDTH_RATIO = 0.7;
|
||||
const PRICE_PADDING_RATIO = 0.1;
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// --- Computed ---
|
||||
const containerStyle = computed(() => ({
|
||||
width: props.responsive ? '100%' : `${props.width}px`,
|
||||
height: props.responsive ? '100%' : `${props.height}px`,
|
||||
position: 'relative',
|
||||
/* backgroundColor: '#111', // Container background provides the dark theme */
|
||||
color: '#fff',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
// --- Functions ---
|
||||
|
||||
// Updated formatTime function
|
||||
const formatTime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // getMonth is 0-indexed
|
||||
// Optional: Include day if needed for granularity
|
||||
// const day = date.getDate().toString().padStart(2, '0');
|
||||
// return `${year}/${month}/${day}`;
|
||||
return `${year}/${month}`;
|
||||
};
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
return price.toFixed(2);
|
||||
};
|
||||
|
||||
const drawChart = () => {
|
||||
const canvas = canvasRef.value;
|
||||
const ctx = ctxRef.value;
|
||||
|
||||
const visibleData = props.data.length > props.maxVisibleCandles
|
||||
? props.data.slice(-props.maxVisibleCandles)
|
||||
: props.data;
|
||||
|
||||
if (!canvas || !ctx || !visibleData || visibleData.length === 0) {
|
||||
if (canvas && ctx) {
|
||||
// Ensure canvas is cleared if no data or context lost
|
||||
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const logicalWidth = canvasWidth.value;
|
||||
const logicalHeight = canvasHeight.value;
|
||||
canvas.width = logicalWidth * dpr;
|
||||
canvas.height = logicalHeight * dpr;
|
||||
canvas.style.width = `${logicalWidth}px`;
|
||||
canvas.style.height = `${logicalHeight}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// --- Clear Canvas ---
|
||||
// This ensures transparency, no background color is filled here.
|
||||
ctx.clearRect(0, 0, logicalWidth, logicalHeight);
|
||||
|
||||
// --- Chart Dimensions ---
|
||||
const chartWidth = logicalWidth - MARGIN_LEFT - MARGIN_RIGHT;
|
||||
const chartHeight = logicalHeight - MARGIN_TOP - MARGIN_BOTTOM;
|
||||
const volumeHeight = chartHeight * VOLUME_CHART_HEIGHT_RATIO;
|
||||
const mainChartHeight = chartHeight - volumeHeight - MAIN_VOLUME_GAP;
|
||||
|
||||
if (chartWidth <= 0 || mainChartHeight <= 0 || volumeHeight <= 0) return;
|
||||
|
||||
// --- Data Analysis (using visibleData) ---
|
||||
let minPrice = Infinity;
|
||||
let maxPrice = -Infinity;
|
||||
let maxVolume = 0;
|
||||
visibleData.forEach(d => {
|
||||
minPrice = Math.min(minPrice, d.low);
|
||||
maxPrice = Math.max(maxPrice, d.high);
|
||||
maxVolume = Math.max(maxVolume, d.volume);
|
||||
});
|
||||
props.orderLines?.forEach(line => {
|
||||
minPrice = Math.min(minPrice, line.price);
|
||||
maxPrice = Math.max(maxPrice, line.price);
|
||||
});
|
||||
if (props.position) {
|
||||
minPrice = Math.min(minPrice, props.position.avgCost);
|
||||
maxPrice = Math.max(maxPrice, props.position.avgCost);
|
||||
}
|
||||
const priceRange = maxPrice - minPrice;
|
||||
if (priceRange > 0) {
|
||||
minPrice -= priceRange * PRICE_PADDING_RATIO;
|
||||
maxPrice += priceRange * PRICE_PADDING_RATIO;
|
||||
} else {
|
||||
minPrice -= 1;
|
||||
maxPrice += 1;
|
||||
}
|
||||
if (maxVolume === 0) maxVolume = 1;
|
||||
|
||||
// --- Scaling (using visibleData length) ---
|
||||
const priceScale = mainChartHeight / (maxPrice - minPrice);
|
||||
const volumeScale = volumeHeight / maxVolume;
|
||||
const candleTotalWidth = chartWidth / visibleData.length;
|
||||
const candleBodyWidth = candleTotalWidth * CANDLE_WIDTH_RATIO;
|
||||
|
||||
// --- Helper Functions ---
|
||||
const getY = (price: number): number => {
|
||||
return MARGIN_TOP + mainChartHeight - (price - minPrice) * priceScale;
|
||||
};
|
||||
const getX = (index: number): number => {
|
||||
return MARGIN_LEFT + index * candleTotalWidth + candleTotalWidth / 2;
|
||||
};
|
||||
|
||||
// --- Draw Grid ---
|
||||
ctx.strokeStyle = GRID_COLOR;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.font = FONT;
|
||||
ctx.fillStyle = AXIS_TEXT_COLOR;
|
||||
|
||||
// Horizontal Grid Lines & Price Axis
|
||||
ctx.textAlign = 'right'; // Align price labels to the right
|
||||
ctx.textBaseline = 'middle';
|
||||
const priceTickCount = 5;
|
||||
const currentPriceRange = maxPrice - minPrice;
|
||||
for (let i = 0; i <= priceTickCount; i++) {
|
||||
const price = minPrice + currentPriceRange * i / priceTickCount;
|
||||
const y = getY(price);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN_LEFT, y);
|
||||
ctx.lineTo(MARGIN_LEFT + chartWidth, y);
|
||||
ctx.stroke();
|
||||
// Draw Price Label (position adjusted slightly for clarity)
|
||||
ctx.fillText(formatPrice(price), logicalWidth - MARGIN_RIGHT + 55, y);
|
||||
}
|
||||
|
||||
// Vertical Grid Lines & Time Axis (with overlap fix)
|
||||
ctx.textBaseline = 'top';
|
||||
const timeTickCount = Math.min(visibleData.length, Math.floor(chartWidth / 80)); // Increase spacing slightly for YYYY/MM
|
||||
const timeTickInterval = Math.max(1, Math.floor(visibleData.length / timeTickCount));
|
||||
const timeAxisY = logicalHeight - MARGIN_BOTTOM + 5; // Y position for time labels
|
||||
|
||||
// Set default alignment for most labels
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
for (let i = 0; i < visibleData.length; i++) {
|
||||
const isLastCandle = i === visibleData.length - 1;
|
||||
const isTickInterval = i % timeTickInterval === 0;
|
||||
|
||||
// Draw label if it's a tick interval OR if it's the very last candle
|
||||
if (isTickInterval || isLastCandle) {
|
||||
const candleData = visibleData[i];
|
||||
const timeLabel = formatTime(candleData.time);
|
||||
const x = getX(i);
|
||||
|
||||
// --- Overlap Prevention for Last Label ---
|
||||
if (isLastCandle) {
|
||||
// Calculate the right boundary of the chart area
|
||||
const chartAreaRightEdge = MARGIN_LEFT + chartWidth;
|
||||
// Measure the text width to see if centering it would cause overflow
|
||||
const textWidth = ctx.measureText(timeLabel).width;
|
||||
const centeredX = x;
|
||||
|
||||
// If the centered position plus half the text width goes beyond the chart area edge...
|
||||
if (centeredX + textWidth / 2 > chartAreaRightEdge) {
|
||||
// ...then right-align the text to the chart area edge
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(timeLabel, chartAreaRightEdge, timeAxisY);
|
||||
} else {
|
||||
// Otherwise, center it as usual
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(timeLabel, centeredX, timeAxisY);
|
||||
}
|
||||
} else {
|
||||
// For all other labels, center them
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(timeLabel, x, timeAxisY);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset textAlign just in case, though subsequent operations set it again
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
|
||||
// --- Draw Volume Bars ---
|
||||
// ... (same as before)
|
||||
const volumeStartY = logicalHeight - MARGIN_BOTTOM;
|
||||
ctx.lineWidth = 1;
|
||||
visibleData.forEach((d, i) => {
|
||||
const x = getX(i);
|
||||
const barHeight = d.volume * volumeScale;
|
||||
const barY = volumeStartY - barHeight;
|
||||
const color = d.close >= d.open ? PRICE_UP_COLOR : PRICE_DOWN_COLOR;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x - candleBodyWidth / 2, barY, candleBodyWidth, barHeight > 0 ? Math.max(1, barHeight) : 0);
|
||||
});
|
||||
|
||||
|
||||
// --- Draw Candlesticks ---
|
||||
// ... (same as before)
|
||||
visibleData.forEach((d, i) => {
|
||||
const x = getX(i);
|
||||
const yOpen = getY(d.open);
|
||||
const yClose = getY(d.close);
|
||||
const yHigh = getY(d.high);
|
||||
const yLow = getY(d.low);
|
||||
const isUp = d.close >= d.open;
|
||||
const color = isUp ? PRICE_UP_COLOR : PRICE_DOWN_COLOR;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yHigh);
|
||||
ctx.lineTo(x, yLow);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = color;
|
||||
const bodyTop = Math.min(yOpen, yClose);
|
||||
const bodyHeight = Math.abs(yOpen - yClose);
|
||||
ctx.fillRect(x - candleBodyWidth / 2, bodyTop, candleBodyWidth, Math.max(1, bodyHeight));
|
||||
});
|
||||
|
||||
// --- Draw Order Lines & Position Cost Line ---
|
||||
// ... (logic mostly same, ensure text alignment is reset before drawing labels)
|
||||
ctx.save();
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = FONT;
|
||||
ctx.textBaseline = 'bottom'; // Align text relative to the line
|
||||
|
||||
// Order Lines
|
||||
props.orderLines?.forEach(line => {
|
||||
const y = getY(line.price);
|
||||
if (y >= MARGIN_TOP && y <= MARGIN_TOP + mainChartHeight + 5) { // Allow slightly outside bounds for visibility
|
||||
let color = '';
|
||||
switch (line.type) {
|
||||
case 'BUY': color = PRICE_UP_COLOR; break;
|
||||
case 'SELL': color = PRICE_DOWN_COLOR; break;
|
||||
case 'LIQUIDATION': color = LIQUIDATION_COLOR; break;
|
||||
}
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN_LEFT, y);
|
||||
ctx.lineTo(MARGIN_LEFT + chartWidth, y);
|
||||
ctx.stroke();
|
||||
const label = `${line.description} ${formatPrice(line.price)}`;
|
||||
// Set alignment for line labels
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(label, logicalWidth - MARGIN_RIGHT - 5, y - 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Position Cost Line
|
||||
if (props.position) {
|
||||
const y = getY(props.position.avgCost);
|
||||
if (y >= MARGIN_TOP && y <= MARGIN_TOP + mainChartHeight + 5) {
|
||||
ctx.strokeStyle = POSITION_COST_COLOR;
|
||||
ctx.fillStyle = POSITION_COST_COLOR;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN_LEFT, y);
|
||||
ctx.lineTo(MARGIN_LEFT + chartWidth, y);
|
||||
ctx.stroke();
|
||||
const label = `Avg Cost: ${formatPrice(props.position.avgCost)}`;
|
||||
let plText = '';
|
||||
if (props.position.profitLoss !== undefined && props.position.profitLossPercent !== undefined) {
|
||||
const plSign = props.position.profitLoss >= 0 ? '+' : '';
|
||||
plText = ` (${plSign}${formatPrice(props.position.profitLoss)} / ${plSign}${props.position.profitLossPercent.toFixed(2)}%)`;
|
||||
}
|
||||
// Set alignment for line labels
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(label + plText, logicalWidth - MARGIN_RIGHT - 5, y - 2);
|
||||
}
|
||||
}
|
||||
ctx.restore(); // Restore line dash setting & potentially textAlign if saved
|
||||
|
||||
// --- Draw Volume Area Separator (Subtle) ---
|
||||
// ... (same as before)
|
||||
ctx.save();
|
||||
ctx.strokeStyle = GRID_COLOR;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
const separatorY = MARGIN_TOP + mainChartHeight + MAIN_VOLUME_GAP / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(MARGIN_LEFT, separatorY);
|
||||
ctx.lineTo(MARGIN_LEFT + chartWidth, separatorY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
};
|
||||
|
||||
// --- setupCanvas, updateDimensions, handleResize ---
|
||||
// (These functions remain unchanged from the previous version)
|
||||
const setupCanvas = () => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
console.error('Failed to get 2D context');
|
||||
return;
|
||||
}
|
||||
ctxRef.value = context;
|
||||
updateDimensions();
|
||||
};
|
||||
|
||||
const updateDimensions = () => {
|
||||
if (props.responsive && chartContainerRef.value) {
|
||||
const { width, height } = chartContainerRef.value.getBoundingClientRect();
|
||||
canvasWidth.value = Math.max(width, 100);
|
||||
canvasHeight.value = Math.max(height, 100);
|
||||
} else {
|
||||
canvasWidth.value = props.width;
|
||||
canvasHeight.value = props.height;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
updateDimensions();
|
||||
};
|
||||
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
// (Unchanged)
|
||||
onMounted(() => {
|
||||
setupCanvas();
|
||||
nextTick(() => {
|
||||
drawChart();
|
||||
});
|
||||
if (props.responsive && chartContainerRef.value) {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(chartContainerRef.value);
|
||||
} else {
|
||||
drawChart();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver && chartContainerRef.value) {
|
||||
resizeObserver.unobserve(chartContainerRef.value);
|
||||
}
|
||||
resizeObserver = null;
|
||||
});
|
||||
|
||||
// --- Watchers ---
|
||||
// (Unchanged)
|
||||
watch(
|
||||
() => [props.data, props.orderLines, props.position, props.maxVisibleCandles],
|
||||
() => {
|
||||
nextTick(drawChart);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.width, props.height, props.responsive],
|
||||
() => {
|
||||
if (props.responsive && !resizeObserver && chartContainerRef.value) {
|
||||
updateDimensions();
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(chartContainerRef.value);
|
||||
} else if (!props.responsive && resizeObserver && chartContainerRef.value) {
|
||||
resizeObserver.unobserve(chartContainerRef.value);
|
||||
resizeObserver = null;
|
||||
updateDimensions();
|
||||
} else {
|
||||
updateDimensions();
|
||||
}
|
||||
nextTick(drawChart);
|
||||
}
|
||||
);
|
||||
|
||||
watch([canvasWidth, canvasHeight], () => {
|
||||
nextTick(drawChart);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kline-chart-container {
|
||||
/* Styles defined in computed 'containerStyle' */
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
217
src/martingale.ts
Normal file
217
src/martingale.ts
Normal file
@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 持仓信息接口
|
||||
*/
|
||||
export interface Position {
|
||||
price: number; // 买入价格
|
||||
amount: number; // 买入数量
|
||||
investment: number; // 投资金额(USDT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 马丁格尔策略状态接口
|
||||
*/
|
||||
export interface MartingaleState {
|
||||
positions: Position[]; // 当前持仓列表
|
||||
}
|
||||
|
||||
/**
|
||||
* 马丁格尔策略参数接口
|
||||
*/
|
||||
export interface MartingaleOption {
|
||||
dropPercentage: number; // 跌多少加仓(例如1.87)
|
||||
takeProfitPercentage: number; // 单周期止盈目标(例如4.00)
|
||||
maxOrders: number; // 最大加仓次数
|
||||
initialRatio: number; // 初始加仓比例(例如0.94)
|
||||
priceMultiplier: number; // 加仓价差倍数(例如1.00)
|
||||
amountMultiplier: number; // 加仓金额倍数(例如1.05)
|
||||
maxInvestment?: number; // 潜在最大投入(开启新周期时需要)
|
||||
currentPrice: number; // 当前市场价格
|
||||
leverage?: number; // 可选杠杆倍数(例如3倍杠杆)
|
||||
maintenanceMargin?: number; // 维持保证金率(例如0.05表示5%)
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂单线接口
|
||||
*/
|
||||
export interface OrderLine {
|
||||
type: 'BUY' | 'SELL' | 'LIQUIDATION'; // 订单类型
|
||||
price: number; // 价格
|
||||
amount: number; // 数量
|
||||
investment?: number; // 投资金额(仅BUY订单)
|
||||
description: string; // 订单描述
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算初始投资金额和所有订单的总投资额
|
||||
* @param option 策略参数
|
||||
* @returns [初始投资金额, 总投资额]
|
||||
*/
|
||||
function calculateInitialInvestment(option: MartingaleOption): number {
|
||||
const maxOrders = option.maxOrders;
|
||||
const initialRatio = option.initialRatio;
|
||||
const amountMultiplier = option.amountMultiplier;
|
||||
const maxInvestment = option.maxInvestment || 0;
|
||||
|
||||
// 计算总投资系数
|
||||
let totalFactor = 1; // 初始订单的系数
|
||||
let currentFactor = initialRatio; // 第一次加仓的系数
|
||||
|
||||
for (let i = 1; i < maxOrders; i++) {
|
||||
totalFactor += currentFactor;
|
||||
currentFactor *= amountMultiplier;
|
||||
}
|
||||
|
||||
// 根据最大投资计算初始投资
|
||||
return maxInvestment / totalFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算强平价格
|
||||
* @param positions 当前持仓列表
|
||||
* @param leverage 杠杆倍数
|
||||
* @param maintenanceMargin 维持保证金率
|
||||
* @returns 强平价格
|
||||
*/
|
||||
function calculateLiquidationPrice(
|
||||
positions: Position[],
|
||||
leverage: number,
|
||||
maintenanceMargin: number
|
||||
): number {
|
||||
// 计算总投资金额和总购买数量
|
||||
let totalInvestment = 0;
|
||||
let totalAmount = 0;
|
||||
|
||||
for (const position of positions) {
|
||||
totalInvestment += position.investment;
|
||||
totalAmount += position.amount;
|
||||
}
|
||||
|
||||
// 计算平均入场价格(考虑杠杆)
|
||||
// 由于position.amount是使用杠杆后的数量,我们需要乘以杠杆来得到真实的平均价格
|
||||
const averageEntryPrice = (totalInvestment / totalAmount) * leverage;
|
||||
|
||||
// 计算强平价格
|
||||
// 强平价格 = 平均入场价格 * (1 - (1 - 维持保证金率) / 杠杆)
|
||||
const liquidationPrice = averageEntryPrice * (1 - (1 - maintenanceMargin) / leverage);
|
||||
|
||||
return liquidationPrice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟马丁格尔策略
|
||||
* @param currentState 当前持仓状态,为空则表示开启新周期
|
||||
* @param option 策略参数
|
||||
* @returns 挂单线数组
|
||||
*/
|
||||
export function simulateMartingale(
|
||||
currentState: MartingaleState | null,
|
||||
option: MartingaleOption
|
||||
): OrderLine[] {
|
||||
const orderLines: OrderLine[] = [];
|
||||
const leverage = option.leverage || 1; // 默认不使用杠杆
|
||||
const maintenanceMargin = option.maintenanceMargin || 0.05; // 默认维持保证金率5%
|
||||
|
||||
// 如果currentState为空或没有持仓,开始新的交易周期
|
||||
if (!currentState || currentState.positions.length === 0) {
|
||||
// 确保有最大投资金额
|
||||
if (!option.maxInvestment) {
|
||||
throw new Error("开启新周期需要提供潜在最大投入金额");
|
||||
}
|
||||
|
||||
// 计算初始投资金额
|
||||
const initialInvestment = calculateInitialInvestment(option);
|
||||
const initialAmount = (initialInvestment * leverage) / option.currentPrice; // 考虑杠杆
|
||||
|
||||
// 添加初始买入订单到挂单线
|
||||
orderLines.push({
|
||||
type: 'BUY',
|
||||
price: option.currentPrice,
|
||||
amount: initialAmount,
|
||||
investment: initialInvestment,
|
||||
description: `初始投资 (潜在最大投入的${((initialInvestment / option.maxInvestment!) * 100).toFixed(2)}%)${leverage > 1 ? ` [${leverage}倍杠杆]` : ''}`
|
||||
});
|
||||
return orderLines;
|
||||
}
|
||||
|
||||
// 计算平均成本
|
||||
let totalInvestment = 0;
|
||||
let totalAmount = 0;
|
||||
|
||||
for (const position of currentState.positions) {
|
||||
totalInvestment += position.investment;
|
||||
totalAmount += position.amount;
|
||||
}
|
||||
|
||||
const averageCost = totalInvestment / totalAmount * (leverage ?? 1);
|
||||
|
||||
// 计算止盈价格
|
||||
const takeProfitPrice = averageCost * (1 + option.takeProfitPercentage / 100);
|
||||
|
||||
// 添加止盈(卖出)订单到挂单线
|
||||
orderLines.push({
|
||||
type: 'SELL',
|
||||
price: takeProfitPrice,
|
||||
amount: totalAmount,
|
||||
description: `止盈点: 平均成本(${averageCost.toFixed(2)})上涨${option.takeProfitPercentage.toFixed(2)}%`
|
||||
});
|
||||
|
||||
// 如果使用杠杆,添加强平线
|
||||
if (leverage > 1 && currentState.positions.length !== 0) {
|
||||
const liquidationPrice = calculateLiquidationPrice(
|
||||
currentState.positions,
|
||||
leverage,
|
||||
maintenanceMargin
|
||||
);
|
||||
orderLines.push({
|
||||
type: 'LIQUIDATION',
|
||||
price: liquidationPrice,
|
||||
amount: totalAmount,
|
||||
description: `强平价: ${liquidationPrice.toFixed(2)} [${leverage}倍杠杆, 维持保证金率${(maintenanceMargin * 100).toFixed(2)}%]`
|
||||
});
|
||||
}
|
||||
|
||||
// 如果未达到最大加仓次数,添加更多买入订单
|
||||
if (currentState.positions.length < option.maxOrders) {
|
||||
let lastBuyPrice = currentState.positions[currentState.positions.length - 1].price;
|
||||
let lastInvestment = currentState.positions[currentState.positions.length - 1].investment;
|
||||
|
||||
// 计算下一次投资金额
|
||||
let nextInvestment = lastInvestment;
|
||||
if (currentState.positions.length === 1) {
|
||||
// 第一次加仓使用initialRatio
|
||||
nextInvestment = lastInvestment * option.initialRatio;
|
||||
} else {
|
||||
// 后续加仓使用amountMultiplier
|
||||
nextInvestment = lastInvestment * option.amountMultiplier;
|
||||
}
|
||||
|
||||
// 为每个潜在的未来买入订单
|
||||
for (let i = currentState.positions.length; i < option.maxOrders; i++) {
|
||||
// 计算此次买入的价格下跌百分比
|
||||
const orderIndex = i - currentState.positions.length;
|
||||
const nextDropPercentage = option.dropPercentage * Math.pow(option.priceMultiplier, orderIndex);
|
||||
|
||||
// 计算下一个买入价格
|
||||
const nextBuyPrice = lastBuyPrice * (1 - nextDropPercentage / 100);
|
||||
|
||||
// 计算下一个买入数量,考虑杠杆
|
||||
const nextBuyAmount = (nextInvestment * leverage) / nextBuyPrice;
|
||||
|
||||
// 添加到挂单线
|
||||
orderLines.push({
|
||||
type: 'BUY',
|
||||
price: nextBuyPrice,
|
||||
amount: nextBuyAmount,
|
||||
investment: nextInvestment,
|
||||
description: `加仓#${i} (比上次价格下跌${nextDropPercentage.toFixed(2)}%)${leverage > 1 ? ` [${leverage}倍杠杆]` : ''}`
|
||||
});
|
||||
|
||||
// 更新下一次迭代的变量
|
||||
lastBuyPrice = nextBuyPrice;
|
||||
lastInvestment = nextInvestment;
|
||||
nextInvestment = nextInvestment * option.amountMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
return orderLines;
|
||||
}
|
||||
8
src/style.css
Normal file
8
src/style.css
Normal file
@ -0,0 +1,8 @@
|
||||
body{
|
||||
margin: 0;
|
||||
font-family: "PingFang SC";
|
||||
}
|
||||
|
||||
*{
|
||||
box-sizing: border-box;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
14
tsconfig.app.json
Normal file
14
tsconfig.app.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user