Spaces:
Running
Running
| <template> | |
| <div> | |
| <div class="title-container"> | |
| <span class="main-title">Agent Leadboard</span> | |
| </div> | |
| <AssetsFilter v-model="filters.assets" :assetOptions="assetOptions" /> | |
| <div v-if="loading" class="loading-overlay"> | |
| <div class="loading-box"> | |
| <ProgressSpinner /> | |
| <div class="mt-3 text-600">Loading Paper Trading Agents...</div> | |
| </div> | |
| </div> | |
| <div class="page-wrapper"> | |
| <div class="p-3 flex flex-column gap-2"> | |
| <!-- Mobile filter button --> | |
| <div class="mobile-filter-btn-container"> | |
| <Button icon="pi pi-filter" label="Filters" @click="drawerVisible = true" size="small" rounded class="mobile-filter-btn" /> | |
| </div> | |
| <!-- Drawer for mobile --> | |
| <Drawer v-model:visible="drawerVisible" header="Filter Matrix" position="left" class="filter-drawer"> | |
| <AgentFilters v-model="filters" :nameOptions="nameOptions" :assetOptions="assetOptions" :modelOptions="modelOptions" :strategyOptions="strategyOptions" :dateBounds="dateBounds" /> | |
| </Drawer> | |
| <div class="flex gap-2 align-items-stretch"> | |
| <!-- Desktop filter panel --> | |
| <div class="col-panel desktop-filter-panel" style="flex: 1; min-width: 280px;"> | |
| <Card class="mb-2 card-full compact-card content-card"> | |
| <template #title> | |
| <div class="mb-4 text-900" style="font-size: 20px; font-weight: 600">Filter Matrix</div> | |
| <Divider /> | |
| </template> | |
| <template #content> | |
| <div> | |
| <AgentFilters v-model="filters" :nameOptions="nameOptions" :assetOptions="assetOptions" :modelOptions="modelOptions" :strategyOptions="strategyOptions" :dateBounds="dateBounds" /> | |
| </div> | |
| </template> | |
| </Card> | |
| </div> | |
| <div class="col-panel" style="flex: 4; min-width: 0;"> | |
| <Card class="mb-2 card-full compact-card content-card"> | |
| <template #content> | |
| <div v-if="filteredRows.length === 0" class="empty-offset"> | |
| <span class="text-600">No data found</span> | |
| </div> | |
| <div v-else> | |
| <div class="flex align-items-center justify-content-between mb-2"> | |
| <div class="flex align-items-center gap-2"> | |
| <span class="p-overlay-badge"> | |
| <Button label="Refresh" icon="pi pi-refresh" size="small" rounded @click="forceRefresh()" v-tooltip.right="'Force Refresh, clear all caches and reload from remote'" /> | |
| </span> | |
| <div class="text-500 ml-2" @click="onUpdatedClick">Last updated: {{ lastUpdatedDisplay }}</div> | |
| <div class="flex align-items-center gap-2 ml-2" v-if="requestButtonVisible"> | |
| <Button label="Request Assets" class="p-button-outlined" size="small" rounded @click="requestAssetsVisible = true" /> | |
| </div> | |
| </div> | |
| <div class="flex gap-2"> | |
| <Button v-if="!selectMode" label="Compare" class="p-button-outlined" size="small" rounded @click="enterSelectMode()" /> | |
| <template v-else> | |
| <Button label="Cancel" class="p-button-text" size="small" rounded severity="secondary" @click="exitSelectMode()" /> | |
| <Button :disabled="selectedRows.length===0" label="Compare Selected" icon="pi pi-chart-line" size="small" rounded @click="openCompareDialog()" /> | |
| </template> | |
| </div> | |
| </div> | |
| <AgentTable :rows="filteredRows" :loading="loading" :selectable="selectMode" v-model:selection="selectedRows" /> | |
| </div> | |
| </template> | |
| </Card> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <Dialog v-model:visible="compareVisible" modal header="Equity Curve Comparison" style="width: 90vw; max-width: 1200px"> | |
| <div> | |
| <CompareChart :selected="selectedRows.map(r => ({ agent_name: r.agent_name, asset: r.asset, model: r.model, strategy: r.strategy, decision_ids: r.decision_ids || [] }))" :visible="compareVisible" /> | |
| </div> | |
| </Dialog> | |
| <Dialog v-model:visible="requestAssetsVisible" modal header="Request Asset" style="width: 90vw; max-width: 400px"> | |
| <div class="flex justify-content-between gap-2"> | |
| <InputText v-model="asset" placeholder="Asset need adding..." /> | |
| <Button label="Request" size="small" rounded @click="requestAsset()" :disabled="!asset" /> | |
| </div> | |
| </Dialog> | |
| </template> | |
| <script> | |
| import { dataService } from '../lib/dataService.js' | |
| import AgentTable from '../components/AgentTable.vue' | |
| import AgentFilters from '../components/AgentFilters.vue' | |
| import AssetsFilter from '../components/AssetsFilter.vue' | |
| import CompareChart from '../components/CompareChart.vue' | |
| import InputText from 'primevue/inputtext' | |
| import Dialog from 'primevue/dialog' | |
| import { countNonTradingDaysBetweenForAsset, countTradingDaysBetweenForAsset } from '../lib/marketCalendar.js' | |
| import { computeBuyHoldEquity, computeStrategyEquity, calculateMetricsFromSeries, computeWinRate } from '../lib/perf.js' | |
| import { STRATEGIES } from '../lib/strategies.js' | |
| import emailjs from 'emailjs-com' | |
| export default { | |
| name: 'LeadboardView', | |
| components: { AgentTable, AgentFilters, AssetsFilter, CompareChart, Dialog, InputText }, | |
| data() { | |
| return { | |
| loading: true, | |
| agents: [], | |
| tableRows: [], | |
| lastUpdated: null, | |
| filters: { names: [], assets: [], models: [], strategies: [], dates: [] }, | |
| dateBounds: { min: null, max: null }, | |
| dateFilteredRows: [], | |
| nameOptions: [], | |
| assetOptions: [], | |
| modelOptions: [], | |
| strategyOptions: [], | |
| selectMode: false, | |
| selectedRows: [], | |
| compareVisible: false, | |
| requestAssetsVisible: false, | |
| requestButtonVisible: false, | |
| updatedClickCount: 0, | |
| asset: '', | |
| drawerVisible: false, | |
| unsubscribe: null | |
| } | |
| }, | |
| watch: { | |
| // persist filters to sessionStorage for cross-page initialization | |
| filters: { | |
| deep: true, | |
| handler(val){ | |
| try { sessionStorage.setItem('homeFilters', JSON.stringify(val || {})) } catch(_) {} | |
| // if filters change while comparing, reset to initial state | |
| if (this.selectMode || this.compareVisible) { | |
| this.exitSelectMode() | |
| } | |
| // recompute on date range change | |
| try { | |
| const ds = (val && Array.isArray(val.dates)) ? val.dates : [] | |
| if (ds.length === 2 && ds[0] && ds[1]) { | |
| const start = new Date(Math.min(new Date(ds[0]).getTime(), new Date(ds[1]).getTime())) | |
| const end = new Date(Math.max(new Date(ds[0]).getTime(), new Date(ds[1]).getTime())) | |
| this.recomputeAllForRange(start, end) | |
| } else { | |
| this.dateFilteredRows = [] | |
| } | |
| } catch(_) {} | |
| } | |
| } | |
| }, | |
| computed: { | |
| lastUpdatedDisplay() { | |
| return this.lastUpdated ? new Date(this.lastUpdated).toLocaleString() : '-' | |
| }, | |
| filteredRows() { | |
| // base rows come from date-filtered recomputation when a range is active | |
| const useDateRange = Array.isArray(this.filters.dates) && this.filters.dates.length === 2 && this.filters.dates[0] && this.filters.dates[1] | |
| let rows = useDateRange ? (this.dateFilteredRows || []) : this.tableRows | |
| const cols = ['names','assets','models','strategies'] | |
| for (const c of cols) { if (!this.filters[c] || this.filters[c].length === 0) return [] } | |
| rows = rows.filter(r => this.filters.names.includes(r.agent_name)) | |
| rows = rows.filter(r => this.filters.assets.includes(r.asset)) | |
| rows = rows.filter(r => this.filters.models.includes(r.model)) | |
| rows = rows.filter(r => this.filters.strategies.includes(r.strategy)) | |
| return rows | |
| } | |
| }, | |
| methods: { | |
| /** | |
| * 从 dataService 同步状态 | |
| */ | |
| syncFromDataService(state) { | |
| this.loading = state.loading | |
| this.agents = state.agents | |
| this.tableRows = state.tableRows | |
| this.lastUpdated = state.lastUpdated | |
| this.dateBounds = state.dateBounds | |
| // 只在选项为空时才从服务同步 | |
| if (!this.nameOptions.length) this.nameOptions = state.nameOptions | |
| if (!this.assetOptions.length) this.assetOptions = state.assetOptions | |
| if (!this.modelOptions.length) this.modelOptions = state.modelOptions | |
| if (!this.strategyOptions.length) this.strategyOptions = state.strategyOptions | |
| // 初始化 filters(全选) | |
| if (!this.filters.names.length && state.nameOptions.length) { | |
| this.filters.names = state.nameOptions.map(o => o.value) | |
| } | |
| if (!this.filters.assets.length && state.assetOptions.length) { | |
| this.filters.assets = state.assetOptions.map(o => o.value) | |
| } | |
| if (!this.filters.models.length && state.modelOptions.length) { | |
| this.filters.models = state.modelOptions.map(o => o.value) | |
| } | |
| if (!this.filters.strategies.length && state.strategyOptions.length) { | |
| this.filters.strategies = state.strategyOptions.map(o => o.value) | |
| } | |
| }, | |
| onUpdatedClick(){ | |
| try { | |
| this.updatedClickCount = (this.updatedClickCount || 0) + 1 | |
| if (this.updatedClickCount >= 5) { | |
| this.requestButtonVisible = true | |
| this.updatedClickCount = 0 | |
| } | |
| } catch(_) {} | |
| }, | |
| async recomputeRowForRange(row, start, end){ | |
| try { | |
| const isCrypto = row.asset === 'BTC' || row.asset === 'ETH' | |
| const seriesAll = Array.isArray(row.series) ? row.series : [] | |
| const inRange = seriesAll.filter(p => { | |
| const d = new Date(p.date) | |
| return d >= start && d <= end | |
| }) | |
| if (!inRange.length) return null | |
| // Get correct strategy config - row.strategy is the ID, we need the strategy type | |
| const strategyCfg = (STRATEGIES || []).find(s => s.id === row.strategy) || { strategy: 'long_short', tradingMode: 'normal', fee: 0.0005 } | |
| const st = computeStrategyEquity(inRange, 100000, strategyCfg.fee, strategyCfg.strategy, strategyCfg.tradingMode) | |
| const stNoFee = computeStrategyEquity(inRange, 100000, 0, strategyCfg.strategy, strategyCfg.tradingMode) | |
| const metrics = calculateMetricsFromSeries(st, isCrypto ? 'crypto' : 'stock') | |
| const metricsNoFee = calculateMetricsFromSeries(stNoFee, isCrypto ? 'crypto' : 'stock') | |
| const { winRate, trades } = computeWinRate(inRange, strategyCfg.strategy, strategyCfg.tradingMode) | |
| const bhSeries = computeBuyHoldEquity(inRange, 100000) | |
| const buy_hold = bhSeries.length ? (bhSeries[bhSeries.length - 1] - bhSeries[0]) / bhSeries[0] : 0 | |
| const dates = inRange.map(s => s.date).filter(Boolean).sort() | |
| const start_date = dates[0] || row.start_date | |
| const end_date = dates[dates.length - 1] || row.end_date | |
| let trading_days = 0 | |
| let closed_days = 0 | |
| if (isCrypto) { | |
| trading_days = Math.max(0, Math.floor((new Date(end_date) - new Date(start_date)) / 86400000) + 1) | |
| closed_days = 0 | |
| } else { | |
| // accurate counts via calendar utils | |
| trading_days = await countTradingDaysBetweenForAsset(row.asset, start_date, end_date) | |
| closed_days = await countNonTradingDaysBetweenForAsset(row.asset, start_date, end_date) | |
| } | |
| return { | |
| ...row, | |
| decision_ids: inRange.map(r => r.id).filter(Boolean), | |
| balance: st.length ? st[st.length - 1] : 100000, | |
| ret_with_fees: metrics.total_return / 100, | |
| ret_no_fees: metricsNoFee.total_return / 100, | |
| buy_hold, | |
| vs_bh_with_fees: (metrics.total_return / 100) - buy_hold, | |
| sharpe: metrics.sharpe_ratio, | |
| win_rate: winRate, | |
| trades, | |
| start_date, | |
| end_date, | |
| trading_days, | |
| closed_days, | |
| closed_date: closed_days | |
| } | |
| } catch(_) { return row } | |
| }, | |
| async recomputeAllForRange(start, end){ | |
| try { | |
| const tasks = (this.tableRows || []).map(r => this.recomputeRowForRange(r, start, end)) | |
| const res = await Promise.all(tasks) | |
| this.dateFilteredRows = (res || []).filter(Boolean) | |
| } catch(_) { | |
| this.dateFilteredRows = [] | |
| } | |
| }, | |
| enterSelectMode(){ this.selectMode = true; this.selectedRows = [] }, | |
| exitSelectMode(){ this.selectMode = false; this.selectedRows = []; this.compareVisible = false }, | |
| openCompareDialog(){ this.compareVisible = true }, | |
| goCompare(){ | |
| try { | |
| const payload = (this.selectedRows || []).map(r => ({ | |
| agent_name: r.agent_name, | |
| asset: r.asset, | |
| model: r.model, | |
| strategy: r.strategy, | |
| decision_ids: Array.isArray(r.decision_ids) ? r.decision_ids : [] | |
| })) | |
| console.log('[Compare] selectedRows:', this.selectedRows) | |
| console.log('[Compare] payload size:', payload.length, payload) | |
| sessionStorage.setItem('compareRows', JSON.stringify(payload)) | |
| } catch(_) {} | |
| // keep old navigation path available, but we now prefer dialog view | |
| // this.$router.push('/equity-comparison') | |
| }, | |
| requestAsset(){ | |
| console.log('[Request Asset] asset:', this.asset) | |
| const svc = import.meta.env.VITE_EMAILJS_SERVICE_ID | |
| const tpl = import.meta.env.VITE_EMAILJS_TEMPLATE_ID | |
| if (!this.asset || !svc || !tpl) { this.requestAssetsVisible = false; this.asset=''; return } | |
| try { | |
| emailjs.send(svc, tpl, { title: String(this.asset), message: String(this.asset) }) | |
| } catch(_) {} | |
| this.asset = '' | |
| this.requestAssetsVisible = false | |
| }, | |
| async forceRefresh(){ | |
| // reset compare state on refresh | |
| try { this.exitSelectMode() } catch(_) {} | |
| // 使用 dataService 强制刷新 | |
| await dataService.forceRefresh() | |
| } | |
| }, | |
| mounted() { | |
| // 订阅 dataService 状态变化 | |
| this.unsubscribe = dataService.subscribe((state) => { | |
| this.syncFromDataService(state) | |
| }) | |
| // 立即同步当前状态 | |
| this.syncFromDataService(dataService.getState()) | |
| // 如果数据还没加载,触发加载 | |
| if (!dataService.loaded && !dataService.loading) { | |
| dataService.load() | |
| } | |
| }, | |
| beforeUnmount() { | |
| // 取消订阅 | |
| if (this.unsubscribe) { | |
| this.unsubscribe() | |
| this.unsubscribe = null | |
| } | |
| } | |
| } | |
| </script> | |
| <style scoped> | |
| .page-wrapper { | |
| max-width: 1600px; | |
| margin: 0 auto; | |
| padding: 0 1rem 1rem 1rem; | |
| } | |
| .title-container { | |
| text-align: center; | |
| } | |
| .main-title { | |
| font-size: 2rem; | |
| letter-spacing: -0.02em; | |
| font-weight: 800; | |
| color: #1f1f33; | |
| } | |
| .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; } | |
| .card--with-divider :deep(.p-card-title) { | |
| border-bottom: 1px solid var(--surface-200); | |
| padding-bottom: 0.75rem; | |
| margin-bottom: 0.75rem; | |
| } | |
| /* equal-height for side-by-side cards */ | |
| .col-panel { display: flex; } | |
| .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; } | |
| /* compact spacing for higher information density */ | |
| .compact-card :deep(.p-card-body) { padding: 0.75rem; } | |
| .compact-card :deep(.p-card-content) { padding-top: 0; padding-bottom: 0; overflow-y: auto; } | |
| .compact-card :deep(.p-card-title) { margin-bottom: 0.5rem; } | |
| /* datatable compact paddings */ | |
| :deep(.p-datatable .p-datatable-header) { padding: 0.5rem 0.75rem; } | |
| :deep(.p-datatable .p-datatable-footer) { padding: 0.5rem 0.75rem; } | |
| :deep(.p-datatable .p-datatable-thead > tr > th) { padding: 0.5rem 0.5rem; font-size: 0.9rem; } | |
| :deep(.p-datatable .p-datatable-tbody > tr > td) { padding: 0.4rem 0.5rem; font-size: 0.9rem; line-height: 1.2; } | |
| /* multiselect and inputs in filter card */ | |
| .compact-card :deep(.p-inputtext), | |
| .compact-card :deep(.p-multiselect) { font-size: 0.9rem; } | |
| .compact-card :deep(.p-inputtext) { padding: 0.35rem 0.5rem; } | |
| .compact-card :deep(.p-multiselect .p-multiselect-label) { padding: 0.35rem 0.5rem; } | |
| .compact-card :deep(.p-multiselect .p-multiselect-trigger) { width: 1.5rem; } | |
| /* dialog compact header/content on this page */ | |
| :deep(.p-dialog .p-dialog-header) { padding: 0.6rem 0.9rem; } | |
| :deep(.p-dialog .p-dialog-content) { padding: 0.6rem 0.9rem; } | |
| /* empty state position: lower center (about 65% height) */ | |
| .empty-offset { flex: 1; min-height: 280px; position: relative; } | |
| .empty-offset > span { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| transform: translate(-50%, -50%); | |
| text-align: center; | |
| } | |
| /* Content cards with adaptive height */ | |
| .content-card { | |
| height: calc(100vh - 280px); | |
| min-height: 500px; | |
| max-height: 850px; | |
| } | |
| /* Responsive design for mobile */ | |
| .mobile-filter-btn-container { | |
| display: none; | |
| } | |
| @media (max-width: 768px) { | |
| /* Show mobile filter button */ | |
| .mobile-filter-btn-container { | |
| display: flex; | |
| justify-content: flex-start; | |
| margin-bottom: 1rem; | |
| } | |
| /* Hide desktop filter panel */ | |
| .desktop-filter-panel { | |
| display: none ; | |
| } | |
| /* Adjust main content to full width on mobile */ | |
| .col-panel { | |
| flex: 1 ; | |
| min-width: 0 ; | |
| } | |
| /* Adjust card height for mobile */ | |
| .content-card { | |
| height: calc(100vh - 350px); | |
| min-height: 400px; | |
| max-height: none; | |
| } | |
| } | |
| /* Landscape mode optimization */ | |
| @media (max-height: 600px) and (orientation: landscape) { | |
| .content-card { | |
| height: calc(100vh - 180px); | |
| min-height: 300px; | |
| max-height: none; | |
| } | |
| .page-wrapper { | |
| padding: 0 1rem 0.5rem 1rem; | |
| } | |
| .title-container { | |
| margin-bottom: 0.5rem; | |
| } | |
| .main-title { | |
| font-size: 1.5rem; | |
| } | |
| } | |
| /* Drawer custom styles */ | |
| :deep(.filter-drawer) { | |
| width: 320px; | |
| max-width: 85vw; | |
| } | |
| :deep(.filter-drawer .p-drawer-content) { | |
| padding: 1rem; | |
| } | |
| </style> | |