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:
@@ -176,3 +176,8 @@ export async function getRiskSeries(taskId: string): Promise<RiskSeriesData> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLog(taskId: string): Promise<string> {
|
||||||
|
const { data } = await apiClient.get<{ log: string }>(`/task/${taskId}/log`)
|
||||||
|
return data.log
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface PaperAccount {
|
|||||||
next_run_at?: string | null
|
next_run_at?: string | null
|
||||||
checkpoint_date?: string | null
|
checkpoint_date?: string | null
|
||||||
error_msg?: string | null
|
error_msg?: string | null
|
||||||
|
/** 列表端点附带的最新净值 / 收益(单账户 GET 不含) */
|
||||||
|
latest_equity?: number | null
|
||||||
|
latest_date?: string | null
|
||||||
|
total_return?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BalancePoint {
|
export interface BalancePoint {
|
||||||
@@ -85,6 +89,11 @@ export async function createPaper(req: PaperCreate): Promise<number> {
|
|||||||
return data.account_id
|
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> {
|
export async function getPaper(aid: number): Promise<PaperAccount> {
|
||||||
const { data } = await apiClient.get<PaperAccount>(`/paper/${aid}`)
|
const { data } = await apiClient.get<PaperAccount>(`/paper/${aid}`)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'factor/progress/:id', name: 'fc-progress', component: () => import('@/views/backtest/Progress.vue') },
|
{ 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: '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/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/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: 'paper/live/:aid', name: 'paper-live', component: () => import('@/views/paper/Live.vue') },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ function onLogout(): void {
|
|||||||
<template #title>
|
<template #title>
|
||||||
<span class="nav-label">模拟</span>
|
<span class="nav-label">模拟</span>
|
||||||
</template>
|
</template>
|
||||||
|
<el-menu-item index="/paper">模拟盘列表</el-menu-item>
|
||||||
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
|
<el-menu-item index="/paper/new">新建模拟盘</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
|
|||||||
@@ -159,21 +159,6 @@ function statusLabel(s: string): string {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.head-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
@@ -193,53 +178,6 @@ function statusLabel(s: string): string {
|
|||||||
.table-card {
|
.table-card {
|
||||||
border: 1px solid var(--border-2);
|
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 {
|
.pager {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -79,25 +79,39 @@ async function onSubmit(): Promise<void> {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page bt-new">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">新建回测</h2>
|
<h2 class="page-title">新建回测</h2>
|
||||||
<p class="page-subtitle">选择策略与标的,提交后进入进度跟踪</p>
|
<p class="page-subtitle">CTA 策略历史回测 · 选择策略与标的,提交后进入进度跟踪</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-card v-loading="loading">
|
|
||||||
|
<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 :model="form" label-width="120px">
|
||||||
<el-form-item label="策略">
|
<el-form-item label="策略">
|
||||||
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 280px">
|
<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-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-for="k in paramsList" :key="k" :label="k">
|
<el-form-item v-if="paramsList.length" label="参数">
|
||||||
<el-input v-model="paramValues[k]" style="width: 220px" />
|
<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-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-form-item label="标的代码">
|
||||||
<el-input v-model="form.symbol" placeholder="如 600000(不带交易所后缀)" style="width: 220px" />
|
<el-input v-model="form.symbol" placeholder="如 600000(不带交易所后缀)" style="width: 240px" />
|
||||||
|
<span class="muted form-hint">A 股代码,无需交易所后缀</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="开始日期">
|
<el-form-item label="开始日期">
|
||||||
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||||
@@ -105,16 +119,38 @@ async function onSubmit(): Promise<void> {
|
|||||||
<el-form-item label="结束日期">
|
<el-form-item label="结束日期">
|
||||||
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="基准">
|
</el-form>
|
||||||
<el-select v-model="form.benchmark" style="width: 220px">
|
</el-card>
|
||||||
<el-option label="沪深300 (hs300)" value="hs300" />
|
|
||||||
<el-option label="中证500 (zz500)" value="zz500" />
|
<el-card class="blk" shadow="never">
|
||||||
</el-select>
|
<template #header><span class="section-title">基准</span></template>
|
||||||
</el-form-item>
|
<el-form :model="form" label-width="120px">
|
||||||
<el-form-item>
|
<el-form-item label="比较基准">
|
||||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交回测</el-button>
|
<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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<div class="submit-bar">
|
||||||
|
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
|
||||||
|
提交回测
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getStrategies, type StrategyItem } from '@/api/strategy'
|
import { getStrategies, type StrategyItem } from '@/api/strategy'
|
||||||
import { submitOptimize, getOptimizationResults, getStatus, type OptRow } from '@/api/backtest'
|
import { submitOptimize, getOptimizationResults, getStatus, type OptRow } from '@/api/backtest'
|
||||||
@@ -43,15 +43,55 @@ function parseGrid(): Record<string, [number, number, number]> | null {
|
|||||||
return grid
|
return grid
|
||||||
}
|
}
|
||||||
|
|
||||||
function statColumns(rows: OptRow[]): string[] {
|
const statColumns = computed(() => {
|
||||||
const set = new Set<string>()
|
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)
|
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 {
|
function fmt(v: unknown): string {
|
||||||
return typeof v === 'number' ? (Math.round(v * 10000) / 10000).toString() : v == null ? '' : String(v)
|
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> {
|
async function onSubmit(): Promise<void> {
|
||||||
const grid = parseGrid()
|
const grid = parseGrid()
|
||||||
@@ -80,6 +120,9 @@ async function onSubmit(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
results.value = await getOptimizationResults(tid)
|
results.value = await getOptimizationResults(tid)
|
||||||
|
// 默认按总收益降序
|
||||||
|
const retKey = statColumns.value.find((k) => /return|收益/i.test(k))
|
||||||
|
if (retKey) { sortKey.value = retKey; sortDir.value = 'desc' }
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('提交失败')
|
ElMessage.error('提交失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -90,17 +133,19 @@ async function onSubmit(): Promise<void> {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page opt-page">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">参数优化</h2>
|
<h2 class="page-title">参数优化</h2>
|
||||||
<p class="page-subtitle">参数网格搜索 · 自动轮询结果</p>
|
<p class="page-subtitle">参数网格搜索 · 自动轮询 · 结果可排序(Top1 高亮)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-card v-loading="loading || polling" :element-loading-text="stageText || '优化中…'">
|
|
||||||
|
<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 :model="form" label-width="120px">
|
||||||
<el-form-item label="策略">
|
<el-form-item label="策略">
|
||||||
<el-select v-model="form.strategy" style="width: 280px">
|
<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-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -108,7 +153,8 @@ async function onSubmit(): Promise<void> {
|
|||||||
<el-input v-model="form.symbol" style="width: 220px" />
|
<el-input v-model="form.symbol" style="width: 220px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="参数网格">
|
<el-form-item label="参数网格">
|
||||||
<el-input v-model="form.gridText" type="textarea" :rows="3" placeholder="每行:name,start,end,step" />
|
<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>
|
||||||
<el-form-item label="开始日期">
|
<el-form-item label="开始日期">
|
||||||
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||||
@@ -120,19 +166,57 @@ async function onSubmit(): Promise<void> {
|
|||||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交优化</el-button>
|
<el-button type="primary" :loading="submitting" @click="onSubmit">提交优化</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-table v-if="results.length" :data="results" stripe size="small" style="margin-top: 16px">
|
<el-card v-if="results.length" class="blk" shadow="never">
|
||||||
<el-table-column label="参数">
|
<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 }">
|
<template #default="{ row }">
|
||||||
<span v-for="(v, k) in row.params" :key="String(k)" style="margin-right: 8px">
|
<span v-for="(v, k) in row.params" :key="String(k)" class="param-pair mono">
|
||||||
{{ k }}={{ fmt(v) }}
|
{{ k }}=<strong>{{ fmt(v) }}</strong>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-for="k in statColumns(results)" :key="k" :label="k">
|
<el-table-column
|
||||||
<template #default="{ row }">{{ fmt(row.statistics?.[k]) }}</template>
|
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-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,55 +1,183 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTask } from '@/composables/useTask'
|
import { useTask } from '@/composables/useTask'
|
||||||
|
import { getLog } from '@/api/backtest'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const taskId = String(route.params.id)
|
const taskId = String(route.params.id)
|
||||||
|
const isFactor = computed(() => route.path.startsWith('/factor'))
|
||||||
|
|
||||||
const { status, stage, start } = useTask(taskId)
|
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) => {
|
watch(status, (s) => {
|
||||||
if (s === 'done') {
|
if (s === 'done') {
|
||||||
const base = route.path.startsWith('/factor') ? '/factor' : '/backtest'
|
const base = isFactor.value ? '/factor' : '/backtest'
|
||||||
router.push(`${base}/result/${taskId}`)
|
router.push(`${base}/result/${taskId}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function pct(): number {
|
// 步骤推导
|
||||||
if (status.value === 'done') return 100
|
interface Step { key: string; label: string }
|
||||||
if (status.value === 'running') return 60
|
const steps: Step[] = [
|
||||||
if (status.value === 'failed') return 100
|
{ key: 'submit', label: '任务提交' },
|
||||||
return 20
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page progress-page">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">任务进行中</h2>
|
<h2 class="page-title">{{ isFactor ? '因子分析' : '回测' }}进行中</h2>
|
||||||
<p class="page-subtitle">任务 ID:{{ taskId }}</p>
|
<p class="page-subtitle">任务 ID:<span class="mono">{{ taskId }}</span></p>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="chip" :class="statusChipClass[status]">{{ statusLabel[status] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-card>
|
|
||||||
<p class="progress-status">
|
<!-- 进度条 -->
|
||||||
状态:
|
<el-card class="blk" shadow="never">
|
||||||
<el-tag :type="status === 'done' ? 'success' : status === 'failed' ? 'danger' : 'warning'">
|
|
||||||
{{ status }}
|
|
||||||
</el-tag>
|
|
||||||
</p>
|
|
||||||
<p class="progress-stage">阶段:{{ stage || '—' }}</p>
|
|
||||||
<el-progress
|
<el-progress
|
||||||
:percentage="pct()"
|
:percentage="pct"
|
||||||
:status="status === 'failed' ? 'exception' : status === 'done' ? 'success' : undefined"
|
: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>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.progress-status { margin-bottom: 8px; }
|
.progress-page { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.progress-stage { margin-bottom: 16px; color: var(--text-2); }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getFactors, submitFactor, type FactorItem } from '@/api/factor'
|
import { getFactors, submitFactor, type FactorItem } from '@/api/factor'
|
||||||
@@ -16,6 +16,20 @@ const form = reactive({
|
|||||||
end: '2024-06-30',
|
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 () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -28,10 +42,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
const symbols = form.symbolsText
|
const symbols = form.symbolsText.split(/[\s,,]+/).map((s) => s.trim()).filter(Boolean)
|
||||||
.split(/[\s,,]+/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
if (!form.factor_names.length || symbols.length < 2) {
|
if (!form.factor_names.length || symbols.length < 2) {
|
||||||
ElMessage.warning('至少选 1 个因子 + 2 个标的(IC 横截面需多标的)')
|
ElMessage.warning('至少选 1 个因子 + 2 个标的(IC 横截面需多标的)')
|
||||||
return
|
return
|
||||||
@@ -55,33 +66,68 @@ async function onSubmit(): Promise<void> {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page factor-new">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">因子分析</h2>
|
<h2 class="page-title">因子分析</h2>
|
||||||
<p class="page-subtitle">IC 横截面分析(≥2 标的)· 生成 tears 报告</p>
|
<p class="page-subtitle">IC 横截面分析(≥2 标的)· 生成分层 tears 报告</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-card v-loading="loading">
|
|
||||||
<el-form label-width="140px">
|
<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-form-item label="因子">
|
||||||
<el-select v-model="form.factor_names" multiple placeholder="选择因子" style="width: 380px">
|
<el-select
|
||||||
<el-option v-for="f in factors" :key="f.name" :label="f.name" :value="f.name" />
|
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>
|
</el-select>
|
||||||
|
<span class="muted form-hint">已选 {{ form.factor_names.length }} 个</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标的(≥2,每行一个)">
|
<el-form-item label="标的池">
|
||||||
<el-input v-model="form.symbolsText" type="textarea" :rows="3" placeholder="600000 000001 300750" />
|
<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-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-form-item label="开始日期">
|
||||||
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="结束日期">
|
<el-form-item label="结束日期">
|
||||||
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交分析</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<div class="submit-bar">
|
||||||
|
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
|
||||||
|
提交分析
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const route = useRoute()
|
|||||||
const taskId = String(route.params.id)
|
const taskId = String(route.params.id)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const icSummary = ref<Record<string, FactorInfo>>({})
|
const icSummary = ref<Record<string, FactorInfo>>({})
|
||||||
|
const activeReport = ref('')
|
||||||
|
|
||||||
const rows = computed(() => {
|
const rows = computed(() => {
|
||||||
const out: Array<Record<string, unknown>> = []
|
const out: Array<Record<string, unknown>> = []
|
||||||
@@ -36,14 +37,43 @@ const rows = computed(() => {
|
|||||||
|
|
||||||
const factors = computed(() => Object.keys(icSummary.value))
|
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 {
|
function fmt(v: unknown): string {
|
||||||
if (typeof v === 'number') return (Math.round(v * 10000) / 10000).toString()
|
if (typeof v === 'number') return (Math.round(v * 10000) / 10000).toString()
|
||||||
return v == null ? '' : String(v)
|
return v == null ? '' : String(v)
|
||||||
}
|
}
|
||||||
|
function icClass(v: unknown): string {
|
||||||
|
if (typeof v !== 'number') return ''
|
||||||
|
return v >= 0 ? 'up' : 'down'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
icSummary.value = (await getIcSummary(taskId)) as Record<string, FactorInfo>
|
icSummary.value = (await getIcSummary(taskId)) as Record<string, FactorInfo>
|
||||||
|
if (factors.value.length) activeReport.value = factors.value[0]
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('IC 摘要加载失败')
|
ElMessage.error('IC 摘要加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,39 +83,67 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-loading="loading" class="page">
|
<div v-loading="loading" class="page factor-result">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">因子分析结果 <span class="muted">{{ taskId }}</span></h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<el-card>
|
|
||||||
<template #header><span class="section-title">IC 统计</span></template>
|
<!-- 汇总指标卡 -->
|
||||||
<el-table :data="rows" stripe size="small" empty-text="无 IC 数据">
|
<div class="metric-row" v-if="summary">
|
||||||
<el-table-column prop="factor" label="因子" width="120" />
|
<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 prop="period" label="周期" width="80" />
|
||||||
<el-table-column label="IC 均值">
|
<el-table-column label="IC 均值" width="100" align="right">
|
||||||
<template #default="{ row }">{{ fmt(row.mean) }}</template>
|
<template #default="{ row }"><span class="mono" :class="icClass(row.mean)">{{ fmt(row.mean) }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="IC 标准差">
|
<el-table-column label="IC 标准差" width="100" align="right">
|
||||||
<template #default="{ row }">{{ fmt(row.std) }}</template>
|
<template #default="{ row }"><span class="mono">{{ fmt(row.std) }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="ICIR">
|
<el-table-column label="ICIR" width="90" align="right">
|
||||||
<template #default="{ row }">{{ fmt(row.icir) }}</template>
|
<template #default="{ row }"><span class="mono" :class="icClass(row.icir)">{{ fmt(row.icir) }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="t 统计">
|
<el-table-column label="t 统计" width="90" align="right">
|
||||||
<template #default="{ row }">{{ fmt(row.t_stat) }}</template>
|
<template #default="{ row }"><span class="mono" :class="icClass(row.t_stat)">{{ fmt(row.t_stat) }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="样本数">
|
<el-table-column label="样本数" width="80" align="right">
|
||||||
<template #default="{ row }">{{ row.count }}</template>
|
<template #default="{ row }"><span class="mono">{{ row.count }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card v-for="f in factors" :key="f">
|
<!-- tears 报告 -->
|
||||||
<template #header><span class="section-title">tears 报告 — {{ f }}</span></template>
|
<el-card class="blk" shadow="never" v-if="factors.length">
|
||||||
<iframe :src="reportUrl(taskId, f)" style="width: 100%; height: 600px; border: 0" />
|
<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>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -16,12 +16,25 @@ const form = ref<PaperCreate>({
|
|||||||
end: '2024-06-30',
|
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> {
|
async function onSubmit(): Promise<void> {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const aid = await createPaper(form.value)
|
const aid = await createPaper(form.value)
|
||||||
ElMessage.success(`已创建模拟盘 #${aid}`)
|
ElMessage.success(`已创建模拟盘 #${aid}`)
|
||||||
router.push(`/paper/result/${aid}`)
|
router.push(form.value.mode === 'live' ? `/paper/live/${aid}` : `/paper/result/${aid}`)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
ElMessage.error(e instanceof Error ? e.message : '创建失败')
|
ElMessage.error(e instanceof Error ? e.message : '创建失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -35,50 +48,102 @@ function onSymbols(v: string): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page paper-new">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">新建模拟盘</h2>
|
<h2 class="page-title">新建模拟盘</h2>
|
||||||
<p class="page-subtitle">回放(replay)或实走(live)· 支持日频 / 15 分钟</p>
|
<p class="page-subtitle">回放(历史重放)/ 实走(每日定时)· 日频 / 15 分钟</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-card>
|
|
||||||
|
<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 :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-form-item label="标的(逗号分隔)">
|
||||||
<el-input :model-value="form.symbols.join(',')" placeholder="600000,000001" @update:model-value="onSymbols" />
|
<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>
|
||||||
<el-form-item label="策略名">
|
<el-form-item label="策略名">
|
||||||
<el-input v-model="form.strategies[0].name" />
|
<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>
|
||||||
<el-form-item label="撮合时点">
|
<el-form-item label="撮合时点">
|
||||||
<el-select v-model="form.strategies[0].match_session">
|
<el-select v-model="form.strategies[0].match_session" style="width: 280px">
|
||||||
<el-option label="次日开盘(next_open,收盘型)" value="next_open" />
|
<el-option v-for="s in sessions" :key="s.value" :label="`${s.label}(${s.desc})`" :value="s.value" />
|
||||||
<el-option label="当日收盘(current_close,尾盘型)" value="current_close" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</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-form>
|
||||||
</el-card>
|
</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>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,42 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
getEquity, getTrades, getStrategies, getPaper,
|
getPaper, getEquity, getTrades, getStrategies,
|
||||||
type BalancePoint, type PaperTrade, type StrategySummary,
|
type BalancePoint, type PaperTrade, type StrategySummary, type PaperAccount,
|
||||||
} from '@/api/paper'
|
} from '@/api/paper'
|
||||||
|
import type { EquityPoint } from '@/api/backtest'
|
||||||
|
import EquityChart from '@/components/charts/EquityChart.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const aid = Number(route.params.id)
|
const aid = Number(route.params.id)
|
||||||
|
const loading = ref(true)
|
||||||
|
const account = ref<PaperAccount | null>(null)
|
||||||
const equity = ref<BalancePoint[]>([])
|
const equity = ref<BalancePoint[]>([])
|
||||||
const trades = ref<PaperTrade[]>([])
|
const trades = ref<PaperTrade[]>([])
|
||||||
const strategies = ref<StrategySummary[]>([])
|
const strategies = ref<StrategySummary[]>([])
|
||||||
const status = ref<string>('')
|
|
||||||
const mode = ref<string>('')
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
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> {
|
async function load(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const acc = await getPaper(aid)
|
const [acc, eq, tr, st] = await Promise.all([
|
||||||
status.value = acc.status
|
getPaper(aid), getEquity(aid), getTrades(aid), getStrategies(aid),
|
||||||
mode.value = acc.mode
|
])
|
||||||
} catch {
|
account.value = acc
|
||||||
/* ignore */
|
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(() => {
|
onMounted(() => {
|
||||||
void load()
|
void load()
|
||||||
timer = setInterval(async () => {
|
timer = setInterval(async () => {
|
||||||
if (status.value === 'done' || status.value === 'failed') {
|
if (!running.value) {
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -47,67 +51,179 @@ onUnmounted(() => {
|
|||||||
if (timer) clearInterval(timer)
|
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 {
|
function rowClass({ row }: { row: PaperTrade }): string {
|
||||||
return row.rejected ? 'rejected-row' : ''
|
return row.rejected ? 'rejected-row' : ''
|
||||||
}
|
}
|
||||||
|
function toLive(): void {
|
||||||
|
router.push(`/paper/live/${aid}`)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div v-loading="loading" class="page paper-result">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">模拟盘结果 <span class="muted">#{{ aid }}</span></h2>
|
<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>
|
</div>
|
||||||
<el-button v-if="mode === 'live'" type="primary" @click="toLive">进入实走监控 →</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-alert
|
<el-alert v-if="running" type="info" :closable="false">
|
||||||
v-if="status === 'running' || status === 'pending' || status === 'created'"
|
回放进行中({{ account?.status }})…每 3 秒刷新,完成自动停止
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
>
|
|
||||||
回放进行中({{ status }})…每 3 秒刷新,完成自动停止
|
|
||||||
</el-alert>
|
|
||||||
<el-alert v-else-if="status === 'failed'" type="error" :closable="false">
|
|
||||||
回放失败
|
|
||||||
</el-alert>
|
</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>
|
<template #header><span class="section-title">账户净值</span></template>
|
||||||
<el-table :data="equity" size="small" max-height="240">
|
<EquityChart :data="equitySeries" />
|
||||||
<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>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card>
|
|
||||||
|
<!-- 分策略归因 -->
|
||||||
|
<el-card class="blk" shadow="never">
|
||||||
<template #header><span class="section-title">分策略归因</span></template>
|
<template #header><span class="section-title">分策略归因</span></template>
|
||||||
<el-table :data="strategies" size="small">
|
<el-table :data="strategies" size="small" empty-text="无策略数据">
|
||||||
<el-table-column prop="strategy_id" label="策略" />
|
<el-table-column prop="strategy_id" label="策略" min-width="160" />
|
||||||
<el-table-column prop="filled" label="成交" class-name="num" />
|
<el-table-column prop="total_orders" label="总委托" width="90" class-name="num" />
|
||||||
<el-table-column prop="rejected" label="拒单" class-name="num" />
|
<el-table-column prop="filled" label="成交" width="80" class-name="num" />
|
||||||
<el-table-column prop="commission" label="费用" 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-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card>
|
|
||||||
<template #header><span class="section-title">成交明细(拒单高亮)</span></template>
|
<!-- 成交明细 -->
|
||||||
<el-table :data="trades" size="small" :row-class-name="rowClass">
|
<el-card class="blk" shadow="never">
|
||||||
<el-table-column prop="bar_date" label="日期" />
|
<template #header><span class="section-title">成交明细 <em class="muted">(拒单标红)</em></span></template>
|
||||||
<el-table-column prop="strategy_id" label="策略" />
|
<el-table :data="trades" size="small" :row-class-name="rowClass" max-height="420" empty-text="无成交">
|
||||||
<el-table-column prop="symbol" label="标的" />
|
<el-table-column prop="bar_date" label="时间" min-width="150" />
|
||||||
<el-table-column prop="direction" label="方向" />
|
<el-table-column prop="strategy_id" label="策略" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="price" label="价格" class-name="num" />
|
<el-table-column prop="symbol" label="标的" width="90" />
|
||||||
<el-table-column prop="volume" label="数量" class-name="num" />
|
<el-table-column label="方向" width="70">
|
||||||
<el-table-column prop="reject_reason" label="拒因" />
|
<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-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
:deep(.rejected-row) {
|
.paper-result { display: flex; flex-direction: column; gap: 16px; }
|
||||||
color: var(--danger);
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -80,6 +80,34 @@ def create_paper(req: PaperCreateRequest):
|
|||||||
return {"account_id": aid, "status": status}
|
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)])
|
@router.get("/paper/{aid}", dependencies=[Depends(verify_token)])
|
||||||
def get_paper(aid: int):
|
def get_paper(aid: int):
|
||||||
db = _db_path["path"]
|
db = _db_path["path"]
|
||||||
|
|||||||
Reference in New Issue
Block a user