From 0f2c7ce9b0983ab06ab078171d436a13ae7776fc Mon Sep 17 00:00:00 2001 From: shy3130 Date: Sat, 22 Aug 2026 20:57:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(backtest):=20=E5=9B=A0=E5=AD=90=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E7=BB=9F=E8=AE=A1=E5=AE=B9=E5=BF=8D=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=3D=E6=AD=A3=E5=BC=8F=E9=A6=96=E6=97=A5,=20?= =?UTF-8?q?=E5=85=A8=E9=83=A8/1=E5=B9=B4=E8=8C=83=E5=9B=B4=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据边界即正式首日 (本地数据首日=回测起点, 如「全部」/「1年」) 时, 首日 无 T-1 环境属正常。策略回测已有 clamp_formal_start_for_regime 首日让渡, 因子回测的环境分组统计路径漏了同样容差, 会整体报错阻断回测。 - align_regime_t_minus_one 新增 first_day_boundary_ok 参数 (默认 False, 过滤场景 fail-closed 行为不变): 统计场景首日无前驱时跳过首日不参与分组 - 因子 _calc_regime_stats 传入容差, 与策略侧首日让渡同口径; 内部缺口 (次日 T-1 缺环境) 仍 fail-closed 报错 - 回归测试: 边界容忍 + 分桶数正确 / 容差仅放行首日 / 内部缺口仍拒绝 --- backend/app/backtest/factor.py | 5 ++ backend/app/backtest/regime_alignment.py | 11 +++- backend/tests/backtest/test_factor_metrics.py | 62 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/backend/app/backtest/factor.py b/backend/app/backtest/factor.py index 5ecfade..bc0f915 100644 --- a/backend/app/backtest/factor.py +++ b/backend/app/backtest/factor.py @@ -856,6 +856,11 @@ class FactorBacktestService: regime_by_date, required_start, required_end, + # 统计场景: 数据边界即正式首日 (如「全部」/「1年」范围起点=本地数据首日) 时, + # 首日无 T-1 环境属正常, 跳过首日不参与环境分组即可, 不阻断回测; + # 与策略回测 clamp_formal_start_for_regime 的「首日让渡」同口径。 + # 内部缺口 (次日 T-1 缺环境) 仍 fail-closed 报错。 + first_day_boundary_ok=True, ) ic_by_date = { str(row["date"])[:10]: float(row["ic"]) diff --git a/backend/app/backtest/regime_alignment.py b/backend/app/backtest/regime_alignment.py index 565f646..d2ac62f 100644 --- a/backend/app/backtest/regime_alignment.py +++ b/backend/app/backtest/regime_alignment.py @@ -70,8 +70,15 @@ def align_regime_t_minus_one( regime_by_date: Mapping[object, Any], required_start: date | None, required_end: date | None, + *, + first_day_boundary_ok: bool = False, ) -> list[RegimePoint | None]: - """Align each label with the preceding label's regime without any I/O.""" + """Align each label with the preceding label's regime without any I/O. + + first_day_boundary_ok: 统计类调用方 (因子环境分组) 允许首日无前驱环境 —— + 数据边界即正式首日 (本地数据从正式首日开始) 时首日没有 T-1 环境属正常, + 跳过首日不参与分组即可, 不应阻断整个回测。内部缺口仍 fail-closed。 + """ regime_map = { _date_text(key): _normalize_regime_point(value) for key, value in regime_by_date.items() @@ -83,7 +90,7 @@ def align_regime_t_minus_one( required_start_text = str(required_start) if required_start is not None else None required_end_text = str(required_end) if required_end is not None else None missing_dates: list[str] = [] - if labels and required_start_text is not None: + if labels and required_start_text is not None and not first_day_boundary_ok: first_label = _date_text(labels[0]) if first_label >= required_start_text and ( required_end_text is None or first_label <= required_end_text diff --git a/backend/tests/backtest/test_factor_metrics.py b/backend/tests/backtest/test_factor_metrics.py index bddf384..ae13e7d 100644 --- a/backend/tests/backtest/test_factor_metrics.py +++ b/backend/tests/backtest/test_factor_metrics.py @@ -305,6 +305,68 @@ def test_factor_regime_stats_accept_injected_t_minus_one_mapping(tmp_path): assert {item["state"] for item in result.regime_stats} == {"range", "strong", "weak"} +def test_factor_regime_stats_tolerates_boundary_formal_start(tmp_path): + """数据边界=正式首日 (如「全部」/「1年」范围起点=本地数据首日) 时不再报错。 + + 首日无 T-1 环境 → 首日不参与环境分组, 其余日期正常分组, 回测不阻断; + 与策略回测 clamp_formal_start_for_regime 的「首日让渡为预热」同口径。 + """ + panel = _daily_panel(days=4) + market_dates = panel["date"].unique().sort().to_list() # 日历从正式首日开始, 无预热日 + for current in market_dates: + partition = tmp_path / "kline_daily_enriched" / f"date={current.isoformat()}" + partition.mkdir(parents=True) + pl.DataFrame({"date": [current]}).write_parquet(partition / "part.parquet") + regimes = { + date(2026, 1, 5): {"state": "weak", "score": 20}, + date(2026, 1, 6): {"state": "strong", "score": 80}, + date(2026, 1, 7): {"state": "strong", "score": 85}, + } + + result = FactorBacktestService(_Engine(panel, tmp_path)).run( + _config(end=date(2026, 1, 8)), + regime_by_date=regimes, + ) + + assert result.error is None + stats = {item["state"]: item for item in result.regime_stats} + assert set(stats) == {"strong", "weak"} + # 首日 (01-05, 无 T-1 环境) 被跳过: weak 桶只含以 01-05 为 T-1 的 01-06 + assert stats["weak"]["n_dates"] == 1 + # strong 桶 = 01-07 (T-1=01-06) + 01-08 (T-1=01-07) + assert stats["strong"]["n_dates"] == 2 + + +def test_align_first_day_boundary_flag_only_tolerates_first_day(): + labels = ("2026-01-05", "2026-01-06", "2026-01-07") + regimes = { + "2026-01-05": ("weak", 20), + "2026-01-06": ("strong", 80), + } + + # 默认 (过滤场景): 正式首日=labels[0] 无前驱 → fail-closed + with pytest.raises(ValueError, match="正式首日"): + align_regime_t_minus_one( + labels, regimes, required_start=date(2026, 1, 5), required_end=None, + ) + + # 统计场景: 首日容差 → 首日 None, 其余正常 T-1 对齐 + aligned = align_regime_t_minus_one( + labels, regimes, + required_start=date(2026, 1, 5), required_end=None, + first_day_boundary_ok=True, + ) + assert aligned == [None, ("weak", 20.0), ("strong", 80.0)] + + # 统计场景内部缺口仍 fail-closed (次日 T-1 缺环境) + with pytest.raises(ValueError, match="缺少前一交易日环境"): + align_regime_t_minus_one( + labels, {"2026-01-05": ("weak", 20)}, + required_start=date(2026, 1, 5), required_end=None, + first_day_boundary_ok=True, + ) + + def test_factor_regime_stats_reject_missing_actual_market_predecessor(tmp_path): panel = _daily_panel(days=3) market_dates = [date(2026, 1, 2), *panel["date"].unique().sort().to_list()]