Files
easy_tdx_max/web-ui/src/views/OptimizeView.vue
T

626 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 参数寻优主页面:左配置(选标的 + 策略 + 寻优参数)/ 右报告(排名表 + 热力图)。
// 取行情已整合进「开始寻优」。另有「一键寻优所有策略」:用各策略预设网格逐策略寻优再全局排名。
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import GradeBadge from '../components/GradeBadge.vue'
import OptimizeHeatmap from '../components/OptimizeHeatmap.vue'
import OptimizeResultTable from '../components/OptimizeResultTable.vue'
import ParamGridPicker from '../components/ParamGridPicker.vue'
import QuoteCarousel from '../components/QuoteCarousel.vue'
import SymbolPicker from '../components/SymbolPicker.vue'
import { gradeGridPoint } from '../grading'
import type { GradeResult } from '../grading'
import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
const router = useRouter()
const route = useRoute()
// SymbolPicker 实例引用,用于触发取行情
const symbolPicker = ref<InstanceType<typeof SymbolPicker> | null>(null)
// 镜像 SymbolPicker 的代码/周期/日期,用于「查看」跳转时拼进 URL query。
// 与 SymbolPicker 通过 v-model 双向同步,初始值与 SymbolPicker 默认一致。
const code = ref('000001')
const category = ref<Category>('DAY')
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const startDate = ref('2020-01-06')
const endDate = ref(isoDaysFromNow(0))
const strategy = ref('ma_cross')
const paramGrid = ref<Record<string, Array<number | string>>>({})
const cash = ref(1000000)
const execution = ref<ExecutionMode>('next_open')
// 一键寻优并发工作进程数:0=串行(同 workers=1);2+=多进程并行(CPU-bound 必须)
const cpuCount = (() => {
// navigator.hardwareConcurrency 返回逻辑核数(含超线程);非浏览器/不可用时回退 4
const n = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined
return n && n > 0 ? n : 4
})()
// 推荐档:min(cpu, 8)。默认 8 进程,若用户改过则记到 localStorage
// 下次以其最后一次选择为默认。
const recommendedWorkers = Math.min(cpuCount, 8)
const DEFAULT_WORKERS = 8
const WORKERS_STORAGE_KEY = 'optimize.workers'
const WORKER_OPTIONS: { value: number; label: string }[] = [
{ value: 0, label: '串行(不并发)' },
{ value: 4, label: '4 进程' },
{ value: 8, label: '8 进程' },
{ value: 16, label: '16 进程' },
]
function loadWorkersFromStorage(): number {
try {
const raw = localStorage.getItem(WORKERS_STORAGE_KEY)
if (raw == null) return DEFAULT_WORKERS
const n = Number(raw)
return Number.isFinite(n) && WORKER_OPTIONS.some((o) => o.value === n)
? n
: DEFAULT_WORKERS
} catch {
return DEFAULT_WORKERS
}
}
const workers = ref(loadWorkersFromStorage())
// 用户修改后持久化,下次以最后选择为默认
watch(workers, (v) => {
try {
localStorage.setItem(WORKERS_STORAGE_KEY, String(v))
} catch {
/* localStorage 不可用时静默忽略 */
}
})
// 成交价模式(精简为 开盘价/收盘价)
const EXECUTIONS: { value: ExecutionMode; label: string }[] = [
{ value: 'next_open', label: '开盘价' },
{ value: 'next_close', label: '收盘价' },
]
const selectedStrategy = computed(
() => store.strategies.find((s) => s.name === strategy.value) ?? null,
)
onMounted(async () => {
store.loadStrategies().catch((e) => {
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
})
// 个股弹窗「一键寻优」入口:/optimize?code=601088&autoAll=1
// 填入代码后自动触发「一键寻优所有策略」(等 nextTick 让 SymbolPicker
// 的 v-model 同步,onRunAll 里的 loadBars 才会取到正确标的)。
const qCode = String(route.query.code ?? '')
if (route.query.autoAll === '1' && /^\d{6}$/.test(qCode)) {
code.value = qCode
await nextTick()
if (!store.optimizeRunning && !store.optimizeAllRunning) {
onRunAll()
}
}
})
// 网格点数(前端预校验,提示用户)
const gridPoints = computed(() => {
const sizes = Object.values(paramGrid.value).map((v) => v.length)
return sizes.reduce((a, b) => a * b, 1)
})
// 取行情(点击「开始寻优」时触发)→ 寻优
async function onRun() {
store.error = ''
// 1. 先取行情
const ok = await symbolPicker.value?.loadBars()
if (!ok) return
// 取行情成功 → 冻结本次寻优真正使用的标的上下文,供「查看」拼 URL(存 store,跨路由保留)
store.setOptimizeContext({
code: code.value,
category: category.value,
startDate: startDate.value,
endDate: endDate.value,
})
// 2. 校验寻优参数
if (Object.keys(paramGrid.value).length === 0) {
store.error = '请勾选至少 1 个参数并填入取值'
return
}
if (gridPoints.value > 200) {
store.error = `网格点数 ${gridPoints.value} 超过上限 200`
return
}
// 3. 寻优
await store.runOptimize({
strategy: strategy.value,
param_grid: paramGrid.value,
cash: cash.value,
execution: execution.value,
ohlcv: store.ohlcv,
})
}
// 一键寻优所有策略:取行情 → 全策略预设网格寻优 → 全局排名
async function onRunAll() {
store.error = ''
// 1. 先取行情
const ok = await symbolPicker.value?.loadBars()
if (!ok) return
// 取行情成功 → 冻结本次寻优真正使用的标的上下文,供「查看」拼 URL(存 store,跨路由保留)
store.setOptimizeContext({
code: code.value,
category: category.value,
startDate: startDate.value,
endDate: endDate.value,
})
// 2. 一键寻优
await store.runOptimizeAll({
cash: cash.value,
execution: execution.value,
workers: workers.value,
ohlcv: store.ohlcv,
})
}
/** 「查看」跳转时,把本次寻优实际使用的标的 + 周期 + 日期范围一并塞进 query,
* 让回测页能完整复现寻优时的行情(而非只带策略参数)。
* 注意:必须读 store.optimizeContext(取行情成功时冻结),不能读输入框实时值 ——
* 否则寻优完成后改了输入框代码,「查看」URL 会被污染,指向一个根本没被回测过的标的;
* 且不能读组件 ref,否则切走再回 /optimize 时排名还在但上下文已丢失。 */
function buildBacktestQuery(strategyName: string, params: Record<string, number | string>) {
const ctx = store.optimizeContext
return {
strategy: strategyName,
params: JSON.stringify(params),
symbol: ctx?.code ?? '',
startDate: ctx?.startDate ?? '',
endDate: ctx?.endDate ?? '',
category: ctx?.category ?? category.value,
}
}
// 点击排名表「查看」→ 跳转单标的页用该参数回测
function onViewParams(params: Record<string, number | string>) {
// 通过 query 传递参数,单标的页接收后自动填充
router.push({ path: '/backtest', query: buildBacktestQuery(strategy.value, params) })
}
// 一键寻优结果点击「查看」→ 跳转单标的页用该策略 + 参数回测
function onViewAll(strategyName: string, params: Record<string, number | string>) {
router.push({ path: '/backtest', query: buildBacktestQuery(strategyName, params) })
}
function pct(v: number | null | undefined): string {
return v !== null && v !== undefined && Number.isFinite(v) ? `${(v * 100).toFixed(2)}%` : '-'
}
/** 买入持有的盈亏配色(A 股习惯:涨红跌绿;非数值不加色) */
function bhClass(v: number | null | undefined): string {
return v !== null && v !== undefined && Number.isFinite(v) ? (v >= 0 ? 'pos' : 'neg') : ''
}
function num(v: number | null | undefined, d = 2): string {
return v !== null && v !== undefined && Number.isFinite(v) ? v.toFixed(d) : '-'
}
// 寻优评级:4 维度降级版(夏普30%/回撤28%/胜率22%/利润因子20%)。
// 各网格点交易数独立判断「样本不足」否决。
const bestGrade = computed<GradeResult | null>(() =>
store.optimizeResult?.best ? gradeGridPoint(store.optimizeResult.best) : null,
)
// 一键寻优全局最佳评级
const bestAllGrade = computed<GradeResult | null>(() =>
store.optimizeAllResult?.best ? gradeGridPoint(store.optimizeAllResult.best) : null,
)
// 一键寻优排名表每行的评级(按需计算,避免大表全量计算)
const rankingGrades = computed<GradeResult[]>(() =>
(store.optimizeAllResult?.ranking ?? []).map((r) => gradeGridPoint(r)),
)
</script>
<template>
<div class="optimize-view">
<aside class="config-panel">
<section class="panel-section">
<h3>行情数据</h3>
<SymbolPicker
ref="symbolPicker"
v-model:code="code"
v-model:category="category"
v-model:start-date="startDate"
v-model:end-date="endDate"
/>
</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" :value="e.value">{{ e.label }}</option>
</select>
</div>
</section>
<section class="panel-section">
<h3>一键寻优并发</h3>
<div class="field">
<label
>工作进程
<span class="hint"
>CPU {{ cpuCount }} · 推荐 {{ recommendedWorkers }}</span
></label
>
<select v-model.number="workers">
<option v-for="o in WORKER_OPTIONS" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<p class="workers-tip">
寻优是 CPU 密集计算多进程可显著提速仅对一键寻优所有策略生效)。机器较弱时建议先用串行测一次再开并发
</p>
</section>
<button
class="primary run-btn"
:disabled="store.optimizeRunning || store.optimizeAllRunning"
@click="onRun"
>
{{ store.optimizeRunning ? '取行情+寻优中…' : '开始寻优' }}
</button>
<button
class="run-all-btn run-btn"
:disabled="store.optimizeRunning || store.optimizeAllRunning"
@click="onRunAll"
>
{{ store.optimizeAllRunning ? '一键寻优所有策略中…' : '一键寻优所有策略' }}
</button>
</aside>
<main class="report-panel">
<div v-if="store.error" class="error-banner"> {{ store.error }}</div>
<div
v-if="!store.optimizeResult && !store.optimizeAllResult && !store.optimizeRunning && !store.optimizeAllRunning && !store.error"
class="placeholder"
>
<p>选标的 选策略 勾选寻优参数 开始寻优或点一键寻优所有策略</p>
</div>
<!-- 一键寻优进行中投资大师名言轮播让等待不枯燥 -->
<QuoteCarousel v-if="store.optimizeAllRunning" :interval="3000" />
<!-- 单策略寻优结果 -->
<div v-if="store.optimizeResult" class="report-content">
<section class="report-section">
<h3>最优结果</h3>
<div v-if="store.optimizeResult.best" class="best-summary">
<GradeBadge v-if="bestGrade" :result="bestGrade" size="md" />
<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 v-if="store.optimizeResult.buy_hold" class="best-bh">
[买入持有<span :class="bhClass(store.optimizeResult.buy_hold.total_return)">{{
pct(store.optimizeResult.buy_hold.total_return)
}}</span>]
</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>
<!-- 一键寻优所有策略结果 -->
<div v-if="store.optimizeAllResult" class="report-content">
<section class="report-section">
<h3>全局最佳</h3>
<div v-if="store.optimizeAllResult.best" class="best-summary">
<GradeBadge v-if="bestAllGrade" :result="bestAllGrade" size="md" />
<span class="best-params">
{{ store.optimizeAllResult.best.strategy_label }}
{{ JSON.stringify(store.optimizeAllResult.best.params) }}
</span>
<span class="best-return pos">
{{ (store.optimizeAllResult.best.total_return! * 100).toFixed(2) }}%
</span>
<span v-if="store.optimizeAllResult.buy_hold" class="best-bh">
[买入持有<span :class="bhClass(store.optimizeAllResult.buy_hold.total_return)">{{
pct(store.optimizeAllResult.buy_hold.total_return)
}}</span>]
</span>
<span class="best-meta">
夏普 {{ store.optimizeAllResult.best.sharpe?.toFixed(2) }} · 回撤
{{ (store.optimizeAllResult.best.max_drawdown! * 100).toFixed(2) }}% · 胜率
{{ (store.optimizeAllResult.best.win_rate! * 100).toFixed(1) }}%
</span>
</div>
<p class="meta-line">
{{ store.optimizeAllResult.ranking.length }} 个策略有效 ·
合计 {{ store.optimizeAllResult.total_grid_points }} 网格点
</p>
</section>
<section class="report-section">
<h3>策略排名按总收益降序</h3>
<table class="opt-table">
<thead>
<tr>
<th>#</th>
<th>评级</th>
<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 store.optimizeAllResult.ranking"
:key="r.strategy"
:class="{ best: i === 0 }"
>
<td class="rank">{{ i + 1 }}</td>
<td class="grade-cell">
<GradeBadge
v-if="rankingGrades[i]"
:result="rankingGrades[i]"
size="sm"
:show-score="false"
/>
</td>
<td>{{ r.strategy_label }}</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="onViewAll(r.strategy, r.params)">查看</button>
</td>
</tr>
</tbody>
</table>
</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;
}
.hint {
font-weight: 400;
color: var(--text-muted, #888);
font-size: 12px;
margin-left: 6px;
}
.workers-tip {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted, #888);
}
.run-btn {
width: 100%;
padding: 10px;
font-size: 14px;
margin-top: 8px;
}
.run-btn:first-of-type {
margin-top: 0;
}
/* 「一键寻优所有策略」按钮:暖橙渐变,比 primary 蓝更醒目 */
.run-all-btn {
background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%);
border: 1px solid #f59e0b;
color: #fff;
font-weight: 600;
box-shadow: 0 4px 14px rgba(245, 158, 11, 0.35);
}
.run-all-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
border-color: #fbbf24;
box-shadow: 0 6px 18px rgba(245, 158, 11, 0.5);
transform: translateY(-1px);
}
.run-all-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.35);
}
/* 运行中:暗橙 disabled,但仍是橙,区别于普通按钮的纯灰 */
.run-all-btn:disabled {
background: linear-gradient(135deg, #b45309, #9a3412);
border-color: #b45309;
color: #fde68a;
opacity: 0.9;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.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;
flex-wrap: wrap;
}
.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);
}
/* 买入持有对比项:标签/括号弱化不抢焦点,百分比按盈亏走 .pos/.neg 红绿 */
.best-bh {
color: var(--text-dim);
font-size: 13px;
font-family: var(--font-mono);
}
.best-meta {
color: var(--text-dim);
font-size: 12px;
}
.meta-line {
color: var(--text-dim);
font-size: 12px;
margin-top: 8px;
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
.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;
}
.view-btn {
font-size: 11px;
padding: 2px 8px;
}
.grade-cell {
width: 56px;
}
</style>