Spaces:
Running
Running
File size: 14,872 Bytes
4ee4763 915f68d adc1d18 4ee4763 915f68d adc1d18 915f68d 4e27475 915f68d 4e27475 379564a 915f68d 99fcf98 379564a 915f68d 379564a 915f68d 2b8f9b8 915f68d a2b7663 379564a 915f68d 379564a 915f68d 83f8ea6 915f68d a2b7663 915f68d 2e6ec86 915f68d 0396965 915f68d 4ee4763 915f68d 4ee4763 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 |
<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>
|