feat(paper): 双轨对账前端页——模拟盘菜单新增「双轨对账」:自动配对卡片(策略/实盘#vs影子#/日期/PASS徽标)+四指标卡(笔数全同/均价差bps/持仓逐只/净值月偏差,各带达标徽标)+成交对比表(symbol+side聚合vwap带符号bps,>10标红)+持仓对比表(数量差≠0标红)+日期选择查询;api getReconcilePairs+mock样例(路由须注册在/paper/{aid}前防吞);dev截图验证 [nas]
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">每笔价差(平均 <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">净值偏差(月累计 <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>
|
||||
Reference in New Issue
Block a user