feat(web): 对账中心上线——恒等式B5只读端点+前端恒等式区块,双轨对账页升级为对账中心(issue#42-A) [nas]
CI/CD / test (push) Failing after 23s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

- API: GET /paper/reconcile/identity?date&refresh——缺省读 supervisor 15:10/23:27
  已存 identity_reconcile 行,无存行现算并落库(与 build/save 同套幂等);
  路由置于 /paper/{aid} 前,与既有 reconcile 端点同组
- 前端: Reconcile.vue 顶部新增恒等式卡片(账户级红绿/未归因±/占比vs容忍0.5%/
  可展开逐票缺口+实例市值拆分),页面与菜单更名为「对账中心」;
  api/paper.ts 补 IdentityReport 类型族
- 测试: 绿线(50/10050=0.497%pass)+存行复用+refresh强制重算(200=1.96%红)+
  snapshot_missing 如实标注,12/12 绿
- 附: docs/session_prompts/ 两份 session 移交提示词入库(fundamentals 18min→
  数据session / 统一ex定寸→策略session,用户08-26拍板)
This commit is contained in:
2026-08-26 20:31:42 +08:00
parent 0c81f5b04b
commit df008b985c
7 changed files with 326 additions and 7 deletions
+38
View File
@@ -222,3 +222,41 @@ export async function getReconcilePairs(date?: string): Promise<ReconcilePair[]>
'/paper/reconcile', { params: date ? { date } : {} })
return data.pairs
}
// ===== 账户恒等式(B5):全账户市值 = Σ实例账本市值 + 未归因(容忍 ≤0.5%) =====
export interface IdentityUnattrPosition {
symbol: string
snapshot_volume: number
instance_volume: number
diff: number
}
export interface ReconcileIdentityRow {
account: string
date: string
status: string // pass | unattributed_over_tol | snapshot_missing | no_instances
snapshot_mv: number | null
instance_mv_total: number
unattributed_mv: number | null
unattributed_pct: number | null
tolerance_pct: number
instances: { id: number; name: string; mv: number }[]
unattributed_positions: IdentityUnattrPosition[]
}
export interface ReconcileIdentityReport {
date: string
rows: ReconcileIdentityRow[]
identity_passed: boolean
}
export async function getReconcileIdentity(
date?: string,
refresh = false,
): Promise<ReconcileIdentityReport> {
const { data } = await apiClient.get<ReconcileIdentityReport>(
'/paper/reconcile/identity',
{ params: { ...(date ? { date } : {}), ...(refresh ? { refresh: true } : {}) } })
return data
}
+3 -3
View File
@@ -37,7 +37,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\/reconcile/, group: '模拟盘', title: '对账中心' },
{ match: /^\/paper$/, group: '模拟盘', title: '模拟盘' },
{ match: /^\/live\/new$/, group: '实盘', title: '新建实盘' },
{ match: /^\/live\/monitor\//, group: '实盘', title: '实盘监控' },
@@ -73,7 +73,7 @@ const CMD_ITEMS: CmdItem[] = [
{ label: '批量评估', group: '投研', path: '/factor/batch' },
{ label: '因子分析', group: '投研', path: '/factor/new' },
{ label: '模拟盘列表', group: '模拟', path: '/paper' },
{ label: '双轨对账', group: '模拟', path: '/paper/reconcile' },
{ label: '对账中心', group: '模拟', path: '/paper/reconcile' },
{ label: '新建模拟盘', group: '模拟', path: '/paper/new' },
{ label: '实盘列表', group: '实盘', path: '/live' },
{ label: '新建实盘', group: '实盘', path: '/live/new' },
@@ -182,7 +182,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/reconcile">对账中心</el-menu-item>
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
</el-sub-menu>
<el-sub-menu index="live">
+99 -4
View File
@@ -1,16 +1,27 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getReconcilePairs, type ReconcilePair } from '@/api/paper'
import {
getReconcilePairs, getReconcileIdentity,
type ReconcilePair, type ReconcileIdentityRow,
} from '@/api/paper'
const pairs = ref<ReconcilePair[]>([])
const identityRows = ref<ReconcileIdentityRow[]>([])
const identityPassed = ref<boolean | null>(null)
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)
const [ps, ident] = await Promise.all([
getReconcilePairs(date.value || undefined),
getReconcileIdentity(date.value || undefined),
])
pairs.value = ps
identityRows.value = ident.rows
identityPassed.value = ident.identity_passed
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
@@ -25,6 +36,23 @@ function num(v: number | null | undefined): string {
return Math.round(v).toLocaleString('zh-CN')
}
function signed(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
const s = Math.round(v).toLocaleString('zh-CN')
return v > 0 ? '+' + s : s
}
function identityStatus(row: ReconcileIdentityRow): { text: string; type: 'success' | 'danger' | 'info' } {
if (row.status === 'pass') return { text: '恒等式通过', type: 'success' }
if (row.status === 'unattributed_over_tol') return { text: '未归因超容忍', type: 'danger' }
if (row.status === 'snapshot_missing') return { text: '无账户快照', type: 'info' }
return { text: '无运行实例', type: 'info' }
}
function overTol(row: ReconcileIdentityRow): boolean {
return row.unattributed_pct != null && row.unattributed_pct > row.tolerance_pct
}
function bps(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return v.toFixed(1) + ' bps'
@@ -44,8 +72,8 @@ function sideText(s: string): string {
<div v-loading="loading" class="page paper-reconcile">
<div class="page-head">
<div>
<h2 class="page-title">双轨对账</h2>
<p class="page-subtitle">影子柜台 vs 实盘模拟 · 同策略同参数并跑差异即真实滑点成本</p>
<h2 class="page-title">对账中心</h2>
<p class="page-subtitle">恒等式全账户市值 = Σ实例账本 + 未归因 0.5%+ 双轨对比影子 vs 实盘 · 每交易日 15:10 / 23:27 自动结算</p>
</div>
<div class="head-right">
<el-date-picker v-model="date" type="date" value-format="YYYY-MM-DD"
@@ -54,6 +82,66 @@ function sideText(s: string): string {
</div>
</div>
<!-- ===== 恒等式B5===== -->
<el-card shadow="never" class="identity-card">
<template #header>
<div class="section-head">
<span class="section-title">账户恒等式</span>
<el-tag v-if="identityPassed === true" type="success" effect="dark">全部通过</el-tag>
<el-tag v-else-if="identityPassed === false" type="danger" effect="dark">存在缺口</el-tag>
</div>
</template>
<el-table :data="identityRows" size="small" empty-text="当日无恒等式数据15:10 首次结算">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-body">
<div class="muted inst-line">
实例市值拆分
<span v-for="i in row.instances" :key="i.id" class="mono inst-item">
#{{ i.id }} {{ i.name }} {{ num(i.mv) }}</span>
</div>
<el-table v-if="row.unattributed_positions.length" :data="row.unattributed_positions"
size="small" class="inner-table">
<el-table-column prop="symbol" label="未归因标的" min-width="110" class-name="mono" />
<el-table-column prop="snapshot_volume" label="账户实际持仓" width="130" class-name="num" />
<el-table-column prop="instance_volume" label="Σ实例账本" width="130" class-name="num" />
<el-table-column label="缺口(股)" width="110" class-name="num">
<template #default="{ row: p }">
<span :class="p.diff !== 0 ? 'down' : ''">{{ signed(p.diff) }}</span>
</template>
</el-table-column>
</el-table>
<div v-else class="muted">逐票无分歧</div>
</div>
</template>
</el-table-column>
<el-table-column prop="account" label="QMT 账户" min-width="120" class-name="mono" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-tag size="small" :type="identityStatus(row).type">{{ identityStatus(row).text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="账户市值(快照)" width="140" class-name="num">
<template #default="{ row }">{{ num(row.snapshot_mv) }}</template>
</el-table-column>
<el-table-column label="Σ实例账本市值" width="140" class-name="num">
<template #default="{ row }">{{ num(row.instance_mv_total) }}</template>
</el-table-column>
<el-table-column label="未归因" width="130" class-name="num">
<template #default="{ row }">
<span :class="overTol(row) ? 'down' : ''">{{ signed(row.unattributed_mv) }}</span>
</template>
</el-table-column>
<el-table-column label="占市值 / 容忍" width="150" class-name="num">
<template #default="{ row }">
<span v-if="row.unattributed_pct != null" :class="overTol(row) ? 'down' : ''">
{{ pct(row.unattributed_pct) }} / {{ row.tolerance_pct }}%</span>
<span v-else class="muted"></span>
</template>
</el-table-column>
</el-table>
</el-card>
<el-alert v-if="!loading && pairs.length === 0" type="info" :closable="false"
title="暂无双轨配对"
description="需要一对同策略的「实盘 + 影子」账户同时运行中才产生配对;数据从并跑首个交易日起累积。" />
@@ -156,6 +244,13 @@ function sideText(s: string): string {
.paper-reconcile { display: flex; flex-direction: column; gap: 16px; }
.head-right { display: flex; align-items: center; gap: 12px; }
.section-head { display: flex; align-items: center; gap: 10px; }
.identity-card :deep(.el-card__body) { padding: 8px 12px; }
.expand-body { padding: 6px 24px; display: flex; flex-direction: column; gap: 8px; }
.inst-line { font-size: 12px; }
.inst-item { margin-right: 12px; }
.inner-table { max-width: 560px; }
.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); }