feat(frontend): P2全平台聚宽级复刻—模拟盘/因子/回测表单页重做+模拟盘列表页

模拟盘模块:
- 新增模拟盘列表页(List.vue):统计行+富表格(名称/模式/频率/收益/最新净值/状态/操作)+搜索筛选
- 后端加 GET /paper 列表端点(类比 GET /task,带最新净值+收益率)
- paper/New 分区富表单(卡式模式选择+频率+撮合时点说明)
- paper/Result 重做(收益/年化/回撤/夏普/波动指标卡+净值曲线+归因表+成交明细,净值客户端算指标)

因子模块:
- factor/Result 重做(最优ICIR/平均IC/显著数指标卡+彩色IC表+tears报告tab化)
- factor/New 重做(因子按类分组多选+标的批量+实时计数)

回测模块:
- backtest/Progress 重做(步骤时间线+进度条+实时日志尾3s刷新)
- backtest/New 重做(策略/参数/标的区间/基准分区富表单)
- backtest/Optimize 重做(结果列头可排序+Top1高亮+按收益默认降序)
- History 迁移scoped chip→全局chips.css(DRY清理)

全局:深色主题统一,复用 tokens.css + chips.css
This commit is contained in:
2026-07-11 21:10:27 +08:00
parent e0d32c0d1a
commit 41a788c431
14 changed files with 963 additions and 296 deletions
+5
View File
@@ -176,3 +176,8 @@ export async function getRiskSeries(taskId: string): Promise<RiskSeriesData> {
return data
}
export async function getLog(taskId: string): Promise<string> {
const { data } = await apiClient.get<{ log: string }>(`/task/${taskId}/log`)
return data.log
}
+9
View File
@@ -32,6 +32,10 @@ export interface PaperAccount {
next_run_at?: string | null
checkpoint_date?: string | null
error_msg?: string | null
/** 列表端点附带的最新净值 / 收益(单账户 GET 不含) */
latest_equity?: number | null
latest_date?: string | null
total_return?: number | null
}
export interface BalancePoint {
@@ -85,6 +89,11 @@ export async function createPaper(req: PaperCreate): Promise<number> {
return data.account_id
}
export async function listPapers(): Promise<PaperAccount[]> {
const { data } = await apiClient.get<{ accounts: PaperAccount[] }>('/paper')
return data.accounts
}
export async function getPaper(aid: number): Promise<PaperAccount> {
const { data } = await apiClient.get<PaperAccount>(`/paper/${aid}`)
return data
+1
View File
@@ -18,6 +18,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'factor/progress/:id', name: 'fc-progress', component: () => import('@/views/backtest/Progress.vue') },
{ 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/result/:id', name: 'paper-result', component: () => import('@/views/paper/Result.vue') },
{ path: 'paper/live/:aid', name: 'paper-live', component: () => import('@/views/paper/Live.vue') },
],
+1
View File
@@ -53,6 +53,7 @@ function onLogout(): void {
<template #title>
<span class="nav-label">模拟</span>
</template>
<el-menu-item index="/paper">模拟盘列表</el-menu-item>
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
</el-sub-menu>
-62
View File
@@ -159,21 +159,6 @@ function statusLabel(s: string): string {
</template>
<style scoped>
.count-badge {
display: inline-block;
margin-left: 8px;
padding: 0 8px;
min-width: 24px;
height: 22px;
line-height: 22px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: var(--text-2);
background: var(--bg-hover);
border-radius: 11px;
vertical-align: middle;
}
.head-actions {
margin-left: auto;
}
@@ -193,53 +178,6 @@ function statusLabel(s: string): string {
.table-card {
border: 1px solid var(--border-2);
}
.mono {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
}
.muted {
color: var(--text-2);
font-size: 12px;
}
.chip {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
/* 类型 chip */
.chip-cta {
color: var(--brand);
background: rgba(24, 144, 255, 0.14);
}
.chip-optimize {
color: #13c2c2;
background: rgba(19, 194, 194, 0.14);
}
.chip-factor {
color: #b37feb;
background: rgba(179, 127, 235, 0.14);
}
/* 状态 chip */
.st-done {
color: var(--down);
background: rgba(63, 185, 80, 0.14);
}
.st-failed {
color: var(--danger);
background: rgba(248, 81, 73, 0.14);
}
.st-running {
color: var(--brand);
background: rgba(24, 144, 255, 0.14);
}
.st-pending {
color: var(--warn);
background: rgba(210, 153, 34, 0.14);
}
.pager {
margin-top: 12px;
display: flex;
+67 -31
View File
@@ -79,42 +79,78 @@ async function onSubmit(): Promise<void> {
</script>
<template>
<div class="page">
<div class="page bt-new">
<div class="page-head">
<div>
<h2 class="page-title">新建回测</h2>
<p class="page-subtitle">选择策略与标的提交后进入进度跟踪</p>
<p class="page-subtitle">CTA 策略历史回测 · 选择策略与标的提交后进入进度跟踪</p>
</div>
</div>
<el-card v-loading="loading">
<el-form :model="form" label-width="120px">
<el-form-item label="策略">
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 280px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item v-for="k in paramsList" :key="k" :label="k">
<el-input v-model="paramValues[k]" style="width: 220px" />
</el-form-item>
<el-form-item label="标的代码">
<el-input v-model="form.symbol" placeholder="如 600000(不带交易所后缀)" style="width: 220px" />
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="基准">
<el-select v-model="form.benchmark" style="width: 220px">
<el-option label="沪深300 (hs300)" value="hs300" />
<el-option label="中证500 (zz500)" value="zz500" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交回测</el-button>
</el-form-item>
</el-form>
<el-card class="blk" shadow="never" v-loading="loading">
<template #header><span class="section-title">策略与参数</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="策略">
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 320px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item v-if="paramsList.length" label="参数">
<div class="param-grid">
<div v-for="k in paramsList" :key="k" class="param-item">
<span class="param-key mono">{{ k }}</span>
<el-input v-model="paramValues[k]" style="width: 140px" />
</div>
</div>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">标的与区间</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="标的代码">
<el-input v-model="form.symbol" placeholder="如 600000(不带交易所后缀)" style="width: 240px" />
<span class="muted form-hint">A 股代码无需交易所后缀</span>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">基准</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="比较基准">
<el-radio-group v-model="form.benchmark">
<el-radio-button value="hs300">沪深 300</el-radio-button>
<el-radio-button value="zz500">中证 500</el-radio-button>
</el-radio-group>
<span class="muted form-hint">用于计算 alpha / beta / 超额收益</span>
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
提交回测
</el-button>
</div>
</div>
</template>
<style scoped>
.bt-new { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.param-grid { display: flex; flex-wrap: wrap; gap: 12px 20px; }
.param-item { display: flex; align-items: center; gap: 8px; }
.param-key { font-size: 12px; color: var(--text-2); min-width: 110px; }
.form-hint { margin-left: 10px; }
.submit-bar { padding: 4px 0; }
</style>
+124 -40
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getStrategies, type StrategyItem } from '@/api/strategy'
import { submitOptimize, getOptimizationResults, getStatus, type OptRow } from '@/api/backtest'
@@ -43,15 +43,55 @@ function parseGrid(): Record<string, [number, number, number]> | null {
return grid
}
function statColumns(rows: OptRow[]): string[] {
const statColumns = computed(() => {
const set = new Set<string>()
rows.forEach((r) => Object.keys(r.statistics || {}).forEach((k) => set.add(k)))
results.value.forEach((r) => Object.keys(r.statistics || {}).forEach((k) => set.add(k)))
return Array.from(set)
})
// 排序
const sortKey = ref('')
const sortDir = ref<'desc' | 'asc'>('desc')
function setSort(k: string): void {
if (sortKey.value === k) {
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
} else {
sortKey.value = k
sortDir.value = 'desc'
}
}
const sortedResults = computed(() => {
if (!sortKey.value) return results.value
const k = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
return [...results.value].sort((a, b) => {
const va = Number(a.statistics?.[k] ?? NaN)
const vb = Number(b.statistics?.[k] ?? NaN)
if (!Number.isFinite(va) && !Number.isFinite(vb)) return 0
if (!Number.isFinite(va)) return 1
if (!Number.isFinite(vb)) return -1
return (va - vb) * dir
})
})
// Top1 行(按当前排序首项)
const topParamsKey = computed(() => {
if (!sortedResults.value.length) return ''
return JSON.stringify(sortedResults.value[0].params)
})
function topRowClass({ row }: { row: OptRow }): string {
return JSON.stringify(row.params) === topParamsKey.value ? 'top-row' : ''
}
function fmt(v: unknown): string {
return typeof v === 'number' ? (Math.round(v * 10000) / 10000).toString() : v == null ? '' : String(v)
}
function isUp(k: string): boolean {
return /return|收益|sharpe|夏普|alpha|ir/i.test(k)
}
async function onSubmit(): Promise<void> {
const grid = parseGrid()
@@ -80,6 +120,9 @@ async function onSubmit(): Promise<void> {
return
}
results.value = await getOptimizationResults(tid)
// 默认按总收益降序
const retKey = statColumns.value.find((k) => /return|收益/i.test(k))
if (retKey) { sortKey.value = retKey; sortDir.value = 'desc' }
} catch {
ElMessage.error('提交失败')
} finally {
@@ -90,49 +133,90 @@ async function onSubmit(): Promise<void> {
</script>
<template>
<div class="page">
<div class="page opt-page">
<div class="page-head">
<div>
<h2 class="page-title">参数优化</h2>
<p class="page-subtitle">参数网格搜索 · 自动轮询结果</p>
<p class="page-subtitle">参数网格搜索 · 自动轮询 · 结果可排序Top1 高亮</p>
</div>
</div>
<el-card v-loading="loading || polling" :element-loading-text="stageText || '优化中…'">
<el-form :model="form" label-width="120px">
<el-form-item label="策略">
<el-select v-model="form.strategy" style="width: 280px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="标的">
<el-input v-model="form.symbol" style="width: 220px" />
</el-form-item>
<el-form-item label="参数网格">
<el-input v-model="form.gridText" type="textarea" :rows="3" placeholder="每行:name,start,end,step" />
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交优化</el-button>
</el-form-item>
</el-form>
<el-table v-if="results.length" :data="results" stripe size="small" style="margin-top: 16px">
<el-table-column label="参数">
<template #default="{ row }">
<span v-for="(v, k) in row.params" :key="String(k)" style="margin-right: 8px">
{{ k }}={{ fmt(v) }}
</span>
</template>
</el-table-column>
<el-table-column v-for="k in statColumns(results)" :key="k" :label="k">
<template #default="{ row }">{{ fmt(row.statistics?.[k]) }}</template>
</el-table-column>
</el-table>
<el-card class="blk" shadow="never" v-loading="loading || polling" :element-loading-text="stageText || '优化中…'">
<template #header><span class="section-title">网格配置</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="策略">
<el-select v-model="form.strategy" style="width: 320px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
</el-form-item>
<el-form-item label="标的">
<el-input v-model="form.symbol" style="width: 220px" />
</el-form-item>
<el-form-item label="参数网格">
<el-input v-model="form.gridText" type="textarea" :rows="3" placeholder="每行:name,start,end,step" style="width: 420px" />
<span class="muted form-hint">每行参数名,起始,结束,步长</span>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交优化</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="results.length" class="blk" shadow="never">
<template #header>
<span class="section-title">优化结果 <span class="count-badge">{{ results.length }}</span></span>
<span class="muted sort-hint">点击列头排序 · 当前
<strong v-if="sortKey" class="mono">{{ sortKey }} ({{ sortDir === 'desc' ? '降序' : '升序' }})</strong>
<em v-else>未排序</em>
</span>
</template>
<el-table :data="sortedResults" size="small" :row-class-name="topRowClass">
<el-table-column label="参数" min-width="220">
<template #default="{ row }">
<span v-for="(v, k) in row.params" :key="String(k)" class="param-pair mono">
{{ k }}=<strong>{{ fmt(v) }}</strong>
</span>
</template>
</el-table-column>
<el-table-column
v-for="k in statColumns" :key="k" :label="k" :align="isUp(k) ? 'right' : 'right'"
:class-name="isUp(k) ? 'num up-col' : 'num'"
>
<template #header>
<span class="th-sort" :class="{ active: sortKey === k }" @click="setSort(k)">
{{ k }}
<span v-if="sortKey === k">{{ sortDir === 'desc' ? '' : '' }}</span>
</span>
</template>
<template #default="{ row }">
<span class="mono">{{ fmt(row.statistics?.[k]) }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.opt-page { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.form-hint { margin-left: 10px; }
.sort-hint { margin-left: 12px; font-size: 12px; }
.th-sort { cursor: pointer; user-select: none; }
.th-sort.active { color: var(--brand); font-weight: 700; }
.th-sort:hover { color: var(--brand); }
.param-pair { margin-right: 10px; color: var(--text-2); }
.param-pair strong { color: var(--text); }
:deep(.num) { font-family: var(--mono); }
:deep(.top-row) { background: rgba(63, 185, 80, 0.10) !important; }
:deep(.top-row td) { font-weight: 600; }
</style>
+150 -22
View File
@@ -1,55 +1,183 @@
<script setup lang="ts">
import { watch } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTask } from '@/composables/useTask'
import { getLog } from '@/api/backtest'
const route = useRoute()
const router = useRouter()
const taskId = String(route.params.id)
const isFactor = computed(() => route.path.startsWith('/factor'))
const { status, stage, start } = useTask(taskId)
start()
// 同时拉日志(3s
const logText = ref('')
const logBox = ref<HTMLElement | null>(null)
let logTimer: ReturnType<typeof setInterval> | null = null
async function pullLog(): Promise<void> {
try {
logText.value = await getLog(taskId)
await nextTick()
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight
} catch {
/* log optional */
}
}
onMounted(() => {
start()
void pullLog()
logTimer = setInterval(pullLog, 3000)
})
onUnmounted(() => {
if (logTimer) clearInterval(logTimer)
})
watch(status, (s) => {
if (s === 'done') {
const base = route.path.startsWith('/factor') ? '/factor' : '/backtest'
const base = isFactor.value ? '/factor' : '/backtest'
router.push(`${base}/result/${taskId}`)
}
})
function pct(): number {
if (status.value === 'done') return 100
if (status.value === 'running') return 60
if (status.value === 'failed') return 100
return 20
// 步骤推导
interface Step { key: string; label: string }
const steps: Step[] = [
{ key: 'submit', label: '任务提交' },
{ key: 'data', label: '数据加载' },
{ key: 'run', label: '回测执行' },
{ key: 'metrics', label: '指标计算' },
{ key: 'done', label: '完成' },
]
function stepState(idx: number): 'done' | 'active' | 'pending' {
const s = status.value
if (s === 'done') return 'done'
if (s === 'failed') {
// 失败:当前进行到的步骤标 active(红),之前标 done
return idx < currentIdx.value ? 'done' : idx === currentIdx.value ? 'active' : 'pending'
}
if (idx < currentIdx.value) return 'done'
if (idx === currentIdx.value) return 'active'
return 'pending'
}
const currentIdx = computed(() => {
const st = (stage.value || '').toLowerCase()
const s = status.value
if (s === 'done') return steps.length - 1
if (s === 'pending' || s === 'unknown') return 0
if (st.includes('metric') || st.includes('指标')) return 3
if (st.includes('run') || st.includes('backtest') || st.includes('回测')) return 2
if (st.includes('data') || st.includes('load') || st.includes('数据')) return 1
return 1
})
const pct = computed(() => {
if (status.value === 'done') return 100
if (status.value === 'failed') return Math.round((currentIdx.value / (steps.length - 1)) * 100)
return Math.round((currentIdx.value / (steps.length - 1)) * 100)
})
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', pending: '排队中', unknown: '初始化' }
const statusChipClass: Record<string, string> = { running: 'st-running', done: 'st-done', failed: 'st-failed', pending: 'st-pending', unknown: 'st-pending' }
</script>
<template>
<div class="page">
<div class="page progress-page">
<div class="page-head">
<div>
<h2 class="page-title">任务进行中</h2>
<p class="page-subtitle">任务 ID{{ taskId }}</p>
<h2 class="page-title">{{ isFactor ? '因子分析' : '回测' }}进行中</h2>
<p class="page-subtitle">任务 ID<span class="mono">{{ taskId }}</span></p>
</div>
<span class="chip" :class="statusChipClass[status]">{{ statusLabel[status] }}</span>
</div>
<el-card>
<p class="progress-status">
状态
<el-tag :type="status === 'done' ? 'success' : status === 'failed' ? 'danger' : 'warning'">
{{ status }}
</el-tag>
</p>
<p class="progress-stage">阶段{{ stage || '—' }}</p>
<!-- 进度条 -->
<el-card class="blk" shadow="never">
<el-progress
:percentage="pct()"
:percentage="pct"
:status="status === 'failed' ? 'exception' : status === 'done' ? 'success' : undefined"
:stroke-width="10"
/>
<div class="stage-text muted">当前阶段{{ stage || '—' }}</div>
</el-card>
<!-- 步骤时间线 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">执行步骤</span></template>
<div class="timeline">
<div
v-for="(s, i) in steps" :key="s.key"
class="tl-node"
:class="[stepState(i), { failed: status === 'failed' && i === currentIdx }]"
:style="{ flex: i === steps.length - 1 ? '0 0 auto' : '1' }"
>
<div class="tl-dot">
<span v-if="stepState(i) === 'done'"></span>
<span v-else>{{ i + 1 }}</span>
</div>
<div class="tl-label">{{ s.label }}</div>
<div v-if="i < steps.length - 1" class="tl-bar" />
</div>
</div>
</el-card>
<!-- 日志 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">运行日志 <em class="muted"> 3s 刷新</em></span></template>
<div ref="logBox" class="log-box">
<pre v-if="logText">{{ logText }}</pre>
<span v-else class="muted">暂无日志输出</span>
</div>
</el-card>
</div>
</template>
<style scoped>
.progress-status { margin-bottom: 8px; }
.progress-stage { margin-bottom: 16px; color: var(--text-2); }
.progress-page { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.stage-text { margin-top: 10px; }
.timeline { display: flex; align-items: flex-start; }
.tl-node { display: flex; flex-direction: column; align-items: center; position: relative; padding: 0 8px; }
.tl-dot {
width: 26px; height: 26px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
background: var(--bg-hover); color: var(--text-3);
border: 1px solid var(--border-2);
z-index: 1;
}
.tl-label { margin-top: 8px; font-size: 12px; color: var(--text-3); }
.tl-bar { position: absolute; top: 13px; left: 50%; width: 100%; height: 2px; background: var(--border-2); z-index: 0; }
.tl-node.done .tl-dot { background: var(--down); color: #fff; border-color: var(--down); }
.tl-node.done .tl-label { color: var(--text); }
.tl-node.done + .tl-node .tl-bar,
.tl-node.done .tl-bar { background: var(--down); }
.tl-node.active .tl-dot { background: var(--brand); color: #fff; border-color: var(--brand); }
.tl-node.active .tl-label { color: var(--brand); }
.tl-node.active.failed .tl-dot { background: var(--danger); border-color: var(--danger); }
.tl-node.active.failed .tl-label { color: var(--danger); }
.log-box {
background: var(--bg);
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
padding: 12px;
max-height: 360px;
overflow: auto;
}
.log-box pre {
margin: 0;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
color: var(--text-2);
white-space: pre-wrap;
word-break: break-all;
}
</style>
+73 -27
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getFactors, submitFactor, type FactorItem } from '@/api/factor'
@@ -16,6 +16,20 @@ const form = reactive({
end: '2024-06-30',
})
// 按 category 分组(el-option-group
const grouped = computed(() => {
const map = new Map<string, FactorItem[]>()
for (const f of factors.value) {
if (!map.has(f.category)) map.set(f.category, [])
map.get(f.category)!.push(f)
}
return Array.from(map, ([cat, items]) => ({ cat, items }))
})
const symbolCount = computed(() =>
form.symbolsText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean).length,
)
onMounted(async () => {
loading.value = true
try {
@@ -28,10 +42,7 @@ onMounted(async () => {
})
async function onSubmit(): Promise<void> {
const symbols = form.symbolsText
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
const symbols = form.symbolsText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean)
if (!form.factor_names.length || symbols.length < 2) {
ElMessage.warning('至少选 1 个因子 + 2 个标的(IC 横截面需多标的)')
return
@@ -55,33 +66,68 @@ async function onSubmit(): Promise<void> {
</script>
<template>
<div class="page">
<div class="page factor-new">
<div class="page-head">
<div>
<h2 class="page-title">因子分析</h2>
<p class="page-subtitle">IC 横截面分析2 标的· 生成 tears 报告</p>
<p class="page-subtitle">IC 横截面分析2 标的· 生成分层 tears 报告</p>
</div>
</div>
<el-card v-loading="loading">
<el-form label-width="140px">
<el-form-item label="因子">
<el-select v-model="form.factor_names" multiple placeholder="选择因子" style="width: 380px">
<el-option v-for="f in factors" :key="f.name" :label="f.name" :value="f.name" />
</el-select>
</el-form-item>
<el-form-item label="标的(≥2,每行一个)">
<el-input v-model="form.symbolsText" type="textarea" :rows="3" placeholder="600000&#10;000001&#10;300750" />
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交分析</el-button>
</el-form-item>
</el-form>
<el-card class="blk" shadow="never" v-loading="loading">
<template #header><span class="section-title">因子与标的</span></template>
<el-form label-width="120px">
<el-form-item label="因子">
<el-select
v-model="form.factor_names"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择因子(可多选,按类分组)"
style="width: 480px"
>
<el-option-group v-for="g in grouped" :key="g.cat" :label="g.cat">
<el-option v-for="f in g.items" :key="f.name" :label="f.name" :value="f.name" />
</el-option-group>
</el-select>
<span class="muted form-hint">已选 {{ form.factor_names.length }} </span>
</el-form-item>
<el-form-item label="标的池">
<el-input
v-model="form.symbolsText"
type="textarea"
:rows="5"
placeholder="每行一个标的代码(≥2)"
style="width: 360px"
/>
<span class="muted form-hint">支持空格/逗号/换行分隔当前 {{ symbolCount }} </span>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">分析区间</span></template>
<el-form label-width="120px">
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
提交分析
</el-button>
</div>
</div>
</template>
<style scoped>
.factor-new { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.form-hint { margin-left: 10px; }
.submit-bar { padding: 4px 0; }
</style>
+77 -19
View File
@@ -22,6 +22,7 @@ const route = useRoute()
const taskId = String(route.params.id)
const loading = ref(true)
const icSummary = ref<Record<string, FactorInfo>>({})
const activeReport = ref('')
const rows = computed(() => {
const out: Array<Record<string, unknown>> = []
@@ -36,14 +37,43 @@ const rows = computed(() => {
const factors = computed(() => Object.keys(icSummary.value))
// 汇总指标卡:所有因子里挑最优 ICIR / 平均 |IC| / 显著数
const summary = computed(() => {
const all = rows.value
if (!all.length) return null
let bestIcir = -Infinity
let bestFactor = '—'
let sumAbsIc = 0
let significant = 0
for (const r of all) {
const icir = typeof r.icir === 'number' ? r.icir : 0
if (icir > bestIcir) { bestIcir = icir; bestFactor = String(r.factor) }
sumAbsIc += Math.abs(typeof r.mean === 'number' ? r.mean : 0)
if (typeof r.t_stat === 'number' && Math.abs(r.t_stat) >= 2) significant++
}
return {
factorCount: factors.value.length,
bestIcir: Number.isFinite(bestIcir) ? bestIcir : null,
bestFactor,
avgAbsIc: sumAbsIc / all.length,
significant,
total: all.length,
}
})
function fmt(v: unknown): string {
if (typeof v === 'number') return (Math.round(v * 10000) / 10000).toString()
return v == null ? '' : String(v)
}
function icClass(v: unknown): string {
if (typeof v !== 'number') return ''
return v >= 0 ? 'up' : 'down'
}
onMounted(async () => {
try {
icSummary.value = (await getIcSummary(taskId)) as Record<string, FactorInfo>
if (factors.value.length) activeReport.value = factors.value[0]
} catch {
ElMessage.error('IC 摘要加载失败')
} finally {
@@ -53,39 +83,67 @@ onMounted(async () => {
</script>
<template>
<div v-loading="loading" class="page">
<div v-loading="loading" class="page factor-result">
<div class="page-head">
<div>
<h2 class="page-title">因子分析结果 <span class="muted">{{ taskId }}</span></h2>
<p class="page-subtitle">IC 统计 / tears 报告</p>
<p class="page-subtitle">IC 横截面统计 · 分层 tears 报告</p>
</div>
</div>
<el-card>
<template #header><span class="section-title">IC 统计</span></template>
<el-table :data="rows" stripe size="small" empty-text=" IC 数据">
<el-table-column prop="factor" label="因子" width="120" />
<!-- 汇总指标卡 -->
<div class="metric-row" v-if="summary">
<div class="metric-card"><div class="m-label">因子数</div><div class="m-value mono">{{ summary.factorCount }}</div></div>
<div class="metric-card"><div class="m-label">最优 ICIR</div><div class="m-value mono up">{{ summary.bestIcir != null ? summary.bestIcir.toFixed(3) : '—' }}</div></div>
<div class="metric-card"><div class="m-label">最优因子</div><div class="m-value" style="font-size:15px">{{ summary.bestFactor }}</div></div>
<div class="metric-card"><div class="m-label">平均 |IC|</div><div class="m-value mono">{{ summary.avgAbsIc.toFixed(4) }}</div></div>
<div class="metric-card"><div class="m-label">显著数 (|t|2)</div><div class="m-value mono">{{ summary.significant }} / {{ summary.total }}</div></div>
</div>
<!-- IC 统计表 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">IC 统计明细</span></template>
<el-table :data="rows" size="small" empty-text=" IC 数据">
<el-table-column prop="factor" label="因子" min-width="140" />
<el-table-column prop="period" label="周期" width="80" />
<el-table-column label="IC 均值">
<template #default="{ row }">{{ fmt(row.mean) }}</template>
<el-table-column label="IC 均值" width="100" align="right">
<template #default="{ row }"><span class="mono" :class="icClass(row.mean)">{{ fmt(row.mean) }}</span></template>
</el-table-column>
<el-table-column label="IC 标准差">
<template #default="{ row }">{{ fmt(row.std) }}</template>
<el-table-column label="IC 标准差" width="100" align="right">
<template #default="{ row }"><span class="mono">{{ fmt(row.std) }}</span></template>
</el-table-column>
<el-table-column label="ICIR">
<template #default="{ row }">{{ fmt(row.icir) }}</template>
<el-table-column label="ICIR" width="90" align="right">
<template #default="{ row }"><span class="mono" :class="icClass(row.icir)">{{ fmt(row.icir) }}</span></template>
</el-table-column>
<el-table-column label="t 统计">
<template #default="{ row }">{{ fmt(row.t_stat) }}</template>
<el-table-column label="t 统计" width="90" align="right">
<template #default="{ row }"><span class="mono" :class="icClass(row.t_stat)">{{ fmt(row.t_stat) }}</span></template>
</el-table-column>
<el-table-column label="样本数">
<template #default="{ row }">{{ row.count }}</template>
<el-table-column label="样本数" width="80" align="right">
<template #default="{ row }"><span class="mono">{{ row.count }}</span></template>
</el-table-column>
</el-table>
</el-card>
<el-card v-for="f in factors" :key="f">
<template #header><span class="section-title">tears 报告 {{ f }}</span></template>
<iframe :src="reportUrl(taskId, f)" style="width: 100%; height: 600px; border: 0" />
<!-- tears 报告 -->
<el-card class="blk" shadow="never" v-if="factors.length">
<template #header><span class="section-title">分层 tears 报告</span></template>
<el-tabs v-model="activeReport" type="card">
<el-tab-pane v-for="f in factors" :key="f" :label="f" :name="f">
<iframe :src="reportUrl(taskId, f)" class="report-frame" />
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<style scoped>
.factor-result { display: flex; flex-direction: column; gap: 16px; }
.metric-row { display: grid; grid-template-columns: repeat(5, 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); }
.report-frame { width: 100%; height: 620px; border: 0; }
</style>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { listPapers, type PaperAccount } from '@/api/paper'
const router = useRouter()
const accounts = ref<PaperAccount[]>([])
const loading = ref(false)
const kw = ref('')
const modeFilter = ref('')
const statusFilter = ref('')
onMounted(refresh)
async function refresh(): Promise<void> {
loading.value = true
try {
accounts.value = await listPapers()
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
const filtered = computed(() =>
accounts.value.filter((a) => {
if (kw.value && !String(a.name + a.id + (a.symbols ?? '')).toLowerCase().includes(kw.value.toLowerCase())) return false
if (modeFilter.value && a.mode !== modeFilter.value) return false
if (statusFilter.value && a.status !== statusFilter.value) return false
return true
}),
)
const stats = computed(() => ({
total: accounts.value.length,
running: accounts.value.filter((a) => a.status === 'running').length,
done: accounts.value.filter((a) => a.status === 'done').length,
failed: accounts.value.filter((a) => a.status === 'failed').length,
}))
const modeLabel: Record<string, string> = { replay: '回放', live: '实走' }
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', created: '已创建', pending: '排队' }
function pct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return (v * 100).toFixed(2) + '%'
}
function num(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '—'
return Math.round(v).toLocaleString('zh-CN')
}
function symbolsShort(s: string | undefined): string {
if (!s) return '—'
const arr = typeof s === 'string' ? s.split(',') : []
return arr.length > 3 ? arr.slice(0, 3).join(',') + ` +${arr.length - 3}` : s
}
function open(a: PaperAccount): void {
router.push(a.mode === 'live' ? `/paper/live/${a.id}` : `/paper/result/${a.id}`)
}
function goNew(): void {
router.push('/paper/new')
}
</script>
<template>
<div class="page paper-list" v-loading="loading">
<div class="page-head">
<div>
<h2 class="page-title">模拟交易 <span class="count-badge">{{ stats.total }}</span></h2>
<p class="page-subtitle">回放 / 实走 · 策略组合模拟盘</p>
</div>
<el-button type="primary" @click="goNew">+ 新建模拟盘</el-button>
</div>
<!-- 统计 -->
<div class="stat-row">
<div class="stat-card"><div class="stat-label">总数</div><div class="stat-value mono">{{ stats.total }}</div></div>
<div class="stat-card"><div class="stat-label">运行中</div><div class="stat-value mono" style="color:var(--brand)">{{ stats.running }}</div></div>
<div class="stat-card"><div class="stat-label">完成</div><div class="stat-value mono up">{{ stats.done }}</div></div>
<div class="stat-card"><div class="stat-label">失败</div><div class="stat-value mono down">{{ stats.failed }}</div></div>
</div>
<!-- 筛选 -->
<div class="filter-row">
<el-input v-model="kw" placeholder="搜索 名称 / ID / 标的" clearable style="width: 240px" />
<el-select v-model="modeFilter" placeholder="全部模式" clearable style="width: 130px">
<el-option label="回放" value="replay" />
<el-option label="实走" value="live" />
</el-select>
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 130px">
<el-option label="运行中" value="running" />
<el-option label="完成" value="done" />
<el-option label="失败" value="failed" />
</el-select>
<el-button link type="primary" @click="refresh">刷新</el-button>
</div>
<el-card class="table-card" shadow="never">
<el-table :data="filtered" size="small" empty-text="暂无模拟盘">
<el-table-column label="名称 / ID" min-width="160">
<template #default="{ row }">
<div class="cell-name">{{ row.name }}</div>
<div class="cell-id mono">#{{ row.id }}</div>
</template>
</el-table-column>
<el-table-column label="模式" width="80">
<template #default="{ row }"><span class="chip chip-paper">{{ modeLabel[row.mode] || row.mode }}</span></template>
</el-table-column>
<el-table-column label="频率" width="70">
<template #default="{ row }"><span class="mono">{{ row.interval }}</span></template>
</el-table-column>
<el-table-column label="标的" min-width="120">
<template #default="{ row }"><span class="mono">{{ symbolsShort(row.symbols) }}</span></template>
</el-table-column>
<el-table-column label="收益率" width="100" align="right">
<template #default="{ row }">
<span class="mono" :class="(row.total_return ?? 0) >= 0 ? 'up' : 'down'">{{ pct(row.total_return) }}</span>
</template>
</el-table-column>
<el-table-column label="最新净值" width="120" align="right">
<template #default="{ row }"><span class="mono">{{ num(row.latest_equity) }}</span></template>
</el-table-column>
<el-table-column label="起止" min-width="180">
<template #default="{ row }"><span class="mono muted">{{ row.start_date }} ~ {{ row.end_date }}</span></template>
</el-table-column>
<el-table-column label="状态" width="84">
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel[row.status] || row.status }}</span></template>
</el-table-column>
<el-table-column label="操作" width="76">
<template #default="{ row }"><el-button link type="primary" @click="open(row)">查看</el-button></template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.paper-list { display: flex; flex-direction: column; gap: 16px; }
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.stat-card { background: var(--bg-card); border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 14px 16px; }
.stat-label { font-size: 12px; color: var(--text-3); }
.stat-value { margin-top: 6px; font-size: 26px; font-weight: 700; color: var(--text); }
.filter-row { display: flex; align-items: center; gap: 10px; }
.table-card { border: 1px solid var(--border-2); }
.cell-name { font-size: 13px; color: var(--text); font-weight: 500; }
.cell-id { font-size: 11px; color: var(--text-3); margin-top: 2px; }
</style>
+105 -40
View File
@@ -16,12 +16,25 @@ const form = ref<PaperCreate>({
end: '2024-06-30',
})
const modes = [
{ value: 'replay', label: '回放', desc: '历史重放,立即出结果' },
{ value: 'live', label: '实走', desc: '每日定时 step,长期跟踪' },
]
const intervals = [
{ value: 'd', label: '日频' },
{ value: '15m', label: '15 分钟' },
]
const sessions = [
{ value: 'next_open', label: '次日开盘', desc: '收盘型信号,T+1 开盘撮合' },
{ value: 'current_close', label: '当日收盘', desc: '尾盘型信号,当日收盘撮合' },
]
async function onSubmit(): Promise<void> {
loading.value = true
try {
const aid = await createPaper(form.value)
ElMessage.success(`已创建模拟盘 #${aid}`)
router.push(`/paper/result/${aid}`)
router.push(form.value.mode === 'live' ? `/paper/live/${aid}` : `/paper/result/${aid}`)
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '创建失败')
} finally {
@@ -35,50 +48,102 @@ function onSymbols(v: string): void {
</script>
<template>
<div class="page">
<div class="page paper-new">
<div class="page-head">
<div>
<h2 class="page-title">新建模拟盘</h2>
<p class="page-subtitle">回放replay或实走live· 支持日频 / 15 分钟</p>
<p class="page-subtitle">回放历史重放/ 实走每日定时· 日频 / 15 分钟</p>
</div>
</div>
<el-card>
<el-form :model="form" label-width="120px">
<el-form-item label="模式">
<el-select v-model="form.mode">
<el-option label="回放(历史重放)" value="replay" />
<el-option label="实走(每日定时)" value="live" />
</el-select>
</el-form-item>
<el-form-item label="频率">
<el-select v-model="form.interval">
<el-option label="日频" value="d" />
<el-option label="15 分钟" value="15m" />
</el-select>
</el-form-item>
<el-form-item label="标的(逗号分隔)">
<el-input :model-value="form.symbols.join(',')" placeholder="600000,000001" @update:model-value="onSymbols" />
</el-form-item>
<el-form-item label="策略名">
<el-input v-model="form.strategies[0].name" />
</el-form-item>
<el-form-item label="撮合时点">
<el-select v-model="form.strategies[0].match_session">
<el-option label="次日开盘(next_open,收盘型)" value="next_open" />
<el-option label="当日收盘(current_close,尾盘型)" value="current_close" />
</el-select>
</el-form-item>
<el-form-item label="起始资金">
<el-input-number v-model="form.initial_capital" :min="10000" :step="100000" />
</el-form-item>
<el-form-item label="区间">
<el-col :span="8"><el-input v-model="form.start" placeholder="YYYY-MM-DD" /></el-col>
<el-col :span="8"><el-input v-model="form.end" placeholder="YYYY-MM-DD" /></el-col>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="onSubmit">创建模拟盘</el-button>
</el-form-item>
</el-form>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">基本配置</span></template>
<div class="seg-label">运行模式</div>
<div class="seg-row">
<div
v-for="m in modes" :key="m.value"
class="seg-card" :class="{ active: form.mode === m.value }"
@click="form.mode = m.value"
>
<div class="seg-title">{{ m.label }}</div>
<div class="seg-desc">{{ m.desc }}</div>
</div>
</div>
<div class="seg-label" style="margin-top:16px">频率</div>
<el-radio-group v-model="form.interval">
<el-radio-button v-for="i in intervals" :key="i.value" :value="i.value">{{ i.label }}</el-radio-button>
</el-radio-group>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">标的与策略</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="标的(逗号分隔)">
<el-input
:model-value="form.symbols.join(',')"
placeholder="600000,000001"
style="width: 360px"
@update:model-value="onSymbols"
/>
<span class="muted form-hint">多个标的用英文逗号分隔</span>
</el-form-item>
<el-form-item label="策略名">
<el-input v-model="form.strategies[0].name" style="width: 280px" />
<span class="muted form-hint">CTA 策略类名 DoubleMaStrategy</span>
</el-form-item>
<el-form-item label="撮合时点">
<el-select v-model="form.strategies[0].match_session" style="width: 280px">
<el-option v-for="s in sessions" :key="s.value" :label="`${s.label}${s.desc}`" :value="s.value" />
</el-select>
</el-form-item>
</el-form>
</el-card>
<el-card class="blk" shadow="never">
<template #header><span class="section-title">资金与区间</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="起始资金">
<el-input-number v-model="form.initial_capital" :min="10000" :step="100000" style="width: 220px" />
<span class="muted form-hint">单位</span>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
</el-form>
</el-card>
<div class="submit-bar">
<el-button type="primary" size="large" :loading="loading" @click="onSubmit">
创建模拟盘
</el-button>
</div>
</div>
</template>
<style scoped>
.paper-new { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.seg-label { font-size: 12px; color: var(--text-3); margin-bottom: 8px; }
.seg-row { display: flex; gap: 12px; }
.seg-card {
flex: 1;
max-width: 240px;
border: 1px solid var(--border-2);
border-radius: var(--r-md);
padding: 12px 14px;
cursor: pointer;
transition: border-color 0.15s var(--ease), background 0.15s var(--ease);
}
.seg-card:hover { border-color: var(--brand); background: var(--bg-hover); }
.seg-card.active { border-color: var(--brand); background: rgba(24, 144, 255, 0.10); }
.seg-title { font-size: 14px; font-weight: 600; color: var(--text); }
.seg-desc { font-size: 12px; color: var(--text-3); margin-top: 4px; }
.form-hint { margin-left: 10px; }
.submit-bar { padding: 4px 0; }
</style>
+171 -55
View File
@@ -1,42 +1,46 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
getEquity, getTrades, getStrategies, getPaper,
type BalancePoint, type PaperTrade, type StrategySummary,
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[]>([])
const status = ref<string>('')
const mode = ref<string>('')
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 = await getPaper(aid)
status.value = acc.status
mode.value = acc.mode
} catch {
/* ignore */
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
}
equity.value = await getEquity(aid)
trades.value = await getTrades(aid)
strategies.value = await getStrategies(aid)
}
function toLive(): void {
router.push(`/paper/live/${aid}`)
}
onMounted(() => {
void load()
timer = setInterval(async () => {
if (status.value === 'done' || status.value === 'failed') {
if (!running.value) {
if (timer) clearInterval(timer)
return
}
@@ -47,67 +51,179 @@ 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: '排队' } 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 class="page">
<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">模式{{ mode || '—' }} · 状态{{ status || '—' }}</p>
<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 class="chip" :class="`st-${account?.status ?? 'pending'}`">{{ statusLabel(account?.status ?? '') }}</span>
<el-button v-if="account?.mode === 'live'" type="primary" @click="toLive">实走监控 </el-button>
</div>
<el-button v-if="mode === 'live'" type="primary" @click="toLive">进入实走监控 </el-button>
</div>
<el-alert
v-if="status === 'running' || status === 'pending' || status === 'created'"
type="info"
:closable="false"
>
回放进行中{{ status }} 3 秒刷新完成自动停止
</el-alert>
<el-alert v-else-if="status === 'failed'" type="error" :closable="false">
回放失败
<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" />
<el-card>
<!-- 指标卡 -->
<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>
<el-table :data="equity" size="small" max-height="240">
<el-table-column prop="date" label="日期" />
<el-table-column prop="total_equity" label="总净值" class-name="num" />
<el-table-column prop="cash" label="现金" class-name="num" />
<el-table-column prop="market_value" label="市值" class-name="num" />
</el-table>
<EquityChart :data="equitySeries" />
</el-card>
<el-card>
<!-- 分策略归因 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">分策略归因</span></template>
<el-table :data="strategies" size="small">
<el-table-column prop="strategy_id" label="策略" />
<el-table-column prop="filled" label="成交" class-name="num" />
<el-table-column prop="rejected" label="拒单" class-name="num" />
<el-table-column prop="commission" label="费用" class-name="num" />
<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>
<template #header><span class="section-title">成交明细拒单高亮</span></template>
<el-table :data="trades" size="small" :row-class-name="rowClass">
<el-table-column prop="bar_date" label="日期" />
<el-table-column prop="strategy_id" label="策略" />
<el-table-column prop="symbol" label="标的" />
<el-table-column prop="direction" label="方向" />
<el-table-column prop="price" label="价格" class-name="num" />
<el-table-column prop="volume" label="数量" class-name="num" />
<el-table-column prop="reject_reason" label="拒因" />
<!-- 成交明细 -->
<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>
:deep(.rejected-row) {
color: var(--danger);
.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>
+28
View File
@@ -80,6 +80,34 @@ def create_paper(req: PaperCreateRequest):
return {"account_id": aid, "status": status}
@router.get("/paper", dependencies=[Depends(verify_token)])
def list_papers():
"""模拟盘列表(启用聚宽级模拟交易列表页)。每行带最新净值 + 收益率。"""
from sanguo_trader.persistence import load_last_balance
db = _db_path["path"]
with sqlite3.connect(db) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM paper_accounts ORDER BY id DESC"
).fetchall()
out = []
for r in rows:
item = dict(r)
cap = item.get("initial_capital") or 0
last = load_last_balance(db, item["id"])
if last and cap:
item["latest_equity"] = last.get("total_equity")
item["latest_date"] = last.get("date")
item["total_return"] = (last.get("total_equity", 0) - cap) / cap
else:
item["latest_equity"] = None
item["latest_date"] = None
item["total_return"] = None
out.append(item)
return {"accounts": out}
@router.get("/paper/{aid}", dependencies=[Depends(verify_token)])
def get_paper(aid: int):
db = _db_path["path"]