From 83232239377961353d10f1c46d6ebb7df2ef7234 Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Fri, 3 Jul 2026 11:32:44 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AF=BB=E4=BC=98=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=8C=83=E5=9B=B4/=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BB=84=E5=90=88/=E6=97=A5=E7=BA=BFx?= =?UTF-8?q?=E8=BD=B4=E5=B9=B4=E4=BB=BD/=E5=AF=BB=E4=BC=98=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E5=A1=AB=E5=85=85=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 个独立修复: 1. 寻优跳过参数范围检查:Param.validate 加 skip_bounds 参数, RegisteredStrategy.build 透传,ParamGridOptimizer 传 skip_bounds=True。 修复寻优时 fast=250 被 max_value=60 拦截的问题(探索超范围值是寻优目的)。 2. 对比页支持组合回测:CompareView 新增 extractComparable() 统一提取净值+指标, 支持单标的(performance/equity_curve)和组合(total_performance/combined_equity)。 修复勾选组合回测任务报「非单标的回测」错误。 3. K线x轴日线显示完整年份:isIntraday 判断从 length>10 改为检查时分秒非零 (日线归一化后带 T00:00:00 后缀,长度也 >10 导致误判为分钟线,slice 砍年份)。 4. 寻优跳转填充参数:BacktestView 加 useRoute(),onMounted 读 query.strategy + query.params,await nextTick 后覆盖(避免 StrategyPicker watch 重置)。 修复寻优点「查看」跳转后参数未填充、仍用默认值的问题。 测试:823 passed, mypy 211 files OK, vue-tsc OK --- src/easy_tdx/backtest/optimizer.py | 5 +- src/easy_tdx/backtest/strategies/registry.py | 48 ++++++++++++----- web-ui/src/components/KlineChart.vue | 9 +++- web-ui/src/views/BacktestView.vue | 25 +++++++-- web-ui/src/views/CompareView.vue | 57 +++++++++++++++----- 5 files changed, 110 insertions(+), 34 deletions(-) diff --git a/src/easy_tdx/backtest/optimizer.py b/src/easy_tdx/backtest/optimizer.py index 62901bb..761c7b0 100644 --- a/src/easy_tdx/backtest/optimizer.py +++ b/src/easy_tdx/backtest/optimizer.py @@ -143,7 +143,7 @@ class ParamGridOptimizer: strategy_name: str, param_grid: dict[str, list[Any]], df: pd.DataFrame, - cash: float = 100_000.0, + cash: float = 1_000_000.0, commission: float = 0.0003, min_commission: float = 5.0, stamp_tax: float = 0.001, @@ -181,7 +181,8 @@ class ParamGridOptimizer: for combo in itertools.product(*value_lists): params = dict(zip(param_names, combo, strict=True)) try: - strategy = entry.build(params) + # 寻优时跳过参数范围检查——探索超范围值是寻优的目的 + strategy = entry.build(params, skip_bounds=True) engine = BacktestEngine( strategy=strategy, cash=self._cash, diff --git a/src/easy_tdx/backtest/strategies/registry.py b/src/easy_tdx/backtest/strategies/registry.py index 4bd1d4b..9e5a181 100644 --- a/src/easy_tdx/backtest/strategies/registry.py +++ b/src/easy_tdx/backtest/strategies/registry.py @@ -114,7 +114,7 @@ class Param: schema["description"] = self.description return schema - def validate(self, value: Any) -> Any: + def validate(self, value: Any, *, skip_bounds: bool = False) -> Any: """校验并强制转换取值,不合法抛 ValueError。 NaN/Inf 的 float 输入会被拒绝(NaN 绕过所有比较,Inf 仅在有界时被拦, @@ -150,14 +150,25 @@ class Param: f"参数 '{self.name}' 期望 {self.type.__name__},得到 {value!r}" ) from exc - if self.choices is not None and self.type is str and converted not in self.choices: - raise ValueError( - f"参数 '{self.name}' 取值 {converted!r} 不在可选范围 {list(self.choices)} 内" - ) - if self.type in (int, float) and self.min_value is not None and converted < self.min_value: - raise ValueError(f"参数 '{self.name}'={converted} 小于下限 {self.min_value}") - if self.type in (int, float) and self.max_value is not None and converted > self.max_value: - raise ValueError(f"参数 '{self.name}'={converted} 大于上限 {self.max_value}") + # skip_bounds=True 时跳过范围/取值集合检查(供寻优器探索超范围值), + # 仍保留类型转换 + NaN/Inf 拦截。 + if not skip_bounds: + if self.choices is not None and self.type is str and converted not in self.choices: + raise ValueError( + f"参数 '{self.name}' 取值 {converted!r} 不在可选范围 {list(self.choices)} 内" + ) + if ( + self.type in (int, float) + and self.min_value is not None + and converted < self.min_value + ): + raise ValueError(f"参数 '{self.name}'={converted} 小于下限 {self.min_value}") + if ( + self.type in (int, float) + and self.max_value is not None + and converted > self.max_value + ): + raise ValueError(f"参数 '{self.name}'={converted} 大于上限 {self.max_value}") return converted @@ -180,10 +191,11 @@ class ParametrizedStrategy(Strategy): # 实例属性:已校验的参数值字典(init/next 中通过 self.p[name] 访问)。 p: dict[str, Any] - def __init__(self, **kwargs: Any) -> None: + def __init__(self, *, skip_bounds: bool = False, **kwargs: Any) -> None: """从 kwargs 构造策略参数。 多余的未知参数抛 ValueError;缺失参数取默认值。 + skip_bounds=True 时跳过参数范围检查(供寻优器探索超范围值)。 """ super().__init__() declared = {param.name: param for param in self.params} @@ -194,7 +206,7 @@ class ParametrizedStrategy(Strategy): resolved: dict[str, Any] = {} for name, param in declared.items(): raw = kwargs.get(name, param.default) - resolved[name] = param.validate(raw) + resolved[name] = param.validate(raw, skip_bounds=skip_bounds) self.p = resolved @@ -220,9 +232,17 @@ class RegisteredStrategy: "params": [p.to_schema() for p in self.params], } - def build(self, params: dict[str, Any] | None = None) -> ParametrizedStrategy: - """用给定参数构造策略实例,缺失参数取默认值。""" - return self.strategy_cls(**(params or {})) + def build( + self, + params: dict[str, Any] | None = None, + *, + skip_bounds: bool = False, + ) -> ParametrizedStrategy: + """用给定参数构造策略实例,缺失参数取默认值。 + + skip_bounds=True 时跳过参数范围检查(供寻优器探索超范围值)。 + """ + return self.strategy_cls(**(params or {}), skip_bounds=skip_bounds) class StrategyRegistry: diff --git a/web-ui/src/components/KlineChart.vue b/web-ui/src/components/KlineChart.vue index 4ef026d..5fbb9ae 100644 --- a/web-ui/src/components/KlineChart.vue +++ b/web-ui/src/components/KlineChart.vue @@ -30,8 +30,13 @@ function buildOption(): echarts.EChartsCoreOption { const keyIndex = new Map() keys.forEach((k, i) => keyIndex.set(k, i)) - // x 轴显示:日线只显示日期,分钟线显示日期+时间 - const isIntraday = keys.some((k) => k.length > 10) + // x 轴显示:日线只显示日期 YYYY-MM-DD,分钟线显示 MM-DD HH:mm。 + // 判断分钟线:datetime 有非零时分秒(日线归一化后是 T00:00:00)。 + // 不能用 length > 10 判断——日线带 T00:00:00 后缀长度也是 19。 + const isIntraday = keys.some((k) => { + const time = k.slice(11, 19) // HH:MM:SS 部分 + return time && time !== '00:00:00' + }) const dates = keys.map((k) => (isIntraday ? k.replace('T', ' ').slice(5, 16) : k.slice(0, 10))) const ohlc = props.bars.map((b) => [b.open, b.close, b.low, b.high]) diff --git a/web-ui/src/views/BacktestView.vue b/web-ui/src/views/BacktestView.vue index 35c14ec..cdae402 100644 --- a/web-ui/src/views/BacktestView.vue +++ b/web-ui/src/views/BacktestView.vue @@ -2,7 +2,8 @@ // 回测主页面:左配置面板 / 右报告面板。 // 编排:取行情 → 选策略+参数 → 回测 → 展示 K线+净值+指标+成交。 -import { onMounted, ref } from 'vue' +import { nextTick, onMounted, ref } from 'vue' +import { useRoute } from 'vue-router' import EquityChart from '../components/EquityChart.vue' import KlineChart from '../components/KlineChart.vue' @@ -14,6 +15,7 @@ import type { ExecutionMode } from '../types' import { useBacktestStore } from '../stores/backtest' const store = useBacktestStore() +const route = useRoute() // 表单状态(v-model 给子组件) const strategy = ref('ma_cross') @@ -25,10 +27,27 @@ const execution = ref('next_open') const EXECUTIONS: ExecutionMode[] = ['next_open', 'next_close', 'this_close', 'worst', 'best'] -onMounted(() => { - store.loadStrategies().catch((e) => { +onMounted(async () => { + await store.loadStrategies().catch((e) => { store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}` }) + + // 从 URL query 读取寻优页传来的 strategy + params(跳转自动填充) + const qStrategy = route.query.strategy as string | undefined + const qParams = route.query.params as string | undefined + if (qStrategy) { + strategy.value = qStrategy + // 等待 StrategyPicker 的 watch(selectedSchema) 触发完默认值重置后, + // 再用 query 的 params 覆盖,避免被 watch 重置掉 + await nextTick() + } + if (qParams) { + try { + params.value = JSON.parse(qParams) as Record + } catch { + // query 参数解析失败,忽略 + } + } }) async function onRun() { diff --git a/web-ui/src/views/CompareView.vue b/web-ui/src/views/CompareView.vue index 7247e42..a9e8dcb 100644 --- a/web-ui/src/views/CompareView.vue +++ b/web-ui/src/views/CompareView.vue @@ -1,19 +1,26 @@