Agent-Market-Arena / src /views /LeadboardView.vue
Jimin Huang
add: Feature
5fa7a59
raw
history blame
18.1 kB
<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 !important;
}
/* Adjust main content to full width on mobile */
.col-panel {
flex: 1 !important;
min-width: 0 !important;
}
/* 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>