Spaces:
Running
Running
| <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% ; | |
| height: 100% ; | |
| } | |
| .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> | |