diff --git a/backend/app/backtest/regime_alignment.py b/backend/app/backtest/regime_alignment.py index 3f0fe5f..565f646 100644 --- a/backend/app/backtest/regime_alignment.py +++ b/backend/app/backtest/regime_alignment.py @@ -35,6 +35,36 @@ def _normalize_regime_point(value: Any) -> RegimePoint: raise ValueError("市场环境数据格式无效") +def clamp_formal_start_for_regime( + labels: Sequence[str], + required_start: date | None, + regime_filter: Mapping[str, Any] | None, +) -> date | None: + """正式首日无前驱交易日时, 把首日让渡为预热, 返回顺延后的正式起点。 + + 触发条件: 环境过滤实际启用, 且面板首日 >= 正式起点 (数据边界即正式起点, + 典型如「全部」范围从本地数据第一天开始)。此时首日的 T-1 环境所需的 + 上一交易日不在面板内, fail-closed 校验会直接拒绝; 把首日降级为预热、 + 正式起点顺延到第二个交易日, 首日环境即成为次日的 T-1。 + 其余情况 (有预热日 / 过滤未启用 / 标签不足两天) 原样返回 required_start, + 无法顺延时由后续校验给出明确报错。 + """ + if not regime_filter or required_start is None: + return required_start + has_states = bool(regime_filter.get("states") or []) + has_score = regime_filter.get("min_score") is not None + if not (has_states or has_score): + return required_start + if len(labels) < 2: + return required_start + if _date_text(labels[0]) < str(required_start): + return required_start + try: + return date.fromisoformat(_date_text(labels[1])) + except ValueError: + return required_start + + def align_regime_t_minus_one( labels: Sequence[str], regime_by_date: Mapping[object, Any], diff --git a/backend/app/backtest/strategy.py b/backend/app/backtest/strategy.py index 2981a5f..a8af4d4 100644 --- a/backend/app/backtest/strategy.py +++ b/backend/app/backtest/strategy.py @@ -11,7 +11,7 @@ import threading import time import uuid from collections.abc import Callable, Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import date, timedelta from pathlib import Path from typing import Literal @@ -873,6 +873,8 @@ class StrategyBacktestService: timing_ms["load_panel"] = direct_load_ms timing_ms["market_data_matrix_build"] = 0.0 timing_ms["market_data_direct_load"] = direct_load_ms + # 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热), 见 _clamp_regime_formal_start + first = self._clamp_regime_formal_start(first, market_data.timestamp_labels) formal_range = self._matrix_date_range_mask( market_data.timestamp_labels, first.start, @@ -1068,6 +1070,11 @@ class StrategyBacktestService: matrix_data_cache_status = prepared.market_data.cache_status matrix_data_cache_hit = matrix_data_cache_status in {"exact", "covering"} matrix_data_cache_timing_ms = prepared.market_data.cache_timing_ms + # 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热) + if config.regime_filter: + config = self._clamp_regime_formal_start( + config, prepared.market_data.timestamp_labels + ) elif s.execution_backend in ("matrix_native", "composite"): t_load = time.perf_counter() max_hold_for_profile = self._override_value( @@ -1113,6 +1120,11 @@ class StrategyBacktestService: matrix_data_cache_status = market_data.cache_status matrix_data_cache_hit = matrix_data_cache_status in {"exact", "covering"} matrix_data_cache_timing_ms = market_data.cache_timing_ms + # 环境过滤下正式起点=矩阵首日时顺延 (首日让渡为预热) + if config.regime_filter: + config = self._clamp_regime_formal_start( + config, market_data.timestamp_labels + ) formal_time_mask = self._matrix_date_range_mask( market_data.timestamp_labels, config.start, @@ -1136,6 +1148,13 @@ class StrategyBacktestService: timing_ms["load_panel"] = round((time.perf_counter() - t_load) * 1000, 1) if panel.is_empty(): return _err("无数据,请检查日期范围或先运行盘后管道") + # 环境过滤下正式起点=面板首日时顺延 (首日让渡为预热) + if config.regime_filter: + date_labels = tuple( + str(value)[:10] + for value in panel.get_column("date").unique().sort().to_list() + ) + config = self._clamp_regime_formal_start(config, date_labels) formal_range = self._date_range_mask(panel, config.start, config.end) if not formal_range.any(): return _err("正式回测区间内无数据") @@ -1715,6 +1734,22 @@ class StrategyBacktestService: count=len(timestamp_labels), ) + @staticmethod + def _clamp_regime_formal_start( + config: StrategyBacktestConfig, labels: tuple[str, ...] | list[str] + ) -> StrategyBacktestConfig: + """环境过滤下正式起点=面板首日 (无前驱交易日) 时, 顺延到第二个交易日。 + + 数据边界即正式起点 (如「全部」范围) 时, T-1 环境校验会 fail-closed 拒绝; + 首日降级为预热后, 其环境即成为次日的 T-1, 仅损失 1 个正式交易日。 + """ + from app.backtest.regime_alignment import clamp_formal_start_for_regime + + shifted = clamp_formal_start_for_regime(labels, config.start, config.regime_filter) + if shifted is not None and shifted != config.start: + return replace(config, start=shifted) + return config + @staticmethod def _build_regime_mask( timestamp_labels: tuple[str, ...], diff --git a/backend/tests/backtest/test_factor_metrics.py b/backend/tests/backtest/test_factor_metrics.py index 4607a48..bddf384 100644 --- a/backend/tests/backtest/test_factor_metrics.py +++ b/backend/tests/backtest/test_factor_metrics.py @@ -389,3 +389,31 @@ def test_align_regime_t_minus_one_is_pure_and_fail_closed_in_required_range(): required_start=date(2026, 1, 6), required_end=date(2026, 1, 7), ) + + +def test_clamp_formal_start_for_regime(): + from app.backtest.regime_alignment import clamp_formal_start_for_regime + + # 复现「全部」范围: 正式起点 = 面板首日 (数据边界), 环境过滤启用 → 顺延到次日 + labels = ("2025-08-18", "2025-08-19", "2025-08-20") + filt = {"states": ["strong", "lean_strong"]} + assert clamp_formal_start_for_regime(labels, date(2025, 8, 18), filt) == date(2025, 8, 19) + # 正式起点早于面板首日 (用户选的日期早于数据) 同样顺延 + assert clamp_formal_start_for_regime(labels, date(2025, 1, 1), filt) == date(2025, 8, 19) + + # 面板首日早于正式起点 (已有预热日) → 不动 + assert clamp_formal_start_for_regime(labels, date(2025, 8, 19), filt) == date(2025, 8, 19) + + # 过滤未启用 / 空 filter (无 states 无 min_score) / 起点 None → 不动 + assert clamp_formal_start_for_regime(labels, date(2025, 8, 18), None) == date(2025, 8, 18) + assert clamp_formal_start_for_regime(labels, date(2025, 8, 18), {}) == date(2025, 8, 18) + assert clamp_formal_start_for_regime(labels, None, filt) is None + + # 面板只有一天, 无法顺延 → 原样返回 (由 fail-closed 校验兜底) + assert clamp_formal_start_for_regime(("2025-08-18",), date(2025, 8, 18), filt) == date(2025, 8, 18) + + # 顺延后 T-1 对齐不再报「正式首日」错误: 首日环境成为次日 T-1 + regimes = {"2025-08-18": ("weak", 20), "2025-08-19": ("strong", 80)} + clamped = clamp_formal_start_for_regime(labels, date(2025, 8, 18), filt) + aligned = align_regime_t_minus_one(labels, regimes, required_start=clamped, required_end=None) + assert aligned[0] is None and aligned[1] == ("weak", 20.0)