diff --git a/audit/20261001_architecture_code_audit/strategy域处置_2026-10-01.md b/audit/20261001_architecture_code_audit/strategy域处置_2026-10-01.md index 7cb60e85..db948d5d 100644 --- a/audit/20261001_architecture_code_audit/strategy域处置_2026-10-01.md +++ b/audit/20261001_architecture_code_audit/strategy域处置_2026-10-01.md @@ -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 域取数+我域定口径),与 §五修复同波执行=「回测成本与前视诚实化」专项。 diff --git a/docs/research/joinquant_strategies/03_momentum_timing/notes.md b/docs/research/joinquant_strategies/03_momentum_timing/notes.md index ace41a9b..6e505cd3 100644 --- a/docs/research/joinquant_strategies/03_momentum_timing/notes.md +++ b/docs/research/joinquant_strategies/03_momentum_timing/notes.md @@ -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 域处置记录 §五。 ## 本地复现要点 diff --git a/sanguo_portfolio/strategies/all_weather.py b/sanguo_portfolio/strategies/all_weather.py index 80b8b61e..6a74f8b8 100644 --- a/sanguo_portfolio/strategies/all_weather.py +++ b/sanguo_portfolio/strategies/all_weather.py @@ -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 ) diff --git a/sanguo_portfolio/strategies/momentum_timing.py b/sanguo_portfolio/strategies/momentum_timing.py index 5529e565..e8d9451e 100644 --- a/sanguo_portfolio/strategies/momentum_timing.py +++ b/sanguo_portfolio/strategies/momentum_timing.py @@ -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, diff --git a/tests/portfolio/test_decision_date_bound.py b/tests/portfolio/test_decision_date_bound.py new file mode 100644 index 00000000..4b1550ec --- /dev/null +++ b/tests/portfolio/test_decision_date_bound.py @@ -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 diff --git a/tests/portfolio/test_momentum_timing.py b/tests/portfolio/test_momentum_timing.py index 34b60df5..0474d6eb 100644 --- a/tests/portfolio/test_momentum_timing.py +++ b/tests/portfolio/test_momentum_timing.py @@ -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 (取强舍弱) ===================