Spaces:
Running
Running
File size: 13,488 Bytes
5fa7a59 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 |
<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>
|