Spaces:
Running
Running
| <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 [ | |
| `{sel.agent_name}`, | |
| sel.model ? `{sel.model}` : '', | |
| `{sel.asset}`, | |
| `{val}` | |
| ].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> | |