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 @@