Jimin Huang commited on
Commit
1e9d37d
·
1 Parent(s): 9821537

Change settings

Browse files
Files changed (1) hide show
  1. src/views/LiveView.vue +173 -71
src/views/LiveView.vue CHANGED
@@ -1,5 +1,6 @@
1
  <template>
2
  <div class="live">
 
3
  <header class="toolbar">
4
  <div class="toolbar__left">
5
  <AssetTabs v-model="asset" :ordered-assets="orderedAssets" />
@@ -12,46 +13,74 @@
12
  </div>
13
  </header>
14
 
 
15
  <section class="panel panel--chart">
16
- <CompareChartE v-if="winnersForChart.length" :selected="winnersForChart" :visible="true" :mode="mode" />
17
- <div v-else class="empty">No data for <strong>{{ asset }}</strong> yet. Check Supabase runs or try another asset.</div>
 
 
 
 
 
 
 
18
  </section>
19
 
 
20
  <section class="panel panel--cards" v-if="cards.length">
21
- <div class="cards5">
22
- <div v-for="c in cards" :key="c.key" class="card" :class="{ 'card--bh': c.kind==='bh', 'card--winner': c.isWinner }">
23
- <div class="card__header">
24
- <div class="card__logo">
 
 
 
 
 
 
 
 
 
 
 
 
25
  <img v-if="c.logo" :src="c.logo" alt="" />
26
- <div v-else class="card__logo-fallback"></div>
27
- <span v-if="c.isWinner" class="card__badge" aria-label="Top performer">👑</span>
28
  </div>
29
-
30
- <div class="card__title-wrap">
31
- <div class="card__title" :title="c.title">{{ c.title }}</div>
32
- <div class="card__subtitle" :title="c.subtitle">{{ c.subtitle }}</div>
33
  </div>
 
34
 
35
- <div class="card__balance">{{ fmtUSD(c.balance) }}</div>
 
 
 
36
  </div>
37
 
38
- <div class="card__meta">
39
- <div class="meta__left">
40
- <div v-if="c.kind==='bh'" class="pill pill--neutral">Buy&nbsp;&amp;&nbsp;Hold</div>
41
- <div v-else class="pill pill--outline">Strategy</div>
42
- </div>
43
- <div class="meta__right" v-if="c.kind==='agent' && c.gapUsd != null">
44
- <div class="pill" :class="{ neg: c.gapUsd < 0 && !c.isWinner, pos: c.gapUsd >= 0 || c.isWinner }">
45
- <span v-if="mode==='usd'">{{ signedMoney(c.gapUsd) }}</span>
46
- <span v-else>{{ signedPct(c.gapPct) }}</span>
47
- </div>
48
- </div>
49
  </div>
50
 
51
- <div class="card__footer">
52
- <div class="card__foot">EOD {{ c.date ? new Date(c.date).toLocaleDateString() : '–' }}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  </div>
54
- </div>
55
  </div>
56
  </section>
57
 
@@ -66,14 +95,17 @@ import { ref, computed, onMounted, watch, shallowRef } from 'vue'
66
  import AssetTabs from '../components/AssetTabs.vue'
67
  import CompareChartE from '../components/CompareChartE.vue'
68
  import { dataService } from '../lib/dataService'
 
 
69
  import { getAllDecisions } from '../lib/dataCache'
70
  import { readAllRawDecisions } from '../lib/idb'
71
  import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
72
  import { STRATEGIES } from '../lib/strategies'
73
  import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
74
 
75
- const orderedAssets = ['BTC','ETH','MSFT','BMRN','TSLA']
76
- const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla'])
 
77
 
