Spaces:
Running
Running
| <template> | |
| <div class="chart-container"> | |
| <canvas ref="canvas"></canvas> | |
| </div> | |
| </template> | |
| <script> | |
| import { getAllDecisions } from '../lib/dataCache' | |
| import { readAllRawDecisions } from '../lib/idb' | |
| import { filterRowsToNyseTradingDays } from '../lib/marketCalendar' | |
| import { STRATEGIES } from '../lib/strategies' | |
| import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf' | |
| import { Chart, LineElement, PointElement, LinearScale, CategoryScale, LineController, Legend, Tooltip } from 'chart.js' | |
| import { getStrategyColor } from '../lib/chartColors' | |
| const vLinePlugin = { | |
| id: 'vLinePlugin', | |
| afterDatasetsDraw(chart, args, pluginOptions) { | |
| const active = (typeof chart.getActiveElements === 'function') ? chart.getActiveElements() : (chart.tooltip && chart.tooltip._active) || [] | |
| if (!active || !active.length) return | |
| const { datasetIndex, index } = active[0] | |
| const meta = chart.getDatasetMeta(datasetIndex) | |
| const pt = meta && meta.data && meta.data[index] | |
| if (!pt) return | |
| const x = pt.x | |
| const { top, bottom } = chart.chartArea | |
| const ctx = chart.ctx | |
| ctx.save() | |
| ctx.beginPath() | |
| ctx.moveTo(x, top) | |
| ctx.lineTo(x, bottom) | |
| ctx.lineWidth = (pluginOptions && pluginOptions.lineWidth) || 1 | |
| ctx.strokeStyle = (pluginOptions && pluginOptions.color) || 'rgba(0,0,0,0.35)' | |
| ctx.setLineDash((pluginOptions && pluginOptions.dash) || [4, 4]) | |
| ctx.stroke() | |
| ctx.restore() | |
| } | |
| } | |
| Chart.register(LineElement, PointElement, LinearScale, CategoryScale, LineController, Legend, Tooltip, vLinePlugin) | |
| export default { | |
| name: 'CompareChart', | |
| props: { | |
| selected: { type: Array, default: () => [] }, | |
| visible: { type: Boolean, default: false } | |
| }, | |
| data(){ | |
| return { chart: null, datasets: [], labels: [] } | |
| }, | |
| watch: { | |
| visible(v){ if (v) { this.$nextTick(() => this.buildAndDraw()) } }, | |
| selected: { deep: true, handler(){ this.buildAndDraw() } } | |
| }, | |
| mounted(){ this.$nextTick(() => this.buildAndDraw()) }, | |
| beforeUnmount(){ try{ this.chart && this.chart.destroy() }catch(_){} }, | |
| methods: { | |
| async buildAndDraw(){ | |
| if (!this.visible) return | |
| await this.rebuildDatasetsFromSelection() | |
| this.draw() | |
| }, | |
| async getAll(){ | |
| let all = getAllDecisions() || [] | |
| if (!all.length) { | |
| try { const cached = await readAllRawDecisions(); if (cached && cached.length) all = cached } catch(_) {} | |
| } | |
| return all | |
| }, | |
| async rebuildDatasetsFromSelection(){ | |
| const selected = Array.isArray(this.selected) ? this.selected : [] | |
| const all = await this.getAll() | |
| const groupKeyToSeq = new Map() | |
| for (const sel of selected) { | |
| const agent = sel.agent_name | |
| const asset = sel.asset | |
| const model = sel.model | |
| const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : [] | |
| let seq = [] | |
| if (ids.length) seq = all.filter(r => ids.includes(r.id)) | |
| else seq = all.filter(r => r.agent_name === agent && r.asset === asset && r.model === model) | |
| seq.sort((a,b) => (a.date > b.date ? 1 : -1)) | |
| const isCrypto = asset === 'BTC' || asset === 'ETH' | |
| const filtered = isCrypto ? seq : await filterRowsToNyseTradingDays(seq) | |
| groupKeyToSeq.set(`${agent}|${asset}|${model}`, filtered) | |
| } | |
| const ds = [] | |
| const keys = Array.from(groupKeyToSeq.keys()) | |
| // labels from first sequence | |
| this.labels = [] | |
| if (keys.length) this.labels = (groupKeyToSeq.get(keys[0]) || []).map(s => s.date) | |
| // 按 agent 分组以应用颜色规则 | |
| const agentGroups = new Map() // agent -> [selection items] | |
| const selectedAssets = new Set() | |
| for (const sel of selected) { | |
| const gk = `${sel.agent_name}|${sel.asset}|${sel.model}` | |
| const seq = groupKeyToSeq.get(gk) || [] | |
| if (!seq.length) continue | |
| selectedAssets.add(sel.asset) | |
| const agent = sel.agent_name | |
| if (!agentGroups.has(agent)) { | |
| agentGroups.set(agent, []) | |
| } | |
| agentGroups.get(agent).push({ sel, seq }) | |
| } | |
| // 为每个 agent 的策略生成系列,同一 agent 使用相同色调 | |
| for (const [agent, items] of agentGroups.entries()) { | |
| items.forEach((item, index) => { | |
| const { sel, seq } = item | |
| const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { | |
| strategy: 'long_short', | |
| tradingMode: 'normal', | |
| fee: 0.0005, | |
| label: 'Selected' | |
| } | |
| const series = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) | |
| if (!series.length) return | |
| // 同一 agent 的策略使用相同色调,通过亮度区分 | |
| const strategyColor = getStrategyColor(agent, false, index) | |
| ds.push({ | |
| label: `${sel.agent_name}|${sel.asset}|${sel.model}|${cfg.label || sel.strategy || 'Strategy'}`, | |
| data: this.labels.length ? series.slice(0, this.labels.length) : series, | |
| borderColor: strategyColor, | |
| pointRadius: 0, | |
| tension: 0.15 | |
| }) | |
| }) | |
| } | |
| // 为每个资产添加 baseline,统一使用黑色实线 | |
| const seenAsset = new Set() | |
| for (const gk of keys) { | |
| const asset = gk.split('|')[1] | |
| if (!selectedAssets.has(asset)) continue | |
| if (seenAsset.has(asset)) continue | |
| seenAsset.add(asset) | |
| const seq = groupKeyToSeq.get(gk) || [] | |
| const bh = computeBuyHoldEquity(seq, 100000) | |
| if (!bh.length) continue | |
| // baseline 统一使用黑色实线 | |
| const baselineColor = getStrategyColor('', true, 0) | |
| ds.push({ | |
| label: `${asset} · Buy&Hold`, | |
| data: this.labels.length ? bh.slice(0, this.labels.length) : bh, | |
| borderColor: baselineColor, | |
| borderWidth: 1.5, | |
| pointRadius: 0, | |
| tension: 0 | |
| }) | |
| } | |
| this.datasets = ds | |
| }, | |
| draw(){ | |
| if (!this.$refs.canvas) return | |
| this.$nextTick(() => { | |
| if (!this.$refs.canvas) return | |
| try{ this.chart && this.chart.destroy() }catch(_){} | |
| const parent = this.$refs.canvas.parentElement | |
| if (parent) { | |
| if (!parent.clientHeight || parent.clientHeight < 50) { parent.style.minHeight = '560px'; parent.style.height = '560px' } | |
| const w = parent.clientWidth || 800 | |
| const h = parent.clientHeight || 560 | |
| this.$refs.canvas.style.width = '100%' | |
| this.$refs.canvas.style.height = '100%' | |
| this.$refs.canvas.width = w | |
| this.$refs.canvas.height = h | |
| } | |
| const labels = this.labels.length ? this.labels : Array.from({ length: Math.max(0, ...this.datasets.map(d => (Array.isArray(d.data) ? d.data.length : 0))) }, (_, i) => `${i+1}`) | |
| const allValues = this.datasets.flatMap(d => (Array.isArray(d.data) ? d.data : [])) | |
| const minV = allValues.length ? Math.min(...allValues) : 0 | |
| const maxV = allValues.length ? Math.max(...allValues) : 1 | |
| const pad = (maxV - minV) * 0.1 || 1000 | |
| const yMin = minV - pad | |
| const yMax = maxV + pad | |
| const ctx = this.$refs.canvas.getContext('2d') | |
| this.chart = new Chart(ctx, { | |
| type: 'line', | |
| data: { labels, datasets: this.datasets }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| animation: false, | |
| interaction: { mode: 'index', intersect: false }, | |
| plugins: { | |
| legend: { display: true, position: 'left', labels: { usePointStyle: true, boxWidth: 8 } }, | |
| vLinePlugin: { color: 'rgba(0,0,0,0.35)', lineWidth: 1, dash: [4,4] } | |
| }, | |
| scales: { x: { type: 'category', ticks: { autoSkip: true, maxTicksLimit: 10 } }, y: { min: yMin, max: yMax } } | |
| } | |
| }) | |
| }) | |
| } | |
| } | |
| } | |
| </script> | |
| <style scoped> | |
| .chart-container { position: relative; height: 560px; display: flex; } | |
| .chart-container > canvas { width: 100% ; height: 100% ; } | |
| </style> | |