Spaces:
Running
Running
| <template> | |
| <div class="live"> | |
| <!-- Toolbar: assets + mode --> | |
| <header class="toolbar"> | |
| <div class="toolbar__left"> | |
| <AssetTabs v-model="asset" :ordered-assets="orderedAssets" /> | |
| </div> | |
| <div class="toolbar__right"> | |
| <div class="mode"> | |
| <button class="mode__btn" :class="{ 'is-active': mode==='usd' }" @click="mode='usd'">$</button> | |
| <button class="mode__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> | |
| <!-- F1 Scoreboard 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="{ winner: c.isWinner, bh: c.kind==='bh', neg: (c.gapUsd ?? 0) < 0, pos: (c.gapUsd ?? 0) >= 0 }" | |
| :style="{ '--bar': (c.barPct ?? 0) + '%'}" | |
| > | |
| <!-- Rank / BH badge --> | |
| <div v-if="c.rank" class="rank">{{ c.rank }}</div> | |
| <div v-else-if="c.kind==='bh'" class="rank bh-badge">B&H</div> | |
| <span v-if="c.isWinner" class="crown" aria-label="Top performer">π</span> | |
| <!-- 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> | |
| <!-- Net value row --> | |
| <div class="net"> | |
| <div class="net__label">Net value</div> | |
| <div class="net__value">{{ fmtUSD(c.balance) }}</div> | |
| </div> | |
| <!-- Performance bar (vs B&H) --> | |
| <div class="bar" :class="{ neg: (c.gapUsd ?? 0) < 0, pos: (c.gapUsd ?? 0) >= 0 }"> | |
| <span :style="{ width: (c.barPct ?? 0) + '%' }"></span> | |
| </div> | |
| <!-- Bottom row: chips + EOD --> | |
| <div class="bottom"> | |
| <div class="chips"> | |
| <span class="chip" :class="{ pos: profitOf(c) >= 0, neg: profitOf(c) < 0 }"> | |
| {{ signedMoney(profitOf(c)) }} | |
| </span> | |
| <span class="chip" :class="{ pos: (c.gapUsd ?? 0) >= 0, neg: (c.gapUsd ?? 0) < 0 }"> | |
| <template v-if="c.kind==='agent'"> | |
| <template v-if="mode==='usd'">{{ signedMoney(c.gapUsd) }}</template> | |
| <template v-else>{{ signedPct(c.gapPct) }}</template> | |
| </template> | |
| <template v-else>β</template> | |
| </span> | |
| </div> | |
| <div class="eod">EOD {{ c.date ? new Date(c.date).toLocaleDateString() : 'β' }}</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, watch, shallowRef } from 'vue' | |
| import AssetTabs from '../components/AssetTabs.vue' | |
| import CompareChartE from '../components/CompareChartE.vue' | |
| import { dataService } from '../lib/dataService' | |
| /* --- same helpers as chart --- */ | |
| import { getAllDecisions } from '../lib/dataCache' | |
| import { readAllRawDecisions } from '../lib/idb' | |
| import { filterRowsToNyseTradingDays } from '../lib/marketCalendar' | |
| import { STRATEGIES } from '../lib/strategies' | |
| import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf' | |
| /* ---------- config ---------- */ | |
| const orderedAssets = ['BTC','ETH','MSFT','BMRN','TSLA'] // (MRNA removed) | |
| const EXCLUDED_AGENT_NAMES = new Set(['vanilla', 'vinilla']) // case-insensitive | |
| 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([]) | |
| /* ---------- bootstrap ---------- */ | |
| onMounted(async () => { | |
| try { | |
| if (!dataService.loaded && !dataService.loading) { | |
| await dataService.load(false) | |
| } | |
| } catch (e) { console.error('LiveView: dataService.load failed', e) } | |
| rowsRef.value = Array.isArray(dataService.tableRows) ? dataService.tableRows : [] | |
| 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 {} | |
| } | |
| }) | |
| /* ---------- helpers ---------- */ | |
| const fmtUSD = (n) => (n ?? 0).toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }) | |
| const signedMoney = (n) => `${n >= 0 ? '+' : 'β'}${fmtUSD(Math.abs(n))}` | |
| const signedPct = (p) => `${(p >= 0 ? '+' : 'β')}${Math.abs(p * 100).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) */ | |
| const filteredRows = computed(() => | |
| (rowsRef.value || []).filter(r => { | |
| if (r.asset !== asset.value) 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(() => | |
| 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 | |
| })) | |
| ) | |
| /* 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 (chart parity) ---------- */ | |
| 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 isCrypto = assetCode === 'BTC' || assetCode === 'ETH' | |
| let filtered = seq | |
| if (!isCrypto) filtered = await filterRowsToNyseTradingDays(seq) | |
| const cutoff = ASSET_CUTOFF[assetCode] | |
| if (cutoff) { | |
| const t0 = new Date(cutoff + 'T00:00:00Z') | |
| filtered = filtered.filter(r => new Date(r.date + 'T00:00:00Z') >= t0) | |
| } | |
| return filtered | |
| } | |
| 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 | |
| return { date: seq[lastIdx].date, stratLast: stratY[lastIdx], bhLast: bhY[lastIdx] } | |
| } | |
| /* 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 } | |
| // Buy & Hold card from the assetβs aligned BH series | |
| 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: first.perf.date, | |
| logo: ASSET_ICONS[assetCode] || null, | |
| profitUsd: (first.perf.bhLast ?? 0) - 100000, | |
| gapUsd: 0, | |
| gapPct: 0, | |
| isWinner: false, | |
| rank: null, | |
| barPct: 0 | |
| } | |
| // Agent cards (gap vs BH) | |
| 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: sel.model, | |
| balance: perf.stratLast, | |
| date: perf.date, | |
| logo: AGENT_LOGOS[sel.agent_name] || null, | |
| gapUsd, gapPct, | |
| profitUsd, | |
| isWinner: false, | |
| 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 }) | |
| // Winner flag | |
| if (agentCards.length) agentCards[0].isWinner = true | |
| // 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)) }) | |
| // Build final list: BH first, then agents in rank order | |
| cards.value = [bhCard, ...agentCards].slice(0,5) | |
| } catch (e) { | |
| console.error('LiveView: compute cards failed', e) | |
| cards.value = [] | |
| } finally { computing = false } | |
| }, | |
| { immediate: true } | |
| ) | |
| </script> | |
| <style scoped> | |
| .live { max-width: 1280px; margin: 0 auto; padding: 12px 20px 28px; background: #ffffff; padding-bottom: 56px; } | |
| /* toolbar */ | |
| .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; } | |
| .mode__btn { height: 30px; min-width: 40px; padding: 0 10px; border-radius: 10px; border: 1px solid #D7DDE7; background: #ffffff; font-weight: 700; color: #0f172a; } | |
| .mode__btn.is-active { background: #0f172a; color: #ffffff; border-color: #0f172a; } | |
| /* panels */ | |
| .panel { background: #ffffff; border: 1px solid #E7ECF3; border-radius: 14px; } | |
| .panel--chart { padding: 10px 10px 2px; } | |
| .panel--cards { padding: 12px; } | |
| /* empty */ | |
| .empty { padding: 14px; border: 1px dashed #D7DDE7; border-radius: 12px; color: #6B7280; font-size: .92rem; background: #ffffff; } | |
| /* GRID */ | |
| .cards-grid-f1 { display: grid; gap: 12px; grid-template-columns: repeat(5, minmax(0,1fr)); } | |
| @media (max-width: 1400px) { .cards-grid-f1 { grid-template-columns: repeat(4, minmax(0,1fr)); } } | |
| @media (max-width: 1100px) { .cards-grid-f1 { grid-template-columns: repeat(3, minmax(0,1fr)); } } | |
| @media (max-width: 900px) { .cards-grid-f1 { grid-template-columns: repeat(2, minmax(0,1fr)); } } | |
| @media (max-width: 640px) { .cards-grid-f1 { grid-template-columns: 1fr; } } | |
| /* F1 Card */ | |
| .card-f1 { | |
| position: relative; | |
| display: grid; | |
| grid-template-rows: auto auto auto; /* head, net, bottom */ | |
| gap: 10px; | |
| padding: 16px 16px 18px; | |
| min-height: 210px; | |
| border-radius: 14px; | |
| background: linear-gradient(145deg,#ffffff,#fafbfd 55%,#ffffff 100%); | |
| border: 1px solid #E7ECF3; | |
| box-shadow: 0 1px 2px rgba(16,24,40,.03), 0 4px 12px rgba(16,24,40,.04); | |
| color: #0f172a; | |
| transition: transform .18s ease, box-shadow .2s ease, border-color .2s ease; | |
| } | |
| .card-f1:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(16,24,40,.08); border-color: #D9E2EF; } | |
| .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); } | |
| .card-f1.bh { border-style: dashed; opacity: 1; } | |
| /* Rank / Crown */ | |
| .rank { position: absolute; top: 10px; left: 12px; font-weight: 900; font-size: 18px; color: rgba(15,23,42,.30); letter-spacing: .04em; } | |
| .rank.bh-badge { font-size: 12px; background: rgba(15,23,42,.06); padding: 2px 6px; border-radius: 6px; color: #4b5563; } | |
| .crown { position: absolute; top: 12px; right: 12px; font-size: 18px; filter: drop-shadow(0 1px 1px rgba(0,0,0,.12)); } | |
| /* Head */ | |
| .head { display: grid; grid-template-columns: 40px minmax(0,1fr); align-items: center; gap: 10px; } | |
| .logo { width: 40px; height: 40px; border-radius: 10px; background: #f3f4f6; display: grid; place-items: center; overflow: hidden; border: 1px solid #E7ECF3; } | |
| .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: #0f172a; 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; } | |
| /* Net row */ | |
| .net { display: grid; grid-template-columns: 1fr auto; align-items: end; } | |
| .net__label { font-size: 12px; color: #6b7280; } | |
| .net__value { font-size: clamp(18px, 2.2vw, 25px); font-weight: 700; letter-spacing: -.01em; color: #0f172a; } | |
| /* Bar vs B&H */ | |
| .bar { height: 6px; border-radius: 999px; background: #F2F4F8; overflow: hidden; border: 1px solid #E7ECF3; } | |
| .bar span { display: block; height: 100%; background: linear-gradient(90deg,#16a34a,#22c55e); width: var(--bar, 40%); transition: width .5s ease; } | |
| .bar.neg span { background: linear-gradient(90deg,#ef4444,#dc2626); } | |
| /* Bottom */ | |
| .bottom { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; align-items: end; gap: 8px; } | |
| .chips{ | |
| grid-column:1 / -1; /* span both columns on row 1 */ | |
| grid-row:1; | |
| display:inline-flex; | |
| gap:8px; | |
| flex-wrap:wrap; /* chips can wrap within row 1 */ | |
| } | |
| .eod{ | |
| grid-column:2; /* row 2, right column */ | |
| grid-row:2; | |
| justify-self:end; /* align to the right */ | |
| /* allow wrapping if needed; remove nowrap */ | |
| font-size:12px; | |
| color:#6b7280; | |
| } | |
| .chip { font-size: 12px; font-weight: 800; padding: 4px 8px; border-radius: 999px; background: #F6F8FB; color: #0f172a; border: 1px solid #E7ECF3; } | |
| .chip.pos { color: #0e7a3a; background: #E9F7EF; border-color: #d7f0e0; } | |
| .chip.neg { color: #B91C1C; background: #FBEAEA; border-color: #F3DADA; } | |
| </style> | |