Jimin Huang commited on
Commit
2e529ca
·
1 Parent(s): 09a48df

Change settings

Browse files
Files changed (1) hide show
  1. src/components/CompareChartE.vue +372 -258
src/components/CompareChartE.vue CHANGED
@@ -1,284 +1,398 @@
1
  <template>
2
- <div class="live-view px-4 py-3 space-y-3">
3
- <!-- Asset tabs (logos, no prices) -->
4
- <AssetTabs v-model="asset" />
5
-
6
- <!-- $ / % mode switch -->
7
- <div class="flex items-center gap-2">
8
- <button
9
- class="switch-btn"
10
- :class="mode==='usd' ? 'switch-btn--active' : ''"
11
- @click="mode='usd'">
12
- $
13
- </button>
14
- <button
15
- class="switch-btn"
16
- :class="mode==='pct' ? 'switch-btn--active' : ''"
17
- @click="mode='pct'">
18
- %
19
- </button>
20
- </div>
21
-
22
- <!-- Chart -->
23
- <CompareChartE
24
- v-if="winnersForChart.length"
25
- :selected="winnersForChart"
26
- :visible="true"
27
- :mode="mode"
28
- />
29
- <div v-else class="empty">
30
- No data for <strong>{{ asset }}</strong> yet. Check Supabase runs or try another asset.
31
- </div>
32
-
33
- <!-- Winner cards (best final equity per agent; computed like the chart) -->
34
- <div v-if="winnersSorted.length" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
35
- <div
36
- v-for="row in winnersSorted"
37
- :key="row.agent_name+'|'+row.asset+'|'+row.model"
38
- class="card">
39
- <div class="min-w-0">
40
- <div class="card-title truncate" :title="row.agent_name">{{ row.agent_name }}</div>
41
- <div class="card-sub truncate" :title="row.model">{{ row.model }}</div>
42
- </div>
43
- <div class="text-right">
44
- <div class="card-balance">{{ fmtUSD(row.balance) }}</div>
45
- <div class="card-sub">
46
- EOD {{ row.end_date ? new Date(row.end_date).toLocaleDateString() : (row.last_nav_ts ? new Date(row.last_nav_ts).toLocaleDateString() : '-') }}
47
- </div>
48
- </div>
49
- </div>
50
- </div>
51
-
52
- <div v-else class="empty">
53
- No winners for <strong>{{ asset }}</strong> yet.
54
- </div>
55
  </div>
56
  </template>
57
 
58
- <script setup>
59
- import { ref, computed, onMounted, shallowRef, watch } from 'vue'
60
- import AssetTabs from '../components/AssetTabs.vue'
61
- import CompareChartE from '../components/CompareChartE.vue'
62
- import { dataService } from '../lib/dataService'
 
 
63
 
64
- /* === use the same helpers as the chart === */
65
  import { getAllDecisions } from '../lib/dataCache'
66
  import { readAllRawDecisions } from '../lib/idb'
67
  import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
68
  import { STRATEGIES } from '../lib/strategies'
69
  import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
70
-
71
- /* ---- state ---- */
72
- const mode = ref('usd') // 'usd' | 'pct'
73
- const asset = ref('BTC')
74
- const agents = ref([]) // leaderboard/table rows
75
- let allDecisions = [] // raw decisions used for perf compute
76
-
77
- /* keep chart parity: cut BTC before Aug 1, 2025 */
78
- const ASSET_CUTOFF = { BTC: '2025-08-01' }
79
- const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla']) // case-insensitive
80
-
81
- /* ---- load once ---- */
82
- onMounted(async () => {
83
- try {
84
- if (!dataService.loaded && !dataService.loading) {
85
- await dataService.load(false) // populates tableRows & cache
86
- }
87
- } catch (e) {
88
- console.error('LiveView: dataService.load failed', e)
89
- }
90
- agents.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
91
-
92
- // decisions cache for perf (same source the chart uses)
93
- allDecisions = getAllDecisions() || []
94
- if (!allDecisions.length) {
95
- try {
96
- const cached = await readAllRawDecisions()
97
- if (cached?.length) allDecisions = cached
98
- } catch {}
99
- }
100
- })
101
-
102
- /* ---- helpers ---- */
103
- function score(row) {
104
- return typeof row.balance === 'number' ? row.balance : -Infinity
105
  }
