Jimin Huang commited on
Commit
1f881b6
·
1 Parent(s): 3136a69

Change settings

Browse files
Files changed (2) hide show
  1. src/components/HeaderOpen.vue +118 -396
  2. src/views/LiveView.vue +39 -134
src/components/HeaderOpen.vue CHANGED
@@ -1,416 +1,138 @@
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
- <!-- F1 Scoreboard Cards -->
30
- <section class="panel panel--cards" v-if="cards.length">
31
- <div class="cards-grid-f1">
32
- <article
33
- v-for="c in cards"
34
- :key="c.key"
35
- class="card-f1"
36
- :class="{
37
- bh: c.kind==='bh',
38
- neg: (c.gapUsd ?? 0) < 0,
39
- pos: (c.gapUsd ?? 0) >= 0,
40
- gold: c.rank === 1,
41
- silver: c.rank === 2,
42
- bronze: c.rank === 3
43
- }"
44
- :style="{ '--bar': (c.barPct ?? 0) + '%'}"
45
- >
46
- <!-- Podium ribbon (rank 1-3 only) -->
47
- <div v-if="c.rank && c.rank <= 3" class="podium-ribbon" :data-rank="c.rank"></div>
48
-
49
- <!-- Header: logo + names -->
50
- <header class="head">
51
- <div class="logo">
52
- <img v-if="c.logo" :src="c.logo" alt="" />
53
- <div v-else class="logo__fallback" aria-hidden="true"></div>
54
- </div>
55
- <div class="names">
56
- <div class="agent">{{ c.kind==='bh' ? 'Buy & Hold' : c.title }}</div>
57
- <div class="model">{{ c.subtitle }}</div>
58
- </div>
59
- </header>
60
-
61
- <!-- Net value row -->
62
- <div class="net">
63
- <div class="net__label">Net value</div>
64
- <div class="net__value">{{ fmtUSD(c.balance) }}</div>
65
- </div>
66
-
67
- <!-- Performance bar (vs B&H) -->
68
- <div class="bar" :class="{ neg: (c.gapUsd ?? 0) < 0, pos: (c.gapUsd ?? 0) >= 0 }">
69
- <span :style="{ width: (c.barPct ?? 0) + '%' }"></span>
70
- </div>
71
-
72
- <!-- Bottom row: chips + EOD -->
73
- <div class="bottom">
74
- <div class="chips">
75
- <span class="chip" :class="{ pos: profitOf(c) >= 0, neg: profitOf(c) < 0 }">
76
- {{ signedMoney(profitOf(c)) }}
77
- </span>
78
- <span class="chip" :class="{ pos: (c.gapUsd ?? 0) >= 0, neg: (c.gapUsd ?? 0) < 0 }">
79
- <template v-if="c.kind==='agent'">
80
- <template v-if="mode==='usd'">{{ signedMoney(c.gapUsd) }}</template>
81
- <template v-else>{{ signedPct(c.gapPct) }}</template>
82
- </template>
83
- <template v-else>—</template>
84
- </span>
85
- </div>
86
- <div class="eod">EOD {{ c.date ? new Date(c.date).toLocaleDateString() : '–' }}</div>
87
- </div>
88
- </article>
89
- </div>
90
- </section>
91
-
92
- <section v-else class="panel panel--cards">
93
- <div class="empty">No card data yet for <strong>{{ asset }}</strong>.</div>
94
- </section>
95
- </div>
96
  </template>
97
 
