Spaces:
Running
Running
| <template> | |
| <DataTable :value="rows" :rows="10" :rowsPerPageOptions="[10,25,50]" paginator scrollable scrollHeight="flex" :loading="loading" :sortMode="'multiple'" :multiSortMeta="multiSortMeta" v-model:expandedRows="expandedRows" :dataKey="'key'" @sort="onSort" @rowToggle="onRowToggle" @rowExpand="onRowExpand" :selection="selection" @update:selection="onSelectionUpdate" class="agent-table-scroll"> | |
| <Column v-if="selectable" selectionMode="multiple" :style="{ width: '50px', minWidth: '50px' }" frozen /> | |
| <Column expander :style="{ width: '50px', minWidth: '50px' }" frozen /> | |
| <Column field="agent_name" header="Agent & Model" :style="{ minWidth: '200px' }" frozen> | |
| <template #body="{ data }"> | |
| <div> | |
| <div style="display: flex; align-items: center; gap: 0.5rem;"> | |
| <span>{{ data.agent_name }}</span> | |
| <span style="font-size: 1.25rem;">{{ getRankMedal(data) }}</span> | |
| </div> | |
| <div style="color:#6b7280; font-size: 0.875rem;">{{ formatModelName(data.model) }}</div> | |
| <!-- <div style="color:#6b7280; font-size: 0.875rem;">{{ data.strategy_label }}</div> --> | |
| </div> | |
| </template> | |
| </Column> | |
| <!-- <Column field="asset" header="Asset"/> --> | |
| <Column field="ret_with_fees" header="Return" sortable :style="{ minWidth: '180px' }"> | |
| <template #body="{ data }"> | |
| <div> | |
| <div :style="pctStyle(data.ret_with_fees)">{{ fmtSignedPct(data.ret_with_fees) }}</div> | |
| <div :style="subPctStyle(data.ret_no_fees)">(No Fees: {{ fmtSignedPct(data.ret_no_fees) }})</div> | |
| </div> | |
| </template> | |
| </Column> | |
| <Column field="vs_bh_with_fees" header="Vs Buy & Hold" sortable :style="{ minWidth: '140px' }"> | |
| <template #body="{ data }"> | |
| <span :style="pctStyle(data.vs_bh_with_fees)">{{ fmtSignedPct(data.vs_bh_with_fees) }}</span> | |
| </template> | |
| </Column> | |
| <Column field="sharpe" header="Sharpe Ratio" sortable :style="{ minWidth: '120px' }"> | |
| <template #body="{ data }"> | |
| {{ fmtNum(data.sharpe) }} | |
| </template> | |
| </Column> | |
| <Column field="win_rate" header="Win Rate" sortable :style="{ minWidth: '110px' }"> | |
| <template #body="{ data }"> | |
| {{ fmtPctNeutral(data.win_rate) }} | |
| </template> | |
| </Column> | |
| <template #expansion="slotProps"> | |
| <ExpansionContent :rowData="slotProps.data" /> | |
| </template> | |
| </DataTable> | |
| </template> | |
| <script> | |
| import ExpansionContent from './ExpansionContent.vue' | |
| export default { | |
| name: 'AgentTable', | |
| props: { rows: { type: Array, default: () => [] }, loading: { type: Boolean, default: false }, selectable: { type: Boolean, default: false }, selection: { type: Array, default: () => [] } }, | |
| emits: ['update:selection'], | |
| components: { ExpansionContent }, | |
| data(){ | |
| return { | |
| expandedRows: [], | |
| multiSortMeta: [ | |
| { field: 'ret_with_fees', order: -1 } | |
| ] | |
| } | |
| }, | |
| computed: { | |
| rankedRows() { | |
| // Sort rows by ret_with_fees descending to determine rank | |
| return [...this.rows].sort((a, b) => { | |
| const aVal = Number(a.ret_with_fees) || 0 | |
| const bVal = Number(b.ret_with_fees) || 0 | |
| return bVal - aVal | |
| }) | |
| } | |
| }, | |
| methods: { | |
| getRankMedal(data) { | |
| // Find the rank of this row based on ret_with_fees | |
| const rank = this.rankedRows.findIndex(row => row.key === data.key) + 1 | |
| if (rank === 1) return '🥇' | |
| if (rank === 2) return '🥈' | |
| if (rank === 3) return '🥉' | |
| return '' | |
| }, | |
| formatModelName(model) { | |
| if (!model) return '' | |
| // Remove date suffix pattern (8 digits at the end, like _20250514) | |
| // Also handles patterns like _YYYYMMDD or just YYYYMMDD at the end | |
| return model.replace(/_?\d{8}$/, '') | |
| }, | |
| onSelectionUpdate(val){ | |
| this.$emit('update:selection', Array.isArray(val) ? val : []) | |
| }, | |
| onSort(){ | |
| // close all rows when sorting | |
| this.expandedRows = [] | |
| }, | |
| onRowToggle(e){ | |
| // keep only one expanded row at a time | |
| const val = e.data || e | |
| if (Array.isArray(val)) { | |
| // when using array mode, restrict to the last toggled row | |
| this.expandedRows = val.length ? [val[val.length - 1]] : [] | |
| } else if (val && typeof val === 'object') { | |
| // object mode; keep only the last key | |
| const keys = Object.keys(val) | |
| if (!keys.length) { this.expandedRows = {}; return } | |
| const lastKey = keys[keys.length - 1] | |
| const map = {} | |
| map[lastKey] = true | |
| this.expandedRows = map | |
| } else { | |
| this.expandedRows = [] | |
| } | |
| }, | |
| onRowExpand(e){ | |
| // ensure only the current row is expanded | |
| const row = e && e.data | |
| if (!row) { this.expandedRows = []; return } | |
| // DataTable may track expandedRows as array or map depending on mode | |
| if (Array.isArray(this.expandedRows)) { | |
| this.expandedRows = [row] | |
| } else { | |
| const map = {} | |
| map[row.key] = true | |
| this.expandedRows = map | |
| } | |
| }, | |
| fmtMoney(v){ try{ return `$${Number(v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}` }catch{return v} }, | |
| fmtNum(v){ if(v==null) return '-'; return Number(v).toFixed(2) }, | |
| // v is always a fraction (0.12 = 12%, 1.5 = 150%). Always render with two decimals + sign | |
| fmtSignedPct(v){ | |
| if(v==null) return '-' | |
| const pct = Number(v) * 100 | |
| const sign = pct > 0 ? '+' : (pct < 0 ? '-' : '') | |
| return `${sign}${Math.abs(pct).toFixed(2)}%` | |
| }, | |
| // neutral percentage (no color/sign) for win rate. v is already in percentage form (0-100). | |
| fmtPctNeutral(v){ | |
| if(v==null) return '-' | |
| return `${Number(v).toFixed(2)}%` | |
| }, | |
| pctStyle(v){ | |
| const val = Number(v) | |
| if (val > 0) return { color: '#16a34a', fontWeight: 'bold' } // green-600 | |
| if (val < 0) return { color: '#dc2626', fontWeight: 'bold' } // red-600 | |
| return {} | |
| }, | |
| subPctStyle(v){ | |
| const val = Number(v) | |
| if (val > 0) return { color: '#22c55e', fontSize: '0.8rem'} // green-500 | |
| if (val < 0) return { color: '#ef4444', fontSize: '0.8rem'} // red-500 | |
| return { color: '#6b7280', fontSize: '0.8rem'} // gray-500 for neutral | |
| } | |
| } | |
| } | |
| </script> | |
| <style scoped> | |
| /* Enable horizontal scrolling */ | |
| :deep(.agent-table-scroll .p-datatable-wrapper) { | |
| overflow-x: auto; | |
| } | |
| :deep(.agent-table-scroll .p-datatable-table) { | |
| min-width: 800px; | |
| } | |
| /* Frozen column styles */ | |
| :deep(.agent-table-scroll .p-frozen-column) { | |
| background: #ffffff; | |
| z-index: 1; | |
| } | |
| :deep(.agent-table-scroll .p-datatable-thead > tr > th.p-frozen-column) { | |
| background: #F6F8FB; | |
| } | |
| /* Better scrollbar */ | |
| :deep(.agent-table-scroll .p-datatable-wrapper)::-webkit-scrollbar { | |
| height: 8px; | |
| } | |
| :deep(.agent-table-scroll .p-datatable-wrapper)::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| border-radius: 4px; | |
| } | |
| :deep(.agent-table-scroll .p-datatable-wrapper)::-webkit-scrollbar-thumb { | |
| background: #cbd5e1; | |
| border-radius: 4px; | |
| } | |
| :deep(.agent-table-scroll .p-datatable-wrapper)::-webkit-scrollbar-thumb:hover { | |
| background: #94a3b8; | |
| } | |
| </style> | |