Agent-Market-Arena / src /components /PerformanceChart.vue
Jimin Huang
add: Feature
ac784c2
raw
history blame
7.78 kB
<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>