98
- <script setup>
99
- import { ref, computed, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue'
100
- import AssetTabs from '../components/AssetTabs.vue'
101
- import CompareChartE from '../components/CompareChartE.vue'
102
- import { dataService } from '../lib/dataService'
103
-
104
- /* --- same helpers as chart --- */
105
- import { getAllDecisions } from '../lib/dataCache'
106
- import { readAllRawDecisions } from '../lib/idb'
107
- import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
108
- import { STRATEGIES } from '../lib/strategies'
109
- import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
110
-
111
- /* ---------- config ---------- */
112
- const orderedAssets = ['BTC','ETH','MSFT','BMRN','TSLA'] // (MRNA removed)
113
- const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla']) // case-insensitive
114
-
115
- const ASSET_ICONS = {
116
- BTC: new URL('../assets/images/assets_images/BTC.png', import.meta.url).href,
117
- ETH: new URL('../assets/images/assets_images/ETH.png', import.meta.url).href,
118
- MSFT: new URL('../assets/images/assets_images/MSFT.png', import.meta.url).href,
119
- BMRN: new URL('../assets/images/assets_images/BMRN.png', import.meta.url).href,
120
- TSLA: new URL('../assets/images/assets_images/TSLA.png', import.meta.url).href,
121
- }
122
- const AGENT_LOGOS = {
123
- 'TradeAgent': new URL('../assets/images/agents_images/tradeagent.png', import.meta.url).href,
124
- 'HedgeFundAgent': new URL('../assets/images/agents_images/hedgefund.png', import.meta.url).href,
125
- 'DeepFundAgent': new URL('../assets/images/agents_images/deepfund.png', import.meta.url).href,
126
- 'InvestorAgent': new URL('../assets/images/agents_images/investor.png', import.meta.url).href,
127
- }
128
- const ASSET_CUTOFF = { BTC: '2025-08-01' }
129
-
130
- /* ---------- state ---------- */
131
- const mode = ref('usd')
132
- const asset = ref('BTC')
133
- const rowsRef = ref([])
134
- let allDecisions = []
135
- const cards = shallowRef([])
136
-
137
- let unsubscribe = null
138
-
139
- /* ---------- bootstrap ---------- */
140
- onMounted(async () => {
141
- unsubscribe = dataService.subscribe((state) => {
142
- rowsRef.value = Array.isArray(state.tableRows) ? state.tableRows : []
143
- })
144
-
145
- // immediate sync with current state (in case data already loaded)
146
- rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
147
-
148
- // trigger a load only if nothing is in-flight
149
- if (!dataService.loaded && !dataService.loading) {
150
- dataService.load(false).catch(e => console.error('LiveView: load failed', e))
151
- }
152
- if (!orderedAssets.includes(asset.value)) asset.value = orderedAssets[0]
153
-
154
- allDecisions = getAllDecisions() || []
155
- if (!allDecisions.length) {
156
- try {
157
- const cached = await readAllRawDecisions()
158
- if (cached?.length) allDecisions = cached
159
- } catch {}
160
- }
161
- })
162
-
163
- onBeforeUnmount(() => {
164
- if (unsubscribe) { unsubscribe(); unsubscribe = null }
165
- })
166
-
167
- /* ---------- helpers ---------- */
168
- const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
169
- const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}`
170
- const signedPct = (p) => `${(p >= 0 ? '+' : '−')}${Math.abs(p * 100).toFixed(2)}%`
171
- const score = (row) => (typeof row.balance === 'number' ? row.balance : -Infinity)
172
- const profitOf = (c) => (typeof c?.profitUsd === 'number' ? c.profitUsd : ((c?.balance ?? 0) - 100000))
173
-
174
- /* rows for selected asset (exclude vanilla/vinilla) - only show long_only strategy like leaderboard */
175
- const filteredRows = computed(() =>
176
- (rowsRef.value || []).filter(r => {
177
- if (r.asset !== asset.value) return false
178
- if (r.strategy !== 'long_only') return false // 只显示 Aggressive Long Only
179
- const name = (r?.agent_name || '').toLowerCase()
180
- return !EXCLUDED_AGENT_NAMES.has(name)
181
- })
182
- )
183
-
184
- /* winners: best model per agent (by leaderboard balance) */
185
- const winners = computed(() => {
186
- const byAgent = new Map()
187
- for (const r of filteredRows.value) {
188
- const k = r.agent_name
189
- const cur = byAgent.get(k)
190
- if (!cur || score(r) > score(cur)) byAgent.set(k, r)
191
- }
192
- return [...byAgent.values()]
193
- })
194
-
195
- /* chart selections */
196
- const winnersForChart = computed(() =>
197
- winners.value.map(w => ({
198
- agent_name: w.agent_name,
199
- asset: w.asset,
200
- model: w.model,
201
- strategy: w.strategy,
202
- decision_ids: Array.isArray(w.decision_ids) ? w.decision_ids : undefined
203
- }))
204
- )
205
-
206
- /* stable key to avoid identity churn */
207
- const winnersKey = computed(() => {
208
- const sels = winnersForChart.value || []
209
- return sels.map(s => `${s.agent_name}|${s.asset}|${s.model}|${s.strategy}|${(s.decision_ids?.length||0)}`).join('||')
210
- })
211
-
212
- /* ---------- PERF (chart parity) ---------- */
213
- async function buildSeq(sel) {
214
- const { agent_name: agentName, asset: assetCode, model } = sel
215
- const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
216
- let seq = ids.length
217
- ? allDecisions.filter(r => ids.includes(r.id))
218
- : allDecisions.filter(r => r.agent_name === agentName && r.asset === assetCode && r.model === model)
219
-
220
- seq.sort((a,b) => (a.date > b.date ? 1 : -1))
221
-
222
- // if using decision_ids, data already prefiltered
223
- if (!ids.length) {
224
- const isCrypto = assetCode === 'BTC' || assetCode === 'ETH'
225
- if (!isCrypto) seq = await filterRowsToNyseTradingDays(seq)
226
-
227
- const cutoff = ASSET_CUTOFF[assetCode]
228
- if (cutoff) {
229
- const t0 = new Date(cutoff + 'T00:00:00Z')
230
- seq = seq.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
231
- }
232
  }
233
-
234
- return seq
235
  }
 
236
 
237
- async function computeEquities(sel) {
238
- const seq = await buildSeq(sel)
239
- if (!seq.length) return null
240
-
241
- const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
242
-
243
- const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
244
- const bhY = computeBuyHoldEquity(seq, 100000) || []
245
- const lastIdx = Math.min(stratY.length, bhY.length) - 1
246
- if (lastIdx < 0) return null
247
-
248
- return { date: seq[lastIdx].date, stratLast: stratY[lastIdx], bhLast: bhY[lastIdx] }
249
  }
 
250
 
251
- /* build cards whenever winners/asset change */
252
- let computing = false
253
- watch(
254
- () => [asset.value, winnersKey.value],
255
- async () => {
256
- if (computing) return
257
- if (!winnersForChart.value.length) { cards.value = []; return }
258
- computing = true
259
- try {
260
- const perfs = (await Promise.all(
261
- winnersForChart.value.map(async sel => ({ sel, perf: await computeEquities(sel) }))
262
- )).filter(x => x.perf)
263
-
264
- if (!perfs.length) { cards.value = []; return }
265
-
266
- // Buy & Hold first
267
- const first = perfs[0]
268
- const assetCode = first.sel.asset
269
- const bhCard = {
270
- key: `bh|${assetCode}`,
271
- kind: 'bh',
272
- title: 'Buy & Hold',
273
- subtitle: assetCode,
274
- balance: first.perf.bhLast,
275
- date: first.perf.date,
276
- logo: ASSET_ICONS[assetCode] || null,
277
- profitUsd: (first.perf.bhLast ?? 0) - 100000,
278
- gapUsd: 0,
279
- gapPct: 0,
280
- rank: null,
281
- barPct: 0
282
- }
283
-
284
- // Agents
285
- const agentCards = perfs.map(({ sel, perf }) => {
286
- const gapUsd = perf.stratLast - perf.bhLast
287
- const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0
288
- const profitUsd = (perf.stratLast ?? 0) - 100000
289
- return {
290
- key: `agent|${sel.agent_name}|${sel.model}`,
291
- kind: 'agent',
292
- title: sel.agent_name,
293
- subtitle: sel.model,
294
- balance: perf.stratLast,
295
- date: perf.date,
296
- logo: AGENT_LOGOS[sel.agent_name] || null,
297
- gapUsd, gapPct,
298
- profitUsd,
299
- rank: null,
300
- barPct: 0
301
- }
302
- })
303
-
304
- // Rank by balance (agents only)
305
- agentCards.sort((a,b) => (b.balance ?? -Infinity) - (a.balance ?? -Infinity))
306
- agentCards.forEach((c, i) => { c.rank = i + 1 })
307
-
308
- // Perf bar width scaled to max |gapUsd|
309
- const maxAbsGap = Math.max(1, ...agentCards.map(c => Math.abs(c.gapUsd ?? 0)))
310
- agentCards.forEach(c => { c.barPct = Math.max(3, Math.round((Math.abs(c.gapUsd ?? 0) / maxAbsGap) * 100)) })
311
-
312
- // BH first, then top agents
313
- cards.value = [bhCard, ...agentCards].slice(0,5)
314
- } finally { computing = false }
315
- },
316
- { immediate: true }
317
- )
318
- </script>
319
-
320
  <style scoped>
321
- .live { max-width: 1280px; margin: 0 auto; padding: 12px 20px 28px; background: #ffffff; padding-bottom: 56px; }
322
-
323
- /* toolbar */
324
- .toolbar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0 10px; background: #ffffff; color: #0f172a; border-bottom: 1px solid #E7ECF3; }
325
- .toolbar__right { display: flex; align-items: center; gap: 12px; }
326
-
327
- /* mode buttons */
328
- .mode__btn { height: 30px; min-width: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid #D7DDE7; background: #ffffff; font-weight: 700; color: #0f172a; }
329
- .mode__btn.is-active { background: #0f172a; color: #ffffff; border-color: #0f172a; }
330
-
331
- /* panels */
332
- .panel { background: #ffffff; border: 1px solid #E7ECF3; border-radius: 14px; }
333
- .panel--chart { padding: 10px 10px 2px; }
334
- .panel--cards { padding: 12px; }
335
-
336
- /* empty */
337
- .empty { padding: 14px; border: 1px dashed #D7DDE7; border-radius: 12px; color: #6B7280; font-size: .92rem; background: #ffffff; }
338
-
339
- /* GRID */
340
- .cards-grid-f1 { display: grid; gap: 12px; grid-template-columns: repeat(5, minmax(0,1fr)); }
341
- @media (max-width: 1400px) { .cards-grid-f1 { grid-template-columns: repeat(4, minmax(0,1fr)); } }
342
- @media (max-width: 1100px) { .cards-grid-f1 { grid-template-columns: repeat(3, minmax(0,1fr)); } }
343
- @media (max-width: 900px) { .cards-grid-f1 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
344
- @media (max-width: 640px) { .cards-grid-f1 { grid-template-columns: 1fr; } }
345
 
346
- /* F1 Card */
347
- .card-f1 {
348
- position: relative;
349
- display: grid;
350
- grid-template-rows: auto auto auto; /* head, net, bottom */
351
- gap: 10px;
352
- padding: 16px 16px 18px;
353
- min-height: 210px;
354
- border-radius: 14px;
355
- background: linear-gradient(145deg,#ffffff,#fafbfd 55%,#ffffff 100%);
356
- border: 1px solid #E7ECF3;
357
- box-shadow: 0 1px 2px rgba(16,24,40,.03), 0 4px 12px rgba(16,24,40,.04);
358
- color: #0f172a;
359
- transition: transform .18s ease, box-shadow .2s ease, border-color .2s ease;
360
  }
361
- .card-f1:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(16,24,40,.08); border-color: #D9E2EF; }
362
- .card-f1.bh { border-style: dashed; opacity: 1; }
363
 
364
- /* Podium accents for top 3 */
365
- .card-f1.gold { border-color: #d4af37; box-shadow: 0 0 0 1px rgba(212,175,55,.25), 0 10px 24px rgba(212,175,55,.12); }
366
- .card-f1.silver { border-color: #c0c0c0; box-shadow: 0 0 0 1px rgba(192,192,192,.25), 0 10px 24px rgba(192,192,192,.10); }
367
- .card-f1.bronze { border-color: #cd7f32; box-shadow: 0 0 0 1px rgba(205,127,50,.22), 0 10px 24px rgba(205,127,50,.10); }
368
- .podium-ribbon { position: absolute; top: 0; left: 0; right: 0; height: 6px; border-top-left-radius: 14px; border-top-right-radius: 14px; }
369
- .card-f1.gold .podium-ribbon { background: linear-gradient(90deg, #f7e27a, #d4af37); }
370
- .card-f1.silver .podium-ribbon { background: linear-gradient(90deg, #eef2f6, #c0c0c0); }
371
- .card-f1.bronze .podium-ribbon { background: linear-gradient(90deg, #f3c39d, #cd7f32); }
 
 
 
 
372
 
373
- /* Head */
374
- .head {
375
- display: grid;
376
- grid-template-columns: 40px minmax(0,1fr);
377
- align-items: center;
378
- gap: 10px;
379
  }
380
- .logo { width: 40px; height: 40px; border-radius: 10px; background: #f3f4f6; display: grid; place-items: center; overflow: hidden; border: 1px solid #E7ECF3; }
381
- .logo img { width: 100%; height: 100%; object-fit: contain; }
382
- .logo__fallback { width: 60%; height: 60%; border-radius: 6px; background: #e5e7eb; }
383
- .names { min-width: 0; }
384
- .agent { font-size: 16px; font-weight: 900; letter-spacing: .02em; text-transform: uppercase; color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
385
- .model { font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
386
 
387
- /* Net row */
388
- .net { display: grid; grid-template-columns: 1fr auto; align-items: end; }
389
- .net__label { font-size: 12px; color: #6b7280; }
390
- .net__value { font-size: clamp(18px, 2.2vw, 25px); font-weight: 700; letter-spacing: -.01em; color: #0f172a; }
 
391
 
392
- /* Bar vs B&H */
393
- .bar { height: 6px; border-radius: 999px; background: #F2F4F8; overflow: hidden; border: 1px solid #E7ECF3; }
394
- .bar span { display: block; height: 100%; background: linear-gradient(90deg,#16a34a,#22c55e); width: var(--bar, 40%); transition: width .5s ease; }
395
- .bar.neg span { background: linear-gradient(90deg,#ef4444,#dc2626); }
 
 
 
 
396
 
397
- /* Bottom */
398
- .bottom { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-items: end; gap: 8px; }
399
- .chips{
400
- grid-column:1 / -1;
401
- grid-row:1;
402
- display:inline-flex;
403
- gap:8px;
404
- flex-wrap:wrap;
 
 
 
 
 
 
 
 
 
405
  }
406
- .eod{
407
- grid-column:2;
408
- grid-row:2;
409
- justify-self:end;
410
- font-size:12px;
411
- color:#6b7280;
 
412
  }
413
- .chip { font-size: 12px; font-weight: 800; padding: 4px 8px; border-radius: 999px; background: #F6F8FB; color: #0f172a; border: 1px solid #E7ECF3; }
414
- .chip.pos { color: #0e7a3a; background: #E9F7EF; border-color: #d7f0e0; }
415
- .chip.neg { color: #B91C1C; background: #FBEAEA; border-color: #F3DADA; }
416
  </style>
 
1
  <template>
2
+ <header class="arena-header">
3
+ <div class="bar">
4
+ <!-- Title (left) -->
5
+ <button class="arena-title" aria-label="Asset Market Arena" @click="navigateTo('/')">
6
+ Asset Market Arena
7
+ </button>
8
+
9
+ <!-- Tabs (right) -->
10
+ <nav class="menu" aria-label="Primary">
11
+ <span
12
+ class="menu-item"
13
+ :class="{ active: isActive('/live') }"
14
+ @click="navigateTo('/live')"
15
+ >Live Arena</span>
16
+
17
+ <span
18
+ class="menu-item"
19
+ :class="{ active: isActive('/leaderboard') }"
20
+ @click="navigateTo('/leaderboard')"
21
+ >Leaderboard</span>
22
+
23
+ <span
24
+ class="menu-item"
25
+ :class="{ active: isActive('/add-asset') }"
26
+ @click="navigateTo('/add-asset')"
27
+ >Agent Arena</span>
28
+ </nav>
29
+ </div>
30
+
31
+ <!-- AMA gradient hairline -->
32
+ <div class="ama-gradient-rule" />
33
+ </header>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  </template>
35
 
36
+ <script>
37
+ export default {
38
+ name: 'ArenaHeader',
39
+ methods: {
40
+ navigateTo(path) { this.$router.push(path) },
41
+ isActive(path) { return this.$route?.path?.startsWith(path) }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
 
 
43
  }
44
+ </script>
45
 
46
+ <!-- GLOBAL (UNSCOPED) — brand tokens must NOT be scoped -->
47
+ <style>
48
+ :root{
49
+ /* AMA brand gradient: rgb(0,0,185) → rgb(240,0,15) */
50
+ --ama-start: 0, 0, 185;
51
+ --ama-end: 240, 0, 15;
52
+
53
+ /* Podium palette (kept for future use) */
54
+ --gold: #D4AF37;
55
+ --silver: #C0C0C0;
56
+ --bronze: #CD7F32;
 
57
  }
58
+ </style>
59
 
60
+ <!-- COMPONENT STYLES -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  <style scoped>
62
+ .arena-header{
63
+ position: sticky; top: 0; z-index: 50;
64
+ background: linear-gradient(135deg,#fbfcff 0%,#ffffff 100%);
65
+ border-bottom: 1px solid #e5e7eb;
66
+ backdrop-filter: blur(8px);
67
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ .bar{
70
+ max-width: 1200px;
71
+ margin: 0 auto;
72
+ padding: 16px 16px 12px;
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ gap: 16px;
 
 
 
 
 
 
77
  }
 
 
78
 
79
+ /* Title (left) with AMA gradient text */
80
+ .arena-title{
81
+ all: unset;
82
+ cursor: pointer;
83
+ font-size: 26px;
84
+ font-weight: 900;
85
+ letter-spacing: -0.01em;
86
+ background-image: linear-gradient(90deg, rgb(var(--ama-start)), rgb(var(--ama-end)));
87
+ -webkit-background-clip: text;
88
+ background-clip: text;
89
+ color: transparent;
90
+ }
91
 
92
+ /* Tabs (right) */
93
+ .menu{
94
+ display: flex; gap: 28px; align-items: center; flex-wrap: wrap;
 
 
 
95
  }
 
 
 
 
 
 
96
 
97
+ .menu-item{
98
+ cursor: pointer; position: relative;
99
+ font-size: 22px; font-weight: 700; color: #1f2937;
100
+ padding-bottom: 4px; transition: color .2s ease;
101
+ }
102
 
103
+ /* hover color + underline */
104
+ .menu-item:hover{ color: rgb(var(--ama-end)); }
105
+ .menu-item::after{
106
+ content:''; position:absolute; left:0; bottom:0; height:2px; width:0%;
107
+ background-image: linear-gradient(90deg, rgb(var(--ama-start)), rgb(var(--ama-end)));
108
+ transition: width .25s ease;
109
+ }
110
+ .menu-item:hover::after{ width:100%; }
111
 
112
+ /* active = gradient text + full underline */
113
+ .menu-item.active{
114
+ background-image: linear-gradient(90deg, rgb(var(--ama-start)), rgb(var(--ama-end)));
115
+ -webkit-background-clip:text; background-clip:text; color:transparent;
116
+ }
117
+ .menu-item.active::after{ width:100%; }
118
+
119
+ /* bottom hairline */
120
+ .ama-gradient-rule{
121
+ width:100%; height:3px; border-radius:2px;
122
+ background-image: linear-gradient(
123
+ 90deg,
124
+ rgba(var(--ama-start),0),
125
+ rgb(var(--ama-start)),
126
+ rgb(var(--ama-end)),
127
+ rgba(var(--ama-end),0)
128
+ );
129
  }
130
+
131
+ /* responsive */
132
+ @media (max-width: 720px){
133
+ .bar{ flex-direction: column; align-items: stretch; }
134
+ .menu{ justify-content: center; gap: 18px; }
135
+ .arena-title{ text-align: center; font-size: 22px; }
136
+ .menu-item{ font-size: 18px; }
137
  }
 
 
 
138
  </style>
src/views/LiveView.vue CHANGED
@@ -6,14 +6,6 @@
6
  <AssetTabs v-model="asset" :ordered-assets="orderedAssets" />
7
  </div>
8
  <div class="toolbar__right">
9
- <button
10
- class="refresh-btn"
11
- @click="refreshData"
12
- :disabled="refreshing"
13
- title="Refresh data from database"
14
- >
15
- <i class="pi pi-refresh" :class="{ 'spinning': refreshing }"></i>
16
- </button>
17
  <div class="mode">
18
  <button class="mode__btn" :class="{ 'is-active': mode==='usd' }" @click="mode='usd'">$</button>
19
  <button class="mode__btn" :class="{ 'is-active': mode==='pct' }" @click="mode='pct'">%</button>
@@ -41,11 +33,18 @@
41
  v-for="c in cards"
42
  :key="c.key"
43
  class="card-f1"
44
- :class="{ winner: c.isWinner, bh: c.kind==='bh', neg: (c.gapUsd ?? 0) < 0, pos: (c.gapUsd ?? 0) >= 0 }"
 
 
 
 
 
 
 
45
  :style="{ '--bar': (c.barPct ?? 0) + '%'}"
46
  >
47
- <!-- Crown -->
48
- <span v-if="c.isWinner" class="crown" aria-label="Top performer">👑</span>
49
 
50
  <!-- Header: logo + names -->
51
  <header class="head">
@@ -132,7 +131,6 @@ const ASSET_CUTOFF = { BTC: '2025-08-01' }
132
  const mode = ref('usd')
133
  const asset = ref('BTC')
134
  const rowsRef = ref([])
135
- const refreshing = ref(false)
136
  let allDecisions = []
137
  const cards = shallowRef([])
138
 
@@ -140,15 +138,14 @@ let unsubscribe = null
140
 
141
  /* ---------- bootstrap ---------- */
142
  onMounted(async () => {
143
-
144
- unsubscribe = dataService.subscribe((state) => {
145
  rowsRef.value = Array.isArray(state.tableRows) ? state.tableRows : []
146
  })
147
 
148
- // 2) immediate sync with current state (in case data already loaded)
149
  rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
150
 
151
- // 3) trigger a load only if nothing is in-flight
152
  if (!dataService.loaded && !dataService.loading) {
153
  dataService.load(false).catch(e => console.error('LiveView: load failed', e))
154
  }
@@ -167,23 +164,6 @@ onBeforeUnmount(() => {
167
  if (unsubscribe) { unsubscribe(); unsubscribe = null }
168
  })
169
 
170
- /* ---------- refresh data ---------- */
171
- async function refreshData() {
172
- if (refreshing.value) return
173
- refreshing.value = true
174
- try {
175
- console.log('[Live] Force refreshing data from Supabase...')
176
- await dataService.forceRefresh()
177
- rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
178
- allDecisions = getAllDecisions() || []
179
- console.log('[Live] Data refreshed successfully')
180
- } catch (e) {
181
- console.error('[Live] Error refreshing data:', e)
182
- } finally {
183
- refreshing.value = false
184
- }
185
- }
186
-
187
  /* ---------- helpers ---------- */
188
  const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
189
  const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}`
