This commit is contained in:
feie9456 2025-10-15 18:07:33 +08:00
parent e3a721a055
commit 15cbd7ce8d
16 changed files with 578986 additions and 1123 deletions

View File

@ -6,10 +6,13 @@
"dependencies": {
"@types/papaparse": "^5.3.15",
"echarts": "^5.6.0",
"gsap": "^3.12.5",
"papaparse": "^5.5.2",
"three": "^0.170.0",
"vue": "^3.5.13",
},
"devDependencies": {
"@types/three": "^0.180.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"sass-embedded": "^1.86.0",
@ -30,6 +33,8 @@
"@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/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=="],
"@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/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/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=="],
"@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=="],
"@webgpu/types": ["@webgpu/types@0.1.64", "", {}, "sha512-84kRIAGV46LJTlJZWxShiOrNL30A+9KokD7RB3dRCIqODFjodS5tCD5yyiZ8kIReGVZSDfA3XkkwyyOIF6K62A=="],
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
"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=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],

View File

@ -12,9 +12,12 @@
"@types/papaparse": "^5.3.15",
"echarts": "^5.6.0",
"papaparse": "^5.5.2",
"vue": "^3.5.13"
"vue": "^3.5.13",
"gsap": "^3.12.5",
"three": "^0.170.0"
},
"devDependencies": {
"@types/three": "^0.180.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"sass-embedded": "^1.86.0",
@ -22,4 +25,4 @@
"vite": "^6.2.0",
"vue-tsc": "^2.2.4"
}
}
}

15071
public/btc2504.csv Normal file

File diff suppressed because it is too large Load Diff

6336
public/btc2507.csv Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

6336
public/eth2507.csv Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
public/春庭雪.mp3 Normal file

Binary file not shown.

View File

