fix(strategy): P0-4 动量/全天候回测前视修复——9:30/14:00 决策取数上界统一 T-1(方案a) [vps]
CI/CD / test (push) Successful in 44s
CI/CD / nas-deploy (push) Successful in 0s
CI/CD / nas-verify (push) Successful in 9s

momentum_timing handle_data 入口换锚 data_date=previous_date: panel 预取/牛熊
分界/成份池/取强舍弱/均线选股/RPS/涨跌停 8 调用点统一传 T-1, pre_date 锚同步;
None 守卫=警告跳过保仓(small_cap 同款)。all_weather monthly(9:30) 判板
current_dt→previous_date, stop_loss(14:00) now_time→prev_date(缺失跳过涨停
观察)。回测不再吃当日未发生的收盘价(10-01 审计 P0-4 本体); VPS 影子盘中当日
bar 未写本就落 T-1=实盘零行为变化, 对齐 small_cap/value/factor_topn 范式。
helper 内部零改动(全经参数传入); _ex 副本已删免二遍修。
测试: 新增 test_decision_date_bound.py(TDD 红→绿钉三面: momentum 全链上界/
all_weather 两处判板); test_handle_data_uses_current_dt_not_today 原断言钉的
正是前视行为, 按新语义更新(日期来自 context 非真实今天, 上界=previous_date)。
8 目录 2495 passed。重跑重评(momentum/all_weather 同窗偏差报告+15.48%夏普
重评)待执行, 见处置档 §5.4/§5.5。

