lfqian's picture
fix: Live page now only shows long_only strategy like Leaderboard
d92eab9
raw
history blame
17 kB
<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) + '%'}"
>
<!-- Crown -->
<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) - only show long_only strategy like leaderboard */
const filteredRows = computed(() =>
(rowsRef.value || []).filter(r => {
if (r.asset !== asset.value) return false
if (r.strategy !== 'long_only') return false // 只显示 Aggressive Long Only 策略
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)
}
const result = [...byAgent.values()]
console.log('[Live winners from leaderboard]', result.map(r => ({
agent: r.agent_name,
model: r.model,
strategy: r.strategy,
balance: r.balance
})))
return result
})
/* 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))
// 如果使用了 decision_ids,数据已经预过滤,不需要再次处理
// 只有在没有 decision_ids 时才需要过滤交易日
if (!ids.length) {
const isCrypto = assetCode === 'BTC' || assetCode === 'ETH'
if (!isCrypto) seq = await filterRowsToNyseTradingDays(seq)
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)
}
}
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 }
console.log('[Live computeEquities]', {
agent: sel.agent_name,
model: sel.model,
strategy: sel.strategy,
config: cfg,
seqLength: seq.length,
decision_ids: sel.decision_ids?.length || 'none'
})
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
console.log('[Live computeEquities result]', {
agent: sel.agent_name,
stratLast: stratY[lastIdx],
bhLast: bhY[lastIdx]
})
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>