Jimin Huang commited on
Commit
379564a
·
1 Parent(s): cf81b3e

Change settings

Browse files
Files changed (1) hide show
  1. src/components/CompareChartE.vue +315 -370
src/components/CompareChartE.vue CHANGED
@@ -1,398 +1,343 @@
1
  <template>
2
- <div class="chart-wrap">
3
- <v-chart :option="option" autoresize class="h-96 w-full" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  </div>
5
  </template>
6
 
7
- <script>
8
- import { defineComponent } from 'vue'
9
- import VChart from 'vue-echarts'
10
- import * as echarts from 'echarts/core'
11
- import { LineChart, ScatterChart } from 'echarts/charts'
12
- import { GridComponent, LegendComponent, TooltipComponent, DataZoomComponent } from 'echarts/components'
13
- import { CanvasRenderer } from 'echarts/renderers'
14
 
 
15
  import { getAllDecisions } from '../lib/dataCache'
16
  import { readAllRawDecisions } from '../lib/idb'
17
  import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
18
  import { STRATEGIES } from '../lib/strategies'
19
  import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
20
- import { getStrategyColor } from '../lib/chartColors'
21
-
22
- echarts.use([LineChart, GridComponent, ScatterChart, LegendComponent, TooltipComponent, DataZoomComponent, CanvasRenderer])
23
-
24
- const ASSET_CUTOFF = {
25
- BTC: '2025-08-01',
26
- // ETH: '2025-08-15',
27
- };
28
-
29
- // Stable palette per agent (tweak as you like)
30
- const AGENT_PALETTE = [
31
- '#6D4CFE', // indigo
32
- '#FF6B6B', // coral
33
- '#10B981', // emerald
34
- '#F59E0B', // amber
35
- '#06B6D4', // cyan
36
- '#A855F7', // violet
37
- '#64748B', // slate
38
- '#E11D48', // rose
39
- '#0EA5E9', // sky
40
- '#84CC16', // lime
41
- ]
42
-
43
- // pick color by agent name + index (from agentColorIndex map)
44
- function getAgentColor(agent, idx = 0) {
45
- // keep simple + deterministic
46
- return AGENT_PALETTE[idx % AGENT_PALETTE.length]
47
  }
 
 
 
 
 
 
 
 
 
 
48
 
