Files
sanguo_vnpy_v2/frontend/src/views/paper/Result.vue
T

234 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPaper, getEquity, getTrades, getStrategies,
type BalancePoint, type PaperTrade, type StrategySummary, type PaperAccount,
} from '@/api/paper'
import type { EquityPoint } from '@/api/backtest'
import EquityChart from '@/components/charts/EquityChart.vue'
const route = useRoute()
const router = useRouter()
const aid = Number(route.params.id)
const loading = ref(true)
const account = ref<PaperAccount | null>(null)
const equity = ref<BalancePoint[]>([])
const trades = ref<PaperTrade[]>([])
const strategies = ref<StrategySummary[]>([])
let timer: ReturnType<typeof setInterval> | null = null
const running = computed(() => {
const s = account.value?.status ?? ''
return s === 'running' || s === 'pending' || s === 'created'
})
async function load(): Promise<void> {
try {
const [acc, eq, tr, st] = await Promise.all([
getPaper(aid), getEquity(aid), getTrades(aid), getStrategies(aid),
])
account.value = acc
equity.value = eq
trades.value = tr
strategies.value = st
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
timer = setInterval(async () => {
if (!running.value) {
if (timer) clearInterval(timer)
return
}
await load()
}, 3000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
// ----- 从净值序列计算关键指标(模拟盘无 metrics 端点) -----
function std(arr: number[]): number {
if (arr.length < 2) return 0
const m = arr.reduce((a, b) => a + b, 0) / arr.length
const v = arr.reduce((a, b) => a + (b - m) ** 2, 0) / (arr.length - 1)
return Math.sqrt(v)
}
const metrics = computed(() => {
const eq = equity.value
if (eq.length < 2) return null
const cur = eq.map((p) => p.total_equity).filter((v) => Number.isFinite(v) && v > 0)
if (cur.length < 2) return null
const start = cur[0]
const last = cur[cur.length - 1]
const totalReturn = last / start - 1
const n = cur.length
const annFactor = 252 / n
const annualReturn = Math.pow(1 + totalReturn, annFactor) - 1
const rets: number[] = []
for (let i = 1; i < cur.length; i++) rets.push(cur[i] / cur[i - 1] - 1)
const vol = std(rets) * Math.sqrt(252)
const sharpe = vol > 0 ? (rets.reduce((a, b) => a + b, 0) / rets.length * 252) / vol : 0
let peak = cur[0]
let mdd = 0
for (const v of cur) {
if (v > peak) peak = v
const dd = (peak - v) / peak
if (dd > mdd) mdd = dd
}
return { totalReturn, annualReturn, vol, sharpe, mdd, last }
})
const equitySeries = computed<EquityPoint[]>(() =>
equity.value.map((p) => ({ date: p.date, balance: p.total_equity })),
)
function pct(v: number | undefined | null): string {
if (v == null || !Number.isFinite(v)) return '—'
return (v * 100).toFixed(2) + '%'
}
function num(v: number | undefined | null): string {
if (v == null || !Number.isFinite(v)) return '—'
return Math.round(v).toLocaleString('zh-CN')
}
function statusLabel(s: string): string {
return ({ running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队', stopped: '已停止' } as Record<string, string>)[s] || s
}
function rowClass({ row }: { row: PaperTrade }): string {
return row.rejected ? 'rejected-row' : ''
}
function toLive(): void {
router.push(`/paper/live/${aid}`)
}
</script>
<template>
<div v-loading="loading" class="page paper-result">
<div class="page-head">
<div>
<h2 class="page-title">模拟盘结果 <span class="muted">#{{ aid }}</span></h2>
<p class="page-subtitle">
{{ account?.name ?? 'paper' }} ·
模式 <span class="mono">{{ account?.mode ?? '—' }}</span> ·
频率 <span class="mono">{{ account?.interval ?? '—' }}</span>
</p>
</div>
<div class="head-actions">
<span v-if="account" class="chip" :class="`st-${account.status}`">{{
account.mode === 'live' && account.status === 'running'
? '实走中 · 每日 20:30 结算'
: statusLabel(account.status)
}}</span>
<el-button v-if="account?.mode === 'live'" type="primary" @click="toLive">实走监控 </el-button>
</div>
</div>
<el-alert v-if="running" type="info" :closable="false">
回放进行中{{ account?.status }} 3 秒刷新完成自动停止
</el-alert>
<el-alert v-else-if="account?.status === 'failed'" type="error" :title="account?.error_msg || '回放失败'" :closable="false" />
<!-- 指标卡 -->
<div class="metric-row">
<div class="metric-card">
<div class="m-label">总收益</div>
<div class="m-value mono" :class="metrics && metrics.totalReturn >= 0 ? 'up' : 'down'">{{ pct(metrics?.totalReturn) }}</div>
</div>
<div class="metric-card">
<div class="m-label">年化收益</div>
<div class="m-value mono" :class="metrics && metrics.annualReturn >= 0 ? 'up' : 'down'">{{ pct(metrics?.annualReturn) }}</div>
</div>
<div class="metric-card">
<div class="m-label">最大回撤</div>
<div class="m-value mono down">{{ pct(metrics?.mdd) }}</div>
</div>
<div class="metric-card">
<div class="m-label">夏普比率</div>
<div class="m-value mono">{{ metrics ? metrics.sharpe.toFixed(2) : '—' }}</div>
</div>
<div class="metric-card">
<div class="m-label">年化波动</div>
<div class="m-value mono">{{ pct(metrics?.vol) }}</div>
</div>
<div class="metric-card">
<div class="m-label">最新净值</div>
<div class="m-value mono">{{ num(metrics?.last ?? account?.initial_capital) }}</div>
</div>
</div>
<!-- 净值曲线 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">账户净值</span></template>
<EquityChart :data="equitySeries" />
</el-card>
<!-- 分策略归因 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">分策略归因</span></template>
<el-table :data="strategies" size="small" empty-text="无策略数据">
<el-table-column prop="strategy_id" label="策略" min-width="160" />
<el-table-column prop="total_orders" label="总委托" width="90" class-name="num" />
<el-table-column prop="filled" label="成交" width="80" class-name="num" />
<el-table-column prop="rejected" label="拒单" width="80" class-name="num" />
<el-table-column label="成交率" width="90">
<template #default="{ row }">
<span class="mono">{{ row.total_orders ? ((row.filled / row.total_orders) * 100).toFixed(1) + '%' : '—' }}</span>
</template>
</el-table-column>
<el-table-column prop="commission" label="费用" width="100" class-name="num" />
</el-table>
</el-card>
<!-- 成交明细 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">成交明细 <em class="muted">拒单标红</em></span></template>
<el-table :data="trades" size="small" :row-class-name="rowClass" max-height="420" empty-text="无成交">
<el-table-column prop="bar_date" label="时间" min-width="150" />
<el-table-column prop="strategy_id" label="策略" min-width="140" show-overflow-tooltip />
<el-table-column prop="symbol" label="标的" width="90" />
<el-table-column label="方向" width="70">
<template #default="{ row }">
<span :class="row.direction === '买' ? 'up' : 'down'">{{ row.direction }}</span>
</template>
</el-table-column>
<el-table-column prop="price" label="价格" width="100" class-name="num" />
<el-table-column prop="volume" label="数量" width="90" class-name="num" />
<el-table-column prop="commission" label="费用" width="90" class-name="num" />
<el-table-column prop="reject_reason" label="拒因" min-width="120" show-overflow-tooltip />
</el-table>
</el-card>
</div>
</template>
<style scoped>
.paper-result { display: flex; flex-direction: column; gap: 16px; }
.head-actions { display: flex; align-items: center; gap: 12px; }
.metric-row {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
.metric-card {
background: var(--bg-card);
border: 1px solid var(--border-2);
border-radius: var(--r-md);
padding: 14px 16px;
}
.m-label { font-size: 12px; color: var(--text-3); }
.m-value { margin-top: 6px; font-size: 20px; font-weight: 700; color: var(--text); }
.blk { border: 1px solid var(--border-2); }
:deep(.num) { font-family: var(--mono); }
:deep(.rejected-row) { color: var(--danger); }
@media (max-width: 1200px) {
.metric-row { grid-template-columns: repeat(3, 1fr); }
}
</style>