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> | |
| <!-- Cards: Buy & Hold + top 4 agents (computed with perf helpers) --> | |
| <section class="panel panel--cards" v-if="cards.length"> | |
| <div class="cards5"> | |
| <div | |
| v-for="c in cards" | |
| :key="c.key" | |
| class="card" | |
| :class="{ 'card--bh': c.kind==='bh', 'card--winner': c.isWinner }" | |
| > | |
| <!-- header: logo | title | balance --> | |
| <div class="card__header"> | |
| <div class="card__logo"> | |
| <img v-if="c.logo" :src="c.logo" alt="" /> | |
| <div v-else class="card__logo-fallback"></div> | |
| <span v-if="c.isWinner" class="card__badge" aria-label="Top performer">π</span> | |
| </div> | |
| <div class="card__title" :title="c.title">{{ c.title }}</div> | |
| <div class="card__balance">{{ fmtUSD(c.balance) }}</div> | |
| </div> | |
| <!-- meta row --> | |
| <div class="card__meta"> | |
| <div class="card__sub ellipsize" :title="c.subtitle">{{ c.subtitle }}</div> | |
| <template v-if="c.kind==='agent' && c.gapUsd != null"> | |
| <div class="pill" :class="{ neg: c.gapUsd < 0 && !c.isWinner, pos: c.gapUsd >= 0 || c.isWinner }"> | |
| <span v-if="mode==='usd'">{{ signedMoney(c.gapUsd) }}</span> | |
| <span v-else>{{ signedPct(c.gapPct) }}</span> | |
| </div> | |
| </template> | |
| <template v-else> | |
| <div class="pill pill--neutral">Buy & Hold</div> | |
| </template> | |
| </div> | |
| <!-- date row --> | |
| <div class="card__footer"> | |
| <div class="card__sub">EOD {{ c.date ? new Date(c.date).toLocaleDateString() : 'β' }}</div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| </template> | |
| <script setup> | |
| import { ref, computed, onMounted, nextTick, watch, shallowRef } from 'vue' | |
| import AssetTabs from '../components/AssetTabs.vue' | |
| import CompareChartE from '../components/CompareChartE.vue' | |
| import { dataService } from '../lib/dataService' | |
| /* ββ same helpers as the 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 | |
| // optional logos | |
| const AGENT_LOGOS = { | |
| // 'DeepFundAgent': new URL('../assets/images/agents/deepfund.png', import.meta.url).href, | |
| } | |
| 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, | |
| } | |
| /* match the chartβs cutoff so numbers align exactly */ | |
| const ASSET_CUTOFF = { BTC: '2025-08-01' } | |
| /* ---------- state ---------- */ | |
| const mode = ref('usd') // 'usd' | 'pct' | |
| const asset = ref('BTC') | |
| const rowsRef = ref([]) | |
| let allDecisions = [] // decisions cache used for perf | |
| /* ---------- 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] | |
| // pull decisions used by chart | |
| allDecisions = getAllDecisions() || [] | |
| if (!allDecisions.length) { | |
| try { | |
| const cached = await readAllRawDecisions() | |
| if (cached?.length) allDecisions = cached | |
| } catch {} | |
| } | |
| await nextTick() | |
| }) | |
| /* ---------- helpers ---------- */ | |
| function score(row) { | |
| return typeof row.balance === 'number' ? row.balance : -Infinity | |
| } | |
| 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)}%` | |
| /* Selected-asset rows (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) | |
| }) | |
| ) | |
| /* Best model per agent (by balance) β from leaderboard rows for winners list */ | |
| 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 mirror winners */ | |
| 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 | |
| })) | |
| ) | |
| /* ---------- PERF: compute B&H + strategy like the chart (ASYNC & SERIALIZED) ---------- */ | |
| /** async: build ordered decision seq for a selection (await calendar filter) */ | |
| async function buildSeq(sel) { | |
| const { agent_name: agent, asset, 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 === agent && r.asset === asset && r.model === model) | |
| seq.sort((a, b) => (a.date > b.date ? 1 : -1)) | |
| const isCrypto = asset === 'BTC' || asset === 'ETH' | |
| let filtered = seq | |
| if (!isCrypto) { | |
| filtered = await filterRowsToNyseTradingDays(seq) // β await was missing before | |
| } | |
| const cutoff = ASSET_CUTOFF[asset] | |
| 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: compute final equity & aligned B&H for a selection */ | |
| 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], | |
| } | |
| } | |
| /* cards are computed via a serialized async watcher (avoid re-entrancy) */ | |
| const cards = shallowRef([]) | |
| let computing = false | |
| watch( | |
| () => [asset.value, winnersForChart.value], // deps | |
| async () => { | |
| if (!allDecisions.length) { cards.value = []; return } | |
| if (computing) return | |
| computing = true | |
| try { | |
| const sels = winnersForChart.value || [] | |
| if (!sels.length) { cards.value = []; return } | |
| // run all perf computations in parallel | |
| const perfs = (await Promise.all( | |
| sels.map(async sel => ({ sel, perf: await computeEquities(sel) })) | |
| )).filter(x => x.perf) | |
| if (!perfs.length) { cards.value = []; return } | |
| // B&H card: use first selection's BH (same asset & cutoff as chart) | |
| const assetCode = perfs[0].sel.asset | |
| const bhCard = { | |
| key: `bh|${assetCode}`, | |
| kind: 'bh', | |
| title: 'Buy & Hold', | |
| subtitle: assetCode, | |
| balance: perfs[0].perf.bhLast, | |
| date: perfs[0].perf.date, | |
| logo: ASSET_ICONS[assetCode] || null, | |
| isWinner: false | |
| } | |
| // Agent cards and winner flag | |
| const agentCards = perfs.map(({ sel, perf }) => { | |
| const gapUsd = perf.stratLast - perf.bhLast | |
| const gapPct = perf.bhLast > 0 ? (perf.stratLast / perf.bhLast - 1) : 0 | |
| 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, | |
| isWinner: false | |
| } | |
| }) | |
| const maxBal = Math.max(...agentCards.map(c => c.balance ?? -Infinity)) | |
| agentCards.forEach(c => { c.isWinner = c.balance === maxBal }) | |
| // Top 4 agents + BH | |
| cards.value = [bhCard, ...agentCards.sort((a,b) => b.balance - a.balance).slice(0,4)] | |
| } catch (e) { | |
| console.error('LiveView: compute cards failed', e) | |
| cards.value = [] | |
| } finally { | |
| computing = false | |
| } | |
| }, | |
| { immediate: true, deep: true } | |
| ) | |
| </script> | |
| <style scoped> | |
| .live { max-width: 1280px; margin: 0 auto; padding: 12px 20px 28px; } | |
| /* 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: #fff; } | |
| .toolbar__left { min-width: 0; } | |
| .toolbar__right { display: flex; align-items: center; gap: 10px; } | |
| /* $/% toggle */ | |
| .mode { display: inline-flex; gap: 8px; } | |
| .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; } | |
| .mode__btn:hover { transform: translateY(-1px); } | |
| .mode__btn.is-active { background: #0F172A; color: #fff; border-color: #0F172A; } | |
| /* panels */ | |
| .panel { background: #fff; border: 1px solid #EDF0F4; border-radius: 14px; } | |
| .panel--chart { padding: 10px 10px 2px; } | |
| .panel--cards { padding: 12px; } | |
| /* empty */ | |
| .empty { padding: 14px; border: 1px dashed #D6DAE1; border-radius: 12px; color: #6B7280; font-size: .92rem; } | |
| /* 5 cards in a row */ | |
| .cards5 { display: grid; gap: 12px; grid-template-columns: repeat(5, minmax(0, 1fr)); } | |
| @media (max-width: 1200px) { .cards5 { grid-template-columns: repeat(2, minmax(0, 1fr)); } } | |
| @media (max-width: 720px) { .cards5 { grid-template-columns: 1fr; } } | |
| /* card */ | |
| .card { display: grid; grid-template-rows: auto auto auto; gap: 8px; padding: 12px 14px; border: 1px solid #EEF1F6; border-radius: 14px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.04); position: relative; } | |
| .card--bh { outline: 2px dashed rgba(15,23,42,.08); } | |
| .card--winner { border-color: #16a34a; box-shadow: 0 0 0 3px rgba(22,163,74,.12); } | |
| /* header layout: logo | title | balance */ | |
| .card__header { display: grid; grid-template-columns: 52px minmax(0,1fr) auto; align-items: start; gap: 12px; } | |
| /* logo */ | |
| .card__logo { width: 44px; height: 44px; border-radius: 999px; background: #F3F4F6; display: grid; place-items: center; overflow: hidden; position: relative; } | |
| .card__logo img { width: 100%; height: 100%; object-fit: contain; } | |
| .card__logo-fallback { width: 60%; height: 60%; border-radius: 999px; background: #E5E7EB; } | |
| .card__badge { position: absolute; right: -6px; top: -6px; font-size: 16px; filter: drop-shadow(0 1px 1px rgba(0,0,0,.15)); } | |
| /* title: clamp to 2 lines so balance never overlaps */ | |
| .card__title { | |
| min-width: 0; font-weight: 800; color: #0F172A; | |
| white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; | |
| overflow: hidden; line-height: 1.15; | |
| } | |
| /* right-side balance never shrinks */ | |
| .card__balance { white-space: nowrap; font-weight: 900; color: #0F172A; font-size: 20px; } | |
| /* meta row + footer */ | |
| .card__meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; } | |
| .card__sub { font-size: 12px; color: #5B6476; opacity: .85; } | |
| .ellipsize { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .card__footer { margin-top: -2px; } | |
| /* pills */ | |
| .pill { padding: 4px 9px; border-radius: 999px; font-size: 12px; font-weight: 800; line-height: 1; white-space: nowrap; background: #EEF2F7; color: #0F172A; } | |
| .pill.neg { background: #FEE2E2; color: #B91C1C; } | |
| .pill.pos { background: #DCFCE7; color: #166534; } | |
| .pill.pill--neutral { background: #EEF2F7; color: #0F172A; } | |
| </style> | |