@ -1,9 +1,11 @@
<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 type { OrderLine, MartingaleState } from './martingale';
import { simulateMartingale } from './martingale';
import CashChart from './components/CashChart.vue';
import ThreeBackground from './components/ThreeBackground.vue';
import { gsap } from 'gsap';
//
// K线
@ -14,6 +16,9 @@ interface KLineData {
low: number; //
close: number; //
volume: number; //
// B/S
buyPrice?: number[];
sellPrice?: number[];
}
//
@ -36,6 +41,8 @@ interface KLineData {
low: number;
close: number;
volume: number;
buyPrice?: number[];
sellPrice?: number[];
}
// K线
@ -82,196 +89,233 @@ function updateTableData() {
}
setInterval(updateTableData, 100);
const strategyIndex = 0
async function handleFileUpload(event: Event, config: KLineConfig = { periodsPerKLine: 96, maxDisplayedKLines: 10000 }) {
const strategyIndex = 1
async function handleStart(
config: KLineConfig = { periodsPerKLine: 16, maxDisplayedKLines: 10000 }) {
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const input = event.target as HTMLInputElement;
const files = input.files;
await wait(5000)
if (!files || files.length === 0) {
uploadStatus.value = '未选择文件';
return;
}
const file = files[0];
fileName.value = file.name;
loading.value = true;
uploadStatus.value = '正在解析文件...';
const reader = new FileReader();
//
const aggregatedMap = new Map<string, KLineData>();
reader.onload = async (e) => {
const content = e.target?.result as string;
const lines = content.split('\n');
const dataLines = lines.slice(1); //
const res = await fetch('./btc2504.csv')
const content = await res.text()
const lines = content.split('\n');
const dataLines = lines.slice(1); //
await wait(50)
loading.value = false;
uploadStatus.value = '开始处理数据...';
await wait(50)
loading.value = false;
uploadStatus.value = '开始处理数据...';
fileName.value = 'btc2504.csv'
simulation: for (let i = 0; i < dataLines.length; i++) {
/* await wait(100) */
const line = dataLines[i];
if (!line.trim()) continue;
const dataPoint = parseDataLine(line);
if (!dataPoint) continue;
if (i == 0) {
orders.value = simulateMartingale(state.value, {
...strategy[strategyIndex],
maxInvestment: strategy[strategyIndex].maxInvestment(cash.value),
currentPrice: dataPoint.open
})
}
updateAggregatedData(dataPoint, aggregatedMap, config.periodsPerKLine);
const sortedData = Array.from(aggregatedMap.values()).sort((a, b) => a.time - b.time);
klineData.value = sortedData.slice(
Math.max(0, sortedData.length - config.maxDisplayedKLines)
);
const updateTable = () => {
if (state.value.positions.length > 0) {
let totalInvestment = state.value.positions.reduce((acc, cur) => acc + cur.investment, 0)
let totalAmount = state.value.positions.reduce((acc, cur) => acc + cur.amount, 0)
let averageCost = totalInvestment / totalAmount * (strategy[strategyIndex].leverage ?? 1)
let profitLoss = dataPoint.close * totalAmount / (strategy[strategyIndex].leverage ?? 1) - totalInvestment
let profitLossPercent = (profitLoss / totalInvestment) * 100
position.value = {
avgCost: averageCost,
profitLoss: profitLoss,
profitLossPercent: profitLossPercent
}
tableData.总资产 = cash.value + dataPoint.close * totalAmount / (strategy[strategyIndex].leverage ?? 1)
tableData.总收益 = tableData.总资产 - initCash
tableData.收益率 = (tableData.总资产 - initCash) / initCash * 100
tableData.浮动收益 = profitLoss
tableData.浮动收益率 = profitLossPercent
tableData.持仓量 = totalAmount
tableData.持仓量USDT = totalAmount * dataPoint.close
tableData.平均持仓成本 = averageCost
tableData.已加仓次数 = state.value.positions.length
cashHistory.value.push(tableData.总资产)
btcHistory.value.push(dataPoint.close)
} else {
position.value = null
tableData.总资产 = cash.value
tableData.总收益 = tableData.总资产 - initCash
tableData.收益率 = (tableData.总资产 - initCash) / initCash * 100
tableData.浮动收益 = 0
tableData.浮动收益率 = 0
tableData.持仓量 = 0
tableData.平均持仓成本 = '-'
tableData.已加仓次数 = 0
cashHistory.value.push(tableData.总资产)
btcHistory.value.push(dataPoint.close)
}
tableData.止盈价格 = orders.value.find(order => order.type === 'SELL')?.price ?? '-'
}
for (const order of orders.value) {
switch (order.type) {
case 'BUY':
if (order.price >= dataPoint.low) {
// 使investment
let dCash = -order.investment!;
cash.value += dCash;
state.value.positions.push({
price: order.price,
amount: order.amount,
investment: order.investment!
});
console.log(`买入 ${order.amount} @ ${order.price} cash ${dCash} USDT`);
}
break;
case 'SELL':
if (order.price <= dataPoint.high) {
//
let totalInvestment = state.value.positions.reduce((acc, cur) => acc + cur.investment, 0);
let totalAmount = state.value.positions.reduce((acc, cur) => acc + cur.amount, 0);
//
let leverageFactor = strategy[strategyIndex].leverage ?? 1;
// ()
let entryPositionValue = totalInvestment * leverageFactor;
//
let exitPositionValue = totalAmount * order.price;
// /()
let pnl = exitPositionValue - entryPositionValue;
// = + /
let dCash = totalInvestment + pnl;
cash.value += dCash;
state.value.positions = [];
tableData.已完成周期++;
console.log(`卖出 ${totalAmount} @ ${order.price} cash ${dCash} USDT (PnL: ${pnl.toFixed(2)})`);
}
break;
case 'LIQUIDATION':
if (order.price >= dataPoint.low) { // 线
//
let totalInvestment = state.value.positions.reduce((acc, cur) => acc + cur.investment, 0);
//
let dCash = 0;
cash.value += dCash;
state.value.positions = [];
tableData.已完成周期++;
tableData.已爆仓次数++;
console.log(`强平 @ ${order.price} 损失 ${totalInvestment.toFixed(2)} USDT`);
}
break;
}
}
updateTable()
if (cash.value <= 1 && position.value == null) {
// ...
initCash += cashEachTime
cash.value += cashEachTime
}
simulation: for (let i = 0; i < dataLines.length; i++) {
/* await wait(100) */
const line = dataLines[i];
if (!line.trim()) continue;
const dataPoint = parseDataLine(line);
if (!dataPoint) continue;
if (i == 0) {
orders.value = simulateMartingale(state.value, {
...strategy[strategyIndex],
maxInvestment: strategy[strategyIndex].maxInvestment(cash.value),
currentPrice: dataPoint.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`);
// BK线
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)})`);
// SK线
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 {
const values = line.split(',');
@ -386,17 +430,52 @@ const strategy = [
amountMultiplier: 1.05, //
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>
<template>
<audio src="./春庭雪.mp3" loop ref="bgm-audio" volume="0.5"></audio>
<div class="app">
<ThreeBackground />
<div class="watermark">
<div class="item">{{ waterMark }}</div>
<div class="item">{{ waterMark }}</div>
@ -406,33 +485,27 @@ const waterMark = "Douyin @feie9454"
</div>
<div class="header" @click="pause = !pause">
<img src="./assets/btc20230419112752.webp" alt="">
<span>BTC/USDT (Binance) - 合约马丁格尔策略 <span style="color: #f9a825;">3
X</span></span>
<!-- <span>BTC/USDT (Binance) - 现货马丁格尔策略 回测</span> -->
</div>
<div class="file-upload-container" v-if="!fileName">
<label for="csv-upload" class="file-upload-label">
<span>选择CSV文件</span>
<span>{{ fileName }}</span>
</label>
<input type="file" id="csv-upload" accept=".csv"
@change="handleFileUpload" class="file-upload-input" />
<span>BTC/USDT - 现货马丁格尔策略 回测</span>
<div class="upload-status" v-if="uploadStatus">
{{ uploadStatus }}
</div>
<h1> 2019 <span :style="{ color: '#25a750' }">{{ cashEachTime }}
<!-- <span>BTC/USDT (Binance) - 合约马丁格尔策略 <span style="color: #f9a825;">3
X</span></span> -->
<!-- <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> 投资
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> 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
:style="{ color: '#25a750' }">{{ cashEachTime }} USDT</span></h2> -->
<h2>
<!-- <h2>
<span style="">单周期最小 /
最大投入</span><br>
<span :style="{ color: '#25a750' }">10000 USDT</span> / <span
@ -440,8 +513,8 @@ const waterMark = "Douyin @feie9454"
</h2>
<h2>能稳定复利吗</h2>
<h3>截至2025 3 30 8:00 UTC+8</h3>
<h2>能稳定复利吗</h2> -->
<h3>截至2025 9 6 8:00 UTC+8</h3>
<table>
<thead>
<tr>
@ -481,7 +554,7 @@ const waterMark = "Douyin @feie9454"
</tr>
</tbody>
</table>
<h3>策略来源欧易OKX 长期稳健投资</h3>
<h3 class="subtitle">策略来源欧易OKX 长期稳健投资</h3>
<h4 style="margin-top: 32px;">
仅为金融策略介绍与分析不包含主观策略评价<br>不构成任何投资建议<br><br>参与加密货币交易时请遵守当地法律法规</h4>
</div>
@ -514,10 +587,14 @@ const waterMark = "Douyin @feie9454"
false)
}} USDT</div>
</div> -->
<div class="item">
<!-- <div class="item">
<div class="title">持仓量</div>
<div class="value">
{{ fmtNum(dTD.持仓量USDT, 2, false) }} USDT</div>
</div> -->
<div class="item">
<div class="title">持仓量</div>
<div class="value">{{ dTD.持仓量.toFixed(5) }} BTC</div>
</div>
<div class="item">
<div class="title">浮动收益</div>
@ -532,24 +609,21 @@ const waterMark = "Douyin @feie9454"
</div>
<!-- <div class="item">
<div class="title">持仓量</div>
<div class="value">{{ dTD.持仓量.toFixed(5) }} BTC</div>
</div> -->
<div class="item">
<div class="title">已加仓次数</div>
<div class="value">{{ dTD.已加仓次数 }}</div>
</div>
<!-- <div class="item">
<div class="item">
<div class="title">止盈价格</div>
<div class="value">{{ dTD.止盈价格 == '-' ? '-' : fmtNum(dTD.止盈价格, 1, false)
}} USDT</div>
</div> -->
<div class="item">
</div>
<!-- <div class="item">
<div class="title">已爆仓次数</div>
<div class="value">{{ dTD.已爆仓次数 }}</div>
</div>
</div> -->
<div class="item">
<div class="title">已完成周期</div>
<div class="value">{{ dTD.已完成周期 }}</div>
@ -561,6 +635,17 @@ const waterMark = "Douyin @feie9454"
<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 {
pointer-events: none;
padding: 10vh 0;
@ -581,8 +666,10 @@ const waterMark = "Douyin @feie9454"
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
transform: rotate(-30deg);
font-weight: 600;
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;
flex-direction: column;
box-sizing: border-box;
background-color: #0c0c0c;
background-color: #030303;
position: relative;
overflow: hidden;
}
.header {
@ -631,11 +720,17 @@ tr:nth-child(even) {
gap: 8px;
font-weight: bold;
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 {
width: 30px;
position: relative;
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;
text-align: center;
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 {
@ -674,6 +798,11 @@ tr:nth-child(even) {
position: relative;
overflow: hidden;
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,
@ -695,6 +824,11 @@ tr:nth-child(even) {
grid-template-rows: repeat(2, 1fr);
padding: 16px 8px 24px;
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 {
color: #909090;
@ -718,6 +852,9 @@ tr:nth-child(even) {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
position: relative;
}
}
</style>
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,5 +1,5 @@
<template>
<div class="chart-container" ref="chartContainer">
<div class="chart-container-inner" ref="chartContainer">
<canvas ref="chartCanvas" @mousemove="handleMouseMove"
@mouseleave="hideTooltip"></canvas>
<div v-if="tooltip.show" class="tooltip" :style="{
@ -31,6 +31,11 @@ const props = withDefaults(defineProps<Props>(), {
trendThreshold: 15.0, // 15%
});
const benchmarkAmounts = computed(() => {
const scaleRadio = props.fundAmounts[0] / props.benchmarkAmounts[0];
return props.benchmarkAmounts.map(v => v * scaleRadio);
})
// DOM
const chartContainer = ref<HTMLDivElement | null>(null);
const chartCanvas = ref<HTMLCanvasElement | null>(null);
@ -209,49 +214,40 @@ const sampledData = computed(() => {
return result.sort((a, b) => a.originalIndex - b.originalIndex);
});
//
// 线
const sampledBenchmarkData = computed(() => {
const data = props.benchmarkAmounts;
const data = benchmarkAmounts.value;
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) {
//
return data.map((value, index) => ({ value, originalIndex: index }));
}
// - 使
const result: { value: number, originalIndex: number }[] = [];
const sampleSize = props.maxDataPoints;
const bucketSize = data.length / sampleSize;
//
result.push({ value: data[0], originalIndex: 0 });
//
for (let i = 1; i < sampleSize - 1; i++) {
const bucketStart = Math.floor(i * bucketSize);
const bucketEnd = Math.floor((i + 1) * bucketSize);
//
let maxVal = data[bucketStart];
let minVal = data[bucketStart];
let maxIdx = bucketStart;
let minIdx = bucketStart;
for (let j = bucketStart; j < bucketEnd; j++) {
if (data[j] > maxVal) {
maxVal = data[j];
maxIdx = j;
}
if (data[j] < minVal) {
minVal = data[j];
minIdx = j;
}
if (data[j] > maxVal) { maxVal = data[j]; maxIdx = j; }
if (data[j] < minVal) { minVal = data[j]; minIdx = j; }
}
//
if (maxIdx !== minIdx) {
//
if (maxIdx < minIdx) {
result.push({ value: maxVal, originalIndex: maxIdx });
result.push({ value: minVal, originalIndex: minIdx });
@ -260,15 +256,10 @@ const sampledBenchmarkData = computed(() => {
result.push({ value: maxVal, originalIndex: maxIdx });
}
} else {
//
result.push({ value: maxVal, originalIndex: maxIdx });
}
}
//
result.push({ value: data[data.length - 1], originalIndex: data.length - 1 });
//
return result.sort((a, b) => a.originalIndex - b.originalIndex);
});
@ -307,7 +298,7 @@ const handleMouseMove = (event: MouseEvent) => {
tooltip.isBenchmark = true;
} 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.color = closestPoint.trendUp ? COLORS.UP : COLORS.DOWN;
tooltip.isBenchmark = false;
@ -356,9 +347,9 @@ const drawChart = () => {
}
//
for (let i = 0; i < props.benchmarkAmounts.length; i++) {
if (props.benchmarkAmounts[i] < minValue) minValue = props.benchmarkAmounts[i];
if (props.benchmarkAmounts[i] > maxValue) maxValue = props.benchmarkAmounts[i];
for (let i = 0; i < benchmarkAmounts.value.length; i++) {
if (benchmarkAmounts.value[i] < minValue) minValue = benchmarkAmounts.value[i];
if (benchmarkAmounts.value[i] > maxValue) maxValue = benchmarkAmounts.value[i];
}
@ -454,78 +445,55 @@ const drawChart = () => {
ctx.fillText('基准', legendX + 20, legendY + 15);
//
const { trendSegments } = trendAnalysis.value;
// 线使线
const sampleData = sampledData.value;
//
const pointTrends = new Map<number, boolean>(); // ->
//
for (const segment of trendSegments) {
for (let i = segment.start; i <= segment.end; i++) {
pointTrends.set(i, segment.isUp);
}
}
//
for (let i = 0; i < trendSegments.length; i++) {
const segment = trendSegments[i];
const isUp = segment.isUp;
//
const segmentPoints = sampleData.filter(
point => point.originalIndex >= segment.start && point.originalIndex <= segment.end
);
if (segmentPoints.length < 2) continue;
//
if (sampleData.length >= 2) {
ctx.beginPath();
//
const firstPoint = segmentPoints[0];
const firstX = scaleX(firstPoint.originalIndex);
const firstY = scaleY(firstPoint.value);
const first = sampleData[0];
const firstX = scaleX(first.originalIndex);
const firstY = scaleY(first.value);
ctx.moveTo(firstX, firstY);
//
chartData.dataPoints.push({
x: firstX,
y: firstY,
value: firstPoint.value,
originalIndex: firstPoint.originalIndex,
trendUp: isUp
});
//
let prevVal = first.value;
let pushedFirst = false;
// 线
for (let j = 1; j < segmentPoints.length; j++) {
const point = segmentPoints[j];
const x = scaleX(point.originalIndex);
const y = scaleY(point.value);
let prevIndex = first.originalIndex;
for (let j = 1; j < sampleData.length; j++) {
const p = sampleData[j];
if (p.originalIndex === prevIndex) continue; // 线/
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);
//
chartData.dataPoints.push({
x,
y,
value: point.value,
originalIndex: point.originalIndex,
trendUp: isUp
value: p.value,
originalIndex: p.originalIndex,
trendUp
});
prevVal = p.value;
prevIndex = p.originalIndex;
}
if (trendSegments[i + 1]) {
let point = sampleData.find(p => p.originalIndex >= trendSegments[i + 1].start);
if (point) {
const x = scaleX(point.originalIndex);
const y = scaleY(point.value);
ctx.lineTo(x, y);
}
}
// 线
ctx.strokeStyle = isUp ? COLORS.UP : COLORS.DOWN;
ctx.lineWidth = 1; // 便
ctx.strokeStyle = COLORS.UP;
ctx.lineWidth = 1.2;
ctx.stroke();
}
@ -553,8 +521,10 @@ const drawChart = () => {
});
// 线
let prevIndex = firstPoint.originalIndex;
for (let j = 1; j < benchmarkData.length; j++) {
const point = benchmarkData[j];
if (point.originalIndex === prevIndex) continue; //
const x = scaleX(point.originalIndex);
const y = scaleY(point.value);
@ -569,6 +539,7 @@ const drawChart = () => {
trendUp: 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;
//
@ -622,32 +593,41 @@ const drawStatistics = (ctx: CanvasRenderingContext2D, width: number, margin: {
ctx.textBaseline = 'top';
ctx.font = '14px PingFang SC';
const left = 200//530
//
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.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.fillText(`年化: ${annualizedReturn.toFixed(2)}%`, 200, margin.top + 40);
ctx.fillText(`年化: ${annualizedReturn.toFixed(2)}%`, left, margin.top + 50);
//
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);
let winRate = alz.winRate;
ctx.fillStyle = winRate >= 50 ? COLORS.UP : COLORS.DOWN;
ctx.fillText(`勝率: ${winRate.toFixed(2)}%`, 200, margin.top + 80);
const initBenchmarkValue = props.benchmarkAmounts[0];
const currentBenchmarkValue = props.benchmarkAmounts[props.benchmarkAmounts.length - 1];
const initBenchmarkValue = benchmarkAmounts.value[0];
const currentBenchmarkValue = benchmarkAmounts.value[benchmarkAmounts.value.length - 1];
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.fillText(`跑贏現貨: ${delta.toFixed(2)}%`, 200, margin.top + 100);
ctx.fillText(`超额收益: ${delta.toFixed(2)}%`, left, margin.top + 90);
};
//
@ -684,7 +664,7 @@ onUnmounted(() => {
</script>
<style scoped>
.chart-container {
.chart-container-inner {
font-family: PingFang SC, sans-serif;
position: relative;
width: 100%;

View File

@ -2,6 +2,12 @@
import { ref, onMounted, reactive, computed, watch, onUnmounted } from 'vue';
import type { OrderLine } from '../martingale';
// new okx
const color = {
green: '#25A750',
red: '#CA3F64'
};
// K线
interface KLineData {
time: number; //
@ -10,6 +16,9 @@ interface KLineData {
low: number; //
close: number; //
volume: number; //
// B/S
buyPrice?: number[];
sellPrice?: number[];
}
//
@ -66,6 +75,10 @@ const chartState = reactive({
selectedIndex: -1,
});
//
const TAG_GAP = 4; //
const TAG_TIP = 5; //
//
const tooltip = reactive({
visible: false,
@ -219,6 +232,21 @@ function render() {
chartState.priceMin = Math.min(...visibleData.map(item => item.low));
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) {
const orderPrices = props.orderLines.map(order => order.price);
@ -259,6 +287,9 @@ function render() {
//
drawVolume(ctx, visibleData);
// B/S
drawBSPoints(ctx, visibleData);
// 线
if (props.orderLines && props.orderLines.length > 0) {
drawOrderLines(ctx);
@ -351,8 +382,8 @@ function drawCandles(ctx: CanvasRenderingContext2D, data: KLineData[]) {
//
const isRising = candle.close > candle.open;
ctx.fillStyle = isRising ? '#26A69A' : '#EF5350';
ctx.strokeStyle = isRising ? '#26A69A' : '#EF5350';
ctx.fillStyle = isRising ? color.green : color.red;
ctx.strokeStyle = isRising ? color.green : color.red;
// K线线
ctx.lineWidth = 1
@ -386,7 +417,9 @@ function drawVolume(ctx: CanvasRenderingContext2D, data: KLineData[]) {
//
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;
@ -403,15 +436,16 @@ function drawOrderLines(ctx: CanvasRenderingContext2D) {
// 线
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
let labelColor = color.green;
switch (order.type) {
case 'BUY':
ctx.strokeStyle = ctx.fillStyle = '#26A69A';
ctx.strokeStyle = ctx.fillStyle = labelColor = color.green;
break;
case 'SELL':
ctx.strokeStyle = ctx.fillStyle = '#EF5350';
ctx.strokeStyle = ctx.fillStyle = labelColor = color.red;
break;
case 'LIQUIDATION':
ctx.strokeStyle = ctx.fillStyle = '#ffb117';
ctx.strokeStyle = ctx.fillStyle = labelColor = '#ffb117';
break;
}
ctx.lineWidth = 1;
@ -426,12 +460,8 @@ function drawOrderLines(ctx: CanvasRenderingContext2D) {
// 线
ctx.setLineDash([]);
//
ctx.font = '11px PingFang SC';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(order.price.toFixed(2), chartState.canvasWidth - chartState.paddingRight + 5, y);
//
drawRightSideTag(ctx, y, order.price.toFixed(2), labelColor);
});
}
@ -445,7 +475,7 @@ function drawPositionLine(ctx: CanvasRenderingContext2D) {
if (y < chartState.paddingTop || y > chartState.priceHeight) return;
// 线 - 线绿线
ctx.strokeStyle = '#26A69A';
ctx.strokeStyle = color.green;
ctx.lineWidth = 1.5;
ctx.setLineDash([]); // 线
@ -456,31 +486,202 @@ function drawPositionLine(ctx: CanvasRenderingContext2D) {
ctx.stroke();
//
ctx.font = '11px PingFang SC';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#26A69A';
ctx.fillText(props.position.avgCost.toFixed(2), chartState.canvasWidth - chartState.paddingRight + 5, y);
drawRightSideTag(ctx, y, props.position.avgCost.toFixed(2), color.green);
//
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 plText = `${plSign}${props.position.profitLoss.toFixed(2)}`;
// 使
ctx.fillStyle = plColor;
ctx.font = '10px PingFang SC';
ctx.fillText(plText, chartState.canvasWidth - chartState.paddingRight + 5, y + 12);
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) {
const plPercentSign = props.position.profitLossPercent >= 0 ? '+' : '';
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
function yForPrice(price: number): number {
return chartState.paddingTop + (chartState.priceMax - price) * chartState.priceScale;
@ -575,7 +776,6 @@ function pad(num: number): string {
<style scoped>
.kline-chart-container {
position: relative;
border: 1px solid #333;
box-sizing: border-box;
width: 100%;
height: 100%;

View File

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

View File

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

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

@ -0,0 +1 @@
declare module 'three';