@@ -195,7 +175,7 @@ const profitOf = (c) => (typeof c?.profitUsd === 'number' ? c.profitUsd : ((c?.b
195
  const filteredRows = computed(() =>
196
  (rowsRef.value || []).filter(r => {
197
  if (r.asset !== asset.value) return false
198
- if (r.strategy !== 'long_only') return false // 只显示 Aggressive Long Only 策略
199
  const name = (r?.agent_name || '').toLowerCase()
200
  return !EXCLUDED_AGENT_NAMES.has(name)
201
  })
@@ -209,14 +189,7 @@ const winners = computed(() => {
209
  const cur = byAgent.get(k)
210
  if (!cur || score(r) > score(cur)) byAgent.set(k, r)
211
  }
212
- const result = [...byAgent.values()]
213
- console.log('[Live winners from leaderboard]', result.map(r => ({
214
- agent: r.agent_name,
215
- model: r.model,
216
- strategy: r.strategy,
217
- balance: r.balance
218
- })))
219
- return result
220
  })
221
 
222
  /* chart selections */
@@ -246,19 +219,18 @@ async function buildSeq(sel) {
246
 
247
  seq.sort((a,b) => (a.date > b.date ? 1 : -1))
248
 
249
- // 如果使用了 decision_ids,数据已经预过滤,不需要再次处理
250
- // 只有在没有 decision_ids 时才需要过滤交易日
251
  if (!ids.length) {
252
  const isCrypto = assetCode === 'BTC' || assetCode === 'ETH'
253
  if (!isCrypto) seq = await filterRowsToNyseTradingDays(seq)
254
-
255
  const cutoff = ASSET_CUTOFF[assetCode]
256
  if (cutoff) {
257
  const t0 = new Date(cutoff + 'T00:00:00Z')
258
  seq = seq.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
259
  }
260
  }
261
-
262
  return seq
263
  }
264
 
@@ -267,26 +239,11 @@ async function computeEquities(sel) {
267
  if (!seq.length) return null
268
 
269
  const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
270
-
271
- console.log('[Live computeEquities]', {
272
- agent: sel.agent_name,
273
- model: sel.model,
274
- strategy: sel.strategy,
275
- config: cfg,
276
- seqLength: seq.length,
277
- decision_ids: sel.decision_ids?.length || 'none'
278
- })
279
 
280
  const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
281
  const bhY = computeBuyHoldEquity(seq, 100000) || []
282
  const lastIdx = Math.min(stratY.length, bhY.length) - 1
283
  if (lastIdx < 0) return null
284
-
285
- console.log('[Live computeEquities result]', {
286
- agent: sel.agent_name,
287
- stratLast: stratY[lastIdx],
288
- bhLast: bhY[lastIdx]
289
- })
290
 
291
  return { date: seq[lastIdx].date, stratLast: stratY[lastIdx], bhLast: bhY[lastIdx] }
292
  }
@@ -306,7 +263,7 @@ watch(
306
 
307
  if (!perfs.length) { cards.value = []; return }
308
 
309
- // Buy & Hold card from the asset’s aligned BH series
310
  const first = perfs[0]
311
  const assetCode = first.sel.asset
312
  const bhCard = {
@@ -320,12 +277,11 @@ watch(
320
  profitUsd: (first.perf.bhLast ?? 0) - 100000,
321
  gapUsd: 0,
322
  gapPct: 0,
323
- isWinner: false,
324
  rank: null,
325
  barPct: 0
326
  }
327
 
328
- // Agent cards (gap vs BH)
329
  const agentCards = perfs.map(({ sel, perf }) => {
330
  const gapUsd = perf.stratLast - perf.bhLast
331
  const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0
@@ -340,7 +296,6 @@ watch(
340
  logo: AGENT_LOGOS[sel.agent_name] || null,
341
  gapUsd, gapPct,
342
  profitUsd,
343
- isWinner: false,
344
  rank: null,
345
  barPct: 0
346
  }
@@ -350,18 +305,12 @@ watch(
350
  agentCards.sort((a,b) => (b.balance ?? -Infinity) - (a.balance ?? -Infinity))
351
  agentCards.forEach((c, i) => { c.rank = i + 1 })
352
 
353
- // Winner flag
354
- if (agentCards.length) agentCards[0].isWinner = true
355
-
356
  // Perf bar width scaled to max |gapUsd|
357
  const maxAbsGap = Math.max(1, ...agentCards.map(c => Math.abs(c.gapUsd ?? 0)))
358
  agentCards.forEach(c => { c.barPct = Math.max(3, Math.round((Math.abs(c.gapUsd ?? 0) / maxAbsGap) * 100)) })
359
 
360
- // Build final list: BH first, then agents in rank order
361
  cards.value = [bhCard, ...agentCards].slice(0,5)
362
- } catch (e) {
363
- console.error('LiveView: compute cards failed', e)
364
- cards.value = []
365
  } finally { computing = false }
366
  },
367
  { immediate: true }
@@ -374,34 +323,8 @@ watch(
374
  /* toolbar */
375
  .toolbar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0 10px; background: #ffffff; color: #0f172a; border-bottom: 1px solid #E7ECF3; }
376
  .toolbar__right { display: flex; align-items: center; gap: 12px; }
377
- .refresh-btn {
378
- height: 30px;
379
- width: 30px;
380
- border-radius: 10px;
381
- border: 1px solid #D7DDE7;
382
- background: #ffffff;
383
- color: #0f172a;
384
- cursor: pointer;
385
- display: flex;
386
- align-items: center;
387
- justify-content: center;
388
- transition: all 0.2s ease;
389
- }
390
- .refresh-btn:hover:not(:disabled) {
391
- background: #f8f9fa;
392
- border-color: #0f172a;
393
- }
394
- .refresh-btn:disabled {
395
- opacity: 0.5;
396
- cursor: not-allowed;
397
- }
398
- .refresh-btn i.spinning {
399
- animation: spin 1s linear infinite;
400
- }
401
- @keyframes spin {
402
- from { transform: rotate(0deg); }
403
- to { transform: rotate(360deg); }
404
- }
405
  .mode__btn { height: 30px; min-width: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid #D7DDE7; background: #ffffff; font-weight: 700; color: #0f172a; }
406
  .mode__btn.is-active { background: #0f172a; color: #ffffff; border-color: #0f172a; }
407
 
@@ -436,39 +359,22 @@ watch(
436
  transition: transform .18s ease, box-shadow .2s ease, border-color .2s ease;
437
  }
438
  .card-f1:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(16,24,40,.08); border-color: #D9E2EF; }
439
- .card-f1.winner { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,.18), 0 10px 26px rgba(16,24,40,.10); }
440
  .card-f1.bh { border-style: dashed; opacity: 1; }
441
 
442
- /* Rank / Crown */
443
- .rank {
444
- position: absolute;
445
- top: 10px;
446
- left: 12px;
447
- font-weight: 900;
448
- font-size: 18px;
449
- color: rgba(15,23,42,.30);
450
- letter-spacing: .04em;
451
- }
452
- .rank.bh-badge {
453
- font-size: 12px;
454
- background: rgba(15,23,42,.06);
455
- padding: 2px 6px;
456
- border-radius: 6px;
457
- color: #4b5563;
458
- }
459
- .crown {
460
- position: absolute;
461
- top: 12px;
462
- right: 12px;
463
- font-size: 18px;
464
- filter: drop-shadow(0 1px 1px rgba(0,0,0,.12));
465
- }
466
 
467
  /* Head */
468
- .head {
469
- display: grid;
470
- grid-template-columns: 40px minmax(0,1fr);
471
- align-items: center;
472
  gap: 10px;
473
  }
474
  .logo { width: 40px; height: 40px; border-radius: 10px; background: #f3f4f6; display: grid; place-items: center; overflow: hidden; border: 1px solid #E7ECF3; }
@@ -491,17 +397,16 @@ watch(
491
  /* Bottom */
492
  .bottom { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-items: end; gap: 8px; }
493
  .chips{
494
- grid-column:1 / -1; /* span both columns on row 1 */
495
  grid-row:1;
496
  display:inline-flex;
497
  gap:8px;
498
- flex-wrap:wrap; /* chips can wrap within row 1 */
499
  }
500
  .eod{
501
- grid-column:2; /* row 2, right column */
502
  grid-row:2;
503
- justify-self:end; /* align to the right */
504
- /* allow wrapping if needed; remove nowrap */
505
  font-size:12px;
506
  color:#6b7280;
507
  }
 
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>
 
33
  v-for="c in cards"
34
  :key="c.key"
35
  class="card-f1"
36
+ :class="{
37
+ bh: c.kind==='bh',
38
+ neg: (c.gapUsd ?? 0) < 0,
39
+ pos: (c.gapUsd ?? 0) >= 0,
40
+ gold: c.rank === 1,
41
+ silver: c.rank === 2,
42
+ bronze: c.rank === 3
43
+ }"
44
  :style="{ '--bar': (c.barPct ?? 0) + '%'}"
45
  >
46
+ <!-- Podium ribbon (rank 1-3 only) -->
47
+ <div v-if="c.rank && c.rank <= 3" class="podium-ribbon" :data-rank="c.rank"></div>
48
 
49
  <!-- Header: logo + names -->
50
  <header class="head">
 
131
  const mode = ref('usd')
132
  const asset = ref('BTC')
133
  const rowsRef = ref([])
 
134
  let allDecisions = []
135
  const cards = shallowRef([])
136
 
 
138
 
139
  /* ---------- bootstrap ---------- */
140
  onMounted(async () => {
141
+ unsubscribe = dataService.subscribe((state) => {
 
142
  rowsRef.value = Array.isArray(state.tableRows) ? state.tableRows : []
143
  })
144
 
145
+ // immediate sync with current state (in case data already loaded)
146
  rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
147
 
148
+ // trigger a load only if nothing is in-flight
149
  if (!dataService.loaded && !dataService.loading) {
150
  dataService.load(false).catch(e => console.error('LiveView: load failed', e))
151
  }
 
164
  if (unsubscribe) { unsubscribe(); unsubscribe = null }
165
  })
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  /* ---------- helpers ---------- */
168
  const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
169
  const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}`
 
175
  const filteredRows = computed(() =>
176
  (rowsRef.value || []).filter(r => {
177
  if (r.asset !== asset.value) return false
178
+ if (r.strategy !== 'long_only') return false // 只显示 Aggressive Long Only
179
  const name = (r?.agent_name || '').toLowerCase()
180
  return !EXCLUDED_AGENT_NAMES.has(name)
181
  })
 
189
  const cur = byAgent.get(k)
190
  if (!cur || score(r) > score(cur)) byAgent.set(k, r)
191
  }
192
+ return [...byAgent.values()]
 
 
 
 
 
 
 
193
  })
194
 
195
  /* chart selections */
 
219
 
220
  seq.sort((a,b) => (a.date > b.date ? 1 : -1))
221
 
222
+ // if using decision_ids, data already prefiltered
 
223
  if (!ids.length) {
224
  const isCrypto = assetCode === 'BTC' || assetCode === 'ETH'
225
  if (!isCrypto) seq = await filterRowsToNyseTradingDays(seq)
226
+
227
  const cutoff = ASSET_CUTOFF[assetCode]
228
  if (cutoff) {
229
  const t0 = new Date(cutoff + 'T00:00:00Z')
230
  seq = seq.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
231
  }
232
  }
233
+
234
  return seq
235
  }
236
 
 
239
  if (!seq.length) return null
240
 
241
  const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
 
 
 
 
 
 
 
 
 
242
 
243
  const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
244
  const bhY = computeBuyHoldEquity(seq, 100000) || []
245
  const lastIdx = Math.min(stratY.length, bhY.length) - 1
246
  if (lastIdx < 0) return null
 
 
 
 
 
 
247
 
248
  return { date: seq[lastIdx].date, stratLast: stratY[lastIdx], bhLast: bhY[lastIdx] }
249
  }
 
263
 
264
  if (!perfs.length) { cards.value = []; return }
265
 
266
+ // Buy & Hold first
267
  const first = perfs[0]
268
  const assetCode = first.sel.asset
269
  const bhCard = {
 
277
  profitUsd: (first.perf.bhLast ?? 0) - 100000,
278
  gapUsd: 0,
279
  gapPct: 0,
 
280
  rank: null,
281
  barPct: 0
282
  }
283
 
284
+ // Agents
285
  const agentCards = perfs.map(({ sel, perf }) => {
286
  const gapUsd = perf.stratLast - perf.bhLast
287
  const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0
 
296
  logo: AGENT_LOGOS[sel.agent_name] || null,
297
  gapUsd, gapPct,
298
  profitUsd,
 
299
  rank: null,
300
  barPct: 0
301
  }
 
305
  agentCards.sort((a,b) => (b.balance ?? -Infinity) - (a.balance ?? -Infinity))
306
  agentCards.forEach((c, i) => { c.rank = i + 1 })
307
 
 
 
 
308
  // Perf bar width scaled to max |gapUsd|
309
  const maxAbsGap = Math.max(1, ...agentCards.map(c => Math.abs(c.gapUsd ?? 0)))
310
  agentCards.forEach(c => { c.barPct = Math.max(3, Math.round((Math.abs(c.gapUsd ?? 0) / maxAbsGap) * 100)) })
311
 
312
+ // BH first, then top agents
313
  cards.value = [bhCard, ...agentCards].slice(0,5)
 
 
 
314
  } finally { computing = false }
315
  },
316
  { immediate: true }
 
323
  /* toolbar */
324
  .toolbar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0 10px; background: #ffffff; color: #0f172a; border-bottom: 1px solid #E7ECF3; }
325
  .toolbar__right { display: flex; align-items: center; gap: 12px; }
326
+
327
+ /* mode buttons */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  .mode__btn { height: 30px; min-width: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid #D7DDE7; background: #ffffff; font-weight: 700; color: #0f172a; }
329
  .mode__btn.is-active { background: #0f172a; color: #ffffff; border-color: #0f172a; }
330
 
 
359
  transition: transform .18s ease, box-shadow .2s ease, border-color .2s ease;
360
  }
361
  .card-f1:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(16,24,40,.08); border-color: #D9E2EF; }
 
362
  .card-f1.bh { border-style: dashed; opacity: 1; }
363
 
364
+ /* Podium accents for top 3 */
365
+ .card-f1.gold { border-color: #d4af37; box-shadow: 0 0 0 1px rgba(212,175,55,.25), 0 10px 24px rgba(212,175,55,.12); }
366
+ .card-f1.silver { border-color: #c0c0c0; box-shadow: 0 0 0 1px rgba(192,192,192,.25), 0 10px 24px rgba(192,192,192,.10); }
367
+ .card-f1.bronze { border-color: #cd7f32; box-shadow: 0 0 0 1px rgba(205,127,50,.22), 0 10px 24px rgba(205,127,50,.10); }
368
+ .podium-ribbon { position: absolute; top: 0; left: 0; right: 0; height: 6px; border-top-left-radius: 14px; border-top-right-radius: 14px; }
369
+ .card-f1.gold .podium-ribbon { background: linear-gradient(90deg, #f7e27a, #d4af37); }
370
+ .card-f1.silver .podium-ribbon { background: linear-gradient(90deg, #eef2f6, #c0c0c0); }
371
+ .card-f1.bronze .podium-ribbon { background: linear-gradient(90deg, #f3c39d, #cd7f32); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
  /* Head */
374
+ .head {
375
+ display: grid;
376
+ grid-template-columns: 40px minmax(0,1fr);
377
+ align-items: center;
378
  gap: 10px;
379
  }
380
  .logo { width: 40px; height: 40px; border-radius: 10px; background: #f3f4f6; display: grid; place-items: center; overflow: hidden; border: 1px solid #E7ECF3; }
 
397
  /* Bottom */
398
  .bottom { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-items: end; gap: 8px; }
399
  .chips{
400
+ grid-column:1 / -1;
401
  grid-row:1;
402
  display:inline-flex;
403
  gap:8px;
404
+ flex-wrap:wrap;
405
  }
406
  .eod{
407
+ grid-column:2;
408
  grid-row:2;
409
+ justify-self:end;
 
410
  font-size:12px;
411
  color:#6b7280;
412
  }