ui优化
This commit is contained in:
parent
e3a721a055
commit
15cbd7ce8d
23
bun.lock
23
bun.lock
@ -6,10 +6,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
|
"gsap": "^3.12.5",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
|
"three": "^0.170.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"sass-embedded": "^1.86.0",
|
"sass-embedded": "^1.86.0",
|
||||||
@ -30,6 +33,8 @@
|
|||||||
|
|
||||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.5.2", "", {}, "sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg=="],
|
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.5.2", "", {}, "sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg=="],
|
||||||
|
|
||||||
|
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="],
|
"@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-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="],
|
||||||
@ -122,12 +127,20 @@
|
|||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.1", "", { "os": "win32", "cpu": "x64" }, "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug=="],
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.1", "", { "os": "win32", "cpu": "x64" }, "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug=="],
|
||||||
|
|
||||||
|
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@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/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=="],
|
"@types/papaparse": ["@types/papaparse@5.3.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg=="],
|
||||||
|
|
||||||
|
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
||||||
|
|
||||||
|
"@types/three": ["@types/three@0.180.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg=="],
|
||||||
|
|
||||||
|
"@types/webxr": ["@types/webxr@0.5.23", "", {}, "sha512-GPe4AsfOSpqWd3xA/0gwoKod13ChcfV67trvxaW2krUbgb9gxQjnCx8zGshzMl8LSHZlNH5gQ8LNScsDuc7nGQ=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="],
|
||||||
@ -160,6 +173,8 @@
|
|||||||
|
|
||||||
"@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
|
"@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
|
||||||
|
|
||||||
|
"@webgpu/types": ["@webgpu/types@0.1.64", "", {}, "sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A=="],
|
||||||
|
|
||||||
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
|
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
@ -184,8 +199,12 @@
|
|||||||
|
|
||||||
"fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
"fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gsap": ["gsap@3.13.0", "", {}, "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw=="],
|
||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
"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=="],
|
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||||
@ -194,6 +213,8 @@
|
|||||||
|
|
||||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||||
|
|
||||||
|
"meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
"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=="],
|
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||||
@ -256,6 +277,8 @@
|
|||||||
|
|
||||||
"sync-message-port": ["sync-message-port@1.1.3", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="],
|
"sync-message-port": ["sync-message-port@1.1.3", "", {}, "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg=="],
|
||||||
|
|
||||||
|
"three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
"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=="],
|
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
|
||||||
|
|||||||
@ -12,9 +12,12 @@
|
|||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13",
|
||||||
|
"gsap": "^3.12.5",
|
||||||
|
"three": "^0.170.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.180.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"sass-embedded": "^1.86.0",
|
"sass-embedded": "^1.86.0",
|
||||||
@ -22,4 +25,4 @@
|
|||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0",
|
||||||
"vue-tsc": "^2.2.4"
|
"vue-tsc": "^2.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
15071
public/btc2504.csv
Normal file
15071
public/btc2504.csv
Normal file
File diff suppressed because it is too large
Load Diff
6336
public/btc2507.csv
Normal file
6336
public/btc2507.csv
Normal file
File diff suppressed because it is too large
Load Diff
268654
public/btc_15m_data_2018_to_2025 (1).csv
Normal file
268654
public/btc_15m_data_2018_to_2025 (1).csv
Normal file
File diff suppressed because it is too large
Load Diff
6336
public/eth2507.csv
Normal file
6336
public/eth2507.csv
Normal file
File diff suppressed because it is too large
Load Diff
281755
public/eth_15m_data_2017_to_2025 (1).csv
Normal file
281755
public/eth_15m_data_2017_to_2025 (1).csv
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/春庭雪.mp3
Normal file
BIN
public/春庭雪.mp3
Normal file
Binary file not shown.
549
src/App.vue
549
src/App.vue
@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted, nextTick, useTemplateRef } from 'vue';
|
||||||
import KLineChart from './components/KLineChart.vue'; // 引入之前创建的K线图组件
|
import KLineChart from './components/KLineChart.vue'; // 引入之前创建的K线图组件
|
||||||
import type { OrderLine, MartingaleState } from './martingale';
|
import type { OrderLine, MartingaleState } from './martingale';
|
||||||
import { simulateMartingale } from './martingale';
|
import { simulateMartingale } from './martingale';
|
||||||
import CashChart from './components/CashChart.vue';
|
import CashChart from './components/CashChart.vue';
|
||||||
|
import ThreeBackground from './components/ThreeBackground.vue';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
// 定义K线数据接口
|
// 定义K线数据接口
|
||||||
@ -14,6 +16,9 @@ interface KLineData {
|
|||||||
low: number; // 最低价
|
low: number; // 最低价
|
||||||
close: number; // 收盘价
|
close: number; // 收盘价
|
||||||
volume: number; // 成交量
|
volume: number; // 成交量
|
||||||
|
// 可选B/S点价格:若提供则在对应位置绘制标记(支持多个点)
|
||||||
|
buyPrice?: number[];
|
||||||
|
sellPrice?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
@ -36,6 +41,8 @@ interface KLineData {
|
|||||||
low: number;
|
low: number;
|
||||||
close: number;
|
close: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
buyPrice?: number[];
|
||||||
|
sellPrice?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// K线配置参数
|
// K线配置参数
|
||||||
@ -82,196 +89,233 @@ function updateTableData() {
|
|||||||
}
|
}
|
||||||
setInterval(updateTableData, 100);
|
setInterval(updateTableData, 100);
|
||||||
|
|
||||||
const strategyIndex = 0
|
const strategyIndex = 1
|
||||||
async function handleFileUpload(event: Event, config: KLineConfig = { periodsPerKLine: 96, maxDisplayedKLines: 10000 }) {
|
|
||||||
|
|
||||||
|
async function handleStart(
|
||||||
|
config: KLineConfig = { periodsPerKLine: 16, maxDisplayedKLines: 10000 }) {
|
||||||
|
|
||||||
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
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;
|
loading.value = true;
|
||||||
uploadStatus.value = '正在解析文件...';
|
uploadStatus.value = '正在解析文件...';
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
// 增量处理的状态变量
|
// 增量处理的状态变量
|
||||||
const aggregatedMap = new Map<string, KLineData>();
|
const aggregatedMap = new Map<string, KLineData>();
|
||||||
|
|
||||||
reader.onload = async (e) => {
|
const res = await fetch('./btc2504.csv')
|
||||||
const content = e.target?.result as string;
|
const content = await res.text()
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const dataLines = lines.slice(1); // 跳过标题行
|
const dataLines = lines.slice(1); // 跳过标题行
|
||||||
|
|
||||||
await wait(50)
|
await wait(50)
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
uploadStatus.value = '开始处理数据...';
|
uploadStatus.value = '开始处理数据...';
|
||||||
|
fileName.value = 'btc2504.csv'
|
||||||
|
|
||||||
simulation: for (let i = 0; i < dataLines.length; i++) {
|
simulation: for (let i = 0; i < dataLines.length; i++) {
|
||||||
/* await wait(100) */
|
/* await wait(100) */
|
||||||
const line = dataLines[i];
|
const line = dataLines[i];
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
const dataPoint = parseDataLine(line);
|
const dataPoint = parseDataLine(line);
|
||||||
if (!dataPoint) continue;
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
orders.value = simulateMartingale(state.value, {
|
orders.value = simulateMartingale(state.value, {
|
||||||
...strategy[strategyIndex],
|
...strategy[strategyIndex],
|
||||||
maxInvestment: strategy[strategyIndex].maxInvestment(cash.value),
|
maxInvestment: strategy[strategyIndex].maxInvestment(cash.value),
|
||||||
currentPrice: dataPoint.close
|
currentPrice: dataPoint.open
|
||||||
})
|
})
|
||||||
|
|
||||||
if (i % 8 == 0)
|
|
||||||
do { await wait(1) }
|
|
||||||
while (pause.value)
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
await wait(2000)
|
|
||||||
endSimulation.value = true
|
|
||||||
|
|
||||||
};
|
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 ?? '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
let bsChanged = false;
|
||||||
|
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`);
|
||||||
|
|
||||||
|
// 记录B点到当前周期K线
|
||||||
|
const minutesPerPeriod = 15 * config.periodsPerKLine;
|
||||||
|
const d = new Date(dataPoint.time);
|
||||||
|
const totalMinutesInDay = d.getHours() * 60 + d.getMinutes();
|
||||||
|
const periodIndex = Math.floor(totalMinutesInDay / minutesPerPeriod);
|
||||||
|
const periodStartMinutes = periodIndex * minutesPerPeriod;
|
||||||
|
const periodStartHours = Math.floor(periodStartMinutes / 60);
|
||||||
|
const periodStartMins = periodStartMinutes % 60;
|
||||||
|
const periodStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), periodStartHours, periodStartMins, 0, 0);
|
||||||
|
const periodKey = periodStart.getTime().toString();
|
||||||
|
const agg = aggregatedMap.get(periodKey);
|
||||||
|
if (agg) {
|
||||||
|
if (!agg.buyPrice) agg.buyPrice = [];
|
||||||
|
agg.buyPrice.push(order.price);
|
||||||
|
bsChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)})`);
|
||||||
|
|
||||||
|
// 记录S点到当前周期K线
|
||||||
|
const minutesPerPeriod = 15 * config.periodsPerKLine;
|
||||||
|
const d = new Date(dataPoint.time);
|
||||||
|
const totalMinutesInDay = d.getHours() * 60 + d.getMinutes();
|
||||||
|
const periodIndex = Math.floor(totalMinutesInDay / minutesPerPeriod);
|
||||||
|
const periodStartMinutes = periodIndex * minutesPerPeriod;
|
||||||
|
const periodStartHours = Math.floor(periodStartMinutes / 60);
|
||||||
|
const periodStartMins = periodStartMinutes % 60;
|
||||||
|
const periodStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), periodStartHours, periodStartMins, 0, 0);
|
||||||
|
const periodKey = periodStart.getTime().toString();
|
||||||
|
const agg = aggregatedMap.get(periodKey);
|
||||||
|
if (agg) {
|
||||||
|
if (!agg.sellPrice) agg.sellPrice = [];
|
||||||
|
agg.sellPrice.push(order.price);
|
||||||
|
bsChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若有B/S标记变更,刷新可视数据以触发渲染
|
||||||
|
if (bsChanged) {
|
||||||
|
const sortedDataAfterBS = Array.from(aggregatedMap.values()).sort((a, b) => a.time - b.time);
|
||||||
|
klineData.value = sortedDataAfterBS.slice(
|
||||||
|
Math.max(0, sortedDataAfterBS.length - config.maxDisplayedKLines)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 % 2 == 0) */
|
||||||
|
do { await wait(1) }
|
||||||
|
while (pause.value)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
await wait(2000)
|
||||||
|
endSimulation.value = true
|
||||||
|
|
||||||
reader.onerror = () => {
|
|
||||||
loading.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
handleStart()
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
|
||||||
|
const bgm = useTemplateRef('bgm-audio')
|
||||||
|
setTimeout(() => {
|
||||||
|
bgm.value?.play()
|
||||||
|
}, 1210);
|
||||||
|
|
||||||
// 解析单行数据
|
// 解析单行数据
|
||||||
function parseDataLine(line: string): KLineData | null {
|
function parseDataLine(line: string): KLineData | null {
|
||||||
const values = line.split(',');
|
const values = line.split(',');
|
||||||
@ -386,17 +430,52 @@ const strategy = [
|
|||||||
amountMultiplier: 1.05, // 加仓金额倍数
|
amountMultiplier: 1.05, // 加仓金额倍数
|
||||||
maxInvestment: (cash: number) => Math.min(cash, Math.max(cashEachTime, cash * .44))
|
maxInvestment: (cash: number) => Math.min(cash, Math.max(cashEachTime, cash * .44))
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '现货马丁格尔 稳健',
|
||||||
|
dropPercentage: 0.71, // 跌多少加仓
|
||||||
|
takeProfitPercentage: 3.5, // 单周期止盈目标
|
||||||
|
maxOrders: 8, // 最大加仓次数
|
||||||
|
initialRatio: 1 / 0.94, // 初始加仓比例
|
||||||
|
priceMultiplier: 1, // 加仓价差倍数
|
||||||
|
amountMultiplier: 1.05, // 加仓金额倍数
|
||||||
|
maxInvestment: (cash: number) => cash
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '现货马丁格尔 稳健 ETH',
|
||||||
|
dropPercentage: 2.41, // 跌多少加仓
|
||||||
|
takeProfitPercentage: 1, // 单周期止盈目标
|
||||||
|
maxOrders: 5, // 最大加仓次数
|
||||||
|
initialRatio: 1 / 1.63, // 初始加仓比例
|
||||||
|
priceMultiplier: 1, // 加仓价差倍数
|
||||||
|
amountMultiplier: 1.05, // 加仓金额倍数
|
||||||
|
maxInvestment: (cash: number) => cash
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const waterMark = "Douyin @feie9454"
|
const waterMark = "小红书 @95241884525"
|
||||||
|
|
||||||
|
// 入场动画
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } });
|
||||||
|
tl.delay(0.92)
|
||||||
|
tl.from('.header', { y: -40, opacity: 0, duration: .8 });
|
||||||
|
tl.from('.file-upload-container h1', { y: 40, opacity: 0, stagger: 0.35, duration: .65 }, '-=0.1');
|
||||||
|
tl.from('.file-upload-container h2, .file-upload-container h3, .file-upload-container h4', { y: 32, opacity: 0, stagger: 0.25, duration: .55 }, '-=0.2');
|
||||||
|
tl.from('.file-upload-container table', { y: 24, opacity: 0, duration: .6 }, '-=0.3');
|
||||||
|
tl.from('.watermark .item', { opacity: 0, rotate: '-30deg', stagger: 0.3, duration: 1.2, ease: 'power2.out' }, '-=0.8');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<audio src="./春庭雪.mp3" loop ref="bgm-audio" volume="0.5"></audio>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
<ThreeBackground />
|
||||||
<div class="watermark">
|
<div class="watermark">
|
||||||
<div class="item">{{ waterMark }}</div>
|
<div class="item">{{ waterMark }}</div>
|
||||||
<div class="item">{{ waterMark }}</div>
|
<div class="item">{{ waterMark }}</div>
|
||||||
@ -406,33 +485,27 @@ const waterMark = "Douyin @feie9454"
|
|||||||
</div>
|
</div>
|
||||||
<div class="header" @click="pause = !pause">
|
<div class="header" @click="pause = !pause">
|
||||||
<img src="./assets/btc20230419112752.webp" alt="">
|
<img src="./assets/btc20230419112752.webp" alt="">
|
||||||
<span>BTC/USDT (Binance) - 合约马丁格尔策略 <span style="color: #f9a825;">3
|
<span>BTC/USDT - 现货马丁格尔策略 回测</span>
|
||||||
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">
|
<!-- <span>BTC/USDT (Binance) - 合约马丁格尔策略 <span style="color: #f9a825;">3
|
||||||
{{ uploadStatus }}
|
X</span></span> -->
|
||||||
</div>
|
|
||||||
<h1> 2019年 <span :style="{ color: '#25a750' }">{{ cashEachTime }}
|
<!-- <img src="./assets/eth20230419112854.webp" alt="">
|
||||||
|
<span>ETH/USDT - 现货马丁格尔策略 回测</span> -->
|
||||||
|
</div>
|
||||||
|
<div class="file-upload-container intro" v-if="!fileName">
|
||||||
|
<!-- <h1> 2025年4月 <span :style="{ color: '#25a750' }">{{ cashEachTime }}
|
||||||
USDT</span><br> 投资
|
USDT</span><br> 投资
|
||||||
BTC 合约马丁格尔策略 <span style="color: #f9a825;">3 X</span> </h1>
|
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> -->
|
<h1>能跑赢现货吗?</h1> -->
|
||||||
|
<h1> 2025年4月 <span :style="{ color: '#25a750' }">{{ cashEachTime }}
|
||||||
|
USDT</span><span style="font-size: x-small;">(72034 RMB)</span><br> 投资
|
||||||
|
BTC 现货马丁格尔策略 </h1>
|
||||||
|
<h1>能跑赢现货吗?</h1>
|
||||||
|
|
||||||
<!-- <h2>每次<span :style="{ color: '#ca3f64' }">归零</span>,重新追加 <span
|
<!-- <h2>每次<span :style="{ color: '#ca3f64' }">归零</span>,重新追加 <span
|
||||||
:style="{ color: '#25a750' }">{{ cashEachTime }} USDT</span></h2> -->
|
:style="{ color: '#25a750' }">{{ cashEachTime }} USDT</span></h2> -->
|
||||||
<h2>
|
<!-- <h2>
|
||||||
<span style="">单周期最小 /
|
<span style="">单周期最小 /
|
||||||
最大投入</span><br>
|
最大投入</span><br>
|
||||||
<span :style="{ color: '#25a750' }">10000 USDT</span> / <span
|
<span :style="{ color: '#25a750' }">10000 USDT</span> / <span
|
||||||
@ -440,8 +513,8 @@ const waterMark = "Douyin @feie9454"
|
|||||||
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<h2>能稳定复利吗?</h2>
|
<h2>能稳定复利吗?</h2> -->
|
||||||
<h3>截至:2025 年 3 月 30 日 8:00 UTC+8</h3>
|
<h3>截至:2025 年 9 月 6 日 8:00 UTC+8</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -481,7 +554,7 @@ const waterMark = "Douyin @feie9454"
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h3>策略来源:欧易OKX 长期稳健投资</h3>
|
<h3 class="subtitle">策略来源:欧易OKX 长期稳健投资</h3>
|
||||||
<h4 style="margin-top: 32px;">
|
<h4 style="margin-top: 32px;">
|
||||||
仅为金融策略介绍与分析,不包含主观策略评价,<br>不构成任何投资建议。<br><br>参与加密货币交易时,请遵守当地法律法规。</h4>
|
仅为金融策略介绍与分析,不包含主观策略评价,<br>不构成任何投资建议。<br><br>参与加密货币交易时,请遵守当地法律法规。</h4>
|
||||||
</div>
|
</div>
|
||||||
@ -514,10 +587,14 @@ const waterMark = "Douyin @feie9454"
|
|||||||
false)
|
false)
|
||||||
}} USDT</div>
|
}} USDT</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
<div class="item">
|
<!-- <div class="item">
|
||||||
<div class="title">持仓量</div>
|
<div class="title">持仓量</div>
|
||||||
<div class="value">
|
<div class="value">
|
||||||
{{ fmtNum(dTD.持仓量USDT, 2, false) }} USDT</div>
|
{{ fmtNum(dTD.持仓量USDT, 2, false) }} USDT</div>
|
||||||
|
</div> -->
|
||||||
|
<div class="item">
|
||||||
|
<div class="title">持仓量</div>
|
||||||
|
<div class="value">{{ dTD.持仓量.toFixed(5) }} BTC</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="title">浮动收益</div>
|
<div class="title">浮动收益</div>
|
||||||
@ -532,24 +609,21 @@ const waterMark = "Douyin @feie9454"
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- <div class="item">
|
|
||||||
<div class="title">持仓量</div>
|
|
||||||
<div class="value">{{ dTD.持仓量.toFixed(5) }} BTC</div>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="title">已加仓次数</div>
|
<div class="title">已加仓次数</div>
|
||||||
<div class="value">{{ dTD.已加仓次数 }}</div>
|
<div class="value">{{ dTD.已加仓次数 }}</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="item">
|
<div class="item">
|
||||||
<div class="title">止盈价格</div>
|
<div class="title">止盈价格</div>
|
||||||
<div class="value">{{ dTD.止盈价格 == '-' ? '-' : fmtNum(dTD.止盈价格, 1, false)
|
<div class="value">{{ dTD.止盈价格 == '-' ? '-' : fmtNum(dTD.止盈价格, 1, false)
|
||||||
}} USDT</div>
|
}} USDT</div>
|
||||||
</div> -->
|
</div>
|
||||||
<div class="item">
|
<!-- <div class="item">
|
||||||
<div class="title">已爆仓次数</div>
|
<div class="title">已爆仓次数</div>
|
||||||
<div class="value">{{ dTD.已爆仓次数 }}</div>
|
<div class="value">{{ dTD.已爆仓次数 }}</div>
|
||||||
</div>
|
</div> -->
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<div class="title">已完成周期</div>
|
<div class="title">已完成周期</div>
|
||||||
<div class="value">{{ dTD.已完成周期 }}</div>
|
<div class="value">{{ dTD.已完成周期 }}</div>
|
||||||
@ -561,6 +635,17 @@ const waterMark = "Douyin @feie9454"
|
|||||||
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.app:before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(circle at 55% 45%, rgba(37, 167, 80, 0.05), transparent 60%),
|
||||||
|
linear-gradient(120deg, rgba(0, 0, 0, 0.65), rgba(0, 0, 0, 0.2) 35%, rgba(0, 0, 0, 0.85));
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.watermark {
|
.watermark {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
padding: 10vh 0;
|
padding: 10vh 0;
|
||||||
@ -581,8 +666,10 @@ const waterMark = "Douyin @feie9454"
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
transform: rotate(-30deg);
|
transform: rotate(-30deg) translateY(0);
|
||||||
|
letter-spacing: .08em;
|
||||||
|
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -620,7 +707,9 @@ tr:nth-child(even) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: #0c0c0c;
|
background-color: #030303;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@ -631,11 +720,17 @@ tr:nth-child(even) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: large;
|
font-size: large;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 0 8px 4px;
|
||||||
|
background: linear-gradient(90deg, rgba(3, 3, 3, 0.9), rgba(3, 3, 3, 0));
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
border-bottom: 1px solid #121212;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.6));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -644,6 +739,35 @@ tr:nth-child(even) {
|
|||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
padding: 16px 24px 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(10, 10, 10, 0.42);
|
||||||
|
box-shadow: 0 0 0 1px #121212, 0 8px 26px -8px rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(12px) saturate(140%);
|
||||||
|
max-width: 980px;
|
||||||
|
margin-inline: auto;
|
||||||
|
transform-origin: center top;
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-upload-label {
|
.file-upload-label {
|
||||||
@ -674,6 +798,11 @@ tr:nth-child(even) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 7;
|
flex: 7;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(10, 10, 10, 0.42);
|
||||||
|
box-shadow: 0 0 0 1px #121212, 0 8px 26px -8px rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px) saturate(140%);
|
||||||
|
border: 1px solid #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data-message,
|
.no-data-message,
|
||||||
@ -695,6 +824,11 @@ tr:nth-child(even) {
|
|||||||
grid-template-rows: repeat(2, 1fr);
|
grid-template-rows: repeat(2, 1fr);
|
||||||
padding: 16px 8px 24px;
|
padding: 16px 8px 24px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgba(10, 10, 10, 0.42);
|
||||||
|
box-shadow: 0 0 0 1px #121212, 0 8px 26px -8px rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px) saturate(140%);
|
||||||
|
border: 1px solid #333;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
color: #909090;
|
color: #909090;
|
||||||
@ -718,6 +852,9 @@ tr:nth-child(even) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
BIN
src/assets/eth20230419112854.webp
Normal file
BIN
src/assets/eth20230419112854.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chart-container" ref="chartContainer">
|
<div class="chart-container-inner" ref="chartContainer">
|
||||||
<canvas ref="chartCanvas" @mousemove="handleMouseMove"
|
<canvas ref="chartCanvas" @mousemove="handleMouseMove"
|
||||||
@mouseleave="hideTooltip"></canvas>
|
@mouseleave="hideTooltip"></canvas>
|
||||||
<div v-if="tooltip.show" class="tooltip" :style="{
|
<div v-if="tooltip.show" class="tooltip" :style="{
|
||||||
@ -31,6 +31,11 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
trendThreshold: 15.0, // 默认15%阈值,高于这个值才认为是趋势转
|
trendThreshold: 15.0, // 默认15%阈值,高于这个值才认为是趋势转
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const benchmarkAmounts = computed(() => {
|
||||||
|
const scaleRadio = props.fundAmounts[0] / props.benchmarkAmounts[0];
|
||||||
|
return props.benchmarkAmounts.map(v => v * scaleRadio);
|
||||||
|
})
|
||||||
|
|
||||||
// DOM引用
|
// DOM引用
|
||||||
const chartContainer = ref<HTMLDivElement | null>(null);
|
const chartContainer = ref<HTMLDivElement | null>(null);
|
||||||
const chartCanvas = ref<HTMLCanvasElement | null>(null);
|
const chartCanvas = ref<HTMLCanvasElement | null>(null);
|
||||||
@ -209,49 +214,40 @@ const sampledData = computed(() => {
|
|||||||
return result.sort((a, b) => a.originalIndex - b.originalIndex);
|
return result.sort((a, b) => a.originalIndex - b.originalIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 计算基准数据的采样
|
// 计算基准数据的采样(与策略采样索引对齐,避免折线不连贯)
|
||||||
const sampledBenchmarkData = computed(() => {
|
const sampledBenchmarkData = computed(() => {
|
||||||
const data = props.benchmarkAmounts;
|
const data = benchmarkAmounts.value;
|
||||||
if (!data || data.length === 0) return [];
|
if (!data || data.length === 0) return [];
|
||||||
|
|
||||||
|
// 若与策略数据长度一致,则直接按策略采样的原始索引取值,保证两条线对齐
|
||||||
|
if (data.length === props.fundAmounts.length && sampledData.value.length > 0) {
|
||||||
|
return sampledData.value.map(p => ({ value: data[p.originalIndex], originalIndex: p.originalIndex }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则退化为与策略相同的简化采样逻辑
|
||||||
if (data.length <= props.maxDataPoints) {
|
if (data.length <= props.maxDataPoints) {
|
||||||
// 数据量不大,直接返回原始数据
|
|
||||||
return data.map((value, index) => ({ value, originalIndex: index }));
|
return data.map((value, index) => ({ value, originalIndex: index }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 采样算法 - 使用相同的采样逻辑,确保与策略数据点对齐
|
|
||||||
const result: { value: number, originalIndex: number }[] = [];
|
const result: { value: number, originalIndex: number }[] = [];
|
||||||
const sampleSize = props.maxDataPoints;
|
const sampleSize = props.maxDataPoints;
|
||||||
const bucketSize = data.length / sampleSize;
|
const bucketSize = data.length / sampleSize;
|
||||||
|
|
||||||
// 总是保留第一个点
|
|
||||||
result.push({ value: data[0], originalIndex: 0 });
|
result.push({ value: data[0], originalIndex: 0 });
|
||||||
|
|
||||||
// 处理中间部分
|
|
||||||
for (let i = 1; i < sampleSize - 1; i++) {
|
for (let i = 1; i < sampleSize - 1; i++) {
|
||||||
const bucketStart = Math.floor(i * bucketSize);
|
const bucketStart = Math.floor(i * bucketSize);
|
||||||
const bucketEnd = Math.floor((i + 1) * bucketSize);
|
const bucketEnd = Math.floor((i + 1) * bucketSize);
|
||||||
|
|
||||||
// 在每个桶中找到最高和最低的点
|
|
||||||
let maxVal = data[bucketStart];
|
let maxVal = data[bucketStart];
|
||||||
let minVal = data[bucketStart];
|
let minVal = data[bucketStart];
|
||||||
let maxIdx = bucketStart;
|
let maxIdx = bucketStart;
|
||||||
let minIdx = bucketStart;
|
let minIdx = bucketStart;
|
||||||
|
|
||||||
for (let j = bucketStart; j < bucketEnd; j++) {
|
for (let j = bucketStart; j < bucketEnd; j++) {
|
||||||
if (data[j] > maxVal) {
|
if (data[j] > maxVal) { maxVal = data[j]; maxIdx = j; }
|
||||||
maxVal = data[j];
|
if (data[j] < minVal) { minVal = data[j]; minIdx = j; }
|
||||||
maxIdx = j;
|
|
||||||
}
|
|
||||||
if (data[j] < minVal) {
|
|
||||||
minVal = data[j];
|
|
||||||
minIdx = j;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加最极端的点(保留波动)
|
|
||||||
if (maxIdx !== minIdx) {
|
if (maxIdx !== minIdx) {
|
||||||
// 按索引顺序添加
|
|
||||||
if (maxIdx < minIdx) {
|
if (maxIdx < minIdx) {
|
||||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||||
result.push({ value: minVal, originalIndex: minIdx });
|
result.push({ value: minVal, originalIndex: minIdx });
|
||||||
@ -260,15 +256,10 @@ const sampledBenchmarkData = computed(() => {
|
|||||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果最大值和最小值是同一个点,只添加一次
|
|
||||||
result.push({ value: maxVal, originalIndex: maxIdx });
|
result.push({ value: maxVal, originalIndex: maxIdx });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 总是保留最后一个点
|
|
||||||
result.push({ value: data[data.length - 1], originalIndex: data.length - 1 });
|
result.push({ value: data[data.length - 1], originalIndex: data.length - 1 });
|
||||||
|
|
||||||
// 按原始索引排序
|
|
||||||
return result.sort((a, b) => a.originalIndex - b.originalIndex);
|
return result.sort((a, b) => a.originalIndex - b.originalIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -307,7 +298,7 @@ const handleMouseMove = (event: MouseEvent) => {
|
|||||||
tooltip.isBenchmark = true;
|
tooltip.isBenchmark = true;
|
||||||
} else {
|
} else {
|
||||||
// 当前点是策略点,且有基准数据
|
// 当前点是策略点,且有基准数据
|
||||||
const benchmarkValue = props.benchmarkAmounts[index] || 0;
|
const benchmarkValue = benchmarkAmounts.value[index] || 0;
|
||||||
tooltip.text = `策略: ${closestPoint.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}\n基准: ${benchmarkValue.toLocaleString(undefined, { maximumFractionDigits: 2 })} (${Math.floor(index * 15)}分钟)`;
|
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.color = closestPoint.trendUp ? COLORS.UP : COLORS.DOWN;
|
||||||
tooltip.isBenchmark = false;
|
tooltip.isBenchmark = false;
|
||||||
@ -356,9 +347,9 @@ const drawChart = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果有基准数据,也考虑其最小和最大值
|
// 如果有基准数据,也考虑其最小和最大值
|
||||||
for (let i = 0; i < props.benchmarkAmounts.length; i++) {
|
for (let i = 0; i < benchmarkAmounts.value.length; i++) {
|
||||||
if (props.benchmarkAmounts[i] < minValue) minValue = props.benchmarkAmounts[i];
|
if (benchmarkAmounts.value[i] < minValue) minValue = benchmarkAmounts.value[i];
|
||||||
if (props.benchmarkAmounts[i] > maxValue) maxValue = props.benchmarkAmounts[i];
|
if (benchmarkAmounts.value[i] > maxValue) maxValue = benchmarkAmounts.value[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -454,78 +445,55 @@ const drawChart = () => {
|
|||||||
ctx.fillText('基准', legendX + 20, legendY + 15);
|
ctx.fillText('基准', legendX + 20, legendY + 15);
|
||||||
|
|
||||||
|
|
||||||
// 获取趋势分析结果
|
// 绘制策略曲线(去掉趋势分段,使用连续线条)
|
||||||
const { trendSegments } = trendAnalysis.value;
|
|
||||||
const sampleData = sampledData.value;
|
const sampleData = sampledData.value;
|
||||||
|
if (sampleData.length >= 2) {
|
||||||
// 为每个数据点确定它所属的趋势
|
|
||||||
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();
|
ctx.beginPath();
|
||||||
|
|
||||||
// 移动到第一个点
|
const first = sampleData[0];
|
||||||
const firstPoint = segmentPoints[0];
|
const firstX = scaleX(first.originalIndex);
|
||||||
const firstX = scaleX(firstPoint.originalIndex);
|
const firstY = scaleY(first.value);
|
||||||
const firstY = scaleY(firstPoint.value);
|
|
||||||
ctx.moveTo(firstX, firstY);
|
ctx.moveTo(firstX, firstY);
|
||||||
|
|
||||||
// 存储第一个点用于交互
|
// 先不推入第一个点,等到第二个点时确定初始趋势
|
||||||
chartData.dataPoints.push({
|
let prevVal = first.value;
|
||||||
x: firstX,
|
let pushedFirst = false;
|
||||||
y: firstY,
|
|
||||||
value: firstPoint.value,
|
|
||||||
originalIndex: firstPoint.originalIndex,
|
|
||||||
trendUp: isUp
|
|
||||||
});
|
|
||||||
|
|
||||||
// 绘制所有线段
|
let prevIndex = first.originalIndex;
|
||||||
for (let j = 1; j < segmentPoints.length; j++) {
|
for (let j = 1; j < sampleData.length; j++) {
|
||||||
const point = segmentPoints[j];
|
const p = sampleData[j];
|
||||||
const x = scaleX(point.originalIndex);
|
if (p.originalIndex === prevIndex) continue; // 跳过重复索引,避免竖线/断续
|
||||||
const y = scaleY(point.value);
|
const x = scaleX(p.originalIndex);
|
||||||
|
const y = scaleY(p.value);
|
||||||
|
const trendUp = p.value >= prevVal;
|
||||||
|
|
||||||
|
if (!pushedFirst) {
|
||||||
|
chartData.dataPoints.push({
|
||||||
|
x: firstX,
|
||||||
|
y: firstY,
|
||||||
|
value: first.value,
|
||||||
|
originalIndex: first.originalIndex,
|
||||||
|
trendUp
|
||||||
|
});
|
||||||
|
pushedFirst = true;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.lineTo(x, y);
|
ctx.lineTo(x, y);
|
||||||
|
|
||||||
// 存储数据点用于交互
|
|
||||||
chartData.dataPoints.push({
|
chartData.dataPoints.push({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
value: point.value,
|
value: p.value,
|
||||||
originalIndex: point.originalIndex,
|
originalIndex: p.originalIndex,
|
||||||
trendUp: isUp
|
trendUp
|
||||||
});
|
});
|
||||||
|
|
||||||
|
prevVal = p.value;
|
||||||
|
prevIndex = p.originalIndex;
|
||||||
}
|
}
|
||||||
if (trendSegments[i + 1]) {
|
|
||||||
let point = sampleData.find(p => p.originalIndex >= trendSegments[i + 1].start);
|
ctx.strokeStyle = COLORS.UP;
|
||||||
if (point) {
|
ctx.lineWidth = 1.2;
|
||||||
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();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -553,8 +521,10 @@ const drawChart = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 绘制所有线段
|
// 绘制所有线段
|
||||||
|
let prevIndex = firstPoint.originalIndex;
|
||||||
for (let j = 1; j < benchmarkData.length; j++) {
|
for (let j = 1; j < benchmarkData.length; j++) {
|
||||||
const point = benchmarkData[j];
|
const point = benchmarkData[j];
|
||||||
|
if (point.originalIndex === prevIndex) continue; // 跳过重复索引
|
||||||
const x = scaleX(point.originalIndex);
|
const x = scaleX(point.originalIndex);
|
||||||
const y = scaleY(point.value);
|
const y = scaleY(point.value);
|
||||||
|
|
||||||
@ -569,6 +539,7 @@ const drawChart = () => {
|
|||||||
trendUp: true,
|
trendUp: true,
|
||||||
isBenchmark: true
|
isBenchmark: true
|
||||||
});
|
});
|
||||||
|
prevIndex = point.originalIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置线条样式并绘制
|
// 设置线条样式并绘制
|
||||||
@ -581,11 +552,11 @@ const drawChart = () => {
|
|||||||
|
|
||||||
|
|
||||||
// 计算并绘制统计数据
|
// 计算并绘制统计数据
|
||||||
drawStatistics(ctx, canvas.width, margin);
|
drawStatistics(ctx, margin);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 绘制关键统计信息
|
// 绘制关键统计信息
|
||||||
const drawStatistics = (ctx: CanvasRenderingContext2D, width: number, margin: { top: number, right: number, bottom: number, left: number }) => {
|
const drawStatistics = (ctx: CanvasRenderingContext2D, margin: { top: number, right: number, bottom: number, left: number }) => {
|
||||||
if (props.fundAmounts.length < 2) return;
|
if (props.fundAmounts.length < 2) return;
|
||||||
|
|
||||||
// 计算百分比变化
|
// 计算百分比变化
|
||||||
@ -622,32 +593,41 @@ const drawStatistics = (ctx: CanvasRenderingContext2D, width: number, margin: {
|
|||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.font = '14px PingFang SC';
|
ctx.font = '14px PingFang SC';
|
||||||
|
|
||||||
|
const left = 200//530
|
||||||
|
|
||||||
// 當前值
|
// 當前值
|
||||||
ctx.fillStyle = COLORS.PRIMARY_TEXT;
|
ctx.fillStyle = COLORS.PRIMARY_TEXT;
|
||||||
ctx.fillText(`當前: ${currentValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}`, 200, margin.top);
|
ctx.fillText(`当前: ${currentValue.toLocaleString(undefined, { maximumFractionDigits: 2 })}`, 200, margin.top);
|
||||||
|
|
||||||
// 百分比變化
|
// 百分比變化
|
||||||
ctx.fillStyle = percentChange >= 0 ? COLORS.UP : COLORS.DOWN;
|
ctx.fillStyle = percentChange >= 0 ? COLORS.UP : COLORS.DOWN;
|
||||||
ctx.fillText(`總收益: ${percentChange.toFixed(2)}%`, 200, margin.top + 20);
|
ctx.fillText(`总收益: ${percentChange.toFixed(2)}%`, left, margin.top + 30);
|
||||||
|
|
||||||
// 年化收益率
|
// 年化收益率
|
||||||
ctx.fillStyle = annualizedReturn >= 0 ? COLORS.UP : COLORS.DOWN;
|
ctx.fillStyle = annualizedReturn >= 0 ? COLORS.UP : COLORS.DOWN;
|
||||||
ctx.fillText(`年化: ${annualizedReturn.toFixed(2)}%`, 200, margin.top + 40);
|
ctx.fillText(`年化: ${annualizedReturn.toFixed(2)}%`, left, margin.top + 50);
|
||||||
|
|
||||||
// 最大回撤
|
// 最大回撤
|
||||||
ctx.fillStyle = COLORS.DOWN;
|
ctx.fillStyle = COLORS.DOWN;
|
||||||
ctx.fillText(`最大回撤: ${maxDrawdown.toFixed(2)}%`, 200, margin.top + 60);
|
ctx.fillText(`最大回撤: ${maxDrawdown.toFixed(2)}%`, left, margin.top + 70);
|
||||||
|
|
||||||
let alz = analyzeInvestment(props.fundAmounts);
|
const initBenchmarkValue = benchmarkAmounts.value[0];
|
||||||
let winRate = alz.winRate;
|
const currentBenchmarkValue = benchmarkAmounts.value[benchmarkAmounts.value.length - 1];
|
||||||
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 benchmarkPercentChange = ((currentBenchmarkValue - initBenchmarkValue) / initBenchmarkValue) * 100;
|
||||||
const delta = (percentChange - benchmarkPercentChange) / benchmarkPercentChange * 100;
|
|
||||||
|
// 使用相对超额收益: (1+Rs)/(1+Rb) - 1,数值更稳定
|
||||||
|
const stratMultiple = initialValue !== 0 ? currentValue / initialValue : 1;
|
||||||
|
const benchMultiple = initBenchmarkValue !== 0 ? currentBenchmarkValue / initBenchmarkValue : 1;
|
||||||
|
let delta = 0;
|
||||||
|
if (Math.abs(benchMultiple) > 1e-9) {
|
||||||
|
delta = (stratMultiple / benchMultiple - 1) * 100;
|
||||||
|
} else {
|
||||||
|
// 退化处理:当基准几乎为0倍时,使用百分比点差
|
||||||
|
delta = percentChange - benchmarkPercentChange;
|
||||||
|
}
|
||||||
|
|
||||||
ctx.fillStyle = delta >= 0 ? COLORS.UP : COLORS.DOWN;
|
ctx.fillStyle = delta >= 0 ? COLORS.UP : COLORS.DOWN;
|
||||||
ctx.fillText(`跑贏現貨: ${delta.toFixed(2)}%`, 200, margin.top + 100);
|
ctx.fillText(`超额收益: ${delta.toFixed(2)}%`, left, margin.top + 90);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 组件挂载时初始化图表和监听器
|
// 组件挂载时初始化图表和监听器
|
||||||
@ -684,7 +664,7 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chart-container {
|
.chart-container-inner {
|
||||||
font-family: PingFang SC, sans-serif;
|
font-family: PingFang SC, sans-serif;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -2,6 +2,12 @@
|
|||||||
import { ref, onMounted, reactive, computed, watch, onUnmounted } from 'vue';
|
import { ref, onMounted, reactive, computed, watch, onUnmounted } from 'vue';
|
||||||
import type { OrderLine } from '../martingale';
|
import type { OrderLine } from '../martingale';
|
||||||
|
|
||||||
|
// new okx
|
||||||
|
const color = {
|
||||||
|
green: '#25A750',
|
||||||
|
red: '#CA3F64'
|
||||||
|
};
|
||||||
|
|
||||||
// 定义K线数据接口
|
// 定义K线数据接口
|
||||||
interface KLineData {
|
interface KLineData {
|
||||||
time: number; // 时间
|
time: number; // 时间
|
||||||
@ -10,6 +16,9 @@ interface KLineData {
|
|||||||
low: number; // 最低价
|
low: number; // 最低价
|
||||||
close: number; // 收盘价
|
close: number; // 收盘价
|
||||||
volume: number; // 成交量
|
volume: number; // 成交量
|
||||||
|
// 可选B/S点价格:若提供则在对应位置绘制标记(支持多个点)
|
||||||
|
buyPrice?: number[];
|
||||||
|
sellPrice?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义平均持仓成本接口
|
// 定义平均持仓成本接口
|
||||||
@ -66,6 +75,10 @@ const chartState = reactive({
|
|||||||
selectedIndex: -1,
|
selectedIndex: -1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 标签绘制常量(保持统一)
|
||||||
|
const TAG_GAP = 4; // 点与标签的间距
|
||||||
|
const TAG_TIP = 5; // 标签三角指向的高度
|
||||||
|
|
||||||
// 鼠标悬停提示
|
// 鼠标悬停提示
|
||||||
const tooltip = reactive({
|
const tooltip = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
@ -219,6 +232,21 @@ function render() {
|
|||||||
chartState.priceMin = Math.min(...visibleData.map(item => item.low));
|
chartState.priceMin = Math.min(...visibleData.map(item => item.low));
|
||||||
chartState.volumeMax = Math.max(...visibleData.map(item => item.volume));
|
chartState.volumeMax = Math.max(...visibleData.map(item => item.volume));
|
||||||
|
|
||||||
|
// 考虑B/S点价格,确保也在视图范围内
|
||||||
|
const bsPrices: number[] = [];
|
||||||
|
for (const item of visibleData) {
|
||||||
|
if (Array.isArray(item.buyPrice)) {
|
||||||
|
bsPrices.push(...item.buyPrice);
|
||||||
|
}
|
||||||
|
if (Array.isArray(item.sellPrice)) {
|
||||||
|
bsPrices.push(...item.sellPrice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bsPrices.length > 0) {
|
||||||
|
chartState.priceMax = Math.max(chartState.priceMax, ...bsPrices);
|
||||||
|
chartState.priceMin = Math.min(chartState.priceMin, ...bsPrices);
|
||||||
|
}
|
||||||
|
|
||||||
// 考虑挂单价和持仓价格,确保它们也在视图范围内
|
// 考虑挂单价和持仓价格,确保它们也在视图范围内
|
||||||
if (props.orderLines && props.orderLines.length > 0) {
|
if (props.orderLines && props.orderLines.length > 0) {
|
||||||
const orderPrices = props.orderLines.map(order => order.price);
|
const orderPrices = props.orderLines.map(order => order.price);
|
||||||
@ -259,6 +287,9 @@ function render() {
|
|||||||
// 绘制成交量
|
// 绘制成交量
|
||||||
drawVolume(ctx, visibleData);
|
drawVolume(ctx, visibleData);
|
||||||
|
|
||||||
|
// 绘制B/S点
|
||||||
|
drawBSPoints(ctx, visibleData);
|
||||||
|
|
||||||
// 绘制挂单线
|
// 绘制挂单线
|
||||||
if (props.orderLines && props.orderLines.length > 0) {
|
if (props.orderLines && props.orderLines.length > 0) {
|
||||||
drawOrderLines(ctx);
|
drawOrderLines(ctx);
|
||||||
@ -351,8 +382,8 @@ function drawCandles(ctx: CanvasRenderingContext2D, data: KLineData[]) {
|
|||||||
|
|
||||||
// 是上涨还是下跌
|
// 是上涨还是下跌
|
||||||
const isRising = candle.close > candle.open;
|
const isRising = candle.close > candle.open;
|
||||||
ctx.fillStyle = isRising ? '#26A69A' : '#EF5350';
|
ctx.fillStyle = isRising ? color.green : color.red;
|
||||||
ctx.strokeStyle = isRising ? '#26A69A' : '#EF5350';
|
ctx.strokeStyle = isRising ? color.green : color.red;
|
||||||
|
|
||||||
// 绘制K线影线
|
// 绘制K线影线
|
||||||
ctx.lineWidth = 1
|
ctx.lineWidth = 1
|
||||||
@ -386,7 +417,9 @@ function drawVolume(ctx: CanvasRenderingContext2D, data: KLineData[]) {
|
|||||||
|
|
||||||
// 是上涨还是下跌
|
// 是上涨还是下跌
|
||||||
const isRising = candle.close > candle.open;
|
const isRising = candle.close > candle.open;
|
||||||
ctx.fillStyle = isRising ? 'rgba(38, 166, 154, 0.5)' : 'rgba(239, 83, 80, 0.5)';
|
|
||||||
|
const opacity = 0.5;
|
||||||
|
ctx.fillStyle = isRising ? `${color.green}${Math.round(opacity * 255).toString(16).padStart(2, '0')}` : `${color.red}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`;
|
||||||
|
|
||||||
// 绘制成交量柱
|
// 绘制成交量柱
|
||||||
const barWidth = chartState.barWidth;
|
const barWidth = chartState.barWidth;
|
||||||
@ -403,15 +436,16 @@ function drawOrderLines(ctx: CanvasRenderingContext2D) {
|
|||||||
|
|
||||||
// 确保线不超出视口
|
// 确保线不超出视口
|
||||||
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
|
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
|
||||||
|
let labelColor = color.green;
|
||||||
switch (order.type) {
|
switch (order.type) {
|
||||||
case 'BUY':
|
case 'BUY':
|
||||||
ctx.strokeStyle = ctx.fillStyle = '#26A69A';
|
ctx.strokeStyle = ctx.fillStyle = labelColor = color.green;
|
||||||
break;
|
break;
|
||||||
case 'SELL':
|
case 'SELL':
|
||||||
ctx.strokeStyle = ctx.fillStyle = '#EF5350';
|
ctx.strokeStyle = ctx.fillStyle = labelColor = color.red;
|
||||||
break;
|
break;
|
||||||
case 'LIQUIDATION':
|
case 'LIQUIDATION':
|
||||||
ctx.strokeStyle = ctx.fillStyle = '#ffb117';
|
ctx.strokeStyle = ctx.fillStyle = labelColor = '#ffb117';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
@ -426,12 +460,8 @@ function drawOrderLines(ctx: CanvasRenderingContext2D) {
|
|||||||
// 重置为实线
|
// 重置为实线
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
// 绘制价格标签
|
// 右侧五边形标签
|
||||||
ctx.font = '11px PingFang SC';
|
drawRightSideTag(ctx, y, order.price.toFixed(2), labelColor);
|
||||||
ctx.textAlign = 'left';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
|
|
||||||
ctx.fillText(order.price.toFixed(2), chartState.canvasWidth - chartState.paddingRight + 5, y);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,7 +475,7 @@ function drawPositionLine(ctx: CanvasRenderingContext2D) {
|
|||||||
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
|
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
|
||||||
|
|
||||||
// 设置线的样式 - 持仓线为绿色实线
|
// 设置线的样式 - 持仓线为绿色实线
|
||||||
ctx.strokeStyle = '#26A69A';
|
ctx.strokeStyle = color.green;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.setLineDash([]); // 确保是实线
|
ctx.setLineDash([]); // 确保是实线
|
||||||
|
|
||||||
@ -456,31 +486,202 @@ function drawPositionLine(ctx: CanvasRenderingContext2D) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// 绘制价格标签
|
// 绘制价格标签
|
||||||
ctx.font = '11px PingFang SC';
|
drawRightSideTag(ctx, y, props.position.avgCost.toFixed(2), color.green);
|
||||||
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) {
|
if (props.position.profitLoss !== undefined) {
|
||||||
const plColor = props.position.profitLoss >= 0 ? '#26A69A' : '#EF5350';
|
const plColor = props.position.profitLoss >= 0 ? color.green : color.red;
|
||||||
const plSign = props.position.profitLoss >= 0 ? '+' : '';
|
const plSign = props.position.profitLoss >= 0 ? '+' : '';
|
||||||
const plText = `${plSign}${props.position.profitLoss.toFixed(2)}`;
|
const plText = `${plSign}${props.position.profitLoss.toFixed(2)}`;
|
||||||
|
// 右侧纯文字(不使用背景),减少拥挤
|
||||||
ctx.fillStyle = plColor;
|
ctx.fillStyle = plColor;
|
||||||
ctx.font = '10px PingFang SC';
|
ctx.font = '10px PingFang SC';
|
||||||
ctx.fillText(plText, chartState.canvasWidth - chartState.paddingRight + 5, y + 12);
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const textX = chartState.canvasWidth - chartState.paddingRight + 5;
|
||||||
|
const y1 = Math.min(chartState.priceHeight - 8, y + 16);
|
||||||
|
ctx.fillText(plText, textX, y1);
|
||||||
|
|
||||||
// 如果有百分比,继续在下方显示
|
// 如果有百分比,继续在下方显示
|
||||||
if (props.position.profitLossPercent !== undefined) {
|
if (props.position.profitLossPercent !== undefined) {
|
||||||
const plPercentSign = props.position.profitLossPercent >= 0 ? '+' : '';
|
const plPercentSign = props.position.profitLossPercent >= 0 ? '+' : '';
|
||||||
const plPercentText = `(${plPercentSign}${props.position.profitLossPercent.toFixed(2)}%)`;
|
const plPercentText = `(${plPercentSign}${props.position.profitLossPercent.toFixed(2)}%)`;
|
||||||
ctx.fillText(plPercentText, chartState.canvasWidth - chartState.paddingRight + 5, y + 24);
|
const y2 = Math.min(chartState.priceHeight - 8, y + 27);
|
||||||
|
ctx.fillText(plPercentText, textX, y2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绘制B/S点
|
||||||
|
function drawBSPoints(ctx: CanvasRenderingContext2D, data: KLineData[]) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '10px PingFang SC';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
const candle = data[i];
|
||||||
|
const x = xForIndex(i);
|
||||||
|
const wickTop = yForPrice(candle.high);
|
||||||
|
const wickBottom = yForPrice(candle.low);
|
||||||
|
|
||||||
|
// Buy 点 - 支持多个价格点
|
||||||
|
if (Array.isArray(candle.buyPrice) && candle.buyPrice.length > 0) {
|
||||||
|
// 绘制所有买入价格的小圆点
|
||||||
|
for (const buyPrice of candle.buyPrice) {
|
||||||
|
const yB = yForPrice(buyPrice);
|
||||||
|
if (yB >= chartState.paddingTop && yB <= chartState.priceHeight) {
|
||||||
|
drawBSMarker(ctx, x, yB, color.green);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 只显示一个字母标签(放在K线下方)
|
||||||
|
const apexY = Math.min(chartState.priceHeight - 2, wickBottom + 2);
|
||||||
|
const yParam = apexY - (TAG_GAP + TAG_TIP);
|
||||||
|
drawCenteredTag(ctx, x, yParam, 'B', color.green, 'down');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sell 点 - 支持多个价格点
|
||||||
|
if (Array.isArray(candle.sellPrice) && candle.sellPrice.length > 0) {
|
||||||
|
// 绘制所有卖出价格的小圆点
|
||||||
|
for (const sellPrice of candle.sellPrice) {
|
||||||
|
const yS = yForPrice(sellPrice);
|
||||||
|
if (yS >= chartState.paddingTop && yS <= chartState.priceHeight) {
|
||||||
|
drawBSMarker(ctx, x, yS, color.red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 只显示一个字母标签(放在K线上方)
|
||||||
|
const apexY = Math.max(chartState.paddingTop + 2, wickTop - 2);
|
||||||
|
const yParam = apexY + TAG_GAP;
|
||||||
|
drawCenteredTag(ctx, x, yParam, 'S', color.red, 'up');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBSMarker(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: string
|
||||||
|
) {
|
||||||
|
// 小圆点(仅颜色小点,不带边框)
|
||||||
|
let r = 2.2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#FFF';
|
||||||
|
ctx.fill();
|
||||||
|
r -= 0.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制中心五边形标签(上/下指向)
|
||||||
|
function drawCenteredTag(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
text: string,
|
||||||
|
color: string,
|
||||||
|
direction: 'up' | 'down'
|
||||||
|
) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = 'bold 8px PingFang SC';
|
||||||
|
const rectW = 10; // 降低最小宽度
|
||||||
|
const rectH = 14; // 固定高度,适配字体
|
||||||
|
|
||||||
|
let rectX = x - rectW / 2;
|
||||||
|
let rectY: number;
|
||||||
|
if (direction === 'up') {
|
||||||
|
// 标签在点上方,三角朝下
|
||||||
|
rectY = y - TAG_GAP - TAG_TIP - rectH;
|
||||||
|
} else {
|
||||||
|
// 标签在点下方,三角朝上
|
||||||
|
rectY = y + TAG_GAP + TAG_TIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 绘制五边形
|
||||||
|
ctx.beginPath();
|
||||||
|
if (direction === 'up') {
|
||||||
|
// 上方矩形 + 朝下三角
|
||||||
|
ctx.moveTo(rectX, rectY);
|
||||||
|
ctx.lineTo(rectX + rectW, rectY);
|
||||||
|
ctx.lineTo(rectX + rectW, rectY + rectH);
|
||||||
|
ctx.lineTo(x, rectY + rectH + TAG_TIP);
|
||||||
|
ctx.lineTo(rectX, rectY + rectH);
|
||||||
|
} else {
|
||||||
|
// 下方矩形 + 朝上三角
|
||||||
|
ctx.moveTo(rectX, rectY + TAG_TIP);
|
||||||
|
ctx.lineTo(x, rectY);
|
||||||
|
ctx.lineTo(rectX + rectW, rectY + TAG_TIP);
|
||||||
|
ctx.lineTo(rectX + rectW, rectY + TAG_TIP + rectH);
|
||||||
|
ctx.lineTo(rectX, rectY + TAG_TIP + rectH);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 文本
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const textCX = rectX + rectW / 2;
|
||||||
|
const textCY = direction === 'up' ? rectY + rectH / 2 : rectY + TAG_TIP + rectH / 2;
|
||||||
|
ctx.fillText(text, textCX, textCY);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制右侧五边形标签(指向左侧,文本白色)
|
||||||
|
function drawRightSideTag(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
y: number,
|
||||||
|
text: string,
|
||||||
|
color: string
|
||||||
|
) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '11px PingFang SC';
|
||||||
|
const metrics = ctx.measureText(text);
|
||||||
|
const textWidth = metrics.width;
|
||||||
|
const padX = 6;
|
||||||
|
const rectH = 16;
|
||||||
|
const tip = 6;
|
||||||
|
const maxRectW = chartState.paddingRight - 12; // 留一点内边距
|
||||||
|
const rectW = Math.max(28, Math.min(maxRectW, textWidth + padX * 2));
|
||||||
|
|
||||||
|
const x0 = chartState.paddingLeft + (chartState.canvasWidth - chartState.paddingLeft - chartState.paddingRight); // 价格区右边界x
|
||||||
|
const rectX = x0 + 2; // 略微留白
|
||||||
|
const polyX = rectX + tip; // 矩形开始位置
|
||||||
|
|
||||||
|
let rectY = y - rectH / 2;
|
||||||
|
// 垂直夹紧
|
||||||
|
if (rectY < chartState.paddingTop + 2) rectY = chartState.paddingTop + 2;
|
||||||
|
if (rectY > chartState.priceHeight - rectH - 2) rectY = chartState.priceHeight - rectH - 2;
|
||||||
|
|
||||||
|
// 五边形路径(左指向)
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x0, y); // 指向点(靠价格区)
|
||||||
|
ctx.lineTo(polyX, rectY);
|
||||||
|
ctx.lineTo(polyX + rectW, rectY);
|
||||||
|
ctx.lineTo(polyX + rectW, rectY + rectH);
|
||||||
|
ctx.lineTo(polyX, rectY + rectH);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// 文本
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const cx = polyX + rectW / 2;
|
||||||
|
const cy = rectY + rectH / 2;
|
||||||
|
ctx.fillText(text, cx, cy);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
// 将价格转换为Y坐标
|
// 将价格转换为Y坐标
|
||||||
function yForPrice(price: number): number {
|
function yForPrice(price: number): number {
|
||||||
return chartState.paddingTop + (chartState.priceMax - price) * chartState.priceScale;
|
return chartState.paddingTop + (chartState.priceMax - price) * chartState.priceScale;
|
||||||
@ -575,7 +776,6 @@ function pad(num: number): string {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.kline-chart-container {
|
.kline-chart-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid #333;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@ -1,336 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,454 +0,0 @@
|
|||||||
<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>
|
|
||||||
157
src/components/ThreeBackground.vue
Normal file
157
src/components/ThreeBackground.vue
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onBeforeUnmount, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
Scene,
|
||||||
|
PerspectiveCamera,
|
||||||
|
WebGLRenderer,
|
||||||
|
BufferGeometry,
|
||||||
|
BufferAttribute,
|
||||||
|
PointsMaterial,
|
||||||
|
Color,
|
||||||
|
AdditiveBlending,
|
||||||
|
Points,
|
||||||
|
IcosahedronGeometry,
|
||||||
|
MeshBasicMaterial,
|
||||||
|
Mesh,
|
||||||
|
TorusGeometry,
|
||||||
|
Clock,
|
||||||
|
Group,
|
||||||
|
Fog,
|
||||||
|
SRGBColorSpace
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
const container = ref<HTMLDivElement | null>(null);
|
||||||
|
let renderer: any = null;
|
||||||
|
let scene: any;
|
||||||
|
let camera: any;
|
||||||
|
let animationId: number;
|
||||||
|
|
||||||
|
function createParticles(count = 900) {
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const r = 180 + Math.random() * 120;
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos((Math.random() * 2) - 1);
|
||||||
|
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||||
|
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||||
|
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||||
|
}
|
||||||
|
geometry.setAttribute('position', new BufferAttribute(positions, 3));
|
||||||
|
const material = new PointsMaterial({
|
||||||
|
size: 1.6,
|
||||||
|
sizeAttenuation: true,
|
||||||
|
color: new Color('#25a750'),
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.55,
|
||||||
|
blending: AdditiveBlending,
|
||||||
|
depthWrite: false
|
||||||
|
});
|
||||||
|
const points = new Points(geometry, material);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWireframe() {
|
||||||
|
const geo = new IcosahedronGeometry(120, 2);
|
||||||
|
const mat = new MeshBasicMaterial({
|
||||||
|
color: '#1c1c1c',
|
||||||
|
wireframe: true,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.35
|
||||||
|
});
|
||||||
|
return new Mesh(geo, mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!container.value) return;
|
||||||
|
scene = new Scene();
|
||||||
|
camera = new PerspectiveCamera(55, container.value.clientWidth / container.value.clientHeight, 0.1, 2000);
|
||||||
|
camera.position.set(0, 20, 420);
|
||||||
|
|
||||||
|
renderer = new WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
|
||||||
|
renderer.outputColorSpace = SRGBColorSpace;
|
||||||
|
container.value.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Mild gradient fog feel
|
||||||
|
scene.fog = new Fog('#030303', 200, 900);
|
||||||
|
|
||||||
|
const group = new Group();
|
||||||
|
scene.add(group);
|
||||||
|
|
||||||
|
const particles = createParticles();
|
||||||
|
group.add(particles);
|
||||||
|
|
||||||
|
const wire = createWireframe();
|
||||||
|
group.add(wire);
|
||||||
|
|
||||||
|
const ringGeo = new TorusGeometry(150, 0.6, 8, 220);
|
||||||
|
const ringMat = new MeshBasicMaterial({ color: '#25a750', wireframe: true, transparent: true, opacity: 0.12 });
|
||||||
|
const ring = new Mesh(ringGeo, ringMat);
|
||||||
|
ring.rotation.x = Math.PI / 2.4;
|
||||||
|
group.add(ring);
|
||||||
|
|
||||||
|
const clock = new Clock();
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
group.rotation.y = t * 0.03;
|
||||||
|
wire.rotation.x = t * 0.02;
|
||||||
|
ring.rotation.z = t * 0.015;
|
||||||
|
particles.rotation.y = t * 0.01;
|
||||||
|
renderer!.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
if (!container.value || !renderer) return;
|
||||||
|
camera.aspect = container.value.clientWidth / container.value.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
renderer?.dispose();
|
||||||
|
scene.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="three-bg" ref="container">
|
||||||
|
<div class="overlay-grad" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.three-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
|
background: radial-gradient(circle at 60% 40%, rgba(37,167,80,0.08), rgba(3,3,3,0.9));
|
||||||
|
}
|
||||||
|
.three-bg canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
filter: brightness(0.9) contrast(1.05) saturate(1.05);
|
||||||
|
}
|
||||||
|
.overlay-grad {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 30% 70%, rgba(255,255,255,0.05), transparent 60%),
|
||||||
|
radial-gradient(circle at 80% 20%, rgba(37,167,80,0.07), transparent 55%),
|
||||||
|
linear-gradient(160deg, rgba(0,0,0,0.4), rgba(0,0,0,0.85));
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
src/types/three.d.ts
vendored
Normal file
1
src/types/three.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module 'three';
|
||||||
Loading…
x
Reference in New Issue
Block a user