Spaces:
Running
Running
| <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> |