|
13 | 13 | from collections.abc import Callable, Mapping |
14 | 14 | from dataclasses import dataclass, field |
15 | 15 | from datetime import date, timedelta |
| 16 | +from pathlib import Path |
16 | 17 | from typing import Literal |
17 | 18 |
|
18 | 19 | import numpy as np |
@@ -473,6 +474,9 @@ class StrategyBacktestConfig: |
473 | 474 | holding_days: int = 5 |
474 | 475 | # 分钟K精确成交: 开启后用当日分钟K确定穿越价/VWAP (需 Pro+ 分钟K能力) |
475 | 476 | minute_fill: bool = False |
| 477 | + # 市场环境过滤: {"states": ["strong",...], "min_score": 60}。 |
| 478 | + # 强制 T-1: regime[T-1] 决定 entry[T](防未来函数)。None=不过滤。 |
| 479 | + regime_filter: dict | None = None |
476 | 480 |
|
477 | 481 | def __post_init__(self) -> None: |
478 | 482 | if self.entry_fill is None: |
@@ -846,6 +850,13 @@ def prepare_matrix_optimization( |
846 | 850 | first.start, |
847 | 851 | first.end, |
848 | 852 | ) |
| 853 | + # 市场环境过滤(优化器共享, 用首个 config 的 regime_filter) |
| 854 | + _rm = self._build_regime_mask( |
| 855 | + market_data.timestamp_labels, first.regime_filter, |
| 856 | + getattr(getattr(self.engine.repo, "store", None), "data_dir", None), |
| 857 | + ) |
| 858 | + if _rm is not None: |
| 859 | + entry_time_mask = entry_time_mask & _rm |
849 | 860 | exit_time_mask = self._matrix_date_range_mask( |
850 | 861 | market_data.timestamp_labels, |
851 | 862 | first.start, |
@@ -1127,6 +1138,13 @@ def _err(msg: str) -> StrategyBacktestResult: |
1127 | 1138 | config.start, |
1128 | 1139 | config.end, |
1129 | 1140 | ) |
| 1141 | + # 市场环境过滤(强制 T-1): 只叠加 entry, 不影响 exit |
| 1142 | + _rm = self._build_regime_mask( |
| 1143 | + market_data.timestamp_labels, config.regime_filter, |
| 1144 | + getattr(getattr(self.engine.repo, "store", None), "data_dir", None), |
| 1145 | + ) |
| 1146 | + if _rm is not None: |
| 1147 | + entry_time_mask = entry_time_mask & _rm |
1130 | 1148 | exit_time_mask = self._matrix_date_range_mask( |
1131 | 1149 | market_data.timestamp_labels, |
1132 | 1150 | config.start, |
@@ -1220,6 +1238,12 @@ def _err(msg: str) -> StrategyBacktestResult: |
1220 | 1238 | config.start, |
1221 | 1239 | config.end, |
1222 | 1240 | ) |
| 1241 | + _rm = self._build_regime_mask( |
| 1242 | + market_data.timestamp_labels, config.regime_filter, |
| 1243 | + getattr(getattr(self.engine.repo, "store", None), "data_dir", None), |
| 1244 | + ) |
| 1245 | + if _rm is not None: |
| 1246 | + entry_time_mask = entry_time_mask & _rm |
1223 | 1247 | exit_time_mask = self._matrix_date_range_mask( |
1224 | 1248 | market_data.timestamp_labels, |
1225 | 1249 | config.start, |
@@ -1620,6 +1644,56 @@ def _matrix_date_range_mask( |
1620 | 1644 | count=len(timestamp_labels), |
1621 | 1645 | ) |
1622 | 1646 |
|
| 1647 | + @staticmethod |
| 1648 | + def _build_regime_mask( |
| 1649 | + timestamp_labels: tuple[str, ...], |
| 1650 | + regime_filter: dict | None, |
| 1651 | + data_dir: Path | None, |
| 1652 | + ) -> np.ndarray | None: |
| 1653 | + """构造逐日 regime mask。强制 T-1 防未来函数: regime[T-1] 决定 entry[T]。 |
| 1654 | +
|
| 1655 | + timestamp_labels[i] 的入场资格 = 它的"前一交易日"的 regime 是否满足条件。 |
| 1656 | + "前一交易日"用 timestamp_labels 自身的顺序确定(回测时间轴上的前一天)。 |
| 1657 | + 边界: 首日无前一日环境 → 默认允许(不阻断)。 |
| 1658 | + regime_filter 为 None 或无 regime 数据时返回 None(不过滤)。 |
| 1659 | + """ |
| 1660 | + if not regime_filter or data_dir is None: |
| 1661 | + return None |
| 1662 | + allowed_states = set(regime_filter.get("states") or []) |
| 1663 | + min_score = regime_filter.get("min_score") |
| 1664 | + if not allowed_states and min_score is None: |
| 1665 | + return None |
| 1666 | + |
| 1667 | + from app.services import regime_builder |
| 1668 | + regime_df = regime_builder.load_regime_history(data_dir) |
| 1669 | + if regime_df.is_empty(): |
| 1670 | + return None |
| 1671 | + |
| 1672 | + # 构建 date(ISO) → (state, score) 映射 |
| 1673 | + regime_map: dict[str, tuple[str, int]] = {} |
| 1674 | + for r in regime_df.iter_rows(named=True): |
| 1675 | + d = r.get("date") |
| 1676 | + ds = str(d)[:10] if d is not None else None |
| 1677 | + if ds: |
| 1678 | + regime_map[ds] = (str(r.get("state", "")), int(r.get("score", 0) or 0)) |
| 1679 | + |
| 1680 | + # 对每个 label, 找它的前一交易日的 regime(timestamp_labels 顺序里的前一天) |
| 1681 | + n = len(timestamp_labels) |
| 1682 | + mask = np.ones(n, dtype=bool) # 默认允许 |
| 1683 | + for i in range(1, n): |
| 1684 | + prev_label = timestamp_labels[i - 1][:10] |
| 1685 | + entry = regime_map.get(prev_label) |
| 1686 | + if entry is None: |
| 1687 | + continue # 无前一日环境数据 → 允许(不阻断) |
| 1688 | + state, score = entry |
| 1689 | + ok = True |
| 1690 | + if allowed_states and state not in allowed_states: |
| 1691 | + ok = False |
| 1692 | + if min_score is not None and score < min_score: |
| 1693 | + ok = False |
| 1694 | + mask[i] = ok |
| 1695 | + return mask |
| 1696 | + |
1623 | 1697 | def _build_candidate_filter_mask( |
1624 | 1698 | self, |
1625 | 1699 | panel: pl.DataFrame, |
|
0 commit comments