234 lines
8.7 KiB
Vue
234 lines
8.7 KiB
Vue
<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>
|