feat: 实盘模拟(live) + 组合回测MVP(portfolio)
[live] 实盘模拟 vnpy+miniQMT 直连(supervisor 轮询, 前后端): - sanguo_live: LiveTradingEngine + AShareCtaTemplate(定寸/禁做空) + runner_supervisor(DB驱动) + persistence(4表WAL) - sanguo_api/routes_live: 9路由(create/start/stop/positions/trades/account/status) - frontend live: New/List/Monitor + api/live.ts; config/live.yaml [portfolio] 组合回测 MVP(BulletTrade, 链路代码完成待验证): - runner_backtest 加 JSON 入口(--json, BacktestEngine 顶层 import) - sanguo_api/routes_portfolio: POST /portfolio/backtest SSH 触发 VPS 跑 - frontend PortfolioBacktest.vue + api/portfolio.ts: 表单+结果+净值曲线 - 路由/菜单注册(/backtest/portfolio 组合回测) - 已知: MVP 链路未端到端验证, agent 改至中途被停; 待 Mac 起服务联调
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { EChartsCoreOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
|
||||
import {
|
||||
postPortfolioBacktest,
|
||||
type PortfolioBacktestResult,
|
||||
type EquityPoint,
|
||||
type StockPicked,
|
||||
type PortfolioTrade,
|
||||
type PortfolioMetrics,
|
||||
} from '@/api/portfolio'
|
||||
|
||||
const submitting = ref(false)
|
||||
const result = ref<PortfolioBacktestResult | null>(null)
|
||||
const equityCurve = ref<EquityPoint[]>([])
|
||||
const stocks = ref<StockPicked[]>([])
|
||||
const trades = ref<PortfolioTrade[]>([])
|
||||
const metrics = ref<PortfolioMetrics | null>(null)
|
||||
const period = ref<{ start: string; end: string; trading_days: number } | null>(null)
|
||||
const errorMsg = ref('')
|
||||
|
||||
const form = reactive({
|
||||
pool: 'hs300_subset',
|
||||
start: '2024-01-01',
|
||||
end: '2024-02-29',
|
||||
cash: 1_000_000,
|
||||
benchmark: '000300.XSHG',
|
||||
})
|
||||
|
||||
const poolOptions = [
|
||||
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
|
||||
{ label: '全市场(慢,非 MVP)', value: 'all' },
|
||||
]
|
||||
|
||||
// 净值曲线
|
||||
const equityEl = ref<HTMLDivElement>()
|
||||
const { setOption: setEquityOption } = useChart(equityEl)
|
||||
function renderEquity(): void {
|
||||
if (!equityCurve.value.length) return
|
||||
const dates = equityCurve.value.map((p) => p.date)
|
||||
const values = equityCurve.value.map((p) => p.equity)
|
||||
const option: EChartsCoreOption = {
|
||||
title: darkTitle('净值曲线'),
|
||||
tooltip: darkTooltip(),
|
||||
grid: darkGrid(),
|
||||
xAxis: { type: 'category', data: dates, ...darkAxis() },
|
||||
yAxis: { type: 'value', scale: true, name: '净值(元)', ...darkAxis() },
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: '策略净值',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#c23531', width: 1.6 },
|
||||
areaStyle: { color: '#c23531', opacity: 0.12 },
|
||||
data: values,
|
||||
},
|
||||
],
|
||||
}
|
||||
setEquityOption(option)
|
||||
}
|
||||
onMounted(() => {
|
||||
// 等 dom 挂载后渲染(若有初始数据)
|
||||
})
|
||||
watch(equityCurve, renderEquity, { deep: true })
|
||||
|
||||
function fmtPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
return `${v.toFixed(2)}%`
|
||||
}
|
||||
function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
return v.toFixed(digits)
|
||||
}
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
submitting.value = true
|
||||
errorMsg.value = ''
|
||||
result.value = null
|
||||
equityCurve.value = []
|
||||
stocks.value = []
|
||||
trades.value = []
|
||||
metrics.value = null
|
||||
try {
|
||||
const r = await postPortfolioBacktest({
|
||||
pool: form.pool,
|
||||
start_date: form.start,
|
||||
end_date: form.end,
|
||||
initial_cash: form.cash,
|
||||
benchmark: form.benchmark,
|
||||
})
|
||||
result.value = r
|
||||
equityCurve.value = r.equity_curve || []
|
||||
stocks.value = r.stocks_selected || []
|
||||
trades.value = r.trades || []
|
||||
metrics.value = r.metrics || null
|
||||
period.value = r.period || null
|
||||
ElMessage.success(
|
||||
`回测完成: ${r.period?.trading_days ?? 0} 交易日, 选股 ${(r.stocks_selected || []).length} 只`,
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { detail?: string } }; message?: string }
|
||||
errorMsg.value = err.response?.data?.detail || err.message || '提交失败'
|
||||
ElMessage.error('回测失败,详见页面提示')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page bt-portfolio">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2 class="page-title">组合策略回测</h2>
|
||||
<p class="page-subtitle">
|
||||
BulletTrade + 全天候轮动策略 · 触发 VPS 跑回测 · MVP 验证链路
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-select v-model="form.pool" style="width: 320px">
|
||||
<el-option
|
||||
v-for="opt in poolOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="muted form-hint">MVP 默认 HS300 子集(20-30 只),快速验证链路</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 label="初始资金">
|
||||
<el-input-number
|
||||
v-model="form.cash"
|
||||
:min="10000"
|
||||
:step="100000"
|
||||
:controls="false"
|
||||
style="width: 220px"
|
||||
/>
|
||||
<span class="muted form-hint">元</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="基准">
|
||||
<el-input v-model="form.benchmark" style="width: 220px" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="submit-bar">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitting ? '回测中(VPS 执行,请等待...)' : '开始回测' }}
|
||||
</el-button>
|
||||
<span v-if="submitting" class="muted form-hint">
|
||||
最长 600s,期间请勿关闭页面
|
||||
</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
type="error"
|
||||
:title="`回测失败:${errorMsg}`"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="blk"
|
||||
/>
|
||||
|
||||
<template v-if="result">
|
||||
<!-- 指标卡片 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header>
|
||||
<span class="section-title">关键指标</span>
|
||||
<span class="muted period">
|
||||
({{ period?.start }} ~ {{ period?.end }} · {{ period?.trading_days ?? 0 }} 交易日)
|
||||
</span>
|
||||
</template>
|
||||
<div class="metric-row">
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">总收益</div>
|
||||
<div class="metric-value" :class="(metrics?.total_return ?? 0) >= 0 ? 'up' : 'down'">
|
||||
{{ fmtPct(metrics?.total_return) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">年化收益</div>
|
||||
<div class="metric-value" :class="(metrics?.annual_return ?? 0) >= 0 ? 'up' : 'down'">
|
||||
{{ fmtPct(metrics?.annual_return) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">最大回撤</div>
|
||||
<div class="metric-value down">{{ fmtPct(metrics?.max_drawdown) }}</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">夏普比率</div>
|
||||
<div class="metric-value">{{ fmtNum(metrics?.sharpe) }}</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">日胜率</div>
|
||||
<div class="metric-value">{{ fmtPct(metrics?.win_rate_daily) }}</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">交易胜率</div>
|
||||
<div class="metric-value">{{ fmtPct(metrics?.win_rate_trade) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 净值曲线 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">净值曲线</span></template>
|
||||
<div ref="equityEl" class="chart-box" />
|
||||
</el-card>
|
||||
|
||||
<!-- 选股名单 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header>
|
||||
<span class="section-title">选股名单(末日持仓)</span>
|
||||
</template>
|
||||
<el-table :data="stocks" stripe size="small" empty-text="无持仓数据">
|
||||
<el-table-column label="代码" prop="code" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" prop="amount" align="right" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ row.amount.toFixed(0) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="均价" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ row.avg_cost.toFixed(3) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="现价" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ row.price.toFixed(3) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="市值" align="right" width="160">
|
||||
<template #default="{ row }"><span class="mono">{{ row.value.toFixed(2) }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 成交明细 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">成交明细({{ trades.length }} 条)</span></template>
|
||||
<el-table :data="trades" stripe size="small" max-height="500" empty-text="无成交">
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }"><span class="mono">{{ row.datetime || row.date || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="代码" prop="code" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ row.code || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方向" width="100">
|
||||
<template #default="{ row }">{{ row.side || row.action || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ (row.filled_amount ?? row.amount ?? 0).toFixed(0) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ (row.filled_price ?? row.price ?? 0).toFixed(3) }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bt-portfolio { display: flex; flex-direction: column; gap: 16px; }
|
||||
.blk { border: 1px solid var(--border-2); }
|
||||
.section-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.period { margin-left: 12px; font-size: 12px; }
|
||||
|
||||
.form-hint { margin-left: 10px; }
|
||||
.submit-bar { padding: 4px 0 8px; }
|
||||
|
||||
.chart-box { width: 100%; height: 360px; }
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.metric-cell {
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.metric-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
|
||||
.metric-value { font-size: 20px; font-weight: 600; color: var(--text); font-family: var(--mono); }
|
||||
.metric-value.up { color: #f56c6c; }
|
||||
.metric-value.down { color: #67c23a; }
|
||||
/* A 股惯例涨红跌绿;回撤是负值用绿色(表示"少亏方向")但习惯 down class 显绿 */
|
||||
|
||||
.muted { color: var(--text-3); font-size: 13px; }
|
||||
.mono { font-family: var(--mono); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user