Agent-Market-Arena / src /pages /EquityComparison.vue
Jimin Huang
add: Feature
5fa7a59
raw
history blame
13.5 kB
<template>
<div class="p-4 flex flex-column gap-3">
<div class="flex align-items-center justify-content-between mb-2">
<div class="flex align-items-center gap-2">
<Button label="← Back" class="p-button-outlined" size="small" rounded @click="$router.back()" />
<div class="text-500 ml-2">Last updated: {{ lastUpdatedDisplay }}</div>
</div>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-box">
<ProgressSpinner />
<div class="mt-3 text-600">Loading Equity Curves...</div>
</div>
</div>
<Card class="card-full">
<template #title>
<div class="mb-2 text-900" style="font-size: 20px; font-weight: 600">Equity Curve Comparison</div>
<Divider />
</template>
<template #content>
<div class="chart-container">
<canvas ref="canvas"></canvas>
</div>
</template>
</Card>
</div>
</template>
<script>
import { supabase } from '../lib/supabase'
import { getAllDecisions, setAllDecisions } from '../lib/dataCache'
import { writeRawDecisions, clearAllStores, readAllRawDecisions } from '../lib/idb'
import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
import { STRATEGIES } from '../lib/strategies'
import { computeBuyHoldEquity, computeStrategyEquity } from '../lib/perf'
import { Chart, LineElement, PointElement, LinearScale, TimeScale, CategoryScale, LineController, Legend, Tooltip } from 'chart.js'
const vLinePlugin = {
id: 'vLinePlugin',
afterDatasetsDraw(chart, args, pluginOptions) {
const active = (typeof chart.getActiveElements === 'function') ? chart.getActiveElements() : (chart.tooltip && chart.tooltip._active) || []
if (!active || !active.length) return
const { datasetIndex, index } = active[0]
const meta = chart.getDatasetMeta(datasetIndex)
const pt = meta && meta.data && meta.data[index]
if (!pt) return
const x = pt.x
const { top, bottom } = chart.chartArea
const ctx = chart.ctx
ctx.save()
ctx.beginPath()
ctx.moveTo(x, top)
ctx.lineTo(x, bottom)
ctx.lineWidth = (pluginOptions && pluginOptions.lineWidth) || 1
ctx.strokeStyle = (pluginOptions && pluginOptions.color) || 'rgba(0,0,0,0.35)'
ctx.setLineDash((pluginOptions && pluginOptions.dash) || [4, 4])
ctx.stroke()
ctx.restore()
}
}
Chart.register(LineElement, PointElement, LinearScale, TimeScale, CategoryScale, LineController, Legend, Tooltip, vLinePlugin)
function color(i) {
const colors = ['#3b82f6', '#22c55e', '#ef4444', '#a855f7', '#f59e0b', '#06b6d4', '#16a34a', '#f97316', '#0ea5e9', '#d946ef']
return colors[i % colors.length]
}
export default {
name: 'EquityComparison',
components: { },
data() {
return {
loading: true,
lastUpdated: null,
groupSeqMap: new Map(),
datasets: [],
chart: null,
labels: []
}
},
computed: {
lastUpdatedDisplay() {
return this.lastUpdated ? new Date(this.lastUpdated).toLocaleString() : '-'
}
},
watch: {},
methods: {
async forceRefresh(){
this.loading = true
try { await clearAllStores() } catch(_) {}
await this.load(true)
},
async load(forceRemote = false) {
this.loading = true
let all = (!forceRemote ? getAllDecisions() : null)
if (!all && !forceRemote) {
try {
const cached = await readAllRawDecisions()
if (cached && cached.length) {
all = cached
setAllDecisions(all)
}
} catch(_) {}
}
if (!all) {
console.log('[EquityComparison] pulling all from remote...')
const pageSize = 1000
let from = 0
all = []
while (true) {
const to = from + pageSize - 1
const { data, error } = await supabase
.from('trading_decisions')
.select('id, agent_name, asset, model, date, price, recommended_action')
.order('updated_at', { ascending: false })
.range(from, to)
if (error) { console.error(error); break }
all = all.concat(data || [])
if (!data || data.length < pageSize) break
from += pageSize
}
setAllDecisions(all)
try { await writeRawDecisions(all) } catch(_) {}
}
console.log('[EquityComparison] all decisions size:', (all || []).length)
// 构建每个 (name|asset|model) 的时间序列(按资产处理交易日)
const keyToSeq = new Map()
const groups = new Map()
for (const row of all) {
if (row.price == null || !row.date) continue
const key = `${row.agent_name}|${row.asset}|${row.model}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(row)
}
console.log('[EquityComparison] groups count:', groups.size)
for (const [key, list] of groups.entries()) {
list.sort((a,b) => (a.date > b.date ? 1 : -1))
const asset = list[0]?.asset
const isCrypto = asset === 'BTC' || asset === 'ETH'
const seq = isCrypto ? list : (await filterRowsToNyseTradingDays(list))
keyToSeq.set(key, seq)
}
this.groupSeqMap = keyToSeq
// 根据 Home 中选择的行构建数据集(内部会在数据就绪后触发 draw)
this.rebuildDatasetsFromSelection()
this.lastUpdated = Date.now()
this.loading = false
},
rebuildDatasetsFromSelection(){
const ds = []
let selected = []
try { selected = JSON.parse(sessionStorage.getItem('compareRows') || '[]') } catch(_) {}
if (!Array.isArray(selected)) selected = []
console.log('[EquityComparison] compareRows:', selected)
const all = getAllDecisions() || []
console.log('[EquityComparison] getAllDecisions size:', all.length)
// Build sequences using PerformanceChart's approach: use decision_ids first, else fallback triple key
const groupKeyToSeq = new Map()
for (const sel of selected) {
const agent = sel.agent_name
const asset = sel.asset
const model = sel.model
const ids = Array.isArray(sel.decision_ids) ? sel.decision_ids : []
let seq = []
if (ids.length) {
seq = all.filter(r => ids.includes(r.id))
} else {
seq = all.filter(r => r.agent_name === agent && r.asset === asset && r.model === model)
}
console.log('[EquityComparison] seq len (before filter)', `${agent}|${asset}|${model}`, seq.length)
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
const isCrypto = asset === 'BTC' || asset === 'ETH'
const filteredSeqPromise = isCrypto ? Promise.resolve(seq) : filterRowsToNyseTradingDays(seq)
groupKeyToSeq.set(`${agent}|${asset}|${model}`, filteredSeqPromise)
}
// Resolve any pending filtered sequences (stocks)
const entries = Array.from(groupKeyToSeq.entries())
// Await all promises for stock filtering
// Note: some entries hold raw arrays (crypto), unify via Promise.resolve
Promise.all(entries.map(([k, v]) => (Array.isArray(v) ? Promise.resolve([k, v]) : v.then(arr => [k, arr])))).then(resolved => {
const resolvedMap = new Map(resolved)
console.log('[EquityComparison] resolved groups:', Array.from(resolvedMap.keys()))
console.log('[EquityComparison] resolved sizes:', Array.from(resolvedMap.entries()).map(([k,v]) => [k, v.length]))
// Construct datasets per selected row with strategy from STRATEGIES matching sel.strategy
let lineIndex = 0
const selectedAssets = new Set()
// build labels from the first resolved sequence's dates
this.labels = []
if (selected && selected.length) {
const first = selected[0]
const gk0 = `${first.agent_name}|${first.asset}|${first.model}`
const seq0 = resolvedMap.get(gk0) || []
this.labels = (seq0 || []).map(s => s.date)
}
for (const sel of selected) {
const agent = sel.agent_name
const asset = sel.asset
const model = sel.model
const gk = `${agent}|${asset}|${model}`
const seq = resolvedMap.get(gk) || []
if (!seq.length) continue
selectedAssets.add(asset)
// Pick strategy by sel.strategy id; if empty, default long_short normal
const strategyCfg = (STRATEGIES || []).find(s => s.id === sel.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005, label: 'Selected' }
const series = computeStrategyEquity(seq, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode)
console.log('[EquityComparison] series len', gk, strategyCfg.id || strategyCfg.label, series.length)
if (!series || series.length === 0) continue
ds.push({
label: `${agent}|${asset}|${model}|${strategyCfg.label || sel.strategy || 'Strategy'}`,
data: this.labels && this.labels.length ? series.slice(0, this.labels.length) : series,
borderColor: color(lineIndex++),
pointRadius: 0,
tension: 0.15
})
}
// Add one Buy&Hold per selected asset
const seenAsset = new Set()
for (const [gk, seq] of resolvedMap.entries()) {
const asset = gk.split('|')[1]
if (!selectedAssets.has(asset)) continue
if (seenAsset.has(asset)) continue
seenAsset.add(asset)
const bh = computeBuyHoldEquity(seq, 100000)
console.log('[EquityComparison] baseline len', asset, bh.length)
if (!bh || bh.length === 0) continue
ds.push({
label: `${asset} · Buy&Hold`,
data: this.labels && this.labels.length ? bh.slice(0, this.labels.length) : bh,
borderColor: color(lineIndex++),
borderDash: [6, 4],
borderWidth: 1.5,
pointRadius: 0,
tension: 0
})
}
this.datasets = ds
console.log('[EquityComparison] datasets count:', ds.length)
this.draw()
})
},
draw(){
if (!this.$refs.canvas) return
this.$nextTick(() => {
if (!this.$refs.canvas) return
if (this.chart) { try{ this.chart.destroy() }catch(_){}; this.chart = null }
// ensure canvas sized to container
try {
const parent = this.$refs.canvas.parentElement
if (parent) {
// ensure parent has non-zero height even inside Card
if (!parent.clientHeight || parent.clientHeight < 50) {
parent.style.minHeight = '560px'
parent.style.height = '560px'
}
const w = parent.clientWidth || 800
const h = parent.clientHeight || 560
this.$refs.canvas.style.width = '100%'
this.$refs.canvas.style.height = '100%'
this.$refs.canvas.width = w
this.$refs.canvas.height = h
}
} catch(_) {}
// use previously computed date labels; fallback to indices
let labels = Array.isArray(this.labels) && this.labels.length ? this.labels : []
if (!labels.length) {
const maxLen = Math.max(0, ...this.datasets.map(d => (Array.isArray(d.data) ? d.data.length : 0)))
labels = Array.from({ length: maxLen }, (_, i) => `${i + 1}`)
}
const allValues = this.datasets.flatMap(d => (Array.isArray(d.data) ? d.data : []))
const minV = allValues.length ? Math.min(...allValues) : 0
const maxV = allValues.length ? Math.max(...allValues) : 1
const pad = (maxV - minV) * 0.1 || 1000
const yMin = minV - pad
const yMax = maxV + pad
const ctx = this.$refs.canvas.getContext('2d')
this.chart = new Chart(ctx, {
type: 'line',
data: { labels, datasets: this.datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
display: true,
position: 'left',
labels: { usePointStyle: true, boxWidth: 8 },
},
vLinePlugin: { color: 'rgba(0,0,0,0.35)', lineWidth: 1, dash: [4,4] }
},
scales: {
x: { type: 'category', ticks: { autoSkip: true, maxTicksLimit: 10 } },
y: { min: yMin, max: yMax }
}
}
})
})
}
},
mounted() { this.load() }
}
</script>
<style scoped>
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.85);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.loading-box { text-align: center; }
.chart-container {
position: relative;
height: 560px;
display: flex;
}
.chart-container > canvas {
width: 100% !important;
height: 100% !important;
}
.card-full { width: 100%; display: flex; flex-direction: column; }
.card-full :deep(.p-card-body) { display: flex; flex-direction: column; height: 100%; }
.card-full :deep(.p-card-content) { flex: 1; display: flex; flex-direction: column; }
.empty-offset { flex: 1; min-height: 400px; position: relative; }
.empty-offset > span {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
</style>