Agent-Market-Arena / src /components /CompareChartE.vue
lfqian's picture
fix: change x-axis date format from Chinese to English (e.g. Aug 1)
0396965
raw
history blame
14.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: '#6D4CFE',
DeepFundAgent: '#FF6B6B',
TradeAgent: '#10B981',
InvestorAgent: '#F59E0B'
}
// 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: {
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} · ${sel.model} · ${cfg.label}`
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">${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 = {
animation: true,
locale: 'en',
grid: { left: 64, right: 200, top: 8, bottom: 52 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
// 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: {
formatter: (value) => {
const date = new Date(value);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
}
},
yAxis: this.mode === 'pct'
? {
type: 'value', scale: true,
axisLabel: { formatter: v => `${Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}%` }
}
: {
type: 'value', scale: true,
axisLabel: { formatter: v => Number(v).toLocaleString(undefined, {style:'currency', currency:'USD', maximumFractionDigits:0 }) }
},
dataZoom: [{ type: 'inside', throttle: 50 }, { type: 'slider', height: 14, bottom: 36 }],
series
}
}
}
})
</script>
<style scoped>
.chart-wrap { width: 100%; }
.h-96 { height: 24rem; }
</style>