Jimin Huang commited on
Commit
10e2649
·
1 Parent(s): 83579f2

Change settings

Browse files
Files changed (1) hide show
  1. src/components/HeaderOpen.vue +359 -119
src/components/HeaderOpen.vue CHANGED
@@ -1,138 +1,378 @@
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>
 
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
+ <!-- Podium + 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 (no crown) -->
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
+ /* helpers */
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']
113
+ const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla'])
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
+ let unsubscribe = null
137
+
138
+ /* bootstrap */
139
+ onMounted(async () => {
140
+ unsubscribe = dataService.subscribe((state) => {
141
+ rowsRef.value = Array.isArray(state.tableRows) ? state.tableRows : []
142
+ })
143
+
144
+ rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
145
+
146
+ if (!dataService.loaded && !dataService.loading) {
147
+ dataService.load(false).catch(e => console.error('LiveView: load failed', e))
148
+ }
149
+ if (!orderedAssets.includes(asset.value)) asset.value = orderedAssets[0]
150
+
151
+ allDecisions = getAllDecisions() || []
152
+ if (!allDecisions.length) {
153
+ try {
154
+ const cached = await readAllRawDecisions()
155
+ if (cached?.length) allDecisions = cached
156
+ } catch {}
157
+ }
158
+ })
159
+
160
+ onBeforeUnmount(() => {
161
+ if (unsubscribe) { unsubscribe(); unsubscribe = null }
162
+ })
163
+
164
+ /* helpers */
165
+ const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
166
+ const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}`
167
+ const signedPct = (p) => `${(p >= 0 ? '+' : '−')}${Math.abs(p * 100).toFixed(2)}%`
168
+ const score = (row) => (typeof row.balance === 'number' ? row.balance : -Infinity)
169
+ const profitOf = (c) => (typeof c?.profitUsd === 'number' ? c.profitUsd : ((c?.balance ?? 0) - 100000))
170
+
171
+ /* filters */
172
+ const filteredRows = computed(() =>
173
+ (rowsRef.value || []).filter(r => {
174
+ if (r.asset !== asset.value) return false
175
+ if (r.strategy !== 'long_only') return false
176
+ const name = (r?.agent_name || '').toLowerCase()
177
+ return !EXCLUDED_AGENT_NAMES.has(name)
178
+ })
179
+ )
180
+
181
+ /* winners */
182
+ const winners = computed(() => {
183
+ const byAgent = new Map()
184
+ for (const r of filteredRows.value) {
185
+ const k = r.agent_name
186
+ const cur = byAgent.get(k)
187
+ if (!cur || score(r) > score(cur)) byAgent.set(k, r)
188
+ }
189
+ return [...byAgent.values()]
190
+ })
191
+
192
+ /* chart selections */
193
+ const winnersForChart = computed(() =>
194
+ winners.value.map(w => ({
195
+ agent_name: w.agent_name,
196
+ asset: w.asset,
197
+ model: w.model,
198
+ strategy: w.strategy,
199
+ decision_ids: Array.isArray(w.decision_ids) ? w.decision_ids : undefined
200
+ }))
201
+ )
202
+ const winnersKey = computed(() => {
203
+ const sels = winnersForChart.value || []
204
+ return sels.map(s => `${s.agent_name}|${s.asset}|${s.model}|${s.strategy}|${(s.decision_ids?.length||0)}`).join('||')
205
+ })
206
+
207
+ /* perf */
208
+ async function buildSeq(sel) {
209
+ const { agent_name: agentName, asset: assetCode, model } = sel
210
+ const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
211
+ let seq = ids.length
212
+ ? allDecisions.filter(r => ids.includes(r.id))
213
+ : allDecisions.filter(r => r.agent_name === agentName && r.asset === assetCode && r.model === model)
214
+
215
+ seq.sort((a,b) => (a.date > b.date ? 1 : -1))
216
+
217
+ if (!ids.length) {
218
+ const isCrypto = assetCode === 'BTC' || assetCode === 'ETH'
219
+ if (!isCrypto) seq = await filterRowsToNyseTradingDays(seq)
220
+
221
+ const cutoff = ASSET_CUTOFF[assetCode]
222
+ if (cutoff) {
223
+ const t0 = new Date(cutoff + 'T00:00:00Z')
224
+ seq = seq.filter(r => new Date(r.date + 'T00:00:00Z') >= t0)
225
+ }
226
  }
227
+
228
+ return seq
229
  }
 
230
 
231
+ async function computeEquities(sel) {
232
+ const seq = await buildSeq(sel)
233
+ if (!seq.length) return null
234
+
235
+ const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
 
236
 
237
+ const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
238
+ const bhY = computeBuyHoldEquity(seq, 100000) || []
239
+ const lastIdx = Math.min(stratY.length, bhY.length) - 1
240
+ if (lastIdx < 0) return null
241
+
242
+ return { date: seq[lastIdx].date, stratLast: stratY[lastIdx], bhLast: bhY[lastIdx] }
243
  }
 
244
 
245
+ /* build cards whenever winners/asset change */
246
+ let computing = false
247
+ watch(
248
+ () => [asset.value, winnersKey.value],
249
+ async () => {
250
+ if (computing) return
251
+ if (!winnersForChart.value.length) { cards.value = []; return }
252
+ computing = true
253
+ try {
254
+ const perfs = (await Promise.all(
255
+ winnersForChart.value.map(async sel => ({ sel, perf: await computeEquities(sel) }))
256
+ )).filter(x => x.perf)
257
+
258
+ if (!perfs.length) { cards.value = []; return }
259
+
260
+ const first = perfs[0]
261
+ const assetCode = first.sel.asset
262
+ const bhCard = {
263
+ key: `bh|${assetCode}`,
264
+ kind: 'bh',
265
+ title: 'Buy & Hold',
266
+ subtitle: assetCode,
267
+ balance: first.perf.bhLast,
268
+ date: first.perf.date,
269
+ logo: ASSET_ICONS[assetCode] || null,
270
+ profitUsd: (first.perf.bhLast ?? 0) - 100000,
271
+ gapUsd: 0,
272
+ gapPct: 0,
273
+ rank: null,
274
+ barPct: 0
275
+ }
276
+
277
+ const agentCards = perfs.map(({ sel, perf }) => {
278
+ const gapUsd = perf.stratLast - perf.bhLast
279
+ const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0
280
+ const profitUsd = (perf.stratLast ?? 0) - 100000
281
+ return {
282
+ key: `agent|${sel.agent_name}|${sel.model}`,
283
+ kind: 'agent',
284
+ title: sel.agent_name,
285
+ subtitle: sel.model,
286
+ balance: perf.stratLast,
287
+ date: perf.date,
288
+ logo: AGENT_LOGOS[sel.agent_name] || null,
289
+ gapUsd, gapPct, profitUsd,
290
+ rank: null,
291
+ barPct: 0
292
+ }
293
+ })
294
+
295
+ agentCards.sort((a,b) => (b.balance ?? -Infinity) - (a.balance ?? -Infinity))
296
+ agentCards.forEach((c, i) => { c.rank = i + 1 })
297
+
298
+ cards.value = [bhCard, ...agentCards].slice(0,5)
299
+ } finally { computing = false }
300
+ },
301
+ { immediate: true }
302
+ )
303
+ </script>
304
+
305
  <style scoped>
306
+ :root {
307
+ --ama-start: 0, 0, 185;
308
+ --ama-end: 240, 0, 15;
309
+ --gold: #d4af37;
310
+ --silver: #c0c0c0;
311
+ --bronze: #cd7f32;
312
  }
313
 
314
+ /* page */
315
+ .live { max-width: 1280px; margin: 0 auto; padding: 16px 24px 64px; background: radial-gradient(ellipse at top, #fdfdfd 0%, #f8f9ff 100%); color: #0f172a; }
 
 
 
 
 
 
 
316
 
317
+ /* toolbar */
318
+ .toolbar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0 12px; background: #ffffff; border-bottom: 2px solid rgba(var(--ama-end), .15); backdrop-filter: blur(10px); }
319
+ .toolbar__right { display: flex; align-items: center; gap: 12px; }
 
 
 
 
 
 
 
 
 
320
 
321
+ /* mode buttons */
322
+ .mode__btn { height: 32px; min-width: 42px; padding: 0 10px; border-radius: 10px; border: 1px solid #CDD3E1; background: #ffffff; font-weight: 700; color: #0f172a; transition: all 0.2s ease; }
323
+ .mode__btn.is-active { background: linear-gradient(90deg, rgb(var(--ama-start)), rgb(var(--ama-end))); color: #ffffff; border: none; box-shadow: 0 0 6px rgba(var(--ama-end), .3); }
 
324
 
325
+ /* panels */
326
+ .panel { background: #ffffff; border: 1px solid #E7ECF3; border-radius: 16px; margin-top: 16px; }
327
+ .panel--chart { padding: 10px; }
328
+ .panel--cards { padding: 16px; }
 
329
 
330
+ /* empty */
331
+ .empty { padding: 20px; border: 1px dashed #D7DDE7; border-radius: 12px; color: #6B7280; font-size: .95rem; text-align: center; }
 
 
 
 
 
 
332
 
333
+ /* grid */
334
+ .cards-grid-f1 { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
 
336
+ /* F1 card base */
337
+ .card-f1 { position: relative; display: grid; grid-template-rows: auto auto auto; gap: 10px; padding: 18px 18px 20px; min-height: 210px; border-radius: 16px; background: linear-gradient(145deg,#ffffff,#f8faff 80%,#ffffff 100%); border: 1px solid #E6E8F0; box-shadow: 0 2px 12px rgba(0,0,0,.04); transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease; }
338
+ .card-f1:hover { transform: translateY(-2px); box-shadow: 0 8px 26px rgba(0,0,0,.08); }
339
+ .card-f1.bh { border-style: dashed; }
340
+
341
+ /* podium styles */
342
+ .card-f1.gold { border-color: var(--gold); box-shadow: 0 0 0 1px rgba(212,175,55,.25), 0 8px 26px rgba(212,175,55,.18); }
343
+ .card-f1.silver { border-color: var(--silver); box-shadow: 0 0 0 1px rgba(192,192,192,.25), 0 8px 26px rgba(192,192,192,.16); }
344
+ .card-f1.bronze { border-color: var(--bronze); box-shadow: 0 0 0 1px rgba(205,127,50,.22), 0 8px 26px rgba(205,127,50,.14); }
345
+
346
+ /* small podium ribbon at top */
347
+ .podium-ribbon { position: absolute; top: 0; left: 0; right: 0; height: 6px; border-top-left-radius: 16px; border-top-right-radius: 16px; }
348
+ .card-f1.gold .podium-ribbon { background: linear-gradient(90deg, #f6e27a, var(--gold)); }
349
+ .card-f1.silver .podium-ribbon { background: linear-gradient(90deg, #e9eef2, var(--silver)); }
350
+ .card-f1.bronze .podium-ribbon { background: linear-gradient(90deg, #f0c6a1, var(--bronze)); }
351
+
352
+ /* head */
353
+ .head { display: grid; grid-template-columns: 44px minmax(0,1fr); align-items: center; gap: 12px; }
354
+ .logo { width: 44px; height: 44px; border-radius: 12px; background: #f3f4f6; display: grid; place-items: center; overflow: hidden; border: 1px solid #E5E7EB; }
355
+ .logo img { width: 100%; height: 100%; object-fit: contain; }
356
+ .logo__fallback { width: 60%; height: 60%; border-radius: 6px; background: #e5e7eb; }
357
+ .names { min-width: 0; }
358
+ .agent { font-size: 16px; font-weight: 900; letter-spacing: .02em; text-transform: uppercase; color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
359
+ .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; }
360
+
361
+ /* net row */
362
+ .net { display: grid; grid-template-columns: 1fr auto; align-items: end; }
363
+ .net__label { font-size: 12px; color: #6b7280; }
364
+ .net__value { font-size: clamp(18px, 2.2vw, 25px); font-weight: 700; letter-spacing: -.01em; color: #0f172a; }
365
+
366
+ /* bar vs B&H */
367
+ .bar { height: 6px; border-radius: 999px; background: #F2F4F8; overflow: hidden; border: 1px solid #E7ECF3; }
368
+ .bar span { display: block; height: 100%; background: linear-gradient(90deg,#16a34a,#22c55e); width: var(--bar, 40%); transition: width .5s ease; }
369
+ .bar.neg span { background: linear-gradient(90deg,#ef4444,#dc2626); }
370
+
371
+ /* bottom */
372
+ .bottom { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-items: end; gap: 8px; }
373
+ .chips{ grid-column:1 / -1; grid-row:1; display:inline-flex; gap:8px; flex-wrap:wrap; }
374
+ .eod{ grid-column:2; grid-row:2; justify-self:end; font-size:12px; color:#6b7280; }
375
+ .chip { font-size: 12px; font-weight: 800; padding: 4px 8px; border-radius: 999px; background: #F6F8FB; color: #0f172a; border: 1px solid #E7ECF3; }
376
+ .chip.pos { color: #0e7a3a; background: #E9F7EF; border-color: #d7f0e0; }
377
+ .chip.neg { color: #B91C1C; background: #FBEAEA; border-color: #F3DADA; }
378
  </style>