78
  const ASSET_ICONS = {
79
  BTC: new URL('../assets/images/assets_images/BTC.png', import.meta.url).href,
@@ -85,20 +117,20 @@ const ASSET_ICONS = {
85
  const AGENT_LOGOS = {}
86
  const ASSET_CUTOFF = { BTC: '2025-08-01' }
87
 
 
88
  const mode = ref('usd')
89
  const asset = ref('BTC')
90
  const rowsRef = ref([])
91
  let allDecisions = []
92
  const cards = shallowRef([])
93
 
 
94
  onMounted(async () => {
95
  try {
96
  if (!dataService.loaded && !dataService.loading) {
97
  await dataService.load(false)
98
  }
99
- } catch (e) {
100
- console.error('LiveView: dataService.load failed', e)
101
- }
102
  rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
103
  if (!orderedAssets.includes(asset.value)) asset.value = orderedAssets[0]
104
 
@@ -111,11 +143,14 @@ onMounted(async () => {
111
  }
112
  })
113
 
 
114
  const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
115
  const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}`
116
  const signedPct = (p) => `${(p >= 0 ? '+' : '−')}${Math.abs(p * 100).toFixed(2)}%`
117
  const score = (row) => (typeof row.balance === 'number' ? row.balance : -Infinity)
 
118
 
 
119
  const filteredRows = computed(() =>
120
  (rowsRef.value || []).filter(r => {
121
  if (r.asset !== asset.value) return false
@@ -124,6 +159,7 @@ const filteredRows = computed(() =>
124
  })
125
  )
126
 
 
127
  const winners = computed(() => {
128
  const byAgent = new Map()
129
  for (const r of filteredRows.value) {
@@ -134,21 +170,24 @@ const winners = computed(() => {
134
  return [...byAgent.values()]
135
  })
136
 
 
137
  const winnersForChart = computed(() =>
138
  winners.value.map(w => ({
139
- agent_name: w.agent_name,
140
- asset: w.asset,
141
- model: w.model,
142
- strategy: w.strategy,
143
  decision_ids: Array.isArray(w.decision_ids) ? w.decision_ids : undefined
144
  }))
145
  )
146
 
 
147
  const winnersKey = computed(() => {
148
  const sels = winnersForChart.value || []
149
  return sels.map(s => `${s.agent_name}|${s.asset}|${s.model}|${s.strategy}|${(s.decision_ids?.length||0)}`).join('||')
150
  })
151
 
 
152
  async function buildSeq(sel) {
153
  const { agent_name: agentName, asset: assetCode, model } = sel
154
  const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
@@ -177,13 +216,14 @@ async function computeEquities(sel) {
177
  const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { 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
  const lastIdx = Math.min(stratY.length, bhY.length) - 1
182
  if (lastIdx < 0) return null
183
 
184
  return { date: seq[lastIdx].date, stratLast: stratY[lastIdx], bhLast: bhY[lastIdx] }
185
  }
186
 
 
187
  let computing = false
188
  watch(
189
  () => [asset.value, winnersKey.value],
@@ -198,6 +238,7 @@ watch(
198
 
199
  if (!perfs.length) { cards.value = []; return }
200
 
 
201
  const first = perfs[0]
202
  const assetCode = first.sel.asset
203
  const bhCard = {
@@ -208,12 +249,19 @@ watch(
208
  balance: first.perf.bhLast,
209
  date: first.perf.date,
210
  logo: ASSET_ICONS[assetCode] || null,
211
- isWinner: false
 
 
 
 
 
212
  }
213
 
 
214
  const agentCards = perfs.map(({ sel, perf }) => {
215
  const gapUsd = perf.stratLast - perf.bhLast
216
  const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0
 
217
  return {
218
  key: `agent|${sel.agent_name}|${sel.model}`,
219
  kind: 'agent',
@@ -223,52 +271,106 @@ watch(
223
  date: perf.date,
224
  logo: AGENT_LOGOS[sel.agent_name] || null,
225
  gapUsd, gapPct,
226
- isWinner: false
 
 
 
227
  }
228
  })
229
 
230
- const maxBal = Math.max(...agentCards.map(c => c.balance ?? -Infinity))
231
- agentCards.forEach(c => { c.isWinner = c.balance === maxBal })
 
232
 
233
- cards.value = [bhCard, ...agentCards.sort((a,b) => b.balance - a.balance).slice(0,4)]
 
 
 
 
 
 
 
 
234
  } catch (e) {
235
  console.error('LiveView: compute cards failed', e)
236
  cards.value = []
237
- } finally {
238
- computing = false
239
- }
240
  },
241
  { immediate: true }
242
  )
243
  </script>
244
 
245
  <style scoped>
246
- .live { max-width: 1280px; margin: 0 auto; padding: 12px 20px 28px; }
247
- .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; }
248
- .mode { display: inline-flex; gap: 8px; }
249
- .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; }
250
- .mode__btn:hover { transform: translateY(-1px); }
251
- .mode__btn.is-active { background: #0F172A; color: #fff; border-color: #0F172A; }
252
- .panel { background: #fff; border: 1px solid #EDF0F4; border-radius: 14px; }
 
 
253
  .panel--chart { padding: 10px 10px 2px; }
254
  .panel--cards { padding: 12px; }
255
- .empty { padding: 14px; border: 1px dashed #D6DAE1; border-radius: 12px; color: #6B7280; font-size: .92rem; }
256
- .cards5 { display: grid; gap: 12px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
257
- .card { display: grid; grid-template-rows: auto auto auto; gap: 10px; padding: 12px 14px; border: 1px solid #EEF1F6; border-radius: 14px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
258
- .card--bh { outline: 2px dashed rgba(15,23,42,.08); }
259
- .card--winner { border-color: #16a34a; box-shadow: 0 0 0 3px rgba(22,163,74,.12); }
260
- .card__header { display: grid; grid-template-columns: 48px minmax(0,1fr) auto; align-items: center; column-gap: 12px; }
261
- .card__logo { width: 44px; height: 44px; border-radius: 999px; background: #F3F4F6; display: grid; place-items: center; overflow: hidden; position: relative; }
262
- .card__logo img { width: 100%; height: 100%; object-fit: contain; }
263
- .card__badge { position: absolute; right: -6px; top: -6px; font-size: 16px; filter: drop-shadow(0 1px 1px rgba(0,0,0,.15)); }
264
- .card__title { font-weight: 800; color: #0F172A; line-height: 1.15; font-size: clamp(16px, 1.6vw, 19px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
265
- .card__subtitle { font-size: 12px; color: #5B6476; opacity: .85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
266
- .card__balance { white-space: nowrap; font-weight: 900; color: #0F172A; font-size: clamp(18px, 1.9vw, 22px); align-self: start; overflow: hidden; text-overflow: ellipsis; }
267
- .card__meta { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; }
268
- .card__footer { font-size: 12px; color: #5B6476; opacity: .85; }
269
- .pill { padding: 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 800; line-height: 1; white-space: nowrap; background: #EEF2F7; color: #0F172A; }
270
- .pill.pos { background: #DCFCE7; color: #166534; }
271
- .pill.neg { background: #FEE2E2; color: #B91C1C; }
272
- .pill--neutral { background: #EEF2F7; color: #0F172A; }
273
- .pill--outline { background: transparent; color: #475569; border: 1px solid #E5E7EB; padding: 3px 8px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  </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" />
 
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="{ winner: c.isWinner, bh: c.kind==='bh', neg: (c.gapUsd ?? 0) < 0, pos: (c.gapUsd ?? 0) >= 0 }"
37
+ :style="{ '--bar': (c.barPct ?? 0) + '%'}"
38
+ >
39
+ <!-- Rank / BH badge -->
40
+ <div v-if="c.rank" class="rank">P{{ c.rank }}</div>
41
+ <div v-else-if="c.kind==='bh'" class="rank bh-badge">B&H</div>
42
+ <span v-if="c.isWinner" class="crown" aria-label="Top performer">👑</span>
43
+
44
+ <!-- Header: logo + names -->
45
+ <header class="head">
46
+ <div class="logo">
47
  <img v-if="c.logo" :src="c.logo" alt="" />
48
+ <div v-else class="logo__fallback" aria-hidden="true"></div>
 
49
  </div>
50
+ <div class="names">
51
+ <div class="agent">{{ c.kind==='bh' ? 'Buy & Hold' : c.title }}</div>
52
+ <div class="model">{{ c.subtitle }}</div>
 
53
  </div>
54
+ </header>
55
 
56
+ <!-- Net value row -->
57
+ <div class="net">
58
+ <div class="net__label">Net value</div>
59
+ <div class="net__value">{{ fmtUSD(c.balance) }}</div>
60
  </div>
61
 
62
+ <!-- Performance bar (vs B&H) -->
63
+ <div class="bar" :class="{ neg: (c.gapUsd ?? 0) < 0, pos: (c.gapUsd ?? 0) >= 0 }">
64
+ <span :style="{ width: (c.barPct ?? 0) + '%' }"></span>
 
 
 
 
 
 
 
 
65
  </div>
66
 
67
+ <!-- Bottom row: chips + EOD -->
68
+ <div class="bottom">
69
+ <div class="chips">
70
+ <span class="chip" :class="{ pos: profitOf(c) >= 0, neg: profitOf(c) < 0 }">
71
+ {{ signedMoney(profitOf(c)) }}
72
+ </span>
73
+ <span class="chip" :class="{ pos: (c.gapUsd ?? 0) >= 0, neg: (c.gapUsd ?? 0) < 0 }">
74
+ <template v-if="c.kind==='agent'">
75
+ <template v-if="mode==='usd'">{{ signedMoney(c.gapUsd) }}</template>
76
+ <template v-else>{{ signedPct(c.gapPct) }}</template>
77
+ </template>
78
+ <template v-else>—</template>
79
+ </span>
80
+ </div>
81
+ <div class="eod">EOD {{ c.date ? new Date(c.date).toLocaleDateString() : '–' }}</div>
82
  </div>
83
+ </article>
84
  </div>
85
  </section>
86
 
 
95
  import AssetTabs from '../components/AssetTabs.vue'
96
  import CompareChartE from '../components/CompareChartE.vue'
97
  import { dataService } from '../lib/dataService'
98
+
99
+ /* --- same helpers as chart --- */
100
  import { getAllDecisions } from '../lib/dataCache'
101
  import { readAllRawDecisions } from '../lib/idb'
102
  import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
103
  import { STRATEGIES } from '../lib/strategies'
104
  import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
105
 
106
+ /* ---------- config ---------- */
107
+ const orderedAssets = ['BTC','ETH','MSFT','BMRN','TSLA'] // (MRNA removed)
108
+ const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla']) // case-insensitive
109
 
110
  const ASSET_ICONS = {
111
  BTC: new URL('../assets/images/assets_images/BTC.png', import.meta.url).href,
 
117
  const AGENT_LOGOS = {}
118
  const ASSET_CUTOFF = { BTC: '2025-08-01' }
119
 
120
+ /* ---------- state ---------- */
121
  const mode = ref('usd')
122
  const asset = ref('BTC')
123
  const rowsRef = ref([])
124
  let allDecisions = []
125
  const cards = shallowRef([])
126
 
127
+ /* ---------- bootstrap ---------- */
128
  onMounted(async () => {
129
  try {
130
  if (!dataService.loaded && !dataService.loading) {
131
  await dataService.load(false)
132
  }
133
+ } catch (e) { console.error('LiveView: dataService.load failed', e) }
 
 
134
  rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : []
135
  if (!orderedAssets.includes(asset.value)) asset.value = orderedAssets[0]
136
 
 
143
  }
144
  })
145
 
146
+ /* ---------- helpers ---------- */
147
  const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 })
148
  const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}`
149
  const signedPct = (p) => `${(p >= 0 ? '+' : '−')}${Math.abs(p * 100).toFixed(2)}%`
150
  const score = (row) => (typeof row.balance === 'number' ? row.balance : -Infinity)
151
+ const profitOf = (c) => (typeof c?.profitUsd === 'number' ? c.profitUsd : ((c?.balance ?? 0) - 100000))
152
 
153
+ /* rows for selected asset (exclude vanilla/vinilla) */
154
  const filteredRows = computed(() =>
155
  (rowsRef.value || []).filter(r => {
156
  if (r.asset !== asset.value) return false
 
159
  })
160
  )
161
 
162
+ /* winners: best model per agent (by leaderboard balance) */
163
  const winners = computed(() => {
164
  const byAgent = new Map()
165
  for (const r of filteredRows.value) {
 
170
  return [...byAgent.values()]
171
  })
172
 
173
+ /* chart selections */
174
  const winnersForChart = computed(() =>
175
  winners.value.map(w => ({
176
+ agent_name: w.agent_name,
177
+ asset: w.asset,
178
+ model: w.model,
179
+ strategy: w.strategy,
180
  decision_ids: Array.isArray(w.decision_ids) ? w.decision_ids : undefined
181
  }))
182
  )
183
 
184
+ /* stable key to avoid identity churn */
185
  const winnersKey = computed(() => {
186
  const sels = winnersForChart.value || []
187
  return sels.map(s => `${s.agent_name}|${s.asset}|${s.model}|${s.strategy}|${(s.decision_ids?.length||0)}`).join('||')
188
  })
189
 
190
+ /* ---------- PERF (chart parity) ---------- */
191
  async function buildSeq(sel) {
192
  const { agent_name: agentName, asset: assetCode, model } = sel
193
  const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
 
216
  const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 }
217
 
218
  const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || []
219
+ const bhY = computeBuyHoldEquity(seq, 100000) || []
220
  const lastIdx = Math.min(stratY.length, bhY.length) - 1
221
  if (lastIdx < 0) return null
222
 
223
  return { date: seq[lastIdx].date, stratLast: stratY[lastIdx], bhLast: bhY[lastIdx] }
224
  }
225
 
226
+ /* build cards whenever winners/asset change */
227
  let computing = false
228
  watch(
229
  () => [asset.value, winnersKey.value],
 
238
 
239
  if (!perfs.length) { cards.value = []; return }
240
 
241
+ // Buy & Hold card from the asset’s aligned BH series
242
  const first = perfs[0]
243
  const assetCode = first.sel.asset
244
  const bhCard = {
 
249
  balance: first.perf.bhLast,
250
  date: first.perf.date,
251
  logo: ASSET_ICONS[assetCode] || null,
252
+ profitUsd: (first.perf.bhLast ?? 0) - 100000,
253
+ gapUsd: 0,
254
+ gapPct: 0,
255
+ isWinner: false,
256
+ rank: null,
257
+ barPct: 0
258
  }
259
 
260
+ // Agent cards (gap vs BH)
261
  const agentCards = perfs.map(({ sel, perf }) => {
262
  const gapUsd = perf.stratLast - perf.bhLast
263
  const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0
264
+ const profitUsd = (perf.stratLast ?? 0) - 100000
265
  return {
266
  key: `agent|${sel.agent_name}|${sel.model}`,
267
  kind: 'agent',
 
271
  date: perf.date,
272
  logo: AGENT_LOGOS[sel.agent_name] || null,
273
  gapUsd, gapPct,
274
+ profitUsd,
275
+ isWinner: false,
276
+ rank: null,
277
+ barPct: 0
278
  }
279
  })
280
 
281
+ // Rank by balance (agents only)
282
+ agentCards.sort((a,b) => (b.balance ?? -Infinity) - (a.balance ?? -Infinity))
283
+ agentCards.forEach((c, i) => { c.rank = i + 1 })
284
 
285
+ // Winner flag
286
+ if (agentCards.length) agentCards[0].isWinner = true
287
+
288
+ // Perf bar width scaled to max |gapUsd|
289
+ const maxAbsGap = Math.max(1, ...agentCards.map(c => Math.abs(c.gapUsd ?? 0)))
290
+ agentCards.forEach(c => { c.barPct = Math.max(3, Math.round((Math.abs(c.gapUsd ?? 0) / maxAbsGap) * 100)) })
291
+
292
+ // Build final list: BH first, then agents in rank order
293
+ cards.value = [bhCard, ...agentCards].slice(0,5)
294
  } catch (e) {
295
  console.error('LiveView: compute cards failed', e)
296
  cards.value = []
297
+ } finally { computing = false }
 
 
298
  },
299
  { immediate: true }
300
  )
