refactor(frontend): 实例选择改级联——策略右边实例下拉(用户设计反馈「实例凌驾策略之上」)——四表单(个股回测/组合回测/模拟盘/实盘)统一:策略下拉旁并排「实例档案(可选·带出参数)」下拉,选中策略后只列该策略的档案(类名→code_file映射,组合按文件名);换策略档案不属自动清空防绑错;移除原顶部大下拉;CTA级联映射缺失(mock中文名/自输类名)退化为全量;布局一行「策略/档案」层级心明(策略→实例从属);build绿+实例测试绿 [vps]
CI/CD / test (push) Successful in 16s
CI/CD / nas-deploy (push) Successful in 34s
CI/CD / nas-verify (push) Successful in 11s

This commit is contained in:
2026-08-15 23:21:04 +08:00
parent 4da714d1c5
commit 1103ae85d5
4 changed files with 144 additions and 72 deletions
+30 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, watch, onMounted } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStrategies, getParams, getInstances, type StrategyItem } from '@/api/strategy'
@@ -112,12 +112,31 @@ function onPickInstance(id: number | null): void {
if (id != null) void applyInstance(id)
}
// 级联:类名→文件名映射(实例按 code_file 归属策略)
const classToFile = ref<Record<string, string>>({})
const instancesOfStrategy = computed(() => {
// 类名不在映射(mock 中文名/自输类名) → 不过滤退化为全量;有映射 → 严格级联
const file = classToFile.value[form.strategy]
return file ? instanceOptions.value.filter((i) => i.code_file === file) : instanceOptions.value
})
// 换策略后档案不属于它 → 清空选择(避免绑错档案)
watch(() => form.strategy, () => {
if (selectedInstanceId.value != null && !instancesOfStrategy.value.some((i) => i.id === selectedInstanceId.value)) {
selectedInstanceId.value = null
}
})
onMounted(async () => {
loading.value = true
try {
strategies.value = await getStrategies()
try {
instanceOptions.value = await getInstances()
const [ins, fr] = await Promise.all([
getInstances(),
apiClient.get<{ files: FileLike[] }>('/strategy/files'),
])
instanceOptions.value = ins
classToFile.value = Object.fromEntries(fr.data.files.map((f) => [f.class_name, f.name]))
} catch {
/* 档案下拉失败不阻塞 */
}
@@ -211,24 +230,22 @@ async function onSubmit(): Promise<void> {
<el-card class="blk" shadow="never" v-loading="loading">
<template #header><span class="section-title">策略与参数</span></template>
<el-form :model="form" label-width="120px">
<el-form-item label="实例档案">
<el-form-item label="策略 / 档案">
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 300px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="选档案带出参数并回写结果(可选);不选=自由回测"
style="width: 420px"
placeholder="实例档案(可选·带出参数)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instanceOptions" :key="i.id" :value="i.id"
:label="`${i.name}${i.code_file || '—'}·${i.interval}`"
v-for="i in instancesOfStrategy" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }}完成后回写档案</span>
</el-form-item>
<el-form-item label="策略">
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 320px">
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">完成后回写档案 #{{ selectedInstanceId }}</span>
</el-form-item>
<el-form-item v-if="paramsList.length" label="参数">
<div class="param-grid">
@@ -411,6 +411,15 @@ onMounted(async () => {
// §12.6 选档案:跳转带入或页面下拉选择,提交带 instance_id 回写四格
const instanceOptions = ref<{ id: number; name: string; code_file: string; interval: string }[]>([])
// 级联:实例按 code_file 归属策略(form.strategy=文件名去 .py
const instancesOfStrategy = computed(() =>
instanceOptions.value.filter((i) => i.code_file.replace(/\.py$/, '') === form.strategy),
)
watch(() => form.strategy, () => {
if (selectedInstanceId.value != null && !instancesOfStrategy.value.some((i) => i.id === selectedInstanceId.value)) {
selectedInstanceId.value = null
}
})
function onPickInstance(id: number | null): void {
selectedInstanceId.value = id
@@ -480,30 +489,28 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
<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
:model-value="selectedInstanceId" clearable filterable
placeholder="选档案带出参数并回写结果(可选);不选=自由回测"
style="width: 420px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instanceOptions" :key="i.id" :value="i.id"
:label="`${i.name}${i.code_file || '—'}·${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }}完成后回写档案</span>
</el-form-item>
<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-select v-model="form.strategy" style="width: 320px">
<el-form-item label="策略 / 档案">
<el-select v-model="form.strategy" style="width: 300px">
<el-option v-for="opt in strategyOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfStrategy" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">完成后回写档案 #{{ selectedInstanceId }}</span>
</el-form-item>
<el-form-item label="选股池上限">
<el-input-number v-model="form.max_pool" :min="0" :step="10" :controls="false" style="width: 220px" />
+47 -22
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createLive, type LiveCreateRequest } from '@/api/live'
@@ -34,13 +34,23 @@ const PORTFOLIO_LABELS = STRATEGY_LABELS
// §12.6 选实例发起:选中即预填+绑定;不选=自由配置(后端发起即建档)
const instanceOptions = ref<Instance[]>([])
const selectedInstanceId = ref<number | null>(null)
// 级联:类名→文件名映射(实例按 code_file 归属策略)
const classToFile = ref<Record<string, string>>({})
const instancesOfCta = computed(() => {
const file = classToFile.value[form.value.strategy_class]
return file ? instanceOptions.value.filter((i) => i.code_file === file) : instanceOptions.value
})
const instancesOfPortfolio = computed(() =>
instanceOptions.value.filter((i) => i.code_file.replace(/\.py$/, '') === portfolioStrategy.value),
)
onMounted(async () => {
try {
const { data } = await apiClient.get<{ files: { name: string; type: string }[] }>('/strategy/files')
const { data } = await apiClient.get<{ files: { name: string; type: string; class_name: string }[] }>('/strategy/files')
portfolioOptions.value = data.files
.filter((f) => f.type === 'portfolio')
.map((f) => f.name.replace(/\.py$/, ''))
classToFile.value = Object.fromEntries(data.files.map((f) => [f.class_name, f.name]))
} catch {
/* 下拉加载失败不阻塞表单 */
@@ -71,6 +81,12 @@ const form = ref<LiveCreateRequest>({
init_wait_sec: 60,
mini_path: 'C:\\国金QMT交易端模拟\\userdata_mini',
})
// 换策略后档案不属于它 → 清空(避免绑错档案)。置 form 声明后(watch 源立即求值)
watch([() => form.value.strategy_class, portfolioStrategy], () => {
if (selectedInstanceId.value == null) return
const pool = isPortfolio.value ? instancesOfPortfolio.value : instancesOfCta.value
if (!pool.some((i) => i.id === selectedInstanceId.value)) selectedInstanceId.value = null
})
const strategyClassOptions = [
{ value: 'AShareDoubleMaStrategy', label: 'AShareDoubleMaStrategy(双均线 A 股策略)' },
@@ -180,21 +196,6 @@ async function onSubmit(): Promise<void> {
</div>
<el-form label-width="140px" style="margin-top:16px">
<el-form-item label="实例档案">
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="选档案发起(推荐);不选=自由配置,提交后自动建档"
style="width: 420px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instanceOptions" :key="i.id" :value="i.id"
:label="`${i.name}${i.code_file || i.symbol_or_pool}·${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }}提交用档案参数发起时快照</span>
<span v-else class="muted form-hint">策略库点实盘会带档案跳进来</span>
</el-form-item>
<el-form-item label="实例名" required>
<el-input v-model="form.name" placeholder="live-600000" style="width: 320px" />
<span class="muted form-hint">页面显示用</span>
@@ -219,8 +220,8 @@ async function onSubmit(): Promise<void> {
<el-card class="blk" shadow="never">
<template #header><span class="section-title">标的与策略</span></template>
<el-form :model="form" label-width="140px">
<el-form-item label="策略">
<el-select v-model="form.strategy_class" style="width: 360px">
<el-form-item label="策略 / 档案">
<el-select v-model="form.strategy_class" style="width: 300px">
<el-option
v-for="opt in strategyClassOptions"
:key="opt.value"
@@ -228,7 +229,19 @@ async function onSubmit(): Promise<void> {
:value="opt.value"
/>
</el-select>
<span class="muted form-hint">实盘需 A 股适配(定寸/禁空/分钟合成),目前仅 DoubleMa 完成适配,其余策略陆续扩展</span>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfCta" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
<span class="muted form-hint">实盘需 A 股适配,目前仅 DoubleMa 完成适配</span>
</el-form-item>
<el-form-item label="策略实例名" required>
<el-input v-model="form.strategy_name" placeholder="AShareDoubleMa_600000" style="width: 320px" />
@@ -296,14 +309,26 @@ async function onSubmit(): Promise<void> {
<template #header><span class="section-title">组合策略与选股</span></template>
<el-form :model="poolForm" label-width="140px">
<el-form-item label="组合策略">
<el-select v-model="portfolioStrategy" style="width: 320px">
<el-select v-model="portfolioStrategy" style="width: 280px">
<el-option
v-for="p in portfolioOptions" :key="p"
:label="PORTFOLIO_LABELS[p] ? `${PORTFOLIO_LABELS[p]}${p}` : p"
:value="p"
/>
</el-select>
<span class="muted form-hint">supervisor 拉起 runner_live 子进程(bullet_trade LiveEngine)</span>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfPortfolio" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint">supervisor 拉起 runner_live 子进程(bullet_trade LiveEngine)</span>
</el-form-item>
<el-form-item label="标的池">
<el-select v-model="poolForm.pool" style="width: 320px">
+44 -21
View File
@@ -21,6 +21,12 @@ const strategyType = ref<'cta' | 'portfolio'>('cta')
// §12.6 选实例发起:选中即预填+绑定;不选=自由配置(后端发起即建档)
const instanceOptions = ref<Instance[]>([])
const selectedInstanceId = ref<number | null>(null)
// 级联:类名→文件名映射(实例按 code_file 归属策略)
const classToFile = ref<Record<string, string>>({})
const instancesOfCta = computed(() => {
const file = classToFile.value[form.value.strategies[0]?.name]
return file ? instanceOptions.value.filter((i) => i.code_file === file) : instanceOptions.value
})
const poolForm = reactive({ pool: 'hs300_subset', max_pool: 30, benchmark: '000300.XSHG' })
const POOL_OPTIONS = [
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
@@ -38,12 +44,13 @@ onMounted(async () => {
try {
const [strats, files] = await Promise.all([
getStrategies(),
apiClient.get<{ files: { name: string; type: string }[] }>('/strategy/files'),
apiClient.get<{ files: { name: string; type: string; class_name: string }[] }>('/strategy/files'),
])
strategyOptions.value = strats
portfolioOptions.value = files.data.files
.filter((f) => f.type === 'portfolio')
.map((f) => f.name.replace(/\.py$/, ''))
classToFile.value = Object.fromEntries(files.data.files.map((f) => [f.class_name, f.name]))
} catch {
/* 下拉加载失败不阻塞表单 */
}
@@ -132,6 +139,15 @@ function onPickInstance(id: number | null): void {
const isPortfolio = computed(() => strategyType.value === 'portfolio')
const portfolioStrategy = ref('all_weather')
const instancesOfPortfolio = computed(() =>
instanceOptions.value.filter((i) => i.code_file.replace(/\.py$/, '') === portfolioStrategy.value),
)
// 换策略后档案不属于它 → 清空(避免绑错档案)
watch([() => form.value.strategies[0]?.name, portfolioStrategy], () => {
if (selectedInstanceId.value == null) return
const pool = strategyType.value === 'portfolio' ? instancesOfPortfolio.value : instancesOfCta.value
if (!pool.some((i) => i.id === selectedInstanceId.value)) selectedInstanceId.value = null
})
function setMode(m: string): void {
// 组合类型不支持回放(历史回放走「组合回测」页)
@@ -237,32 +253,27 @@ function onSymbols(v: string): void {
<el-card class="blk" shadow="never">
<template #header><span class="section-title">{{ isPortfolio ? '组合策略与选股' : '标的与策略' }}</span></template>
<el-form label-width="120px" style="margin-bottom: 4px">
<el-form-item label="实例档案">
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="选档案发起(推荐);不选=自由配置,提交后自动建档"
style="width: 420px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instanceOptions" :key="i.id" :value="i.id"
:label="`${i.name}${i.code_file || i.symbol_or_pool}·${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">已绑档案 #{{ selectedInstanceId }}提交用档案参数发起时快照</span>
<span v-else class="muted form-hint">策略库点实走/影子会带档案跳进来</span>
</el-form-item>
</el-form>
<el-form v-if="isPortfolio" :model="poolForm" label-width="120px">
<el-form-item label="组合策略">
<el-select v-model="portfolioStrategy" style="width: 320px">
<el-select v-model="portfolioStrategy" style="width: 280px">
<el-option
v-for="p in portfolioOptions" :key="p"
:label="PORTFOLIO_LABELS[p] ? `${PORTFOLIO_LABELS[p]}${p}` : p"
:value="p"
/>
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfPortfolio" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
</el-form-item>
<el-form-item label="标的池">
<el-select v-model="poolForm.pool" style="width: 320px">
@@ -313,11 +324,23 @@ function onSymbols(v: string): void {
filterable
allow-create
placeholder="选择或输入策略类名"
style="width: 320px"
style="width: 280px"
>
<el-option v-for="s in strategyOptions" :key="s.name" :label="s.name" :value="s.name" />
</el-select>
<span class="muted form-hint">CTA 策略个股 DoubleMaStrategy</span>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="实例档案(可选·带出参数)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfCta" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}发起时快照</span>
<span v-else class="muted form-hint">CTA 策略个股 DoubleMaStrategy</span>
</el-form-item>
<el-form-item label="撮合时点">
<el-select v-model="form.strategies[0].match_session" style="width: 280px">