feat(paper): 双轨对账前端页——模拟盘菜单新增「双轨对账」:自动配对卡片(策略/实盘#vs影子#/日期/PASS徽标)+四指标卡(笔数全同/均价差bps/持仓逐只/净值月偏差,各带达标徽标)+成交对比表(symbol+side聚合vwap带符号bps,>10标红)+持仓对比表(数量差≠0标红)+日期选择查询;api getReconcilePairs+mock样例(路由须注册在/paper/{aid}前防吞);dev截图验证 [nas]
CI/CD / test (push) Successful in 11s
CI/CD / nas-deploy (push) Failing after 14m53s
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-08-15 08:55:00 +08:00
parent 64b10fda41
commit d649d59c44
6 changed files with 280 additions and 0 deletions
+57
View File
@@ -160,3 +160,60 @@ export async function updatePaper(aid: number, req: PaperUpdateReq): Promise<{ u
const { data } = await apiClient.put(`/paper/${aid}`, req)
return data
}
// ===== 双轨对账(影子柜台 vs 实盘模拟,设计 §8.2)=====
export interface ReconcileTradeRow {
symbol: string
side: string
live_volume: number
shadow_volume: number
live_vwap: number | null
shadow_vwap: number | null
price_diff_bps: number | null
}
export interface ReconcilePositionRow {
symbol: string
live_volume: number
shadow_volume: number
volume_diff: number
}
export interface ReconcileReport {
date: string
live_account_id: number
shadow_account_id: number
trades: {
live_count: number
shadow_count: number
count_match: boolean
rows: ReconcileTradeRow[]
avg_price_diff_bps: number
pass_price: boolean
}
positions: { rows: ReconcilePositionRow[]; match: boolean }
nav: {
live_total: number | null
shadow_total: number | null
mtd_deviation_pct: number | null
pass_nav: boolean | null
live_mtd_return_pct?: number
shadow_mtd_return_pct?: number
note?: string
}
passed: boolean
}
export interface ReconcilePair {
live_account_id: number
shadow_account_id: number
strategy: string
report: ReconcileReport
}
export async function getReconcilePairs(date?: string): Promise<ReconcilePair[]> {
const { data } = await apiClient.get<{ pairs: ReconcilePair[] }>(
'/paper/reconcile', { params: date ? { date } : {} })
return data.pairs
}
+42
View File
@@ -423,3 +423,45 @@ export const icSummaryMock = {
/* ========== 任务状态(progress 轮询) ========== */
export const taskStatusDoneMock = { task_id: 'cta_7a92d1f6', status: 'done', stage: 'compute_metrics' }
export const taskStatusRunningMock = { task_id: 'cta_running01', status: 'running', stage: 'run_backtesting' }
// 双轨对账(影子 vs 实盘,§8.2 四指标样例)
export const reconcileMock = {
pairs: [{
live_account_id: 5,
shadow_account_id: 39,
strategy: 'channel_test',
report: {
date: '2026-08-17',
live_account_id: 5,
shadow_account_id: 39,
trades: {
live_count: 6,
shadow_count: 6,
count_match: true,
rows: [
{ symbol: '510300', side: 'B', live_volume: 20000, shadow_volume: 20000, live_vwap: 3.986, shadow_vwap: 3.982, price_diff_bps: 1.0 },
{ symbol: '159915', side: 'B', live_volume: 15000, shadow_volume: 15000, live_vwap: 2.114, shadow_vwap: 2.108, price_diff_bps: 2.8 },
{ symbol: '600000', side: 'S', live_volume: 8000, shadow_volume: 8000, live_vwap: 8.912, shadow_vwap: 8.903, price_diff_bps: 1.0 },
],
avg_price_diff_bps: 1.6,
pass_price: true,
},
positions: {
rows: [
{ symbol: '510300', live_volume: 20000, shadow_volume: 20000, volume_diff: 0 },
{ symbol: '159915', live_volume: 15000, shadow_volume: 15000, volume_diff: 0 },
],
match: true,
},
nav: {
live_total: 1004218.5,
shadow_total: 1003886.2,
mtd_deviation_pct: 0.03,
pass_nav: true,
live_mtd_return_pct: 0.42,
shadow_mtd_return_pct: 0.39,
},
passed: true,
},
}],
}
+1
View File
@@ -55,6 +55,7 @@ const routes: Route[] = [
{ m: 'get', re: /^\/kline$/, build: () => D.klineMock },
// paper
{ m: 'get', re: /^\/paper$/, build: () => D.paperListMock },
{ m: 'get', re: /^\/paper\/reconcile/, build: () => D.reconcileMock },
{ m: 'get', re: /^\/paper\/[^/]+$/, build: () => D.paperDetailMock },
{ m: 'get', re: /^\/paper\/[^/]+\/equity$/, build: () => D.paperEquityMock },
{ m: 'get', re: /^\/paper\/[^/]+\/trades$/, build: () => D.paperTradesMock },
+1
View File
@@ -27,6 +27,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'factor/result/:id', name: 'fc-result', component: () => import('@/views/factor/Result.vue') },
{ path: 'paper/new', name: 'paper-new', component: () => import('@/views/paper/New.vue') },
{ path: 'paper', name: 'paper-list', component: () => import('@/views/paper/List.vue') },
{ path: 'paper/reconcile', name: 'paper-reconcile', component: () => import('@/views/paper/Reconcile.vue') },
{ path: 'paper/result/:id', name: 'paper-result', component: () => import('@/views/paper/Result.vue') },
{ path: 'paper/live/:aid', name: 'paper-live', component: () => import('@/views/paper/Live.vue') },
{ path: 'live/new', name: 'live-new', component: () => import('@/views/live/New.vue') },
+3
View File
@@ -34,6 +34,7 @@ const PAGE_TITLE: Array<{ match: RegExp; group: string; title: string }> = [
{ match: /^\/paper\/new$/, group: '模拟盘', title: '新建模拟盘' },
{ match: /^\/paper\/result\//, group: '模拟盘', title: '模拟盘结果' },
{ match: /^\/paper\/live\//, group: '模拟盘', title: '实走监控' },
{ match: /^\/paper\/reconcile/, group: '模拟盘', title: '双轨对账' },
{ match: /^\/paper$/, group: '模拟盘', title: '模拟盘' },
{ match: /^\/live\/new$/, group: '实盘', title: '新建实盘' },
{ match: /^\/live\/monitor\//, group: '实盘', title: '实盘监控' },
@@ -67,6 +68,7 @@ const CMD_ITEMS: CmdItem[] = [
{ label: '任务列表', group: '回测', path: '/backtest/history' },
{ label: '因子分析', group: '投研', path: '/factor/new' },
{ label: '模拟盘列表', group: '模拟', path: '/paper' },
{ label: '双轨对账', group: '模拟', path: '/paper/reconcile' },
{ label: '新建模拟盘', group: '模拟', path: '/paper/new' },
{ label: '实盘列表', group: '实盘', path: '/live' },
{ label: '新建实盘', group: '实盘', path: '/live/new' },
@@ -173,6 +175,7 @@ function onLogout(): void {
<el-sub-menu index="paper">
<template #title><span class="nav-label">模拟盘</span></template>
<el-menu-item index="/paper">模拟盘列表</el-menu-item>
<el-menu-item index="/paper/reconcile">双轨对账</el-menu-item>
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
</el-sub-menu>
<el-sub-menu index="live">
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getReconcilePairs, type ReconcilePair } from '@/api/paper'
const pairs = ref<ReconcilePair[]>([])
const loading = ref(false)
const date = ref<string>(new Date().toISOString().slice(0, 10))
async function refresh(): Promise<void> {
loading.value = true
try {
pairs.value = await getReconcilePairs(date.value || undefined)
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
onMounted(refresh)
function num(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return Math.round(v).toLocaleString('zh-CN')
}
function bps(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return v.toFixed(1) + ' bps'
}
function pct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return v.toFixed(3) + '%'
}
function sideText(s: string): string {
return s === 'B' ? '买' : '卖'
}
</script>
<template>
<div v-loading="loading" class="page paper-reconcile">
<div class="page-head">
<div>
<h2 class="page-title">双轨对账</h2>
<p class="page-subtitle">影子柜台 vs 实盘模拟 · 同策略同参数并跑差异即真实滑点成本</p>
</div>
<div class="head-right">
<el-date-picker v-model="date" type="date" value-format="YYYY-MM-DD"
placeholder="对账日期" :clearable="false" style="width: 150px" />
<el-button type="primary" :loading="loading" @click="refresh">查询</el-button>
</div>
</div>
<el-alert v-if="!loading && pairs.length === 0" type="info" :closable="false"
title="暂无双轨配对"
description="需要一对同策略的「实盘 + 影子」账户同时运行中才产生配对;数据从并跑首个交易日起累积。" />
<div v-for="p in pairs" :key="`${p.live_account_id}-${p.shadow_account_id}`" class="pair-block">
<div class="pair-head">
<span class="pair-title mono">{{ p.strategy }}</span>
<span class="muted">实盘 #{{ p.live_account_id }} vs 影子 #{{ p.shadow_account_id }} · {{ p.report.date }}</span>
<el-tag :type="p.report.passed ? 'success' : 'danger'" effect="dark">
{{ p.report.passed ? '对账通过' : '存在偏差' }}
</el-tag>
</div>
<!-- 四指标 -->
<div class="stat-row">
<div class="stat">
<span class="stat-label">成交笔数实盘 / 影子</span>
<span class="stat-value mono">{{ p.report.trades.live_count }} / {{ p.report.trades.shadow_count }}</span>
<el-tag size="small" :type="p.report.trades.count_match ? 'success' : 'danger'">
{{ p.report.trades.count_match ? '完全相同' : '笔数不一致' }}
</el-tag>
</div>
<div class="stat">
<span class="stat-label">每笔价差平均 &lt;10bps</span>
<span class="stat-value mono">{{ bps(p.report.trades.avg_price_diff_bps) }}</span>
<el-tag size="small" :type="p.report.trades.pass_price ? 'success' : 'danger'">
{{ p.report.trades.pass_price ? '达标' : '超限' }}
</el-tag>
</div>
<div class="stat">
<span class="stat-label">收盘持仓逐只对比</span>
<span class="stat-value mono">{{ p.report.positions.rows.length }} </span>
<el-tag size="small" :type="p.report.positions.match ? 'success' : 'danger'">
{{ p.report.positions.match ? '完全相同' : '存在分歧' }}
</el-tag>
</div>
<div class="stat">
<span class="stat-label">净值偏差月累计 &lt;0.5%</span>
<span class="stat-value mono">{{ pct(p.report.nav.mtd_deviation_pct) }}</span>
<el-tag v-if="p.report.nav.pass_nav !== null" size="small"
:type="p.report.nav.pass_nav ? 'success' : 'danger'">
{{ p.report.nav.pass_nav ? '达标' : '超限' }}
</el-tag>
<el-tag v-else size="small" type="info">净值序列不全</el-tag>
</div>
</div>
<div class="pair-nav mono muted" v-if="p.report.nav.note">
{{ p.report.nav.note }} · 实盘总值 {{ num(p.report.nav.live_total) }} / 影子总值 {{ num(p.report.nav.shadow_total) }}
</div>
<!-- 成交明细 -->
<el-card shadow="never">
<template #header><span class="section-title">成交对比按标的+方向聚合 vwap</span></template>
<el-table :data="p.report.trades.rows" size="small" empty-text="当日无成交">
<el-table-column prop="symbol" label="标的" min-width="100" class-name="mono" />
<el-table-column label="方向" width="70">
<template #default="{ row }">{{ sideText(row.side) }}</template>
</el-table-column>
<el-table-column prop="live_volume" label="实盘量" width="110" class-name="num" />
<el-table-column prop="shadow_volume" label="影子量" width="110" class-name="num" />
<el-table-column prop="live_vwap" label="实盘均价" width="110" class-name="num" />
<el-table-column prop="shadow_vwap" label="影子均价" width="110" class-name="num" />
<el-table-column label="价差" width="110" class-name="num">
<template #default="{ row }">
<span v-if="row.price_diff_bps != null"
:class="Math.abs(row.price_diff_bps) > 10 ? 'down' : ''">{{ bps(row.price_diff_bps) }}</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 持仓明细 -->
<el-card shadow="never">
<template #header><span class="section-title">收盘持仓对比</span></template>
<el-table :data="p.report.positions.rows" size="small" empty-text="双方均无持仓">
<el-table-column prop="symbol" label="标的" min-width="100" class-name="mono" />
<el-table-column prop="live_volume" label="实盘持仓" width="130" class-name="num" />
<el-table-column prop="shadow_volume" label="影子持仓" width="130" class-name="num" />
<el-table-column label="数量差" width="130" class-name="num">
<template #default="{ row }">
<span :class="row.volume_diff !== 0 ? 'down' : ''">{{ row.volume_diff }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</div>
</template>
<style scoped>
.paper-reconcile { display: flex; flex-direction: column; gap: 16px; }
.head-right { display: flex; align-items: center; gap: 12px; }
.pair-block { display: flex; flex-direction: column; gap: 12px; }
.pair-head { display: flex; align-items: center; gap: 12px; }
.pair-title { font-size: 15px; font-weight: 700; color: var(--text); }
.pair-nav { font-size: 12px; }
.stat-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-3);
}
.stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.stat-label { font-size: 11px; color: var(--text-3); letter-spacing: 0.3px; }
.stat-value { font-size: 17px; color: var(--text); font-weight: 600; }
@media (max-width: 1200px) { .stat-row { grid-template-columns: repeat(2, 1fr); } }
</style>