feat(backtest): 参数网格寻优(optimizer + 前端寻优页)

对单个策略的 1-2 个参数做网格搜索,遍历用户指定的取值列表笛卡尔积,
每个组合跑一次回测,按 total_return 排序,返回排名表 + 热力图。

后端:
- ParamGridOptimizer(backtest/optimizer.py):itertools.product 遍历网格,
  每点 entry.build(params) + BacktestEngine.run(df),复用同一 DataFrame
- 网格大小上限 200 防组合爆炸,单点失败容错(跳过不中断)
- 2 参数时生成热力图矩阵(x/y 轴取值 + cell 收益率)
- POST /backtest/optimize/run/async 端点(后台任务)
- OptimizeBacktestRequest schema(param_grid 1-2 参数)

前端(/optimize 寻优页):
- ParamGridPicker:勾选 1-2 个寻优参数,逗号分隔填取值列表
- OptimizeResultTable:网格点排名表(按收益降序,最优高亮)
- OptimizeHeatmap:2 参数热力图(ECharts heatmap,绿→红映射收益)
- 最优点「查看」按钮跳转单标的页用该参数回测

测试:821 passed(+10 寻优器单测 + 3 寻优路由测试)
This commit is contained in:
Justin Gu
2026-07-03 03:55:35 +08:00
parent 6aab7a82b5
commit 87fafe9131
15 changed files with 1284 additions and 6 deletions
+1
View File
@@ -9,6 +9,7 @@
<nav class="app-nav">
<RouterLink to="/" active-class="active">单标的回测</RouterLink>
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
</nav>
</header>
<main class="app-main">
+14
View File
@@ -7,6 +7,7 @@ import type {
BacktestResult,
Bar,
Category,
OptimizeBacktestRequest,
PortfolioBacktestRequest,
StrategiesResponse,
TaskState,
@@ -124,6 +125,19 @@ export async function submitPortfolioTask(
return (await resp.json()) as TaskSubmitResponse
}
/** 提交参数网格寻优后台任务,返回 task_id。 */
export async function submitOptimizeTask(
req: OptimizeBacktestRequest,
): Promise<TaskSubmitResponse> {
const resp = await fetch(`${BASE}/backtest/optimize/run/async`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
})
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskSubmitResponse
}
/** 查询后台任务状态(轮询用)。 */
export async function fetchTask(taskId: string): Promise<TaskState> {
const resp = await fetch(`${BASE}/backtest/tasks/${taskId}`)
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
// 2 参数寻优热力图(ECharts heatmap)。x=参数1取值,y=参数2取值,cell=total_return。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts from '../echarts-setup'
import type { OptimizeHeatmap } from '../types'
const props = defineProps<{
heatmap: OptimizeHeatmap
}>()
const container = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render() {
if (!container.value) return
chart ??= echarts.init(container.value, 'dark')
chart.setOption(buildOption(), true)
}
function buildOption(): echarts.EChartsCoreOption {
const { x, y, data, x_name, y_name } = props.heatmap
// 计算 visualMap 范围
const values = data.map((d) => d[2]).filter((v): v is number => v !== null)
const min = values.length ? Math.min(...values) : 0
const max = values.length ? Math.max(...values) : 1
return {
backgroundColor: 'transparent',
tooltip: {
position: 'top',
formatter: (p: { data: [number, number, number | null] }) => {
const xv = x[p.data[0]]
const yv = y[p.data[1]]
const ret = p.data[2]
const retStr = ret !== null ? `${(ret * 100).toFixed(2)}%` : '-'
return `${x_name}=${xv}, ${y_name}=${yv}<br/>收益: ${retStr}`
},
},
grid: { left: '12%', right: '5%', top: 20, bottom: 60 },
xAxis: { type: 'category', data: x.map(String), name: x_name, splitArea: { show: true } },
yAxis: { type: 'category', data: y.map(String), name: y_name, splitArea: { show: true } },
visualMap: {
min,
max,
calculable: true,
orient: 'horizontal',
left: 'center',
bottom: 0,
formatter: (v: number) => `${(v * 100).toFixed(0)}%`,
inRange: { color: ['#18a058', '#2a2e3a', '#ef4146'] }, // 绿(低)→暗→红(高)
},
series: [
{
type: 'heatmap',
data,
label: { show: false },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } },
},
],
}
}
function resize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
watch(() => props.heatmap, render)
</script>
<template>
<div ref="container" class="heatmap-chart"></div>
</template>
<style scoped>
.heatmap-chart {
width: 100%;
height: 380px;
}
</style>
@@ -0,0 +1,101 @@
<script setup lang="ts">
// 网格点排名表,按 total_return 降序,最优高亮。
import type { GridPointResult } from '../types'
defineProps<{
results: GridPointResult[]
bestIndex?: number
}>()
defineEmits<{ select: [params: Record<string, number | string>] }>()
function pct(v: number | null): string {
return v !== null && Number.isFinite(v) ? `${(v * 100).toFixed(2)}%` : '-'
}
function num(v: number | null, d = 2): string {
return v !== null && Number.isFinite(v) ? v.toFixed(d) : '-'
}
</script>
<template>
<table class="opt-table">
<thead>
<tr>
<th>#</th>
<th>参数</th>
<th class="num">总收益</th>
<th class="num">夏普</th>
<th class="num">最大回撤</th>
<th class="num">交易数</th>
<th class="num">胜率</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in results" :key="i" :class="{ best: i === bestIndex }">
<td class="rank">{{ i + 1 }}</td>
<td class="params">{{ JSON.stringify(r.params) }}</td>
<td class="num" :class="r.total_return !== null && r.total_return > 0 ? 'pos' : 'neg'">
{{ pct(r.total_return) }}
</td>
<td class="num">{{ num(r.sharpe) }}</td>
<td class="num neg">{{ pct(r.max_drawdown) }}</td>
<td class="num">{{ r.total_trades }}</td>
<td class="num">{{ pct(r.win_rate) }}</td>
<td><button class="view-btn" @click="$emit('select', r.params)">查看</button></td>
</tr>
</tbody>
</table>
</template>
<style scoped>
.opt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.opt-table th,
.opt-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
}
.opt-table th {
color: var(--text-dim);
font-size: 12px;
position: sticky;
top: 0;
background: var(--bg-panel);
}
.num {
text-align: right;
font-family: var(--font-mono);
}
.params {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
.rank {
color: var(--text-dim);
width: 32px;
}
.best {
background: rgba(74, 158, 255, 0.08);
}
.best .rank {
color: var(--accent);
font-weight: 700;
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
.view-btn {
font-size: 11px;
padding: 2px 8px;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
// 寻优参数选择:从策略参数里勾选 1-2 个,各填取值列表(逗号分隔)。
import { computed, ref, watch } from 'vue'
import type { StrategySchema } from '../types'
const props = defineProps<{
strategy: StrategySchema | null
modelValue: Record<string, Array<number | string>>
}>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, Array<number | string>>] }>()
// 每个参数的取值输入框原始文本
const inputs = ref<Record<string, string>>({})
// 选中要寻优的参数
const selected = ref<Set<string>>(new Set())
function toggle(name: string) {
if (selected.value.has(name)) {
selected.value.delete(name)
} else {
if (selected.value.size >= 2) return // 最多 2 个
selected.value.add(name)
}
// 触发响应式
selected.value = new Set(selected.value)
syncOutputs()
}
function syncOutputs() {
const out: Record<string, Array<number | string>> = {}
for (const name of selected.value) {
const raw = inputs.value[name] ?? ''
out[name] = raw
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const n = Number(s)
return Number.isFinite(n) ? n : s
})
}
emit('update:modelValue', out)
}
function onInput(name: string, val: string) {
inputs.value[name] = val
syncOutputs()
}
// 切换策略时清空选择
watch(
() => props.strategy?.name,
() => {
selected.value = new Set()
inputs.value = {}
syncOutputs()
},
)
const gridPoints = computed(() => {
const sizes = Array.from(selected.value).map((n) => {
const raw = inputs.value[n] ?? ''
return raw.split(/[,\s]+/).filter((s) => s.trim()).length
})
return sizes.reduce((a, b) => a * b, 1)
})
</script>
<template>
<div class="grid-picker">
<p class="hint">勾选 1-2 个参数寻优填入取值列表逗号分隔</p>
<div v-for="p in strategy?.params" :key="p.name" class="param-row">
<label class="check">
<input
type="checkbox"
:checked="selected.has(p.name)"
:disabled="!selected.has(p.name) && selected.size >= 2"
@change="toggle(p.name)"
/>
<span>{{ p.label }}{{ p.name }}</span>
</label>
<input
v-if="selected.has(p.name)"
:value="inputs[p.name] ?? ''"
:placeholder="`如 ${p.default}, ${p.default}, ...`"
class="values-input"
@input="onInput(p.name, ($event.target as HTMLInputElement).value)"
/>
</div>
<p class="grid-size">网格点数{{ gridPoints }}上限 200</p>
</div>
</template>
<style scoped>
.hint {
color: var(--text-muted);
font-size: 12px;
margin-bottom: 10px;
}
.param-row {
margin-bottom: 10px;
}
.check {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text);
margin-bottom: 4px;
}
.check input[type='checkbox'] {
width: auto;
}
.values-input {
margin-top: 4px;
font-family: var(--font-mono);
}
.grid-size {
color: var(--text-dim);
font-size: 11px;
margin-top: 4px;
}
</style>
+6 -3
View File
@@ -1,8 +1,8 @@
// ECharts 按需引入。只注册 MVP-A 用到的图表类型,避免全量引入(~1MB → ~400KB)。
// 用到的:candlestickK线)、line(净值/回撤曲线)、markPoint(买卖点标注)。
// ECharts 按需引入。只注册用到的图表类型,避免全量引入(~1MB → ~400KB)。
// 用到的:candlestickK线)、line(净值/回撤曲线)、markPoint(买卖点标注)、heatmap(寻优热力图)
import * as echarts from 'echarts/core'
import { BarChart, CandlestickChart, LineChart } from 'echarts/charts'
import { BarChart, CandlestickChart, HeatmapChart, LineChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
@@ -10,6 +10,7 @@ import {
MarkPointComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
@@ -18,12 +19,14 @@ echarts.use([
CandlestickChart,
LineChart,
BarChart,
HeatmapChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
MarkPointComponent,
VisualMapComponent,
])
// A股惯例:红涨绿跌
+3 -1
View File
@@ -1,12 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
import BacktestView from './views/BacktestView.vue'
import OptimizeView from './views/OptimizeView.vue'
import PortfolioView from './views/PortfolioView.vue'
// 单标的回测(/+ 组合回测(/portfolio参数寻优/结果对比留待 Phase 4-5
// 单标的回测(/+ 组合回测(/portfolio+ 参数寻优/optimize
const routes = [
{ path: '/', name: 'backtest', component: BacktestView },
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
{ path: '/optimize', name: 'optimize', component: OptimizeView },
]
export const router = createRouter({
+46 -1
View File
@@ -4,13 +4,22 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { fetchStrategies, formatError, runBacktest, submitPortfolioTask, fetchTask } from '../api'
import {
fetchStrategies,
formatError,
runBacktest,
submitPortfolioTask,
submitOptimizeTask,
fetchTask,
} from '../api'
import type {
BacktestRequest,
BacktestResult,
Bar,
PortfolioBacktestRequest,
PortfolioResult,
OptimizeBacktestRequest,
OptimizeResult,
StrategySchema,
} from '../types'
@@ -105,6 +114,39 @@ export const useBacktestStore = defineStore('backtest', () => {
error.value = ''
}
// ── 参数网格寻优(Phase 4) ─────────────────────────────────────────────
const optimizeResult = ref<OptimizeResult | null>(null)
const optimizeRunning = ref(false)
/** 提交寻优后台任务并轮询直到完成。 */
async function runOptimize(req: OptimizeBacktestRequest) {
optimizeRunning.value = true
error.value = ''
optimizeResult.value = null
try {
const { task_id } = await submitOptimizeTask(req)
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
const state = await fetchTask(task_id)
if (state.status === 'done' && state.result) {
optimizeResult.value = state.result as OptimizeResult
break
}
if (state.status === 'failed') {
throw new Error(state.error || '寻优失败')
}
if (Date.now() - start > 180_000) throw new Error('寻优超时(180s')
await new Promise((r) => setTimeout(r, 400))
}
} catch (e) {
error.value = formatError(e)
optimizeResult.value = null
} finally {
optimizeRunning.value = false
}
}
return {
// state
strategies,
@@ -116,6 +158,8 @@ export const useBacktestStore = defineStore('backtest', () => {
error,
portfolioResult,
portfolioRunning,
optimizeResult,
optimizeRunning,
// getters
hasBars,
// actions
@@ -125,5 +169,6 @@ export const useBacktestStore = defineStore('backtest', () => {
clearResult,
runPortfolio,
clearPortfolio,
runOptimize,
}
})
+44 -1
View File
@@ -130,7 +130,7 @@ export type TaskStatus = 'pending' | 'running' | 'done' | 'failed'
export interface TaskState {
task_id: string
status: TaskStatus
result: BacktestResult | PortfolioResult | null
result: BacktestResult | PortfolioResult | OptimizeResult | null
error: string | null
description: string
elapsed: number
@@ -163,6 +163,49 @@ export interface PortfolioResult {
combined_equity: EquityPoint[]
}
// ── 参数网格寻优(Phase 4) ──────────────────────────────────────────────────
export interface OptimizeBacktestRequest {
strategy: string
cash?: number
commission?: number
slippage?: number
execution?: ExecutionMode
param_grid: Record<string, Array<number | string>>
ohlcv?: Bar[]
symbol?: string
category?: Category
count?: number
start_date?: string
end_date?: string
}
export interface GridPointResult {
params: Record<string, number | string>
total_return: number | null
sharpe: number | null
max_drawdown: number | null
total_trades: number
win_rate: number | null
profit_factor: number | null
}
export interface OptimizeHeatmap {
x_name: string
y_name: string
x: Array<number | string>
y: Array<number | string>
data: Array<[number, number, number | null]>
}
export interface OptimizeResult {
strategy: string
param_names: string[]
results: GridPointResult[]
best: GridPointResult | null
heatmap: OptimizeHeatmap | null
}
// ── 错误响应(后端 ApiErrorResponse) ─────────────────────────────────────────
export interface ApiError {
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
// 参数网格寻优主页面:左配置(选标的 + 策略 + 寻优参数)/ 右报告(排名表 + 热力图)。
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import OptimizeHeatmap from '../components/OptimizeHeatmap.vue'
import OptimizeResultTable from '../components/OptimizeResultTable.vue'
import ParamGridPicker from '../components/ParamGridPicker.vue'
import SymbolPicker from '../components/SymbolPicker.vue'
import type { ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
const router = useRouter()
const strategy = ref('ma_cross')
const paramGrid = ref<Record<string, Array<number | string>>>({})
const cash = ref(100000)
const execution = ref<ExecutionMode>('next_open')
const EXECUTIONS: ExecutionMode[] = ['next_open', 'next_close', 'this_close', 'worst', 'best']
const selectedStrategy = computed(
() => store.strategies.find((s) => s.name === strategy.value) ?? null,
)
onMounted(() => {
store.loadStrategies().catch((e) => {
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
})
})
// 网格点数(前端预校验,提示用户)
const gridPoints = computed(() => {
const sizes = Object.values(paramGrid.value).map((v) => v.length)
return sizes.reduce((a, b) => a * b, 1)
})
async function onRun() {
if (!store.hasBars) {
store.error = '请先取行情数据'
return
}
if (Object.keys(paramGrid.value).length === 0) {
store.error = '请勾选至少 1 个参数并填入取值'
return
}
if (gridPoints.value > 200) {
store.error = `网格点数 ${gridPoints.value} 超过上限 200`
return
}
await store.runOptimize({
strategy: strategy.value,
param_grid: paramGrid.value,
cash: cash.value,
execution: execution.value,
ohlcv: store.ohlcv,
})
}
// 点击排名表「查看」→ 跳转单标的页用该参数回测
function onViewParams(params: Record<string, number | string>) {
// 通过 query 传递参数,单标的页接收后自动填充
router.push({
path: '/',
query: { strategy: strategy.value, params: JSON.stringify(params) },
})
}
</script>
<template>
<div class="optimize-view">
<aside class="config-panel">
<section class="panel-section">
<h3>行情数据</h3>
<SymbolPicker />
</section>
<section class="panel-section">
<h3>策略</h3>
<div class="field">
<select v-model="strategy">
<option v-for="s in store.strategies" :key="s.name" :value="s.name">
{{ s.label }}{{ s.name }}
</option>
</select>
</div>
</section>
<section class="panel-section">
<h3>寻优参数</h3>
<ParamGridPicker v-model="paramGrid" :strategy="selectedStrategy" />
</section>
<section class="panel-section">
<h3>资金</h3>
<div class="field">
<label>初始资金</label>
<input v-model.number="cash" type="number" min="1000" step="10000" />
</div>
<div class="field">
<label>成交模式</label>
<select v-model="execution">
<option v-for="e in EXECUTIONS" :key="e" :value="e">{{ e }}</option>
</select>
</div>
</section>
<button
class="primary run-btn"
:disabled="store.optimizeRunning || !store.hasBars"
@click="onRun"
>
{{ store.optimizeRunning ? '寻优中…' : '开始寻优' }}
</button>
</aside>
<main class="report-panel">
<div v-if="store.error" class="error-banner"> {{ store.error }}</div>
<div
v-if="!store.optimizeResult && !store.optimizeRunning && !store.error"
class="placeholder"
>
<p>选标的 取行情 选策略 勾选寻优参数 开始寻优</p>
</div>
<div v-if="store.optimizeResult" class="report-content">
<section class="report-section">
<h3>最优结果</h3>
<div v-if="store.optimizeResult.best" class="best-summary">
<span class="best-params">{{ JSON.stringify(store.optimizeResult.best.params) }}</span>
<span class="best-return pos">
{{ (store.optimizeResult.best.total_return! * 100).toFixed(2) }}%
</span>
<span class="best-meta">
夏普 {{ store.optimizeResult.best.sharpe?.toFixed(2) }} · 回撤
{{ (store.optimizeResult.best.max_drawdown! * 100).toFixed(2) }}%
</span>
</div>
</section>
<section v-if="store.optimizeResult.heatmap" class="report-section">
<h3>参数热力图{{ store.optimizeResult.heatmap.x_name }} × {{ store.optimizeResult.heatmap.y_name }}</h3>
<OptimizeHeatmap :heatmap="store.optimizeResult.heatmap" />
</section>
<section class="report-section">
<h3>网格点排名{{ store.optimizeResult.results.length }} </h3>
<OptimizeResultTable
:results="store.optimizeResult.results"
:best-index="0"
@select="onViewParams"
/>
</section>
</div>
</main>
</div>
</template>
<style scoped>
.optimize-view {
display: flex;
height: 100%;
}
.config-panel {
width: 320px;
flex-shrink: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.panel-section:last-of-type {
border-bottom: none;
}
.panel-section h3 {
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
.run-btn {
width: 100%;
padding: 10px;
font-size: 14px;
}
.report-panel {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-dim);
}
.error-banner {
background: rgba(239, 65, 70, 0.12);
border: 1px solid var(--up);
color: var(--up);
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.report-section {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 16px;
}
.report-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12px;
}
.best-summary {
display: flex;
align-items: baseline;
gap: 16px;
}
.best-params {
font-family: var(--font-mono);
font-size: 14px;
color: var(--accent);
}
.best-return {
font-size: 22px;
font-weight: 700;
font-family: var(--font-mono);
}
.best-meta {
color: var(--text-dim);
font-size: 12px;
}
.pos {
color: var(--up);
}
</style>