301
  </script>
302
 
303
  <style scoped>
304
+ .live { max-width: 1280px; margin: 0 auto; padding: 12px 20px 28px; background: #ffffff; }
305
+
306
+ /* toolbar */
307
+ .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; }
308
+ .mode__btn { height: 30px; min-width: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid #D7DDE7; background: #ffffff; font-weight: 700; color: #0f172a; }
309
+ .mode__btn.is-active { background: #0f172a; color: #ffffff; border-color: #0f172a; }
310
+
311
+ /* panels */
312
+ .panel { background: #ffffff; border: 1px solid #E7ECF3; border-radius: 14px; }
313
  .panel--chart { padding: 10px 10px 2px; }
314
  .panel--cards { padding: 12px; }
315
+
316
+ /* empty */
317
+ .empty { padding: 14px; border: 1px dashed #D7DDE7; border-radius: 12px; color: #6B7280; font-size: .92rem; background: #ffffff; }
318
+
319
+ /* GRID */
320
+ .cards-grid-f1 { display: grid; gap: 12px; grid-template-columns: repeat(5, minmax(0,1fr)); }
321
+ @media (max-width: 1400px) { .cards-grid-f1 { grid-template-columns: repeat(4, minmax(0,1fr)); } }
322
+ @media (max-width: 1100px) { .cards-grid-f1 { grid-template-columns: repeat(3, minmax(0,1fr)); } }
323
+ @media (max-width: 900px) { .cards-grid-f1 { grid-template-columns: repeat(2, minmax(0,1fr)); } }
324
+ @media (max-width: 640px) { .cards-grid-f1 { grid-template-columns: 1fr; } }
325
+
326
+ /* F1 Card */
327
+ .card-f1 {
328
+ position: relative;
329
+ display: grid;
330
+ grid-template-rows: auto auto auto; /* head, net, bottom */
331
+ gap: 10px;
332
+ padding: 16px 16px 18px;
333
+ min-height: 210px;
334
+ border-radius: 14px;
335
+ background: linear-gradient(145deg,#ffffff,#fafbfd 55%,#ffffff 100%);
336
+ border: 1px solid #E7ECF3;
337
+ box-shadow: 0 1px 2px rgba(16,24,40,.03), 0 4px 12px rgba(16,24,40,.04);
338
+ color: #0f172a;
339
+ transition: transform .18s ease, box-shadow .2s ease, border-color .2s ease;
340
+ }
341
+ .card-f1:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(16,24,40,.08); border-color: #D9E2EF; }
342
+ .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); }
343
+ .card-f1.bh { border-style: dashed; opacity: 1; }
344
+
345
+ /* Rank / Crown */
346
+ .rank { position: absolute; top: 10px; left: 12px; font-weight: 900; font-size: 18px; color: rgba(15,23,42,.30); letter-spacing: .04em; }
347
+ .rank.bh-badge { font-size: 12px; background: rgba(15,23,42,.06); padding: 2px 6px; border-radius: 6px; color: #4b5563; }
348
+ .crown { position: absolute; top: 12px; right: 12px; font-size: 18px; filter: drop-shadow(0 1px 1px rgba(0,0,0,.12)); }
349
+
350
+ /* Head */
351
+ .head { display: grid; grid-template-columns: 40px minmax(0,1fr); align-items: center; gap: 10px; }
352
+ .logo { width: 40px; height: 40px; border-radius: 10px; background: #f3f4f6; display: grid; place-items: center; overflow: hidden; border: 1px solid #E7ECF3; }
353
+ .logo img { width: 100%; height: 100%; object-fit: contain; }
354
+ .logo__fallback { width: 60%; height: 60%; border-radius: 6px; background: #e5e7eb; }
355
+ .names { min-width: 0; }
356
+ .agent { font-size: 16px; font-weight: 900; letter-spacing: .02em; text-transform: uppercase; color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
357
+ .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; }
358
+
359
+ /* Net row */
360
+ .net { display: grid; grid-template-columns: 1fr auto; align-items: end; }
361
+ .net__label { font-size: 12px; color: #6b7280; }
362
+ .net__value { font-size: clamp(22px, 2.2vw, 30px); font-weight: 900; letter-spacing: -.01em; color: #0f172a; }
363
+
364
+ /* Bar vs B&H */
365
+ .bar { height: 6px; border-radius: 999px; background: #F2F4F8; overflow: hidden; border: 1px solid #E7ECF3; }
366
+ .bar span { display: block; height: 100%; background: linear-gradient(90deg,#16a34a,#22c55e); width: var(--bar, 40%); transition: width .5s ease; }
367
+ .bar.neg span { background: linear-gradient(90deg,#ef4444,#dc2626); }
368
+
369
+ /* Bottom */
370
+ .bottom { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; }
371
+ .chips { display: inline-flex; gap: 8px; }
372
+ .chip { font-size: 12px; font-weight: 800; padding: 4px 8px; border-radius: 999px; background: #F6F8FB; color: #0f172a; border: 1px solid #E7ECF3; }
373
+ .chip.pos { color: #0e7a3a; background: #E9F7EF; border-color: #d7f0e0; }
374
+ .chip.neg { color: #B91C1C; background: #FBEAEA; border-color: #F3DADA; }
375
+ .eod { font-size: 12px; color: #6b7280; }
376
  </style>