Spaces:
Running
Running
| <template> | |
| <div class="live"> | |
| <!-- About Text --> | |
| <div class="about-banner"> | |
| <p class="about-text"> | |
| A real-time, ever-evolving platform for continuous competition among AI trading agents. | |
| </p> | |
| </div> | |
| <!-- Toolbar: assets + mode --> | |
| <header class="toolbar"> | |
| <div class="toolbar__left"> | |
| <!-- Asset tabs styled per AMA --> | |
| <div class="asset-tabs"> | |
| <button | |
| v-for="a in orderedAssets" | |
| :key="a" | |
| class="asset-tab" | |
| :class="{ 'is-active': asset === a }" | |
| @click="asset = a" | |
| > | |
| {{ a }} | |
| </button> | |
| </div> | |
| </div> | |
| <div class="toolbar__right"> | |
| <!-- Segmented mode switch (USD / %) --> | |
| <div class="mode-switch" role="tablist" aria-label="Value mode"> | |
| <button | |
| role="tab" | |
| aria-selected="mode==='usd'" | |
| class="mode-switch__btn" | |
| :class="{ 'is-active': mode==='usd' }" | |
| @click="mode='usd'" | |
| > | |
| $ | |
| </button> | |
| <button | |
| role="tab" | |
| aria-selected="mode==='pct'" | |
| class="mode-switch__btn" | |
| :class="{ 'is-active': mode==='pct' }" | |
| @click="mode='pct'" | |
| > | |
| % | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Chart --> | |
| <section class="panel panel--chart"> | |
| <CompareChartE | |
| v-if="winnersForChart.length" | |
| :selected="winnersForChart" | |
| :visible="true" | |
| :mode="mode" | |
| /> | |
| <div v-else class="empty"> | |
| No data for <strong>{{ asset }}</strong> yet. Check Supabase runs or try another asset. | |
| </div> | |
| </section> | |
| <!-- Tournament Cards --> | |
| <section class="panel panel--cards" v-if="cards.length"> | |
| <div class="cards-grid-f1"> | |
| <article | |
| v-for="c in cards" | |
| :key="c.key" | |
| class="card-f1" | |
| :class="{ | |
| bh: c.kind==='bh', | |
| neg: (c.gapUsd ?? 0) < 0, | |
| pos: (c.gapUsd ?? 0) >= 0, | |
| gold: c.rank === 1, | |
| silver: c.rank === 2, | |
| bronze: c.rank === 3 | |
| }" | |
| :style="{ '--bar': (c.barPct ?? 0) + '%'}" | |
| > | |
| <!-- Podium ribbon (rank 1-3 only) --> | |
| <div v-if="c.rank && c.rank <= 3" class="podium-ribbon" :data-rank="c.rank"></div> | |
| <!-- Header: logo + names --> | |
| <header class="head"> | |
| <div class="logo"> | |
| <img v-if="c.logo" :src="c.logo" alt="" /> | |
| <div v-else class="logo__fallback" aria-hidden="true"></div> | |
| </div> | |
| <div class="names"> | |
| <div class="agent">{{ c.kind==='bh' ? 'Buy & Hold' : c.title }}</div> | |
| <div class="model">{{ c.subtitle }}</div> | |
| </div> | |
| </header> | |
| <!-- KPI row: Net value + P&L --> | |
| <div class="kpis"> | |
| <div class="kpi"> | |
| <div class="kpi__label">Net Value</div> | |
| <div class="kpi__value">{{ fmtUSD(c.balance) }}</div> | |
| </div> | |
| <div class="kpi align-right"> | |
| <div class="kpi__label">P&L</div> | |
| <div class="kpi__pill" :class="{ pos: profitOf(c) >= 0, neg: profitOf(c) < 0 }"> | |
| {{ signedMoney(profitOf(c)) }} | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Quality row: Sharpe / MaxDD --> | |
| <div class="quality"> | |
| <div class="qitem"> | |
| <span class="qitem__label">Sharpe</span> | |
| <span class="qitem__value" :class="{ strong: (c.sharpe ?? 0) >= 2 }">{{ fmtSharpe(c.sharpe) }}</span> | |
| </div> | |
| <div class="qitem"> | |
| <span class="qitem__label">MaxDD</span> | |
| <span class="qitem__value dd">{{ signedPct(c.maxDrawdown) }}</span> | |
| </div> | |
| </div> | |
| <!-- Match stats: Win Rate / Trades / Days --> | |
| <div class="stats"> | |
| <div class="stat"><span class="stat__label">Win Rate</span><span class="stat__val" :class="{ pos: (c.winRate ?? 0) >= 0.5, neg: (c.winRate ?? 0) < 0.5 }">{{ fmtRate(c.winRate) }}</span></div> | |
| <div class="stat"><span class="stat__label">Trades</span><span class="stat__val">{{ c.trades ?? '—' }}</span></div> | |
| <div class="stat"><span class="stat__label">Days</span><span class="stat__val">{{ c.days ?? '—' }}</span></div> | |
| </div> | |
| <!-- Performance bar (vs B&H) --> | |
| <div | |
| class="bar" | |
| :class="{ neg: (c.gapUsd ?? 0) < 0, pos: (c.gapUsd ?? 0) >= 0 }" | |
| :aria-label="mode==='usd' ? ('Gap vs B&H: ' + signedMoney(c.gapUsd)) : ('Gap vs B&H: ' + signedPct(c.gapPct))" | |
| > | |
| <span :style="{ width: (c.barPct ?? 0) + '%' }"></span> | |
| </div> | |
| <!-- Meta row: Gap vs B&H + EOD --> | |
| <div class="meta"> | |
| <div class="gap chip" :class="{ pos: (c.gapUsd ?? 0) >= 0, neg: (c.gapUsd ?? 0) < 0 }"> | |
| <template v-if="c.kind==='agent'"> | |
| <span class="chip__label">vs B&H</span> | |
| <span class="chip__value"> | |
| <template v-if="mode==='usd'">{{ signedMoney(c.gapUsd) }}</template> | |
| <template v-else>{{ signedPct(c.gapPct) }}</template> | |
| </span> | |
| </template> | |
| <template v-else>—</template> | |
| </div> | |
| <div class="eod">EOD {{ c.date || '–' }}</div> | |
| </div> | |
| </article> | |
| </div> | |
| </section> | |
| <section v-else class="panel panel--cards"> | |
| <div class="empty">No card data yet for <strong>{{ asset }}</strong>.</div> | |
| </section> | |
| </div> | |
| </template> | |
| <script setup> | |
| import { ref, computed, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue' | |
| import CompareChartE from '../components/CompareChartE.vue' | |
| import { dataService } from '../lib/dataService' | |
| /* helpers & metrics */ | |
| import { getAllDecisions } from '../lib/dataCache' | |
| import { readAllRawDecisions } from '../lib/idb' | |
| import { filterRowsToNyseTradingDays } from '../lib/marketCalendar' | |
| import { STRATEGIES } from '../lib/strategies' | |
| import { | |
| computeBuyHoldEquity, | |
| computeStrategyEquity, | |
| calculateMetricsFromSeries, | |
| computeWinRate | |
| } from '../lib/perf' | |
| /* ---------- config ---------- */ | |
| const orderedAssets = ['BTC','ETH','BMRN','TSLA'] | |
| const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla']) | |
| import { supabase } from '../lib/supabase' | |
| const ASSET_ICONS = { | |
| BTC: new URL('../assets/images/assets_images/BTC.png', import.meta.url).href, | |
| ETH: new URL('../assets/images/assets_images/ETH.png', import.meta.url).href, | |
| MSFT: new URL('../assets/images/assets_images/MSFT.png', import.meta.url).href, | |
| BMRN: new URL('../assets/images/assets_images/BMRN.png', import.meta.url).href, | |
| TSLA: new URL('../assets/images/assets_images/TSLA.png', import.meta.url).href, | |
| } | |
| const AGENT_LOGOS = { | |
| 'TradeAgent': new URL('../assets/images/agents_images/tradeagent.png', import.meta.url).href, | |
| 'HedgeFundAgent': new URL('../assets/images/agents_images/hedgefund.png', import.meta.url).href, | |
| 'DeepFundAgent': new URL('../assets/images/agents_images/deepfund.png', import.meta.url).href, | |
| 'InvestorAgent': new URL('../assets/images/agents_images/investor.png', import.meta.url).href, | |
| } | |
| const ASSET_CUTOFF = { BTC: '2025-08-01' } | |
| /* ---------- state ---------- */ | |
| const mode = ref('usd') | |
| const asset = ref('BTC') | |
| const rowsRef = ref([]) | |
| let allDecisions = [] | |
| const cards = shallowRef([]) | |
| window.cards = cards | |
| const refreshing = ref(false) | |
| let unsubscribe = null | |
| /* ---------- bootstrap ---------- */ | |
| onMounted(async () => { | |
| unsubscribe = dataService.subscribe((state) => { | |
| rowsRef.value = Array.isArray(state.tableRows) ? state.tableRows : [] | |
| }) | |
| rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : [] | |
| // Force refresh data from Supabase to get latest end_date | |
| if (!dataService.loading) { | |
| dataService.load(true).catch(e => console.error('LiveView: force refresh failed', e)) | |
| } | |
| if (!orderedAssets.includes(asset.value)) asset.value = orderedAssets[0] | |
| allDecisions = getAllDecisions() || [] | |
| if (!allDecisions.length) { | |
| try { | |
| const cached = await readAllRawDecisions() | |
| if (cached?.length) allDecisions = cached | |
| } catch {} | |
| } | |
| }) | |
| onBeforeUnmount(() => { | |
| if (unsubscribe) { unsubscribe(); unsubscribe = null } | |
| }) | |
| /* ---------- helpers ---------- */ | |
| const formatModelName = (model) => { | |
| if (!model) return '' | |
| return model.replace(/_?\d{8}$/, '') | |
| } | |
| const asRatio = (x) => { | |
| if (x == null || Number.isNaN(x)) return null; | |
| // if |x| > 1.2 we assume it's already a % number (e.g., 58 or -79.23), convert to ratio | |
| return Math.abs(x) > 0.5 ? (x / 100) : x; | |
| }; | |
| const signedPct = (p) => { | |
| const r = asRatio(p); | |
| if (r == null) return '—'; | |
| const sign = r >= 0 ? '+' : '−'; | |
| return `${sign}${(Math.abs(r) * 100).toFixed(2)}%`; | |
| }; | |
| const fmtRate = (r) => { | |
| const rr = asRatio(r); | |
| if (rr == null) return '—'; | |
| return `${(rr * 100).toFixed(0)}%`; | |
| }; | |
| const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }) | |
| const signedMoney = (n) => `${n >= 0 ? '+' : '−'}${fmtUSD(Math.abs(n))}` | |
| const fmtSharpe = (s) => (s == null ? '—' : Number(s).toFixed(2)) | |
| const score = (row) => (typeof row.balance === 'number' ? row.balance : -Infinity) | |
| const profitOf = (c) => (typeof c?.profitUsd === 'number' ? c.profitUsd : ((c?.balance ?? 0) - 100000)) | |
| /* rows for selected asset (exclude vanilla/vinilla) - only show long_only */ | |
| const filteredRows = computed(() => | |
| (rowsRef.value || []).filter(r => { | |
| if (r.asset !== asset.value) return false | |
| if (r.strategy !== 'long_only') return false | |
| const name = (r?.agent_name || '').toLowerCase() | |
| return !EXCLUDED_AGENT_NAMES.has(name) | |
| }) | |
| ) | |
| /* winners: best model per agent (by leaderboard balance) */ | |
| const winners = computed(() => { | |
| const byAgent = new Map() | |
| for (const r of filteredRows.value) { | |
| const k = r.agent_name | |
| const cur = byAgent.get(k) | |
| if (!cur || score(r) > score(cur)) byAgent.set(k, r) | |
| } | |
| return [...byAgent.values()] | |
| }) | |
| /* chart selections */ | |
| const winnersForChart = computed(() => { | |
| // Get max end_date for current asset from all rows | |
| const rowsForAsset = (rowsRef.value || []).filter(r => r.asset === asset.value) | |
| const endDates = rowsForAsset.map(r => r.end_date).filter(Boolean) | |
| const maxEndDate = endDates.length ? endDates.sort().pop() : null | |
| return winners.value.map(w => ({ | |
| agent_name: w.agent_name, | |
| asset: w.asset, | |
| model: w.model, | |
| strategy: w.strategy, | |
| decision_ids: Array.isArray(w.decision_ids) ? w.decision_ids : undefined, | |
| end_date: maxEndDate || w.end_date // use max end_date from all rows for this asset | |
| })) | |
| }) | |
| /* stable key to avoid identity churn */ | |
| const winnersKey = computed(() => { | |
| const sels = winnersForChart.value || [] | |
| return sels.map(s => `${s.agent_name}|${s.asset}|${s.model}|${s.strategy}|${(s.decision_ids?.length||0)}`).join('||') | |
| }) | |
| /* ---------- PERF ---------- */ | |
| async function buildSeq(sel) { | |
| const { agent_name: agentName, asset: assetCode, model } = sel | |
| const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : [] | |
| let seq = ids.length | |
| ? allDecisions.filter(r => ids.includes(r.id)) | |
| : allDecisions.filter(r => r.agent_name === agentName && r.asset === assetCode && r.model === model) | |
| seq.sort((a,b) => (a.date > b.date ? 1 : -1)) | |
| const cutoff = ASSET_CUTOFF[assetCode] | |
| if (cutoff) { | |
| const t0 = new Date(cutoff + 'T00:00:00Z') | |
| seq = seq.filter(r => new Date(r.date + 'T00:00:00Z') >= t0) | |
| } | |
| // if using decision_ids, data is already prefiltered | |
| if (!ids.length) { | |
| const isCrypto = assetCode === 'BTC' || assetCode === 'ETH' | |
| if (!isCrypto) seq = await filterRowsToNyseTradingDays(seq) | |
| } | |
| return seq | |
| } | |
| async function computeEquities(sel) { | |
| const seq = await buildSeq(sel) | |
| if (!seq.length) return null | |
| const cfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_only', tradingMode: 'aggressive', fee: 0.0005 } | |
| const stratY = computeStrategyEquity(seq, 100000, cfg.fee, cfg.strategy, cfg.tradingMode) || [] | |
| const bhY = computeBuyHoldEquity(seq, 100000) || [] | |
| const lastIdx = Math.min(stratY.length, bhY.length) - 1 | |
| if (lastIdx < 0) return null | |
| // quality metrics | |
| const isCrypto = sel.asset === 'BTC' || sel.asset === 'ETH' | |
| const metrics = calculateMetricsFromSeries(stratY, isCrypto ? 'crypto' : 'stock') || {} | |
| const { sharpe_ratio: sharpe, max_drawdown: maxDrawdown, total_return: totalReturnPct } = metrics | |
| // win rate / trades | |
| let winRate = null | |
| let trades = null | |
| try { | |
| const r = computeWinRate(seq, cfg.strategy, cfg.tradingMode) || {} | |
| winRate = r.winRate | |
| trades = r.trades | |
| } catch {} | |
| // approximate "days" from sequence | |
| let days = 0 | |
| try { | |
| const start = new Date(seq[0].date) | |
| const end = new Date(seq[lastIdx].date) | |
| days = Math.max(1, Math.round((end - start) / 86400000) + 1) | |
| } catch {} | |
| return { | |
| date: seq[lastIdx]?.date || sel.end_date, // prefer actual sequence date over leaderboard end_date | |
| stratLast: stratY[lastIdx], | |
| bhLast: bhY[lastIdx], | |
| sharpe, | |
| maxDrawdown, // as fraction (e.g., -0.053) | |
| winRate, trades, days, | |
| totalReturnPct // for future use if needed | |
| } | |
| } | |
| /* build cards whenever winners/asset change */ | |
| let computing = false | |
| watch( | |
| () => [asset.value, winnersKey.value], | |
| async () => { | |
| if (computing) return | |
| if (!winnersForChart.value.length) { cards.value = []; return } | |
| computing = true | |
| try { | |
| const perfs = (await Promise.all( | |
| winnersForChart.value.map(async sel => ({ sel, perf: await computeEquities(sel) })) | |
| )).filter(x => x.perf) | |
| if (!perfs.length) { cards.value = []; return } | |
| // Get max date directly from Supabase for this asset | |
| const currentAsset = asset.value | |
| let maxEndDate = null | |
| try { | |
| const { data, error } = await supabase | |
| .from('trading_decisions') | |
| .select('date') | |
| .eq('asset', currentAsset) | |
| .order('date', { ascending: false }) | |
| .limit(1) | |
| if (!error && data && data.length > 0) { | |
| maxEndDate = data[0].date | |
| console.log('[LiveView] Direct Supabase query - Asset:', currentAsset, 'Max date:', maxEndDate) | |
| } else { | |
| console.log('[LiveView] Supabase query failed or no data:', error) | |
| } | |
| } catch (e) { | |
| console.error('[LiveView] Error querying Supabase:', e) | |
| } | |
| // Buy & Hold first | |
| const first = perfs[0] | |
| const assetCode = first.sel.asset | |
| const bhCard = { | |
| key: `bh|${assetCode}`, | |
| kind: 'bh', | |
| title: 'Buy & Hold', | |
| subtitle: assetCode, | |
| balance: first.perf.bhLast, | |
| date: maxEndDate || first.perf.date, // Use max date from Supabase | |
| logo: ASSET_ICONS[assetCode] || null, | |
| profitUsd: (first.perf.bhLast ?? 0) - 100000, | |
| gapUsd: 0, | |
| gapPct: 0, | |
| rank: null, | |
| barPct: 0, | |
| // BH quality fields are neutral/na | |
| sharpe: null, | |
| maxDrawdown: null, | |
| winRate: null, | |
| trades: null, | |
| days: null | |
| } | |
| // Agents | |
| const agentCards = perfs.map(({ sel, perf }) => { | |
| const gapUsd = perf.stratLast - perf.bhLast | |
| const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0 | |
| const profitUsd = (perf.stratLast ?? 0) - 100000 | |
| return { | |
| key: `agent|${sel.agent_name}|${sel.model}`, | |
| kind: 'agent', | |
| title: sel.agent_name, | |
| subtitle: formatModelName(sel.model), | |
| balance: perf.stratLast, | |
| date: maxEndDate || perf.date, // Use max date from Supabase | |
| logo: AGENT_LOGOS[sel.agent_name] || null, | |
| gapUsd, gapPct, | |
| profitUsd, | |
| sharpe: perf.sharpe, | |
| maxDrawdown: perf.maxDrawdown, | |
| winRate: perf.winRate, | |
| trades: perf.trades, | |
| days: perf.days, | |
| rank: null, | |
| barPct: 0 | |
| } | |
| }) | |
| // Rank by balance (agents only) | |
| agentCards.sort((a,b) => (b.balance ?? -Infinity) - (a.balance ?? -Infinity)) | |
| agentCards.forEach((c, i) => { c.rank = i + 1 }) | |
| // Perf bar width scaled to max |gapUsd| | |
| const maxAbsGap = Math.max(1, ...agentCards.map(c => Math.abs(c.gapUsd ?? 0))) | |
| agentCards.forEach(c => { c.barPct = Math.max(3, Math.round((Math.abs(c.gapUsd ?? 0) / maxAbsGap) * 100)) }) | |
| // BH first, then top agents (limit 5) | |
| cards.value = [bhCard, ...agentCards].slice(0,5) | |
| } finally { computing = false } | |
| }, | |
| { immediate: true } | |
| ) | |
| </script> | |
| <!-- Global brand tokens (unscoped to ensure cascade) --> | |
| <!-- Paste this inside your Live view component as the full style --> | |
| <style scoped> | |
| /* ======= Page ======= */ | |
| .live{ | |
| max-width:1280px; | |
| margin:0 auto; | |
| padding:0 24px 64px; | |
| color:var(--ink-900); | |
| background: radial-gradient(ellipse at 30% -10%, #fdfdfd 0%, var(--surface-2) 60%, var(--surface-0) 100%); | |
| margin-top: -40px; | |
| } | |
| /* ======= About Banner ======= */ | |
| .about-banner{ | |
| padding: 8px 24px 16px 56px; | |
| background: var(--surface-0); | |
| margin: 0 -24px 0 -24px; | |
| } | |
| .about-text{ | |
| margin: 0; | |
| font-size: 15px; | |
| font-weight: 500; | |
| color: #6b7280; | |
| letter-spacing: 0.01em; | |
| line-height: 1.5; | |
| } | |
| .about-text .paper-link{ | |
| color: rgb(var(--ama-start)); | |
| font-weight: 700; | |
| text-decoration: none; | |
| transition: all 0.2s ease; | |
| border-bottom: 1px solid transparent; | |
| } | |
| .about-text .paper-link:hover{ | |
| color: rgb(var(--ama-end)); | |
| border-bottom-color: rgb(var(--ama-end)); | |
| } | |
| /* ======= Toolbar (assets + mode) ======= */ | |
| .toolbar{ | |
| position:sticky; top:0; z-index:10; | |
| display:flex; align-items:center; justify-content:space-between; gap:16px; | |
| padding:12px 0 14px; background:var(--surface-0); | |
| border-bottom:2px solid rgba(var(--ama-end), .15); | |
| backdrop-filter: blur(10px); | |
| } | |
| .toolbar__right{ display:flex; align-items:center; gap:12px; } | |
| /* ======= Asset tabs ======= */ | |
| .asset-tabs{ | |
| display:inline-flex; gap:6px; padding:4px; | |
| border-radius:12px; background:var(--surface-0); | |
| border:1px solid var(--bd-subtle); box-shadow:0 2px 8px rgba(0,0,0,.04); | |
| } | |
| .asset-tab{ | |
| min-width:54px; height:32px; padding:0 10px; | |
| border-radius:8px; border:1px solid transparent; | |
| background:transparent; color:var(--ink-900); | |
| font-weight:800; letter-spacing:.02em; cursor:pointer; transition:all .2s ease; | |
| } | |
| .asset-tab:hover{ color:rgb(var(--ama-end)); border-color:rgba(var(--ama-end), .28); } | |
| .asset-tab.is-active{ | |
| color:#fff; border:none; | |
| background: linear-gradient(90deg, rgb(var(--ama-start)), rgb(var(--ama-end))); | |
| box-shadow: 0 0 0 1px rgba(var(--ama-end), .22), 0 8px 18px rgba(var(--ama-end), .22); | |
| } | |
| /* ======= Mode switch ($ / %) ======= */ | |
| .mode-switch{ | |
| display:inline-grid; grid-template-columns:1fr 1fr; | |
| border-radius:12px; overflow:hidden; | |
| border:1px solid var(--bd-subtle); background:var(--surface-0); | |
| box-shadow:0 2px 8px rgba(0,0,0,.04); | |
| } | |
| .mode-switch__btn{ | |
| height:32px; min-width:44px; padding:0 12px; | |
| font-weight:800; letter-spacing:.02em; border:none; background:transparent; | |
| color:var(--ink-900); cursor:pointer; transition:all .2s ease; | |
| } | |
| .mode-switch__btn:not(.is-active):hover{ color:rgb(var(--ama-end)); background:#f8fafc; } | |
| .mode-switch__btn.is-active{ | |
| color:#fff; | |
| background: linear-gradient(90deg, rgb(var(--ama-start)), rgb(var(--ama-end))); | |
| box-shadow: inset 0 -1px 0 rgba(255,255,255,.2); | |
| } | |
| /* ======= Panels ======= */ | |
| .panel{ background:var(--surface-0); border:1px solid var(--bd-soft); border-radius:16px; margin-top:16px; } | |
| .panel--chart{ padding:10px; } | |
| .panel--cards{ padding:16px; } | |
| /* ======= Empty State ======= */ | |
| .empty{ | |
| padding:20px; border:1px dashed var(--bd-soft); border-radius:12px; | |
| color:var(--ink-600); font-size:.95rem; background:var(--surface-0); text-align:center; | |
| } | |
| /* ======= Grid ======= */ | |
| .cards-grid-f1{ display:grid; gap:14px; grid-template-columns:repeat(auto-fit, minmax(260px,1fr)); } | |
| /* ======= Cards (Tournament) ======= */ | |
| .card-f1{ | |
| position:relative; | |
| display:grid; grid-template-rows:auto auto auto auto auto; gap:10px; | |
| padding:18px 18px 20px; min-height:230px; border-radius:16px; | |
| background: linear-gradient(145deg, var(--surface-0), var(--surface-2) 75%, var(--surface-0) 100%); | |
| border:1px solid rgba(0,0,0,.05); | |
| box-shadow:0 2px 10px rgba(var(--ama-start), .05), 0 6px 20px rgba(var(--ama-end), .08); | |
| color:var(--ink-900); transition: transform .25s ease, box-shadow .25s ease; | |
| } | |
| .card-f1:hover{ transform: translateY(-2px); box-shadow:0 10px 28px rgba(0,0,0,.08); } | |
| .card-f1.bh{ border-style:dashed; } | |
| /* Podium metals (attach .gold/.silver/.bronze) */ | |
| .card-f1.gold{ | |
| border-color:var(--gold); | |
| box-shadow:0 0 0 1px rgba(212,175,55,.32), 0 10px 28px rgba(212,175,55,.18); | |
| } | |
| .card-f1.silver{ | |
| border-color:var(--silver); | |
| box-shadow:0 0 0 1px rgba(192,192,192,.28), 0 10px 28px rgba(192,192,192,.14); | |
| } | |
| .card-f1.bronze{ | |
| border-color:var(--bronze); | |
| box-shadow:0 0 0 1px rgba(205,127,50,.28), 0 10px 28px rgba(205,127,50,.14); | |
| } | |
| /* Optional thin ribbon bar on podium cards */ | |
| .podium-ribbon{ position:absolute; top:0; left:0; right:0; height:6px; border-top-left-radius:16px; border-top-right-radius:16px; } | |
| .card-f1.gold .podium-ribbon{ background: linear-gradient(90deg, #f6e27a, var(--gold)); } | |
| .card-f1.silver .podium-ribbon{ background: linear-gradient(90deg, #e9eef2, var(--silver)); } | |
| .card-f1.bronze .podium-ribbon{ background: linear-gradient(90deg, #f0c6a1, var(--bronze)); } | |
| /* ======= Card: Head ======= */ | |
| .head{ display:grid; grid-template-columns:44px minmax(0,1fr); align-items:center; gap:12px; } | |
| .logo{ width:44px; height:44px; border-radius:12px; background:#f3f4f6; display:grid; place-items:center; overflow:hidden; border:1px solid #E5E7EB; } | |
| .logo img{ width:100%; height:100%; object-fit:contain; } | |
| .logo__fallback{ width:60%; height:60%; border-radius:6px; background:#e5e7eb; } | |
| .names{ min-width:0; } | |
| .agent{ font-size:16px; font-weight:900; letter-spacing:.02em; text-transform:uppercase; color:var(--ink-900); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| .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; } | |
| /* ======= KPIs (Net Value + P&L) ======= */ | |
| .kpis{ display:grid; grid-template-columns:1fr auto; align-items:end; gap:12px; } | |
| .kpi__label{ font-size:12px; color:var(--ink-600); } | |
| .kpi__value{ font-size: clamp(18px, 2.2vw, 25px); font-weight:700; letter-spacing:-.01em; color:var(--ink-900); } | |
| .kpi__pill{ | |
| display:inline-block; font-size:12px; font-weight:800; padding:4px 10px; border-radius:999px; | |
| border:1px solid var(--bd-soft); background:#F6F8FB; color:var(--ink-900); | |
| } | |
| .kpi__pill.pos{ color:var(--pos-fg); background:var(--pos-bg); border-color:var(--pos-br); } | |
| .kpi__pill.neg{ color:var(--neg-fg); background:var(--neg-bg); border-color:var(--neg-br); } | |
| /* ======= Quality (Sharpe, MaxDD) ======= */ | |
| .quality{ display:grid; grid-template-columns:1fr 1fr; gap:12px; padding:2px 0 0; } | |
| .qitem{ display:flex; align-items:baseline; gap:8px; } | |
| .qitem__label{ font-size:12px; color:var(--ink-600); } | |
| .qitem__value{ font-size:14px; font-weight:800; color:var(--ink-900); } | |
| .qitem__value.strong{ color:var(--pos-fg); } | |
| .qitem__value.dd-pos{ color:var(--pos-fg); } /* small drawdown */ | |
| .qitem__value.dd-mid{ color:var(--ink-900); } /* medium drawdown */ | |
| .qitem__value.dd-neg{ color:var(--neg-fg); } /* large drawdown */ | |
| /* ======= Stats (WinRate, Trades, Days) ======= */ | |
| .stats{ display:grid; grid-template-columns:repeat(3, 1fr); gap:10px; font-size:12px; color:var(--ink-700); } | |
| .stat{ display:flex; align-items:center; justify-content:space-between; background:#F8FAFD; border:1px solid var(--bd-soft); border-radius:10px; padding:6px 8px; } | |
| .stat__label{ color:var(--ink-600); } | |
| .stat__val{ font-weight:800; color:var(--ink-900); } | |
| .stat__val.pos{ color:var(--pos-fg); } | |
| .stat__val.neg{ color:var(--neg-fg); } | |
| /* ======= Bar vs B&H ======= */ | |
| .bar{ height:6px; border-radius:999px; background:#F2F4F8; overflow:hidden; border:1px solid var(--bd-soft); margin:2px 0 8px; } | |
| .bar span{ display:block; height:100%; width:var(--bar, 40%); transition:width .5s ease; | |
| background: linear-gradient(90deg,#16a34a,#22c55e); | |
| } | |
| .bar.neg span{ background: linear-gradient(90deg,#ef4444,#dc2626); } | |
| /* ======= Meta (Gap vs B&H chip + EOD) ======= */ | |
| .meta{ display:grid; grid-template-columns:1fr auto; align-items:center; gap:8px; } | |
| .chip{ | |
| display:inline-flex; align-items:center; gap:6px; | |
| font-size:12px; font-weight:800; padding:4px 8px; border-radius:999px; | |
| background:#F6F8FB; color:var(--ink-900); border:1px solid var(--bd-soft); | |
| } | |
| .chip.pos{ color:var(--pos-fg); background:var(--pos-bg); border-color:var(--pos-br); } | |
| .chip.neg{ color:var(--neg-fg); background:var(--neg-bg); border-color:var(--neg-br); } | |
| .chip__label{ opacity:.8; } | |
| .chip__value{ font-variant-numeric: tabular-nums; } | |
| .eod{ font-size:12px; color:var(--ink-600); } | |
| /* ======= Responsive ======= */ | |
| @media (max-width: 900px){ | |
| .stats{ grid-template-columns:1fr 1fr; } | |
| } | |
| @media (max-width: 640px){ | |
| .cards-grid-f1{ grid-template-columns:1fr; } | |
| .kpi__value{ font-size:22px; } | |
| .about-text{ font-size: 14px; } | |
| } | |
| </style> | |