49
- function drawImageInCircle(ctx, img, cx, cy, radius, { mode = 'contain', padding = 0 } = {}) {
50
- if (!img) return;
51
- const r = Math.max(0, radius - padding);
52
- ctx.save();
53
- ctx.beginPath();
54
- ctx.arc(cx, cy, r, 0, Math.PI * 2);
55
- ctx.clip();
56
-
57
- const iw = img.naturalWidth || img.width;
58
- const ih = img.naturalHeight || img.height;
59
- if (!iw || !ih) { ctx.restore(); return; }
60
-
61
- // target box (square) that we want to fill with the image
62
- const tw = r * 2;
63
- const th = r * 2;
64
-
65
- // scale (cover vs contain)
66
- const scale = (mode === 'contain')
67
- ? Math.min(tw / iw, th / ih)
68
- : Math.max(tw / iw, th / ih); // cover (default)
69
-
70
- const dw = iw * scale;
71
- const dh = ih * scale;
72
-
73
- // center align
74
- const dx = cx - dw / 2;
75
- const dy = cy - dh / 2;
76
-
77
- ctx.imageSmoothingEnabled = true;
78
- ctx.imageSmoothingQuality = 'high';
79
- ctx.drawImage(img, dx, dy, dw, dh);
80
- ctx.restore();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
82
 
 
 
 
 
83
 
84
- // helper: convert [ [date, y], ... ] into % since first y
85
- function toPct(points){
86
- if (!Array.isArray(points) || !points.length) return points
87
- const y0 = points[0][1]
88
- if (typeof y0 !== 'number' || !isFinite(y0) || y0 === 0) return points
89
- return points.map(([t, y]) => [t, ((y / y0) - 1) * 100])
 
 
 
 
 
 
 
 
 
 
 
90
  }
91
 
92
- // --- Agent & Model logo registries (fill with your actual assets)
93
- const AGENT_LOGOS = {
94
- 'HedgeFundAgent': new URL('../assets/images/agents_images/hedgefund.png', import.meta.url).href,
95
- 'DeepFundAgent': new URL('../assets/images/agents_images/deepfund.png', import.meta.url).href,
96
- 'TradeAgent': new URL('../assets/images/agents_images/trade.png', import.meta.url).href,
97
- 'InvestorAgent': new URL('../assets/images/agents_images/investor.png', import.meta.url).href,
98
- 'BTC': new URL('../assets/images/assets_images/BTC.png', import.meta.url).href,
99
- 'ETH': new URL('../assets/images/assets_images/ETH.png', import.meta.url).href,
100
- 'MSFT': new URL('../assets/images/assets_images/MSFT.png', import.meta.url).href,
101
- 'BMRN': new URL('../assets/images/assets_images/BMRN.png', import.meta.url).href,
102
- 'MRNA': new URL('../assets/images/assets_images/MRNA.png', import.meta.url).href,
103
- 'TSLA': new URL('../assets/images/assets_images/TSLA.png', import.meta.url).href,
104
- // 'InvestorAgent': new URL('../assets/images/agents_images/investor.png', import.meta.url).href,
105
- };
106
- const MODEL_LOGOS = {
107
- 'claude_3_5_haiku_20241022': new URL('../assets/images/models_images/claude.png', import.meta.url).href,
108
- 'claude_sonnet_4_2025051': new URL('../assets/images/models_images/claude.png', import.meta.url).href,
109
- 'gpt_4o': new URL('../assets/images/models_images/gpt.png', import.meta.url).href,
110
- 'gpt_4.1': new URL('../assets/images/models_images/gpt.png', import.meta.url).href,
111
- 'gemini_2.0_flash': new URL('../assets/images/models_images/gemini.png', import.meta.url).href,
112
- };
113
-
114
- // Canvas badge cache: key = `${agent}|${model}|${color}`
115
- const BADGE_CACHE = new Map();
116
-
117
- const loadImg = (url) => new Promise((resolve, reject) => {
118
- if (!url) return resolve(null);
119
- const img = new Image();
120
- img.crossOrigin = 'anonymous';
121
- img.onload = () => resolve(img);
122
- img.onerror = () => resolve(null); // fail soft
123
- img.src = url;
124
- });
125
-
126
- // Compose a badge: [ colored circle with agent logo ] + [ white rounded square with model logo ]
127
- async function composeBadge(agent, model, color = '#666') {
128
- const key = `circ|${agent}|${model ?? ''}|${color}`;
129
- if (BADGE_CACHE.has(key)) return BADGE_CACHE.get(key);
130
-
131
- const aImg = await loadImg(AGENT_LOGOS[agent]);
132
- const mImg = await loadImg(MODEL_LOGOS[model]);
133
-
134
- // uniform canvas
135
- const S = 30; // badge size (px)
136
- const R = S / 2;
137
- const canvas = document.createElement('canvas');
138
- canvas.width = S; canvas.height = S;
139
- const ctx = canvas.getContext('2d');
140
-
141
- const ring = 3; // outer colored ring thickness
142
- const padImg = 4; // extra breathing room for the logo
143
-
144
- // base colored circle (ring)
145
- ctx.fillStyle = color;
146
- ctx.beginPath(); ctx.arc(R, R, R, 0, Math.PI * 2); ctx.fill();
147
-
148
- // inner white disk
149
- ctx.fillStyle = '#fff';
150
- ctx.beginPath(); ctx.arc(R, R, R - ring, 0, Math.PI * 2); ctx.fill();
151
-
152
- // agent logo (fit into inner circle)
153
- if (aImg) {
154
- drawImageInCircle(ctx, aImg, R, R, R - ring, { mode: 'contain', padding: padImg });
155
  }
156
 
157
- // model puck (bottom-right)
158
- if (mImg) {
159
- const d = 20, r = d / 2; // puck diameter
160
- const cx = S - r + 1, cy = S - r + 1; // slight outside bias
161
- // white border
162
- ctx.fillStyle = '#fff';
163
- ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
164
- // inner image circle
165
- ctx.save();
166
- ctx.beginPath(); ctx.arc(cx, cy, r - 1.5, 0, Math.PI * 2); ctx.clip();
167
- const imgInset = 3;
168
- ctx.drawImage(mImg, cx - r + imgInset, cy - r + imgInset, d - imgInset * 2, d - imgInset * 2);
169
- ctx.restore();
170
  }
171
 
172
- const url = canvas.toDataURL('image/png');
173
- BADGE_CACHE.set(key, url);
174
- return url;
175
- }
 
 
 
 
 
 
 
 
 
176
 
177
- let markerToLine = new Map()
178
-
179
- export default defineComponent({
180
- name: 'CompareChartE',
181
- components: { VChart },
182
- props: {
183
- selected: { type: Array, default: () => [] },
184
- visible: { type: Boolean, default: true },
185
- // NEW: $/% toggle
186
- mode: { type: String, default: 'usd' } // 'usd' | 'pct'
187
- },
188
- data(){ return { option: {} } },
189
- watch: {
190
- selected: { deep: true, handler(){ this.rebuild() } },
191
- visible(v){ if (v) this.$nextTick(() => this.rebuild()) },
192
- // NEW: rebuild when the toggle changes
193
- mode(){ this.rebuild() }
194
- },
195
- mounted(){ this.$nextTick(() => this.rebuild()) },
196
- methods: {
197
- async getAll(){
198
- let all = getAllDecisions() || []
199
- if (!all.length) {
200
- try { const cached = await readAllRawDecisions(); if (cached?.length) all = cached } catch {}
201
- }
202
- return all
203
- },
204
- async rebuild(){
205
- if (!this.visible) return
206
- const selected = Array.isArray(this.selected) ? this.selected : []
207
- const all = await this.getAll()
208
- const groupKeyToSeq = new Map()
209
-
210
- // 1) Build sequences exactly like CompareChart.vue
211
- for (const sel of selected) {
212
- const { agent_name: agent, asset, model } = sel
213
- const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
214
- let seq = ids.length ? all.filter(r => ids.includes(r.id))
215
- : all.filter(r => r.agent_name === agent && r.asset === asset && r.model === model)
216
- seq.sort((a,b) => (a.date > b.date ? 1 : -1))
217
- const isCrypto = asset === 'BTC' || asset === 'ETH'
218
- let filtered = isCrypto ? seq : await filterRowsToNyseTradingDays(seq)
219
-
220
- // --- asset-specific cutoff ---
221
- const cutoff = ASSET_CUTOFF[asset]
222
- if (cutoff) {
223
- const t0 = new Date(cutoff + 'T00:00:00Z')
224
- filtered = filtered.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
225
- }
226
- groupKeyToSeq.set(`${agent}|${asset}|${model}`, { sel, seq: filtered })
227
- }
228
-
229
- // 2) Build series using (time,value) pairs
230
- const series = []
231
- const legend = []
232
- const assets = new Set()
233
- const agentColorIndex = new Map()
234
-
235
- for (const [_, { sel, seq }] of groupKeyToSeq.entries()) {
236
- if (!seq.length) continue
237
- const agent = sel.agent_name
238
- const asset = sel.asset
239
- assets.add(asset)
240
-
241
- const idx = agentColorIndex.get(agent) ?? agentColorIndex.size
242
- agentColorIndex.set(agent, idx)
243
-
244
- const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005, label: 'Selected' }
245
- const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
246
- let points = seq.map((row, i) => [row.date, stratY[i]])
247
-
248
- // NEW: convert to % mode if requested
249
- if (this.mode === 'pct') points = toPct(points)
250
-
251
- const name = `${agent} · ${sel.model} · ${cfg.label}`
252
- legend.push(name)
253
- series.push({
254
- name,
255
- type: 'line',
256
- showSymbol: false,
257
- smooth: false,
258
- emphasis: {
259
- focus: 'series',
260
- lineStyle: { width: 3.5 },
261
- },
262
- lineStyle: { width: 2, color: getAgentColor(agent, idx) },
263
- data: points
264
- })
265
- const lineSeriesIndex = series.length - 1;
266
- const last = points?.[points.length - 1];
267
- if (last && Number.isFinite(last[1])) {
268
- const lineColor = getAgentColor(agent, idx);
269
- const badgeUrl = await composeBadge(agent, null, lineColor); // ← NEW
270
- series.push({
271
- name: name + ' •badge',
272
- type: 'scatter',
273
- data: [ last ],
274
- symbol: badgeUrl ? `image://${badgeUrl}` : 'circle',
275
- symbolSize: 30,
276
- z: 20,
277
- tooltip: {
278
- trigger: 'item',
279
- appendToBody: true,
280
- formatter: (p) => {
281
- const v = p.value?.[1]
282
- const val = this.mode === 'pct'
283
- ? `${v >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
284
- : Number(v ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
285
- return [
286
- `<div style="font-weight:600">${sel.agent_name}</div>`,
287
- sel.model ? `<div style="opacity:.8">${sel.model}</div>` : '',
288
- `<div style="opacity:.8">${sel.asset}</div>`,
289
- `<div style="margin-top:4px">${val}</div>`
290
- ].join('')
291
- }
292
- },
293
- label: {
294
- show: true,
295
- position: 'right',
296
- padding: [4,8],
297
- borderRadius: 10,
298
- backgroundColor: lineColor,
299
- color: '#fff',
300
- fontWeight: 700,
301
- formatter: (p) => {
302
- const v = p.value?.[1];
303
- if (this.mode === 'pct') return (v >= 0 ? '+' : '') + (Number(v).toFixed(2)) + '%';
304
- return Number(v ?? 0).toLocaleString(undefined, { style:'currency', currency:'USD', maximumFractionDigits: 2 });
305
- }
306
- },
307
- itemStyle: { color: lineColor }
308
- });
309
- }
310
- }
311
-
312
- // 3) Buy & Hold baseline per asset
313
- for (const asset of assets) {
314
- const entry = [...groupKeyToSeq.values()].find(v => v.sel.asset === asset)
315
- if (!entry) continue
316
- const bhY = computeBuyHoldEquity(entry.seq, 100000) || []
317
- let bhPoints = entry.seq.map((row, i) => [row.date, bhY[i]])
318
-
319
- // NEW: % mode for baseline too
320
- if (this.mode === 'pct') bhPoints = toPct(bhPoints)
321
-
322
- series.push({
323
- name: `${asset} · Buy&Hold`,
324
- type: 'line',
325
- showSymbol: false,
326
- lineStyle: { width: 1.5, type: 'dashed' },
327
- color: getStrategyColor('', true, 0),
328
- data: bhPoints
329
- })
330
- const lastBH = bhPoints[bhPoints.length - 1]
331
- if (lastBH && Number.isFinite(lastBH[1])) {
332
- const baseColor = getStrategyColor('', true, 0);
333
- const badgeUrl = await composeBadge(asset, null, baseColor); // ← NEW
334
- series.push({
335
- name: `${asset} · Buy&Hold •badge`,
336
- type: 'scatter',
337
- data: [ lastBH ],
338
- symbol: badgeUrl ? `image://${badgeUrl}` : 'circle',
339
- symbolSize: 30,
340
- z: 19,
341
- tooltip: { show: false },
342
- label: {
343
- show: true,
344
- position: 'right',
345
- padding: [4,8],
346
- borderRadius: 10,
347
- backgroundColor: baseColor,
348
- color: '#fff',
349
- fontWeight: 700,
350
- formatter: (p) => {
351
- const v = p.value?.[1];
352
- if (this.mode === 'pct') return (v >= 0 ? '+' : '') + (Number(v).toFixed(2)) + '%';
353
- return Number(v ?? 0).toLocaleString(undefined, { style:'currency', currency:'USD', maximumFractionDigits: 2 });
354
- }
355
- },
356
- itemStyle: { color: baseColor }
357
- });
358
- }
359
- legend.push(`${asset} · Buy&Hold`)
360
- }
361
-
362
- this.option = {
363
- animation: true,
364
- grid: { left: 64, right: 200, top: 8, bottom: 52 },
365
- tooltip: {
366
- trigger: 'axis',
367
- axisPointer: { type: 'line' },
368
- // NEW: format per mode
369
- valueFormatter: v => {
370
- if (typeof v !== 'number') return v
371
- return this.mode === 'pct'
372
- ? `${v.toFixed(2)}%`
373
- : v.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
374
- }
375
- },
376
- legend: { show: false },
377
- xAxis: { type: 'time' },
378
- yAxis: this.mode === 'pct'
379
- ? {
380
- type: 'value', scale: true,
381
- axisLabel: { formatter: v => `${Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}%` }
382
- }
383
- : {
384
- type: 'value', scale: true,
385
- axisLabel: { formatter: v => Number(v).toLocaleString(undefined, {style:'currency', currency:'USD', maximumFractionDigits:0 }) }
386
- },
387
- dataZoom: [{ type: 'inside', throttle: 50 }, { type: 'slider', height: 14, bottom: 36 }],
388
- series
389
- }
390
  }
391
- }
 
 
 
 
 
 
392
  })
393
  </script>
394
 
395
  <style scoped>
396
- .chart-wrap { width: 100%; }
397
- .h-96 { height: 24rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  </style>
 
1
  <template>
2
+ <div class="live">
3
+ <!-- Toolbar: assets + mode -->
4
+ <header class="toolbar">
5
+ <div class="toolbar__left">
6
+ <AssetTabs v-model="asset" :ordered-assets="orderedAssets" />
7
+ </div>
8
+ <div class="toolbar__right">
9
+ <div class="mode">
10
+ <button class="mode__btn" :class="{ 'is-active': mode==='usd' }" @click="mode='usd'">$</button>
11
+ <button class="mode__btn" :class="{ 'is-active': mode==='pct' }" @click="mode='pct'">%</button>
12
+ </div>
13
+ </div>
14
+ </header>
15
+
16
+ <!-- Chart -->
17
+ <section class="panel panel--chart">
18
+ <CompareChartE
19
+ v-if="winnersForChart.length"
20
+ :selected="winnersForChart"
21
+ :visible="true"
22
+ :mode="mode"
23
+ />
24
+ <div v-else class="empty">
25
+ No data for <strong>{{ asset }}</strong> yet. Check Supabase runs or try another asset.
26
+ </div>
27
+ </section>
28
+
29
+ <!-- Cards: Buy & Hold + top 4 agents (computed with perf helpers) -->
30
+ <section class="panel panel--cards" v-if="cards.length">
31
+ <div class="cards5">
32
+ <div
33
+ v-for="c in cards"
34
+ :key="c.key"
35
+ class="card"
36
+ :class="{ 'card--bh': c.kind==='bh', 'card--winner': c.isWinner }"
37
+ >
38
+ <div class="card__header">
39
+ <div class="card__logo">
40
+ <img v-if="c.logo" :src="c.logo" alt="" />
41
+ <div v-else class="card__logo-fallback"></div>
42
+ <span v-if="c.isWinner" class="card__badge" aria-label="Top performer">👑</span>
43
+ </div>
44
+
45
+ <div class="card__title" :title="c.title">{{ c.title }}</div>
46
+ <div class="card__balance">{{ fmtUSD(c.balance) }}</div>
47
+ </div>
48
+
49
+ <div class="card__meta">
50
+ <div class="card__sub ellipsize" :title="c.subtitle">{{ c.subtitle }}</div>
51
+
52
+ <template v-if="c.kind==='agent' && c.gapUsd != null">
53
+ <div class="pill" :class="{ neg: c.gapUsd < 0 && !c.isWinner, pos: c.gapUsd >= 0 || c.isWinner }">
54
+ <span v-if="mode==='usd'">{{ signedMoney(c.gapUsd) }}</span>
55
+ <span v-else>{{ signedPct(c.gapPct) }}</span>
56
+ </div>
57
+ </template>
58
+
59
+ <template v-else>
60
+ <div class="pill pill--neutral">Buy&nbsp;&amp;&nbsp;Hold</div>
61
+ </template>
62
+ </div>
63
+
64
+ <div class="card__footer">
65
+ <div class="card__sub">EOD {{ c.date ? new Date(c.date).toLocaleDateString() : '–' }}</div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </section>
70
  </div>
71
  </template>
72
 
73
+ <script setup>
74
+ import { ref, computed, onMounted, watchEffect } from 'vue'
75
+ import AssetTabs from '../components/AssetTabs.vue'
76
+ import CompareChartE from '../components/CompareChartE.vue'
77
+ import { dataService } from '../lib/dataService'
 
 
78
 
79
+ /* —— use the same helpers as the chart —— */
80
  import { getAllDecisions } from '../lib/dataCache'
81
  import { readAllRawDecisions } from '../lib/idb'
82
  import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
83
  import { STRATEGIES } from '../lib/strategies'
84
  import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
85
+
86
+ /* ---------- config ---------- */
87
+ const orderedAssets = ['BTC','ETH','MSFT','BMRN','TSLA'] // (MRNA removed)
88
+ const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla']) // case-insensitive
89
+
90
+ // optional logos
91
+ const AGENT_LOGOS = {
92
+ // 'DeepFundAgent': new URL('../assets/images/agents/deepfund.png', import.meta.url).href,
93
+ // 'InvestorAgent': new URL('../assets/images/agents/investor.png', import.meta.url).href,
94
+ // 'TradeAgent': new URL('../assets/images/agents/trade.png', import.meta.url).href,
95
+ // 'HedgeFundAgent': new URL('../assets/images/agents/hedge.png', import.meta.url).href,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  }
97
+ const ASSET_ICONS = {
98
+ BTC: new URL('../assets/images/assets_images/BTC.png', import.meta.url).href,
99
+ ETH: new URL('../assets/images/assets_images/ETH.png', import.meta.url).href,
100
+ MSFT: new URL('../assets/images/assets_images/MSFT.png', import.meta.url).href,
101
+ BMRN: new URL('../assets/images/assets_images/BMRN.png', import.meta.url).href,
102
+ TSLA: new URL('../assets/images/assets_images/TSLA.png', import.meta.url).href,
103
+ }
104
+
105
+ /* match the chart’s cutoff so numbers align */
106
+ const ASSET_CUTOFF = { BTC: '2025-08-01' }
107
 
108
+ /* ---------- state ---------- */
109
+ const mode = ref('usd') // 'usd' | 'pct'
110
+ const asset = ref('BTC')
111
+ const rowsRef = ref([])
112
+
113
+ let allDecisions = [] // in-memory decisions for perf calc
114
+
115
+ /* ---------- bootstrap ---------- */
116
+ onMounted(async () => {
117
+ try {
118
+ if (!dataService.loaded && !dataService.loading) {
119
+ await dataService.load(false)
120
+ }
121
+ } catch (e) {
122
+ console.error('LiveView: dataService.load failed', e)
123
+ }
124
+ rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
125
+ if (!orderedAssets.includes(asset.value)) asset.value = orderedAssets[0]
126
+
127
+ // pull the same decisions cache the chart uses
128
+ allDecisions = getAllDecisions() || []
129
+ if (!allDecisions.length) {
130
+ try {
131
+ const cached = await readAllRawDecisions()
132
+ if (cached?.length) allDecisions = cached
133
+ } catch {}
134
+ }
135
+ })
136
+
137
+ /* ---------- helpers ---------- */
138
+ function score(row) {
139
+ return typeof row.balance === 'number' ? row.balance : -Infinity
140
+ }
141
+ const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
142
+ const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}`
143
+ const signedPct = (p) => `${(p >= 0 ? '+' : '−')}${Math.abs(p * 100).toFixed(2)}%`
144
+
145
+ /* Selected-asset rows (exclude vanilla/vinilla) */
146
+ const filteredRows = computed(() =>
147
+ (rowsRef.value || []).filter(r => {
148
+ if (r.asset !== asset.value) return false
149
+ const name = (r?.agent_name || '').toLowerCase()
150
+ return !EXCLUDED_AGENT_NAMES.has(name)
151
+ })
152
+ )
153
+
154
+ /* Best model per agent (by balance) — still from leaderboard rows for picking winners */
155
+ const winners = computed(() => {
156
+ const byAgent = new Map()
157
+ for (const r of filteredRows.value) {
158
+ const k = r.agent_name
159
+ const cur = byAgent.get(k)
160
+ if (!cur || score(r) > score(cur)) byAgent.set(k, r)
161
+ }
162
+ return [...byAgent.values()]
163
+ })
164
+
165
+ /* Chart selections built from winners */
166
+ const winnersForChart = computed(() =>
167
+ winners.value.map(w => ({
168
+ agent_name: w.agent_name,
169
+ asset: w.asset,
170
+ model: w.model,
171
+ strategy: w.strategy,
172
+ decision_ids: Array.isArray(w.decision_ids) ? w.decision_ids : undefined
173
+ }))
174
+ )
175
+
176
+ /* ---------- PERF: compute B&H + strategy the same way as the chart ---------- */
177
+
178
+ /** build the ordered decision seq for a selection (same logic as chart) */
179
+ function buildSeq(sel) {
180
+ const { agent_name: agent, asset, model } = sel
181
+ const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
182
+ let seq = ids.length
183
+ ? allDecisions.filter(r => ids.includes(r.id))
184
+ : allDecisions.filter(r => r.agent_name === agent && r.asset === asset && r.model === model)
185
+
186
+ seq.sort((a, b) => (a.date > b.date ? 1 : -1))
187
+
188
+ const isCrypto = asset === 'BTC' || asset === 'ETH'
189
+ let filtered = isCrypto ? seq : filterRowsToNyseTradingDays(seq)
190
+
191
+ const cutoff = ASSET_CUTOFF[asset]
192
+ if (cutoff) {
193
+ const t0 = new Date(cutoff + 'T00:00:00Z')
194
+ filtered = filtered.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
195
+ }
196
+ return filtered
197
  }
198
 
199
+ /** compute final equity & aligned B&H for a selection */
200
+ function computeEquities(sel) {
201
+ const seq = buildSeq(sel)
202
+ if (!seq.length) return null
203
 
204
+ // strategy params (mirror CompareChartE)
205
+ const cfg =
206
+ (STRATEGIES || []).find(s => s.id === sel.strategy) ||
207
+ { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
208
+
209
+ const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
210
+ const bhY = computeBuyHoldEquity(seq, 100000) || []
211
+
212
+ const lastIdx = Math.min(stratY.length, bhY.length) - 1
213
+ if (lastIdx < 0) return null
214
+
215
+ return {
216
+ date: seq[lastIdx].date,
217
+ stratLast: stratY[lastIdx],
218
+ bhLast: bhY[lastIdx],
219
+ seq, stratY, bhY
220
+ }
221
  }
222
 
223
+ /* compute cards from perf (no leaderboard math) */
224
+ const cards = ref([])
225
+
226
+ watchEffect(() => {
227
+ if (!winnersForChart.value.length) {
228
+ cards.value = []
229
+ return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  }
231
 
232
+ // compute perf for each winner
233
+ const perfs = winnersForChart.value
234
+ .map(sel => ({ sel, perf: computeEquities(sel) }))
235
+ .filter(x => x.perf)
236
+
237
+ if (!perfs.length) {
238
+ cards.value = []
239
+ return
 
 
 
 
 
240
  }
241
 
242
+ // Buy & Hold card: use the first winner’s BH last for the asset
243
+ const first = perfs[0]
244
+ const assetCode = first.sel.asset
245
+ const bhCard = {
246
+ key: `bh|${assetCode}`,
247
+ kind: 'bh',
248
+ title: 'Buy & Hold',
249
+ subtitle: assetCode,
250
+ balance: first.perf.bhLast,
251
+ date: first.perf.date,
252
+ logo: ASSET_ICONS[assetCode] || null,
253
+ isWinner: false
254
+ }
255
 
256
+ // agent cards and winner flag
257
+ const agentCards = perfs.map(({ sel, perf }) => {
258
+ const gapUsd = perf.stratLast - perf.bhLast
259
+ const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0
260
+ return {
261
+ key: `agent|${sel.agent_name}|${sel.model}`,
262
+ kind: 'agent',
263
+ title: sel.agent_name,
264
+ subtitle: sel.model,
265
+ balance: perf.stratLast,
266
+ date: perf.date,
267
+ logo: AGENT_LOGOS[sel.agent_name] || null,
268
+ gapUsd, gapPct,
269
+ isWinner: false // set below
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  }
271
+ })
272
+
273
+ const maxBal = Math.max(...agentCards.map(c => c.balance ?? -Infinity))
274
+ agentCards.forEach(c => { c.isWinner = c.balance === maxBal })
275
+
276
+ // top 4 agents + BH card
277
+ cards.value = [bhCard, ...agentCards.sort((a,b) => b.balance - a.balance).slice(0,4)]
278
  })
279
  </script>
280
 
281
  <style scoped>
282
+ .live { max-width: 1280px; margin: 0 auto; padding: 12px 20px 28px; }
283
+
284
+ /* toolbar */
285
+ .toolbar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0 10px; background: #fff; }
286
+ .toolbar__left { min-width: 0; }
287
+ .toolbar__right { display: flex; align-items: center; gap: 10px; }
288
+
289
+ /* $/% toggle */
290
+ .mode { display: inline-flex; gap: 8px; }
291
+ .mode__btn { height: 32px; min-width: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid #D6DAE1; background: #fff; font-weight: 700; color: #0F172A; transition: all .12s ease; }
292
+ .mode__btn:hover { transform: translateY(-1px); }
293
+ .mode__btn.is-active { background: #0F172A; color: #fff; border-color: #0F172A; }
294
+
295
+ /* panels */
296
+ .panel { background: #fff; border: 1px solid #EDF0F4; border-radius: 14px; }
297
+ .panel--chart { padding: 10px 10px 2px; }
298
+ .panel--cards { padding: 12px; }
299
+
300
+ /* empty */
301
+ .empty { padding: 14px; border: 1px dashed #D6DAE1; border-radius: 12px; color: #6B7280; font-size: .92rem; }
302
+
303
+ /* 5 cards in a row */
304
+ .cards5 { display: grid; gap: 12px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
305
+ @media (max-width: 1200px) { .cards5 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
306
+ @media (max-width: 720px) { .cards5 { grid-template-columns: 1fr; } }
307
+
308
+ /* card */
309
+ .card { display: grid; grid-template-rows: auto auto auto; gap: 8px; padding: 12px 14px; border: 1px solid #EEF1F6; border-radius: 14px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.04); position: relative; }
310
+ .card--bh { outline: 2px dashed rgba(15,23,42,.08); }
311
+ .card--winner { border-color: #16a34a; box-shadow: 0 0 0 3px rgba(22,163,74,.12); }
312
+
313
+ /* header layout: logo | title | balance */
314
+ .card__header { display: grid; grid-template-columns: 52px minmax(0,1fr) auto; align-items: start; gap: 12px; }
315
+
316
+ /* logo */
317
+ .card__logo { width: 44px; height: 44px; border-radius: 999px; background: #F3F4F6; display: grid; place-items: center; overflow: hidden; position: relative; }
318
+ .card__logo img { width: 100%; height: 100%; object-fit: contain; }
319
+ .card__logo-fallback { width: 60%; height: 60%; border-radius: 999px; background: #E5E7EB; }
320
+ .card__badge { position: absolute; right: -6px; top: -6px; font-size: 16px; filter: drop-shadow(0 1px 1px rgba(0,0,0,.15)); }
321
+
322
+ /* title: clamp to 2 lines so balance never overlaps */
323
+ .card__title {
324
+ min-width: 0; font-weight: 800; color: #0F172A;
325
+ white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
326
+ overflow: hidden; line-height: 1.15;
327
+ }
328
+
329
+ /* right-side balance never shrinks */
330
+ .card__balance { white-space: nowrap; font-weight: 900; color: #0F172A; font-size: 20px; }
331
+
332
+ /* meta row + footer */
333
+ .card__meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
334
+ .card__sub { font-size: 12px; color: #5B6476; opacity: .85; }
335
+ .ellipsize { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
336
+ .card__footer { margin-top: -2px; }
337
+
338
+ /* pills */
339
+ .pill { padding: 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 800; line-height: 1; white-space: nowrap; background: #EEF2F7; color: #0F172A; }
340
+ .pill.neg { background: #FEE2E2; color: #B91C1C; }
341
+ .pill.pos { background: #DCFCE7; color: #166534; }
342
+ .pill.pill--neutral { background: #EEF2F7; color: #0F172A; }
343
  </style>