Spaces:
Running
Running
| <template> | |
| <div style="position: relative;"> | |
| <canvas ref="perfCanvas" height="280"></canvas> | |
| <div v-if="isZoomed" style="position: absolute; top: 10px; right: 10px; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; z-index: 10;"> | |
| <button | |
| @click="resetZoom" | |
| style="padding: 6px 12px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.2);" | |
| @mouseover="$event.target.style.background='#2563eb'" | |
| @mouseout="$event.target.style.background='#3b82f6'" | |
| > | |
| Reset Zoom | |
| </button> | |
| <span style="font-size: 11px; color: #64748b; background: rgba(255,255,255,0.95); padding: 4px 8px; border-radius: 3px; white-space: nowrap; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"> | |
| Drag to zoom | Shift+Drag to pan | |
| </span> | |
| </div> | |
| </div> | |
| </template> | |
| <script> | |
| import { getAllDecisions } from '../lib/dataCache' | |
| import { STRATEGIES } from '../lib/strategies' | |
| import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf' | |
| import { filterRowsToNyseTradingDays } from '../lib/marketCalendar' | |
| import { Chart, LineElement, PointElement, LinearScale, TimeScale, CategoryScale, LineController, Legend, Tooltip } from 'chart.js' | |
| import zoomPlugin from 'chartjs-plugin-zoom' | |
| // vertical crosshair plugin | |
| 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, TimeScale, CategoryScale, LineController, Legend, Tooltip, vLinePlugin, zoomPlugin) | |
| export default { | |
| name: 'PerformanceChart', | |
| props: { | |
| data: { type: Object, required: true } | |
| }, | |
| data(){ | |
| return { | |
| chart: null, | |
| isZoomed: false | |
| } | |
| }, | |
| watch: { | |
| data: { | |
| handler(){ this.draw() }, immediate: true, deep: true | |
| } | |
| }, | |
| mounted(){ this.$nextTick(() => this.draw()) }, | |
| beforeUnmount(){ try{ this.chart && this.chart.destroy() }catch(_){} }, | |
| methods: { | |
| getSeqFromIds(row){ | |
| const all = getAllDecisions() || [] | |
| const ids = row && (row.decision_ids || row.ids || []) | |
| let seq = [] | |
| if (ids && ids.length) { | |
| seq = all.filter(r => ids.includes(r.id)) | |
| } else if (row) { | |
| // Only fallback to full data if no decision_ids were provided at all | |
| seq = all.filter(r => r.agent_name === row.agent_name && r.asset === row.asset && r.model === row.model) | |
| } | |
| seq.sort((a,b) => (a.date > b.date ? 1 : -1)) | |
| return seq | |
| }, | |
| fmtMoney(v){ try{ return `$${Number(v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}` }catch{return v} }, | |
| async draw(){ | |
| if (!this.$refs.perfCanvas || !this.data) return | |
| const row = this.data | |
| const rawSeq = this.getSeqFromIds(row) | |
| const isCrypto = row.asset === 'BTC' || row.asset === 'ETH' | |
| const seq = isCrypto ? rawSeq : (await filterRowsToNyseTradingDays(rawSeq) || []) | |
| if (!seq.length) { try{ this.chart && this.chart.destroy() }catch(_){}; return } | |
| const strategyCfg = (STRATEGIES || []).find(s => s.id === row.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005 } | |
| const st = computeStrategyEquity(seq, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode) | |
| const bh = computeBuyHoldEquity(seq, 100000) | |
| const labels = seq.map(s => s.date) | |
| // keep arrays aligned in length | |
| const n = Math.min(st.length, bh.length, labels.length) | |
| const stS = st.slice(0, n) | |
| const bhS = bh.slice(0, n) | |
| const lab = labels.slice(0, n) | |
| const stRet = stS.length ? ((stS[stS.length-1] - stS[0]) / stS[0]) * 100 : 0 | |
| const bhRet = bhS.length ? ((bhS[bhS.length-1] - bhS[0]) / bhS[0]) * 100 : 0 | |
| const vsBh = stRet - bhRet | |
| const agentColor = vsBh >= 0 ? '#16a34a' : '#dc2626' // green when outperform, red when underperform | |
| const agentBgColor = vsBh >= 0 ? 'rgba(22,163,74,0.15)' : 'rgba(220,38,38,0.15)' | |
| const baselineColor = '#3b82f6' // blue | |
| try{ this.chart && this.chart.destroy() }catch(_){ } | |
| this.chart = new Chart(this.$refs.perfCanvas.getContext('2d'), { | |
| type: 'line', | |
| data: { | |
| labels: lab, | |
| datasets: [ | |
| { | |
| label: 'Agent Balance', | |
| data: stS, | |
| borderColor: agentColor, | |
| backgroundColor: agentBgColor, | |
| pointRadius: 3, | |
| tension: 0.2 | |
| }, | |
| { | |
| label: 'Baseline', | |
| data: bhS, | |
| borderColor: baselineColor, | |
| borderDash: [6,4], | |
| pointRadius: 0, | |
| tension: 0.2 | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| animation: false, | |
| interaction: { mode: 'index', intersect: false }, | |
| plugins: { | |
| legend: { display: true }, | |
| tooltip: { | |
| callbacks: { | |
| title: (items) => items && items.length ? `Date: ${items[0].label}` : '', | |
| afterTitle: () => '', | |
| label: (ctx) => `${ctx.dataset.label}: ${this.fmtMoney(ctx.parsed.y)}`, | |
| afterBody: (items) => { | |
| const idx = items && items.length ? items[0].dataIndex : null | |
| let sRet = stRet | |
| let bRet = bhRet | |
| if (idx != null && idx >= 0) { | |
| if (stS.length > 0 && stS[idx] != null) sRet = ((stS[idx] - stS[0]) / stS[0]) * 100 | |
| if (bhS.length > 0 && bhS[idx] != null) bRet = ((bhS[idx] - bhS[0]) / bhS[0]) * 100 | |
| } | |
| return [ | |
| `Strategy Return: ${sRet.toFixed(2)}%`, | |
| `Baseline Return: ${bRet.toFixed(2)}%` | |
| ] | |
| } | |
| } | |
| }, | |
| vLinePlugin: { color: 'rgba(0,0,0,0.35)', lineWidth: 1, dash: [4,4] }, | |
| zoom: { | |
| zoom: { | |
| drag: { | |
| enabled: true, | |
| backgroundColor: 'rgba(59, 130, 246, 0.2)', | |
| borderColor: 'rgba(59, 130, 246, 0.8)', | |
| borderWidth: 1 | |
| }, | |
| mode: 'x', | |
| onZoomComplete: () => { | |
| this.isZoomed = true | |
| } | |
| }, | |
| pan: { | |
| enabled: true, | |
| mode: 'x', | |
| modifierKey: 'shift' | |
| }, | |
| limits: { | |
| x: { min: 'original', max: 'original' } | |
| } | |
| } | |
| }, | |
| scales: { | |
| x: { ticks: { autoSkip: true, maxTicksLimit: 8 } }, | |
| y: { beginAtZero: false } | |
| } | |
| } | |
| }) | |
| }, | |
| resetZoom(){ | |
| if (this.chart) { | |
| this.chart.resetZoom() | |
| this.isZoomed = false | |
| } | |
| } | |
| } | |
| } | |
| </script> |