106
- const fmtUSD = (n) =>
107
- (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
108
-
109
- /* winners by agent (from leaderboard rows), filtered by asset & excluding vanilla/vinilla */
110
- const winners = computed(() => {
111
- const rows = (agents.value || []).filter(r => {
112
- if (r.asset !== asset.value) return false
113
- const name = (r?.agent_name || '').toLowerCase()
114
- return !EXCLUDED_AGENT_NAMES.has(name)
115
- })
116
- const byAgent = new Map()
117
- for (const r of rows) {
118
- const k = r.agent_name
119
- const cur = byAgent.get(k)
120
- if (!cur || score(r) > score(cur)) byAgent.set(k, r)
121
- }
122
- return [...byAgent.values()]
123
- })
124
 
125
- /* selections for the chart */
126
- const winnersForChart = computed(() =>
127
- winners.value.map(w => ({
128
- agent_name: w.agent_name,
129
- asset: w.asset,
130
- model: w.model,
131
- strategy: w.strategy,
132
- decision_ids: Array.isArray(w.decision_ids) ? w.decision_ids : undefined
133
- }))
134
- )
135
-
136
- /* stable key to avoid watcher re-trigger due to new object identities */
137
- const winnersKey = computed(() => {
138
- const sels = winnersForChart.value || []
139
- return sels
140
- .map(s => `${s.agent_name}|${s.asset}|${s.model}|${s.strategy}|${(s.decision_ids?.length||0)}`)
141
- .join('||')
142
- })
143
-
144
- /* ========== PERF: compute bottom-card balances exactly like the chart ========== */
 
 
 
 
 
 
 
 
 
 
 
 
 
145
 
146
- /** async: build ordered decision seq for a selection (await NYSE filter; apply cutoff) */
147
- async function buildSeq(sel) {
148
- const { agent_name: agentName, asset: assetCode, model } = sel
149
- const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
150
- let seq = ids.length
151
- ? allDecisions.filter(r => ids.includes(r.id))
152
- : allDecisions.filter(r => r.agent_name === agentName && r.asset === assetCode && r.model === model)
153
 
154
- seq.sort((a,b) => (a.date > b.date ? 1 : -1))
 
 
 
 
 
 
155
 
156
- const isCrypto = assetCode === 'BTC' || assetCode === 'ETH'
157
- let filtered = seq
158
- if (!isCrypto) {
159
- filtered = await filterRowsToNyseTradingDays(seq)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  }
161
 
162
- const cutoff = ASSET_CUTOFF[assetCode]
163
- if (cutoff) {
164
- const t0 = new Date(cutoff + 'T00:00:00Z')
165
- filtered = filtered.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
 
 
 
 
 
 
 
 
 
166
  }
167
- return filtered
168
- }
169
-
170
- /** async: compute final equity for strategy & B&H using perf helpers */
171
- async function computeFinals(sel) {
172
- const seq = await buildSeq(sel)
173
- if (!seq.length) return null
174
-
175
- const cfg =
176
- (STRATEGIES || []).find(s => s.id === sel.strategy) ||
177
- { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
178
-
179
- const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
180
- const bhY = computeBuyHoldEquity(seq, 100000) || []
181
 
182
- const n = Math.min(stratY.length, bhY.length)
183
- if (!n) return null
184
-
185
- return {
186
- date: seq[n-1].date,
187
- stratLast: stratY[n-1],
188
- bhLast: bhY[n-1],
189
- }
190
  }
191
 
192
- /* winnersSorted used by the simple cards list (values recomputed via perf) */
193
- const winnersSorted = shallowRef([])
194
-
195
- /* serialized async watcher to compute the cards (avoid re-entrancy & identity churn) */
196
- let computing = false
197
- watch(
198
- () => [asset.value, winnersKey.value], // stable deps
199
- async () => {
200
- if (!allDecisions.length) { winnersSorted.value = []; return }
201
- if (computing) return
202
- computing = true
203
- try {
204
- const sels = winnersForChart.value || []
205
- if (!sels.length) { winnersSorted.value = []; return }
206
-
207
- const perfs = (await Promise.all(
208
- sels.map(async sel => ({ sel, perf: await computeFinals(sel) }))
209
- )).filter(x => x.perf)
210
-
211
- const uiRows = perfs.map(({ sel, perf }) => ({
212
- agent_name: sel.agent_name,
213
- model: sel.model,
214
- asset: sel.asset,
215
- strategy: sel.strategy,
216
- balance: perf.stratLast, // final strategy equity (matches chart right edge)
217
- end_date: perf.date,
218
- last_nav_ts: perf.date
219
- }))
220
-
221
- winnersSorted.value = uiRows.sort((a,b) => (b.balance ?? -Infinity) - (a.balance ?? -Infinity))
222
- } catch (e) {
223
- console.error('LiveView: perf compute failed', e)
224
- winnersSorted.value = []
225
- } finally {
226
- computing = false
227
- }
228
  },
229
- { immediate: true }
230
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  </script>
232
 
233
  <style scoped>
234
- .live-view { max-width: 1400px; margin: 0 auto; }
235
-
236
- /* toggle buttons */
237
- .switch-btn {
238
- padding: 6px 10px;
239
- border: 1px solid #1f2937; /* gray-800 */
240
- border-radius: 8px;
241
- background: #fff;
242
- font-weight: 700;
243
- line-height: 1;
244
- transition: background .12s ease, color .12s ease, transform .06s ease;
245
- }
246
- .switch-btn:hover { transform: translateY(-1px); }
247
- .switch-btn--active {
248
- background: #111827; /* gray-900 */
249
- color: #fff;
250
- border-color: #111827;
251
- }
252
-
253
- /* empty state */
254
- .empty {
255
- padding: 12px 14px;
256
- border: 1px dashed #d1d5db; /* gray-300 */
257
- border-radius: 12px;
258
- color: #6b7280; /* gray-500 */
259
- font-size: 0.9rem;
260
- }
261
-
262
- /* winner cards */
263
- .card {
264
- display: flex;
265
- justify-content: space-between;
266
- align-items: center;
267
- gap: 12px;
268
- padding: 14px 16px;
269
- border: 1px solid #e5e7eb; /* gray-200 */
270
- border-radius: 16px;
271
- box-shadow: 0 1px 2px rgba(0,0,0,0.04);
272
- background: #fff;
273
- }
274
- .card-title { font-weight: 600; }
275
- .card-sub { font-size: 12px; opacity: 0.65; }
276
- .card-balance { font-weight: 800; font-size: 18px; line-height: 1.1; }
277
-
278
- /* small util */
279
- .truncate {
280
- overflow: hidden;
281
- text-overflow: ellipsis;
282
- white-space: nowrap;
283
- }
284
  </style>
 
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>