Agent-Market-Arena / src /components /ExpansionContent.vue
Jimin Huang
add: Feature
ac784c2
raw
history blame
9.14 kB
<template>
<div class="p-4" style="border: 1px solid #e5e7eb; border-radius: 8px; background: #fafafa;">
<Panel class="mb-3" header="Details">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<div>
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Trading Days</div>
<div style="font-weight: 600;">{{ rowData.trading_days || 0 }}</div>
</div>
<div>
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Start Date</div>
<div style="font-weight: 600;">{{ rowData.start_date || '-' }}</div>
</div>
<div>
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">End Date</div>
<div style="font-weight: 600;">{{ rowData.end_date || '-' }}</div>
</div>
<div>
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Closed Days</div>
<div style="font-weight: 600;">{{ rowData.closed_date || 0 }}</div>
</div>
<div>
<div style="color: #6b7280; font-size: 0.875rem; margin-bottom: 0.25rem;">Final Balance</div>
<div style="font-weight: 600;">{{ fmtMoney(rowData.balance) }}</div>
</div>
</div>
</Panel>
<Panel header="Performance">
<PerformanceChart :data="rowData" />
</Panel>
<div class="flex justify-content-end mt-3">
<Button size="small" rounded icon="pi pi-search" label="Decisions View" @click="decisionsVisible = true" />
</div>
</div>
<Dialog v-model:visible="decisionsVisible" modal :header="`${rowData.agent_name} (${rowData.asset}) - ${rowData.model}`" :style="{ width: '90vw', maxWidth: '1200px' }">
<div>
<div v-if="decisionsLoading" class="flex justify-content-center p-4">
<ProgressSpinner style="width:40px;height:40px" strokeWidth="4" />
</div>
<div v-else class="decisions-table-wrapper">
<DataTable :value="decisionsRows" :rows="25" :rowsPerPageOptions="[25,50]" paginator :sortField="'date'" :sortOrder="1">
<Column field="date" header="Date" sortable />
<Column field="action" header="Action">
<template #body="{ data }">
<Tag :value="data.action" :severity="getTagColor(data.action)" rounded/>
</template>
</Column>
<Column field="price" header="Price">
<template #body="{ data }">{{ Number(data.price).toFixed(2) }}</template>
</Column>
<Column field="equity" header="Equity (w/ fees)">
<template #body="{ data }">{{ fmtMoney(data.equity) }}</template>
</Column>
<Column field="position" header="Position">
<template #body="{ data }">
<Tag :value="data.position" :severity="getTagColor(data.position)" rounded/>
</template>
</Column>
<Column field="sentiment" header="Sentiment" />
</DataTable>
</div>
</div>
</Dialog>
</template>
<script>
import Panel from 'primevue/panel'
import Dialog from 'primevue/dialog'
import Tag from 'primevue/tag'
import PerformanceChart from './PerformanceChart.vue'
import { getAllDecisions } from '../lib/dataCache'
import { readAllRawDecisions } from '../lib/idb'
import { filterRowsToNyseTradingDays } from '../lib/marketCalendar'
import { STRATEGIES } from '../lib/strategies'
import { computeStrategyEquity } from '../lib/perf'
export default {
name: 'ExpansionContent',
components: {
Panel,
PerformanceChart,
Dialog,
Tag
},
props: {
rowData: { type: Object, required: true }
},
data(){
return {
decisionsVisible: false,
decisionsLoading: false,
decisionsRows: []
}
},
watch: {
decisionsVisible: {
async handler(v){ if (v) await this.loadDecisions() }
}
},
methods: {
fmtMoney(v){ try{ return `$${Number(v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}` }catch{return v} },
getSeqFromIds(row){
const all = getAllDecisions() || []
const ids = row && (row.decision_ids || row.ids || [])
let seq = []
if (ids && ids.length) {
seq = all.filter(r => ids.includes(r.id))
} else if (row) {
// Only fallback to full data if no decision_ids were provided at all
seq = all.filter(r => r.agent_name === row.agent_name && r.asset === row.asset && r.model === row.model)
}
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
return seq
},
async getSeqWithFallback(row){
let seq = this.getSeqFromIds(row)
if (seq.length) return seq
try {
const all = await readAllRawDecisions()
const ids = row && (row.decision_ids || row.ids || [])
if (ids && ids.length) {
seq = all.filter(r => ids.includes(r.id))
} else if (row) {
// Only fallback to full data if no decision_ids were provided at all
seq = all.filter(r => r.agent_name === row.agent_name && r.asset === row.asset && r.model === row.model)
}
seq.sort((a,b) => (a.date > b.date ? 1 : -1))
} catch(_) {}
return seq
},
computePositionSeries(rows, strategyCfg){
const data = (rows || []).filter(r => r && r.date && r.price != null).sort((a,b) => (a.date > b.date ? 1 : -1))
if (!data.length) return []
let position = 'FLAT'
let entryPrice = 0
const positions = []
for (let i = 0; i < data.length; i++) {
const price = Number(data[i].price)
const action = String(data[i].recommended_action || 'HOLD').toUpperCase()
if ((strategyCfg.tradingMode || 'normal') === 'normal') {
if (action === 'BUY') {
if (position === 'FLAT') { position = 'LONG'; entryPrice = price }
else if (position === 'SHORT') { position = 'LONG'; entryPrice = price }
} else if (action === 'SELL') {
if (position === 'LONG') { position = 'FLAT'; entryPrice = 0 }
else if (position === 'FLAT' && (strategyCfg.strategy || 'long_short') === 'long_short') { position = 'SHORT'; entryPrice = price }
}
} else { // aggressive
if (action === 'HOLD') {
if (position === 'LONG' || position === 'SHORT') { position = 'FLAT'; entryPrice = 0 }
} else if (action === 'BUY') {
if (position === 'SHORT') { position = 'FLAT'; entryPrice = 0 }
if (position === 'FLAT') { position = 'LONG'; entryPrice = price }
} else if (action === 'SELL') {
if (position === 'LONG') { position = 'FLAT'; entryPrice = 0 }
if (position === 'FLAT' && (strategyCfg.strategy || 'long_short') === 'long_short') { position = 'SHORT'; entryPrice = price }
}
}
positions.push(position === 'LONG' ? 'Long' : (position === 'SHORT' ? 'Short' : 'Flat'))
}
// force last flat only affects capital; positions array already recorded daily state
return positions
},
async loadDecisions(){
this.decisionsLoading = true
try {
const row = this.rowData
const rawSeq = await this.getSeqWithFallback(row)
const isCrypto = row.asset === 'BTC' || row.asset === 'ETH'
const seq = isCrypto ? rawSeq : (await filterRowsToNyseTradingDays(rawSeq) || [])
if (!seq.length) { this.decisionsRows = []; return }
const strategyCfg = (STRATEGIES || []).find(s => s.id === row.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005 }
const equitySeries = computeStrategyEquity(seq, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode)
const positionSeries = this.computePositionSeries(seq, strategyCfg)
const n = Math.min(seq.length, equitySeries.length, positionSeries.length)
const rows = []
for (let i = 0; i < n; i++) {
const r = seq[i]
rows.push({
date: r.date,
action: String(r.recommended_action || 'HOLD').toUpperCase(),
price: r.price,
equity: equitySeries[i],
position: positionSeries[i],
sentiment: r.sentiment
})
}
this.decisionsRows = rows
} finally {
this.decisionsLoading = false
}
},
getTagColor(action) {
const a = String(action || '').trim().toUpperCase()
// Actions
if (a === 'BUY') return 'success'
if (a === 'SELL') return 'danger'
if (a === 'HOLD') return 'secondary'
// Positions
if (a === 'LONG') return 'success'
if (a === 'SHORT') return 'danger'
if (a === 'FLAT') return 'secondary'
return 'secondary'
}
}
}
</script>
<style scoped>
.decisions-table-wrapper {
overflow-x: auto;
overflow-y: visible;
}
/* 确保在小屏幕上表格可以滚动 */
@media (max-width: 768px) {
.decisions-table-wrapper {
max-width: 100%;
}
.decisions-table-wrapper :deep(.p-datatable) {
min-width: 600px;
}
}
</style>