first commit

This commit is contained in:
feie9456 2025-06-27 09:18:00 +08:00
commit e3a721a055
22 changed files with 3655 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

119
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

1
src/assets/vue.svg Normal file
View 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

View 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();
// XDay
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;
}
}
// (2522415)
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>

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

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

View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

14
tsconfig.app.json Normal file
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View 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
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})