Files
shy3130 0f2c7ce9b0 fix(backtest): 因子环境统计容忍数据边界=正式首日, 全部/1年范围不再报错
数据边界即正式首日 (本地数据首日=回测起点, 如「全部」/「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 报错
- 回归测试: 边界容忍 + 分桶数正确 / 容差仅放行首日 / 内部缺口仍拒绝
2026-08-22 20:57:40 +08:00

167 lines
5.9 KiB
Python

from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import date
from typing import Any
import numpy as np
RegimePoint = tuple[str, float]
REGIME_THREE_LEVEL_MAP = {
"strong": "strong",
"lean_strong": "strong",
"range": "range",
"lean_weak": "weak",
"weak": "weak",
}
def three_level_regime(state: str) -> str:
return REGIME_THREE_LEVEL_MAP.get(state, state)
def _date_text(value: object) -> str:
return str(value)[:10]
def _normalize_regime_point(value: Any) -> RegimePoint:
if isinstance(value, Mapping):
state = str(value.get("state", ""))
score = float(value.get("score", 0) or 0)
return state, score
if isinstance(value, (tuple, list)) and len(value) >= 2:
return str(value[0]), float(value[1] or 0)
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],
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.
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()
}
if not regime_map:
raise ValueError("市场环境数据为空, 请先在数据页完成市场环境计算后再回测")
aligned: list[RegimePoint | None] = [None] * len(labels)
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 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
):
raise ValueError(
f"市场环境数据覆盖不完整: 正式首日 {first_label} 缺少前一交易日环境, "
"请把前一交易日行情包含在预热区间"
)
for index in range(1, len(labels)):
current_label = _date_text(labels[index])
previous_label = _date_text(labels[index - 1])
point = regime_map.get(previous_label)
if point is not None:
aligned[index] = point
continue
required = (
(required_start_text is None or current_label >= required_start_text)
and (required_end_text is None or current_label <= required_end_text)
)
if required:
missing_dates.append(previous_label)
if missing_dates:
first_missing = missing_dates[0]
suffix = f" 等 {len(missing_dates)} 天" if len(missing_dates) > 1 else ""
raise ValueError(
f"市场环境数据覆盖不完整: 缺少前一交易日环境 {first_missing}{suffix}, "
"请先补算对应区间"
)
return aligned
def build_regime_filter_mask(
labels: Sequence[str],
regime_filter: Mapping[str, Any] | None,
regime_by_date: Mapping[object, Any],
*,
required_start: date | None = None,
required_end: date | None = None,
) -> np.ndarray | None:
"""Build a T-1 regime filter mask from caller-supplied regime data.
States are matched against the raw five-level labels, so each regime
level can be filtered on its own. Callers that want the aggregated
three-level view must list the raw states explicitly, e.g.
``["strong", "lean_strong"]`` for the strong bucket.
"""
if not regime_filter:
return None
allowed_states = {
str(state)
for state in (regime_filter.get("states") or [])
}
min_score = regime_filter.get("min_score")
if not allowed_states and min_score is None:
return None
aligned = align_regime_t_minus_one(
labels,
regime_by_date,
required_start,
required_end,
)
mask = np.ones(len(labels), dtype=bool)
for index, point in enumerate(aligned):
if point is None:
continue
state, score = point
mask[index] = (
(not allowed_states or state in allowed_states)
and (min_score is None or score >= float(min_score))
)
return mask