Agent-Market-Arena / src /components /CompareChartE.vue
lfqian's picture
Remove strategy label from chart series names (only show agent and model)
9b0c947
raw
history blame
15.9 kB
<template>
<div class="chart-wrap">
<v-chart :option="option" autoresize class="h-96 w-full" />
</div>
</template>
<script>
import { defineComponent } from 'vue'
import VChart from 'vue-echarts'
import * as echarts from 'echarts/core'
import { LineChart, ScatterChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent, DataZoomComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
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 { getStrategyColor } from '../lib/chartColors'
echarts.use([LineChart, GridComponent, ScatterChart, LegendComponent, TooltipComponent, DataZoomComponent, CanvasRenderer])
const ASSET_CUTOFF = {
BTC: '2025-08-01',
// ETH: '2025-08-15',
};
const AGENT_COLOR_MAP = {
HedgeFundAgent: '#3A0CA3', // violet-blue
DeepFundAgent: '#F72585', // magenta
TradeAgent: '#00BFA6', // teal
InvestorAgent: '#FFB703', // golden
};
// pick color by agent name + index (from agentColorIndex map)
function getAgentColor(agent, idx = 0) {
// keep simple + deterministic
return AGENT_COLOR_MAP[agent] || '#999'
}
function drawImageInCircle(ctx, img, cx, cy, radius, { mode = 'contain', padding = 0 } = {}) {
if (!img) return;
const r = Math.max(0, radius - padding);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
const iw = img.naturalWidth || img.width;
const ih = img.naturalHeight || img.height;
if (!iw || !ih) { ctx.restore(); return; }
// target box (square) that we want to fill with the image
const tw = r * 2;
const th = r * 2;
// scale (cover vs contain)
const scale = (mode === 'contain')
? Math.min(tw / iw, th / ih)
: Math.max(tw / iw, th / ih); // cover (default)
const dw = iw * scale;
const dh = ih * scale;
// center align
const dx = cx - dw / 2;
const dy = cy - dh / 2;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, dx, dy, dw, dh);
ctx.restore();
}
// helper: convert [ [date, y], ... ] into % since first y
function toPct(points){
if (!Array.isArray(points) || !points.length) return points
const y0 = points[0][1]
if (typeof y0 !== 'number' || !isFinite(y0) || y0 === 0) return points
return points.map(([t, y]) => [t, ((y / y0) - 1) * 100])
}
// --- Agent & Model logo registries (fill with your actual assets)
const AGENT_LOGOS = {
'HedgeFundAgent': new URL('../assets/images/agents_images/hedgefund.png', import.meta.url).href,
'DeepFundAgent': new URL('../assets/images/agents_images/deepfund.png', import.meta.url).href,
'TradeAgent': new URL('../assets/images/agents_images/tradeagent.png', import.meta.url).href,
'InvestorAgent': new URL('../assets/images/agents_images/investor.png', import.meta.url).href,
'BTC': new URL('../assets/images/assets_images/BTC.png', import.meta.url).href,
'ETH': new URL('../assets/images/assets_images/ETH.png', import.meta.url).href,
'MSFT': new URL('../assets/images/assets_images/MSFT.png', import.meta.url).href,
'BMRN': new URL('../assets/images/assets_images/BMRN.png', import.meta.url).href,
'MRNA': new URL('../assets/images/assets_images/MRNA.png', import.meta.url).href,
'TSLA': new URL('../assets/images/assets_images/TSLA.png', import.meta.url).href,
// 'InvestorAgent': new URL('../assets/images/agents_images/investor.png', import.meta.url).href,
};
const MODEL_LOGOS = {
'claude_3_5_haiku_20241022': new URL('../assets/images/models_images/claude.png', import.meta.url).href,
'claude_sonnet_4_2025051': new URL('../assets/images/models_images/claude.png', import.meta.url).href,
'gpt_4o': new URL('../assets/images/models_images/gpt.png', import.meta.url).href,
'gpt_4.1': new URL('../assets/images/models_images/gpt.png', import.meta.url).href,
'gemini_2.0_flash': new URL('../assets/images/models_images/gemini.png', import.meta.url).href,
};
// Canvas badge cache: key = `${agent}|${model}|${color}`
const BADGE_CACHE = new Map();
const loadImg = (url) => new Promise((resolve, reject) => {
if (!url) return resolve(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => resolve(null); // fail soft
img.src = url;
});
// Compose a badge: [ colored circle with agent logo ] + [ white rounded square with model logo ]
async function composeBadge(agent, model, color = '#666') {
const key = `circ|${agent}|${model ?? ''}|${color}`;
if (BADGE_CACHE.has(key)) return BADGE_CACHE.get(key);
const aImg = await loadImg(AGENT_LOGOS[agent]);
const mImg = await loadImg(MODEL_LOGOS[model]);
// uniform canvas
const S = 30; // badge size (px)
const R = S / 2;
const canvas = document.createElement('canvas');
canvas.width = S; canvas.height = S;
const ctx = canvas.getContext('2d');
const ring = 3; // outer colored ring thickness
const padImg = 4; // extra breathing room for the logo
// base colored circle (ring)
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(R, R, R, 0, Math.PI * 2); ctx.fill();
// inner white disk
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(R, R, R - ring, 0, Math.PI * 2); ctx.fill();
// agent logo (fit into inner circle)
if (aImg) {
drawImageInCircle(ctx, aImg, R, R, R - ring, { mode: 'contain', padding: padImg });
}
// model puck (bottom-right)
if (mImg) {
const d = 20, r = d / 2; // puck diameter
const cx = S - r + 1, cy = S - r + 1; // slight outside bias
// white border
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
// inner image circle
ctx.save();
ctx.beginPath(); ctx.arc(cx, cy, r - 1.5, 0, Math.PI * 2); ctx.clip();
const imgInset = 3;
ctx.drawImage(mImg, cx - r + imgInset, cy - r + imgInset, d - imgInset * 2, d - imgInset * 2);
ctx.restore();
}
const url = canvas.toDataURL('image/png');
BADGE_CACHE.set(key, url);
return url;
}
let markerToLine = new Map()
export default defineComponent({
name: 'CompareChartE',
components: { VChart },
props: {
selected: { type: Array, default: () => [] },
visible: { type: Boolean, default: true },
// NEW: $/% toggle
mode: { type: String, default: 'usd' } // 'usd' | 'pct'
},
data(){ return { option: {} } },
watch: {
selected: { deep: true, handler(){ this.rebuild() } },
visible(v){ if (v) this.$nextTick(() => this.rebuild()) },
// NEW: rebuild when the toggle changes
mode(){ this.rebuild() }
},
mounted(){ this.$nextTick(() => this.rebuild()) },
methods: {
formatModelName(model) {
if (!model) return ''
return model.replace(/_?\d{8}$/, '')
},
async getAll(){
let all = getAllDecisions() || []
if (!all.length) {
try { const cached = await readAllRawDecisions(); if (cached?.length) all = cached } catch {}
}
return all
},
async rebuild(){
if (!this.visible) return
const selected = Array.isArray(this.selected) ? this.selected : []
const all = await this.getAll()
const groupKeyToSeq = new Map()
// 1) Build sequences exactly like CompareChart.vue
for (const sel of selected) {
const { agent_name: agent, asset, model } = sel
const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
let seq = ids.length ? all.filter(r => ids.includes(r.id))
: 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'
let filtered = isCrypto ? seq : await filterRowsToNyseTradingDays(seq)
// --- asset-specific cutoff ---
const cutoff = ASSET_CUTOFF[asset]
if (cutoff) {
const t0 = new Date(cutoff + 'T00:00:00Z')
filtered = filtered.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
}
groupKeyToSeq.set(`${agent}|${asset}|${model}`, { sel, seq: filtered })
}
// 2) Build series using (time,value) pairs
const series = []
const legend = []
const assets = new Set()
const agentColorIndex = new Map()
for (const [_, { sel, seq }] of groupKeyToSeq.entries()) {
if (!seq.length) continue
const agent = sel.agent_name
const asset = sel.asset
assets.add(asset)
const idx = agentColorIndex.get(agent) ?? agentColorIndex.size
agentColorIndex.set(agent, idx)
const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005, label: 'Selected' }
const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
let points = seq.map((row, i) => [row.date, stratY[i]])
// NEW: convert to % mode if requested
if (this.mode === 'pct') points = toPct(points)
const name = `${agent} · ${this.formatModelName(sel.model)}`
legend.push(name)
series.push({
name,
type: 'line',
showSymbol: false,
smooth: false,
emphasis: {
focus: 'series',
lineStyle: { width: 3.5 },
},
lineStyle: { width: 2, color: getAgentColor(agent, idx) },
data: points
})
const lineSeriesIndex = series.length - 1;
const last = points?.[points.length - 1];
if (last && Number.isFinite(last[1])) {
const lineColor = getAgentColor(agent, idx);
const badgeUrl = await composeBadge(agent, null, lineColor); // ← NEW
series.push({
name: name + ' •badge',
type: 'scatter',
data: [ last ],
symbol: badgeUrl ? `image://${badgeUrl}` : 'circle',
symbolSize: 30,
z: 20,
tooltip: {
trigger: 'item',
appendToBody: true,
formatter: (p) => {
const v = p.value?.[1]
const val = this.mode === 'pct'
? `${v >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
: Number(v ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
return [
`<div style="font-weight:600">${sel.agent_name}</div>`,
sel.model ? `<div style="opacity:.8">${this.formatModelName(sel.model)}</div>` : '',
`<div style="opacity:.8">${sel.asset}</div>`,
`<div style="margin-top:4px">${val}</div>`
].join('')
}
},
label: {
show: true,
position: 'right',
padding: [4,8],
borderRadius: 10,
backgroundColor: lineColor,
color: '#fff',
fontWeight: 700,
formatter: (p) => {
const v = p.value?.[1];
if (this.mode === 'pct') return (v >= 0 ? '+' : '') + (Number(v).toFixed(2)) + '%';
return Number(v ?? 0).toLocaleString(undefined, { style:'currency', currency:'USD', maximumFractionDigits: 2 });
}
},
itemStyle: { color: lineColor }
});
}
}
// 3) Buy & Hold baseline per asset
for (const asset of assets) {
const entry = [...groupKeyToSeq.values()].find(v => v.sel.asset === asset)
if (!entry) continue
const bhY = computeBuyHoldEquity(entry.seq, 100000) || []
let bhPoints = entry.seq.map((row, i) => [row.date, bhY[i]])
// NEW: % mode for baseline too
if (this.mode === 'pct') bhPoints = toPct(bhPoints)
series.push({
name: `${asset} · Buy&Hold`,
type: 'line',
showSymbol: false,
lineStyle: { width: 1.5, type: 'dashed' },
color: getStrategyColor('', true, 0),
data: bhPoints
})
const lastBH = bhPoints[bhPoints.length - 1]
if (lastBH && Number.isFinite(lastBH[1])) {
const baseColor = getStrategyColor('', true, 0);
const badgeUrl = await composeBadge(asset, null, baseColor); // ← NEW
series.push({
name: `${asset} · Buy&Hold •badge`,
type: 'scatter',
data: [ lastBH ],
symbol: badgeUrl ? `image://${badgeUrl}` : 'circle',
symbolSize: 30,
z: 19,
tooltip: { show: false },
label: {
show: true,
position: 'right',
padding: [4,8],
borderRadius: 10,
backgroundColor: baseColor,
color: '#fff',
fontWeight: 700,
formatter: (p) => {
const v = p.value?.[1];
if (this.mode === 'pct') return (v >= 0 ? '+' : '') + (Number(v).toFixed(2)) + '%';
return Number(v ?? 0).toLocaleString(undefined, { style:'currency', currency:'USD', maximumFractionDigits: 2 });
}
},
itemStyle: { color: baseColor }
});
}
legend.push(`${asset} · Buy&Hold`)
}
this.option = {
backgroundColor: 'transparent',
grid: { left: 60, right: 160, top: 20, bottom: 60 },
animation: true,
locale: 'en',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
backgroundColor: 'rgba(255,255,255,0.9)',
borderColor: 'rgba(0,0,0,0.1)',
textStyle: { color: '#111', fontWeight: 600, fontSize: 13 },
extraCssText: 'box-shadow: 0 4px 10px rgba(0,0,0,0.08); backdrop-filter: blur(6px);',
// NEW: format per mode
valueFormatter: v => {
if (typeof v !== 'number') return v
return this.mode === 'pct'
? `${v.toFixed(2)}%`
: v.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
}
},
legend: { show: false },
xAxis: {
type: 'time',
axisLabel: {
fontWeight: 600,
color: '#333',
formatter: (value) => {
const date = new Date(value);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
},
axisLine: { lineStyle: { color: 'rgba(0,0,0,0.2)' } },
splitLine: { show: false }
},
yAxis: {
type: 'value',
scale: true,
axisLabel: {
color: '#444',
formatter: (v) => this.mode === 'pct'
? `${v.toFixed(0)}%`
: v.toLocaleString(undefined, { style:'currency', currency:'USD', maximumFractionDigits:0 })
},
splitLine: { lineStyle: { color: 'rgba(0,0,0,0.05)' } }
},
dataZoom: [{ type: 'inside', throttle: 50 }, { type: 'slider', height: 14, bottom: 36 }],
series
}
}
}
})
</script>
<style scoped>
.chart-wrap {
width: 100%;
background: linear-gradient(180deg, #ffffff 0%, #f7f9fb 100%);
border: 1px solid rgba(0,0,0,0.05);
border-radius: 16px;
box-shadow: 0 4px 16px rgba(0,0,0,0.04);
overflow: hidden;
padding: 10px;
}
.h-96 { height: 24rem; }
:deep(.echarts-tooltip) {
font-family: 'Inter', sans-serif;
backdrop-filter: blur(8px);
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
}
</style>