feat(strategy): 策略参数 UI 元数据(级联显隐 / 分组 / 预设批量应用) (#447)

* feat(strategy): 策略参数 UI 元数据(级联显隐 / 分组 / 预设批量应用)

META["params"] 的 dict 项本就由后端 _normalize_param_item 原样透传额外
key,三个可选 UI 元数据字段零后端改动即可生效,未声明的参数行为不变:

- visible_if: 级联显隐,支持 {param,in} 单条件与 all/any 递归组合;
  隐藏仅是显示层过滤,参数值仍按当前值随请求提交(策略语义不受影响)
- group: 相同组名的相邻参数在参数面板归并为一节(带节标题)
- option_applies: select 选中某选项时批量应用一组参数值(如标的池预设)

实现:
- 新增 lib/strategyParams.ts(递归条件求值 + 分组归并,含 vitest 单测)
- 回测页参数面板:分节渲染 + 级联联动 + 命名组内横排布局
- 策略设置弹窗:同样分节 + 级联 + 预设应用
- paramSweep:级联隐藏参数不参与扫描(按持久化默认值判定显隐)
- strategy-guide 双文档补元数据说明

* fix(strategy): 精简指南移除 UI 元数据注释行 — 修复 5000 字符上限测试

* fix(frontend): 参数扫描面板短标签加组名前缀 — paramDisplayLabel

分组内短标签(「止盈」「入池」)脱离分组上下文后不可区分: 扫描面板是
平铺列表, 同名参数挤在一起无法辨认, 级联隐藏提示行同样不可读。
新增 paramDisplayLabel (组名 · 标签), 用于扫描行与隐藏提示;
参数面板本身有组标题不受影响。
This commit is contained in:
intfoo
2026-10-16 14:06:37 +08:00
committed by GitHub
parent d3363b5a10
commit 991cb6768c
9 changed files with 283 additions and 20 deletions
@@ -37,6 +37,14 @@ META = {
# 策略参数 (只把用户可能调节的阈值放这里,公式常数不必参数化)
# type 支持: float / int / bool / select(带 options) / date(格式 "YYYY-MM-DD") / string(自由文本)
# float/int 可带 min/max/step;select 带 options: [{label, value}];date/string 的 default 是字符串
#
# 可选 UI 元数据 (只影响参数面板展示, 不影响策略语义; 后端原样透传):
# "group": "组名" — 相同组名的相邻参数归并为一节显示
# "visible_if": {...} — 级联显隐: {"param": "pid", "in": [值...]} 单条件,
# 或 {"all": [...]} / {"any": [...]} 递归组合;
# in 值类型应与目标参数一致 (bool 用 True/False 而非字符串);
# 隐藏只是不渲染, 参数值仍按当前值随请求提交
# "option_applies": {选项: {pid: 值}} — select 选中某选项时批量应用一组参数值 (如预设)
"params": [
],
@@ -5,6 +5,8 @@ import { X, Settings2, RotateCcw, Save, Filter, Star, TrendingUp, Sparkles, Down
import { api, type StrategyDetail, type StrategyParamDef, type CompositeChildInfo, type ScoringDirection, type CustomSignalCondition } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { toPercentages, normalizeWeights } from '@/lib/weights'
import { groupParams, visibleParams } from '@/lib/strategyParams'
import { toast } from '@/components/Toast'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { color } from '@/lib/colors'
import { SignalPicker } from './SignalPicker'
@@ -167,10 +169,12 @@ function RiskPctField({ label, value, onChange, min, max, step = 0.5, unit = '%'
// 策略参数字段
// memo: 单参数修改只重渲本行 (父组件需传稳定回调, 否则 memo 失效)
const ParamField = memo(function ParamField({ def, value, onChange }: {
const ParamField = memo(function ParamField({ def, value, onChange, onApplyValues }: {
def: StrategyParamDef
value: any
onChange: (pid: string, v: any) => void
/** select 带 option_applies 时, 选中某选项需批量应用一组参数值 (如标的池预设) */
onApplyValues?: (values: Record<string, any>) => void
}) {
if (def.type === 'bool') {
const checked = value === true || value === 'true' || value === 'True'
@@ -199,7 +203,15 @@ const ParamField = memo(function ParamField({ def, value, onChange }: {
<span className={PARAM_LABEL_CLS} title={def.label}>{def.label}</span>
<select
value={value ?? def.default}
onChange={e => onChange(def.id, e.target.value)}
onChange={e => {
const v = e.target.value
const applies = def.option_applies?.[v]
if (applies && onApplyValues) {
onApplyValues({ [def.id]: v, ...applies })
// 预设会覆盖其他参数的当前值, 显式提示避免静默改动
toast(`已应用预设「${v}」,覆盖 ${Object.keys(applies).length} 个参数`, 'success')
} else onChange(def.id, v)
}}
className={`${FIELD_INPUT_CLS} w-full max-w-[240px] px-1.5`}
>
{def.options.map(o => <option key={o} value={o}>{o}</option>)}
@@ -221,6 +233,7 @@ const ParamField = memo(function ParamField({ def, value, onChange }: {
)
}
return (
<div className="flex items-center gap-2">
<span className={PARAM_LABEL_CLS} title={def.label}>{def.label}</span>
@@ -709,8 +722,17 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
extra={detail.params.length > 0 ? <span className="text-[10px] text-muted/60">{detail.params.length} 项</span> : undefined}
>
{detail.params.length > 0 ? (
<div className="space-y-2">
{detail.params.map(p => <ParamField key={p.id} def={p} value={params[p.id]} onChange={handleParamChange} />)}
<div className="space-y-2.5">
{groupParams(visibleParams(detail.params, params)).map((g, gi) => (
<div key={g.name ?? `ug${gi}`}>
{g.name && (
<div className="mb-1 border-b border-border/20 pb-0.5 text-[10px] font-medium text-muted/70">{g.name}</div>
)}
<div className="space-y-1.5">
{g.items.map(p => <ParamField key={p.id} def={p} value={params[p.id]} onChange={handleParamChange} onApplyValues={values => setParams(prev => ({ ...prev, ...values }))} />)}
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-center text-xs text-muted/60">该策略无可调参数</div>
+15
View File
@@ -792,6 +792,15 @@ export interface AuctionBenchmarkPayload {
}
// ===== Strategy Engine =====
/** 参数级联显隐条件 (UI 元数据, 后端原样透传)。三种形态可递归组合:
* { param, in } — values[param] ∈ in; { all: [...] } — 全部成立; { any: [...] } — 任一成立 */
export interface ParamVisibleIf {
param?: string
in?: (string | number | boolean)[]
all?: ParamVisibleIf[]
any?: ParamVisibleIf[]
}
export interface StrategyParamDef {
id: string
label: string
@@ -801,6 +810,12 @@ export interface StrategyParamDef {
max?: number
step?: number
options?: string[]
/** UI 元数据: 分组节标题 (相同组名的相邻参数归并) */
group?: string
/** UI 元数据: 级联显隐 — 条件成立时显示 (见 ParamVisibleIf) */
visible_if?: ParamVisibleIf
/** UI 元数据: select 各选项被选中时批量应用的参数值 (如标的池预设 → 开关+默认值) */
option_applies?: Record<string, Record<string, string | number | boolean>>
}
export interface CompositeChildInfo {
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import type { StrategyParamDef } from '@/lib/api'
import { groupParams, isParamVisible, paramDisplayLabel, visibleParams } from '@/lib/strategyParams'
const p = (id: string, extra: Partial<StrategyParamDef> = {}): StrategyParamDef => ({
id,
label: id,
type: 'float',
default: 0,
...extra,
})
describe('isParamVisible', () => {
it('无 visible_if 恒显示', () => {
expect(isParamVisible(p('a'), {})).toBe(true)
})
it('单条件: values 命中/未命中/缺省回落 default', () => {
const def = p('use_x', { visible_if: { param: 'pool', in: ['custom'] } })
const pool = p('pool', { type: 'select', default: 'classic4' })
expect(isParamVisible(def, { pool: 'custom' }, [pool, def])).toBe(true)
expect(isParamVisible(def, { pool: 'classic4' }, [pool, def])).toBe(false)
// values 缺省 → 按 pool 的 default('classic4') 判定 → 隐藏
expect(isParamVisible(def, {}, [pool, def])).toBe(false)
})
it('边界: 空 all 为空真、空 any 为 false', () => {
expect(isParamVisible(p('a', { visible_if: { all: [] } }), {})).toBe(true)
expect(isParamVisible(p('a', { visible_if: { any: [] } }), {})).toBe(false)
})
it('边界: 引用不存在的 param → 隐藏(不报错)', () => {
const def = p('a', { visible_if: { param: 'nonexistent', in: [1] } })
expect(isParamVisible(def, {})).toBe(false)
})
it('bool 容错: in 值误写字符串 "true"/"True" 也能匹配布尔值', () => {
const def = p('use_x', { type: 'bool', default: true, visible_if: { param: 'use_x', in: ['true'] } })
expect(isParamVisible(def, { use_x: true }, [def])).toBe(true)
expect(isParamVisible(def, { use_x: false }, [def])).toBe(false)
const def2 = p('use_x', { type: 'bool', default: true, visible_if: { param: 'use_x', in: ['True'] } })
expect(isParamVisible(def2, { use_x: true }, [def2])).toBe(true)
})
it('递归: all / any 组合(标的池联动场景)', () => {
// 显示条件 = pool ∈ presets OR (pool=custom AND use_gold)
const def = p('tp_gold', {
visible_if: {
any: [
{ param: 'pool', in: ['classic4', 'no_hs300'] },
{ all: [{ param: 'pool', in: ['custom'] }, { param: 'use_gold', in: [true] }] },
],
},
})
const useGold = p('use_gold', { type: 'bool', default: true })
const all = [def, useGold]
// 预设模式: 不看勾选状态
expect(isParamVisible(def, { pool: 'classic4', use_gold: false }, all)).toBe(true)
// custom 勾选 → 显示; 取消勾选 → 隐藏
expect(isParamVisible(def, { pool: 'custom', use_gold: true }, all)).toBe(true)
expect(isParamVisible(def, { pool: 'custom', use_gold: false }, all)).toBe(false)
})
})
describe('visibleParams / groupParams', () => {
it('过滤隐藏参数并按组归并(保序)', () => {
const params = [
p('pool', { type: 'select', default: 'classic4', group: '标的池' }),
p('use_x', { type: 'bool', default: true, group: '标的池', visible_if: { param: 'pool', in: ['custom'] } }),
p('m_days', { group: '动量' }),
p('tp_x', { group: '止盈' }),
p('lock_x', { group: '止盈' }),
]
const visible = visibleParams(params, { pool: 'classic4' })
expect(visible.map(x => x.id)).toEqual(['pool', 'm_days', 'tp_x', 'lock_x'])
const groups = groupParams(visible)
expect(groups.map(g => [g.name, g.items.length])).toEqual([
['标的池', 1],
['动量', 1],
['止盈', 2],
])
})
it('非相邻同组名不归并(保序分节)', () => {
const groups = groupParams([
p('a', { group: 'X' }),
p('b', { group: 'Y' }),
p('c', { group: 'X' }),
])
expect(groups.map(g => [g.name, g.items.length])).toEqual([
['X', 1],
['Y', 1],
['X', 1],
])
})
})
describe('paramDisplayLabel', () => {
it('有组名加「组名 · 标签」前缀(平铺列表可区分),无组名原样', () => {
expect(paramDisplayLabel(p('tp_gold', { label: '止盈', group: '黄金 159934.SZ' })))
.toBe('黄金 159934.SZ · 止盈')
expect(paramDisplayLabel(p('m_days', { label: '动量窗口(交易日)' })))
.toBe('动量窗口(交易日)')
})
})
+68
View File
@@ -0,0 +1,68 @@
import type { ParamVisibleIf, StrategyParamDef } from '@/lib/api'
/**
* 策略参数级联显隐与分组(META 里的 visible_if/group 为 UI 元数据,后端原样透传)。
*
* - visible_if 支持递归条件: { param, in } 单条件 / { all: [...] } 与 / { any: [...] } 或;
* values 缺省时回落该参数的 default(未触碰过的控件按默认值判定)。
* - group: 相同组名的相邻声明归并为一节;无 group 的参数归入无名节(不显示节标题)。
*/
function evalVisibleIf(
cond: ParamVisibleIf,
values: Record<string, any>,
allParams?: StrategyParamDef[],
): boolean {
// 空集合语义: all:[] 按空真(vacuous true)处理; any:[] 无候选可成立 → false
if (cond.all !== undefined) return cond.all.every(c => evalVisibleIf(c, values, allParams))
if (cond.any !== undefined) return cond.any.length > 0 && cond.any.some(c => evalVisibleIf(c, values, allParams))
if (cond.param) {
const actual = values[cond.param] ?? allParams?.find(x => x.id === cond.param)?.default
const expected = cond.in ?? []
if (typeof actual === 'boolean') {
// 容错: META 里 bool 条件的 in 值误写字符串 "true"/"false" 也能匹配
return expected.some(e => e === actual || String(e).toLowerCase() === String(actual))
}
return expected.includes(actual)
}
return true
}
export function isParamVisible(
p: StrategyParamDef,
values: Record<string, any>,
allParams?: StrategyParamDef[],
): boolean {
if (!p.visible_if) return true
return evalVisibleIf(p.visible_if, values, allParams)
}
export function visibleParams(
params: StrategyParamDef[],
values: Record<string, any>,
): StrategyParamDef[] {
return params.filter(p => isParamVisible(p, values, params))
}
export interface ParamGroup {
name: string | null
items: StrategyParamDef[]
}
/**
* 带分组前缀的展示标签。分组内短标签(如「止盈」「入池」)脱离分组上下文后
* 不可区分(扫描面板是平铺列表),限定为「组名 · 标签」。
*/
export function paramDisplayLabel(p: StrategyParamDef): string {
return p.group ? `${p.group} · ${p.label}` : p.label
}
export function groupParams(params: StrategyParamDef[]): ParamGroup[] {
const groups: ParamGroup[] = []
for (const p of params) {
const name = p.group ?? null
const last = groups[groups.length - 1]
if (last && last.name === name) last.items.push(p)
else groups.push({ name, items: [p] })
}
return groups
}
@@ -15,6 +15,7 @@ import {
REGIME_STATE_COLORS,
} from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { groupParams, visibleParams } from '@/lib/strategyParams'
import { storage } from '@/lib/storage'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { boardTag } from '@/lib/board'
@@ -718,10 +719,12 @@ function ConfigSection({ title, hint, actions, children }: { title: string; hint
// memo: 单参数修改只重渲本行 (父组件需传稳定回调, 否则 memo 失效)
const StrategyParamInput = memo(function StrategyParamInput({ param, value, onChange }: {
const StrategyParamInput = memo(function StrategyParamInput({ param, value, onChange, onApplyValues }: {
param: StrategyParamDef
value: any
onChange: (pid: string, value: any) => void
/** select 带 option_applies 时, 选中某选项需批量应用一组参数值 (如标的池预设) */
onApplyValues?: (values: Record<string, any>) => void
}) {
if (param.type === 'bool') {
const checked = value === true || value === 'true' || value === 'True' || value === true
@@ -747,7 +750,19 @@ const StrategyParamInput = memo(function StrategyParamInput({ param, value, onCh
return (
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">{param.label}</span>
<select value={value ?? param.default} onChange={e => onChange(param.id, e.target.value)} className={INPUT_CLS}>
<select
value={value ?? param.default}
onChange={e => {
const v = e.target.value
const applies = param.option_applies?.[v]
if (applies && onApplyValues) {
onApplyValues({ [param.id]: v, ...applies })
// 预设会覆盖其他参数的当前值, 显式提示避免静默改动
toast(`已应用预设「${v}」,覆盖 ${Object.keys(applies).length} 个参数`, 'success')
} else onChange(param.id, v)
}}
className={INPUT_CLS}
>
{(param.options ?? []).map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
@@ -2935,14 +2950,25 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
actions={detail.params.length > 0 ? <span className="text-[10px] text-muted">{detail.params.length} 项</span> : undefined}
>
{detail.params.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{detail.params.map(param => (
<StrategyParamInput
key={param.id}
param={param}
value={strategyParams[param.id]}
onChange={handleStrategyParamChange}
/>
<div className="space-y-4">
{groupParams(visibleParams(detail.params, strategyParams)).map((g, gi) => (
<div key={g.name ?? `ug${gi}`}>
{g.name && (
<div className="mb-2 border-b border-border/40 pb-1 text-[11px] font-medium text-muted">{g.name}</div>
)}
{/* 命名组横排(如「入池开关 + 止盈 + 锁定」同组一行);无名组回退网格 */}
<div className={g.name ? 'flex flex-wrap items-end gap-x-4 gap-y-3' : 'grid grid-cols-1 gap-3 sm:grid-cols-2'}>
{g.items.map(param => (
<StrategyParamInput
key={param.id}
param={param}
value={strategyParams[param.id]}
onChange={handleStrategyParamChange}
onApplyValues={values => setStrategyParams(prev => ({ ...prev, ...values }))}
/>
))}
</div>
</div>
))}
</div>
) : (
@@ -105,7 +105,7 @@ export function StrategyOptimizer() {
</select>
</div>
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} />
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} hiddenLabels={sweep.hiddenParamLabels} />
<CombosHint show={!!sweep.strategyId} combos={sweep.combos} gridError={sweep.gridError} />
{task?.isPending ? (
@@ -176,7 +176,7 @@ export function StrategyWalkForward() {
</select>
</div>
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} />
<SweepParamList params={sweep.params} sweeps={sweep.sweeps} updateSweep={sweep.updateSweep} hiddenLabels={sweep.hiddenParamLabels} />
<CombosHint show={!!sweep.strategyId} combos={sweep.combos} gridError={sweep.gridError} />
<div className="text-[11px] text-secondary">每折跑 {sweep.combos || 0} 组优化 × N 折,耗时较长</div>
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import type { StrategyDetail, StrategyParamDef } from '@/lib/api'
import { paramDisplayLabel, visibleParams } from '@/lib/strategyParams'
/** 参数扫描配置的共享逻辑与 UI — 优化器与 walk-forward 复用。 */
@@ -69,7 +70,11 @@ export function useParamSweep(strategies: StrategyDetail[], onStrategyChange?: (
const [sweeps, setSweeps] = useState<Record<string, Sweep>>({})
const selected = strategies.find(s => s.id === strategyId)
const params = selected?.params ?? []
// 级联隐藏参数不参与扫描: 扫描面板未扫描参数固定为默认值, 按默认值判定显隐
const params = useMemo(
() => visibleParams(selected?.params ?? [], selected?.params_defaults ?? {}),
[selected],
)
const selectStrategy = (id: string) => {
setStrategyId(id)
@@ -112,7 +117,14 @@ export function useParamSweep(strategies: StrategyDetail[], onStrategyChange?: (
return grid
}
return { strategyId, selected, selectStrategy, params, sweeps, updateSweep, combos, gridError, buildGrid }
// 因级联规则 (visible_if) 在默认配置下隐藏、不参与扫描的参数, 供 UI 提示
// (paramDisplayLabel: 平铺列表无组上下文, 短标签需带组名才可区分)
const hiddenParamLabels = useMemo(
() => (selected?.params ?? []).filter(p => !params.includes(p)).map(p => paramDisplayLabel(p)),
[selected, params],
)
return { strategyId, selected, selectStrategy, params, sweeps, updateSweep, combos, gridError, buildGrid, hiddenParamLabels }
}
/** 策略选择器。 */
@@ -130,15 +142,22 @@ export function StrategySelect({ strategies, value, onChange }: {
}
/** 可扫参数列表 (勾选 + min/max/step)。 */
export function SweepParamList({ params, sweeps, updateSweep }: {
export function SweepParamList({ params, sweeps, updateSweep, hiddenLabels = [] }: {
params: StrategyParamDef[]
sweeps: Record<string, Sweep>
updateSweep: (pid: string, patch: Partial<Sweep>) => void
/** 因级联规则 (visible_if) 在默认配置下隐藏的参数标签, 仅作提示 */
hiddenLabels?: string[]
}) {
if (!params.length) return null
return (
<div>
<div className="mb-1.5 text-xs font-medium text-secondary">扫描参数 (勾选后设范围)</div>
{hiddenLabels.length > 0 && (
<div className="mb-1.5 text-[11px] text-muted">
{hiddenLabels.length} 个参数因级联规则在默认配置下隐藏,不参与扫描:{hiddenLabels.join('、')}
</div>
)}
<div className="space-y-2">
{params.map(p => {
const s = sweeps[p.id] ?? defaultSweep(p)
@@ -159,7 +178,7 @@ export function SweepParamList({ params, sweeps, updateSweep }: {
<div key={p.id} className="rounded-input border border-border/60 p-2">
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={s.enabled} onChange={e => updateSweep(p.id, { enabled: e.target.checked })} />
<span className="font-medium text-foreground">{p.label}</span>
<span className="font-medium text-foreground">{paramDisplayLabel(p)}</span>
<span className="text-secondary">({p.type})</span>
</label>
{s.enabled && numeric && (