Co-Authored-By: Claude Code <reply@anthropic.com>
This commit is contained in:
2026-10-02 08:42:36 +08:00
parent e06bf6c522
commit 6251451a76
6 changed files with 183 additions and 20 deletions
@@ -94,6 +94,14 @@
2. factor 基线(factor_topn 链)本就干净,不受影响,**无需重跑**。
3. 时序:**P1-13 删除先行**(避免母本副本修两遍)→ P0-4 修复 → 重跑重评。
### 5.5 落地记录(10-02,方案 a)
- **momentum_timing**:`handle_data` 入口换锚 `data_date = _previous_date_str(context)`(None→警告跳过保仓,small_cap 同款守卫),panel 预取/牛熊分界/成份池/取强舍弱/均线选股/RPS/涨跌停 **8 个调用点**统一传 `data_date`;`pre_date` 锚从 `data_date` 起 shift。helper 内部零改动(全部经参数传入)。
- **all_weather**:`monthly_adjustment` 三 filter 共享判板 `current_dt`→`previous_date`;`stop_loss` 14:00 判板 `now_time`→`prev_date`(缺失→跳过涨停观察,数据缺失≠卖出)。
- **测试**:新增 `tests/portfolio/test_decision_date_bound.py`(TDD 红→绿:momentum 全链上界=T-1、all_weather 两处判板=T-1);`test_momentum_timing.py` 的 `test_handle_data_uses_current_dt_not_today` 原断言钉的正是前视行为(end=决策日),按新语义更新(日期仍来自 context、真实今天不混入,上界=previous_date)。
- `_previous_date_str` 带 current_dt 兜底(基本面 PIT 语义,同 small_cap 契约):引擎正常供 previous_date 时兜底不触发。
- 重跑重评(§5.4.1)**待执行**:待同窗口回测重跑出偏差报告。
## 六、P2-2/P2-5 延期理由(抗辩式)
- **P2-2 滑点三处**(回测默认引擎 24.6bps 或显式 / live FixedSlippage 0.001 / 策略内 0):印花税分量**本轮已修**(事实修正);佣金已一致(万3+5 元)。滑点分量=口径选择,**需用 reconcile 历史(实盘成交价 vs 影子成交价差)标定后才有唯一真相**——三处统一到一个未标定值只是把错误收敛成一致的错误。挂账:reconcile 数据在库(infra 域取数+我域定口径),与 §五修复同波执行=「回测成本与前视诚实化」专项。
@@ -40,7 +40,7 @@
> ⚠️ 因前两个致命 bug,原帖回测收益曲线**不可信**——RPS 排名实际没起作用、取数日期还是错的。复现前必须先修。
>
> ⚠️ **「已修复前视」的边界(2026-10-01 勘误)**:上表两致命 bug 修复只覆盖「真实今天 vs 回测当日」与取数区间错;**数据层当日 bar 前视仍在**——移植版 9:30 决策但 `get_closes_panel`/`get_limit_status_batch` 上界含当日(`momentum_timing.py` 五处 panel + 涨跌停查询),回测库里当日日线已存在=9:30 拿到当日收盘价。详见 `audit/20261001_architecture_code_audit/` P0-4 与 strategy 域处置记录。
> ⚠️ **「已修复前视」的边界(2026-10-01 勘误;2026-10-02 已修)**:上表两致命 bug 修复只覆盖「真实今天 vs 回测当日」与取数区间错;**数据层当日 bar 前视**曾另存——移植版 9:30 决策但 `get_closes_panel`/`get_limit_status_batch` 上界含当日(五处 panel + 涨跌停查询),回测库里当日日线已存在=9:30 拿到当日收盘价。**2026-10-02 P0-4 方案 a 落地**:`handle_data` 数据上界统一 `previous_date`(T-1),对齐 small_cap/value/factor_topn 范式;VPS 影子盘中本取不到当日 bar=实盘零行为变化。详见 `audit/20261001_architecture_code_audit/` P0-4 与 strategy 域处置记录 §五。
## 本地复现要点
+8 -3
View File
@@ -163,13 +163,16 @@ class AllWeatherStrategy:
"""昨日涨停打开则卖出;持仓 price < avg_cost*0.92 止损;补仓最多 N 只。"""
positions = _get_positions(context, self.broker)
num_sold = 0
now_time = _current_dt(context)
# P0-4 前视修复(10-01 审计,10-02 落地):14:00 判板日期=T-1——回测不再
# 吃当日收盘;影子盘中 provider 本就落 T-1=零行为变化。缺失→跳过涨停
# 观察(数据缺失≠卖出)。
prev_date = _previous_date_str(context)
# 1) 昨日涨停观察:尾盘不涨停则卖出
for stock in self.yesterday_hl_list:
if stock not in positions:
continue
status = self._limit_status(stock, now_time)
status = self._limit_status(stock, prev_date) if prev_date else None
if status is None:
continue
if status.get("is_limit_up"):
@@ -246,7 +249,9 @@ class AllWeatherStrategy:
# 4) 过滤涨停/跌停/停牌(P1.3 批量预取:一次查三 filter 共享,原逐 filter
# 不传 status_map/date → 全部走"无数据保留"死分支,照买涨停照交易停牌)
pos_keys = list(_get_positions(context, self.broker).keys())
status_map = self._get_limit_status(target, current_dt)
# P0-4 前视修复(10-02):9:30 判板口径=T-1(与 small_cap/value 同款,
# 回测不再吃当日收盘;影子盘中零行为变化)
status_map = self._get_limit_status(target, previous_date)
target = filters.filter_limitup_stock(
target, self.provider, positions=pos_keys, status_map=status_map
)
+20 -9
View File
@@ -37,6 +37,7 @@ from .all_weather import (
_current_dt,
_dedup,
_get_positions,
_previous_date_str,
_total_value,
)
@@ -143,11 +144,21 @@ class MomentumTimingStrategy:
logger.warning("handle_data: context.current_dt 为 None,跳过")
return
cur_date = _to_date_str(cur_dt)
pre_date = _to_date_str(cur_dt - datetime.timedelta(days=cfg.past_day))
# P0-4 前视修复(10-01 审计,10-02 落地):9:30 决策的数据上界统一 T-1
# (previous_date)——panel/牛熊分界/RPS/选股/涨跌停全链不再吃回测当日
# 收盘价;VPS 影子盘中当日 bar 未写本就取 T-1=零行为变化。
# 对齐 small_cap/value_selection/factor_topn 现行范式。
data_date = _previous_date_str(context)
if data_date is None:
logger.warning(
"[%s] previous_date 缺失,跳过当日调仓(持仓不动,不清仓)", cur_date,
)
return
pre_date = _shift_date(data_date, -cfg.past_day)
# 1) 牛熊分界(当日预取:先只拉指数点位;熊市日不付股票池全量 IO,同原行为)
self._ensure_day_panel(cfg.index_list, cur_date)
buy_sign = self._cal_buy_sign(cfg.index_list, cfg.past_day, cur_date)
self._ensure_day_panel(cfg.index_list, data_date)
buy_sign = self._cal_buy_sign(cfg.index_list, cfg.past_day, data_date)
if buy_sign is None:
# 数据失败 ≠ 熊市(2026-08-19 假熊市清仓事故):跳过当日调仓保住持仓
logger.warning(
@@ -174,27 +185,27 @@ class MomentumTimingStrategy:
_t0 = time.monotonic()
union_stocks: List[str] = []
for each_index in cfg.index_list:
union_stocks.extend(self._stock_pool_cached(each_index, cur_date))
union_stocks.extend(self._stock_pool_cached(each_index, data_date))
logger.info("[%s] 选股1/3 池预取完成: %d 只 %.1fs",
cur_date, len(union_stocks), time.monotonic() - _t0)
self._ensure_day_panel(union_stocks, cur_date)
self._ensure_day_panel(union_stocks, data_date)
logger.info("[%s] 选股2/3 close宽表预取完成 %.1fs",
cur_date, time.monotonic() - _t0)
# 取强舍弱(每行业 RPS top_k 并集) → 候选池
candidates = self._find_stock_pool(cfg.index_list, cur_date, pre_date)
candidates = self._find_stock_pool(cfg.index_list, data_date, pre_date)
# 均线动量过滤(close > MA_short > MA_long)
stocks = self._select_stocks(candidates, cur_date)
stocks = self._select_stocks(candidates, data_date)
# 候选过多时再按 RPS 取前 top_k (原策略 handle_data 第 171-175 行)
if len(stocks) > cfg.top_k:
rps_df = self._cal_rps(stocks, cur_date, pre_date)
rps_df = self._cal_rps(stocks, data_date, pre_date)
stocks = list(rps_df["code"])[: cfg.top_k]
# 过滤涨停/跌停/停牌(复用 sanguo_portfolio.filters)
# 批量预取当日涨跌停/停牌状态(数据 session 判断好),三个 filter 共享一次查询
status_map = self._get_limit_status(stocks, cur_date)
status_map = self._get_limit_status(stocks, data_date)
stocks = filters.filter_limitup_stock(
stocks, self.provider,
positions=list(positions.keys()), status_map=status_map,
+134
View File
@@ -0,0 +1,134 @@
"""P0-4 前视修复(10-01 审计最重件,10-02 落地):9:30/14:00 决策取数上界钉死 T-1。
病灶(修前):momentum_timing 9:30 handler 五处 ``get_closes_panel(end=cur_date)``
+ 涨跌停 ``cur_date``;all_weather monthly(9:30) ``current_dt`` + stop_loss(14:00)
``now_time``——回测库当日日线在 → 9:30/14:00 吃到当日收盘价=前视;
VPS 影子盘中当日 bar 未写 → 实际取 T-1,修复=回测对齐实盘,**零行为变化**。
本文件钉三件事:
1. momentum handle_data 全链(panel/牛熊分界/池/选股/涨跌停)上界=previous_date;
2. previous_date 缺失 → 跳过当日调仓保持仓(数据缺失≠清仓,08-19 事故同款纪律);
3. all_weather 两处判板日期=previous_date。
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock
from tests.portfolio.conftest import FakeContext, FakePosition
from sanguo_portfolio.strategies.all_weather import AllWeatherStrategy
from sanguo_portfolio.strategies.momentum_timing import MomentumTimingStrategy
_T = "2026-10-02" # 决策日(9:30 / 14:00)
_T1 = "2026-09-30" # T-1(上一交易日)
class _PlainBroker:
"""素 broker:无实例账本通道 → _get_positions 走 context 持仓回退路径。"""
def __init__(self) -> None:
self.orders: list = []
self.values: list = []
def order_target_value(self, code: str, value: float):
self.orders.append((code, value))
return object()
def order_value(self, code: str, value: float):
self.values.append((code, value))
return object()
def _ctx(hour: int, previous_date: str | None = _T1) -> FakeContext:
return FakeContext(
current_dt=datetime(2026, 10, 2, hour, 0),
previous_date=previous_date,
positions={
"OLD.XSHG": FakePosition("OLD.XSHG", avg_cost=10.0, price=10.5, total=100),
},
)
# =================== momentum_timing:9:30 全链上界 ===================
class TestMomentumBoundAtPreviousDate:
def test_handle_data_bounds_all_queries_at_previous_date(self):
"""panel 预取/牛熊分界/成份池/选股窗口/涨跌停查询的日期参=previous_date。"""
broker = _PlainBroker()
strat = MomentumTimingStrategy(provider=MagicMock(), broker=broker)
seen: dict = {}
def _panel(symbols, d):
seen["panel"] = d
strat._ensure_day_panel = _panel
def _buy_sign(idx, past, d):
seen["buy_sign"] = d
return True
def _pool(idx, d):
seen.setdefault("pool", []).append(d)
return []
def _find(idx, d, pre):
seen["find"] = (d, pre)
return ["S1.XSHG"]
def _select(stocks, d):
seen["select"] = d
return list(stocks)
def _limit(ss, d):
seen["limit"] = d
return {}
strat._cal_buy_sign = _buy_sign
strat._stock_pool_cached = _pool
strat._find_stock_pool = _find
strat._select_stocks = _select
strat._get_limit_status = _limit
strat.handle_data(_ctx(9))
assert seen["panel"] == _T1
assert seen["buy_sign"] == _T1
assert seen["pool"] and set(seen["pool"]) == {_T1} # 十行业池逐个全 T-1
assert seen["find"][0] == _T1 # RPS/取强舍弱窗口终点=T-1
assert seen["select"] == _T1
assert seen["limit"] == _T1 # 判板口径=T-1(对齐 small_cap/value)
# 持仓里不在目标里的 OLD 照卖(正常轮换语义不受影响)
assert broker.orders == [("OLD.XSHG", 0)]
# =================== all_weather:两处判板日期 ===================
class TestAllWeatherLimitAtPreviousDate:
def test_stop_loss_judges_limit_at_previous_date(self):
"""14:00 stop_loss 的「昨日涨停观察」判板日期=previous_date(非当日)。"""
strat = AllWeatherStrategy(provider=MagicMock(), broker=_PlainBroker())
strat.yesterday_hl_list = ["OLD.XSHG"]
seen: dict = {}
def _limit(ss, d):
seen["limit"] = d
return {"OLD.XSHG": {"is_limit_up": True, "is_limit_down": False,
"is_paused": False}}
strat._get_limit_status = _limit
strat.stop_loss(_ctx(14))
assert seen["limit"] == _T1
def test_monthly_adjustment_judges_limit_at_previous_date(self):
"""9:30 monthly_adjustment 三 filter 共享的批量判板日期=previous_date。"""
strat = AllWeatherStrategy(provider=MagicMock(), broker=_PlainBroker())
seen: dict = {}
strat._stock_pool = lambda idx, d: ["B1.XSHG", "B2.XSHG"]
strat._market_cap_top = lambda stocks, d, top, n: list(stocks)[:n]
strat._trend_mean = lambda lst, d, w: 1.0 # 大盘趋势起 → big universe
strat._pick_big_universe = lambda b, cur, prev: ["BIG1.XSHG"]
def _limit(ss, d):
seen["limit"] = d
return {}
strat._get_limit_status = _limit
strat.monthly_adjustment(_ctx(9))
assert seen["limit"] == _T1
+12 -7
View File
@@ -439,10 +439,13 @@ class TestHandleData:
assert buys
def test_handle_data_uses_current_dt_not_today(self):
"""⚠️ 修复原始 bug 验证:handle_data 必须用 context.current_dt 计算 cur_date,
不能用 datetime.date.today()(后者取真实今天)。
"""⚠️ 修复原始 bug 验证:日期必须来自 context,不能用 datetime.date.today()。
P0-4(10-02)后语义:get_closes_panel 上界=context.previous_date(T-1,
9:30 决策不吃当日收盘);决策日 current_dt 只进日志。两者都来自 context,
真实今天永不混入。
"""
# 用一个明显不同的 current_dt,确认 get_closes_panel 的 end 跟随它
# 用明显不同的 current_dt/previous_date,确认 end 跟随 context
cfg = MomentumTimingConfig(index_list=["IDX.XSHG"])
s = make_strategy(config=cfg)
captured_ends: List[Any] = []
@@ -454,15 +457,17 @@ class TestHandleData:
# 下行 → 熊市(快速 return,不查其他)
return _make_close_wide(
["IDX.XSHG"], [[40.0 - i for i in range(30)]],
end_date=str(end or "2024-10-08")[:10],
end_date=str(end or "2024-09-30")[:10],
days=30,
)
s.provider.get_closes_panel.side_effect = _gcp
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30))
ctx = FakeContext(current_dt=datetime(2024, 10, 8, 9, 30),
previous_date="2024-09-30")
s.handle_data(ctx)
# 至少一次 get_closes_panel 的 end 是 "2024-10-08"(来自 current_dt),非今天
assert any("2024-10-08" in d for d in captured_ends)
# 至少一次 get_closes_panel 的 end 是 "2024-09-30"(来自 previous_date)
assert any("2024-09-30" in d for d in captured_ends)
assert not any(d.startswith("2026-") for d in captured_ends) # 真实今天不混入
# =================== _find_stock_pool (取强舍弱) ===================