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>