fix(strategy): 实盘选股 lookback 窗口用自然日裁剪导致偏少 (#90)

策略 LOOKBACK_DAYS 语义是交易日(见 strategy-guide.md), 但实盘选股路径
用 timedelta(days=N) 按自然日裁剪历史窗口。N 个自然日 ≈ N×5/7 个交易日,
周末/节假日让窗口系统性偏少, 导致 filter_history 策略的滚动窗口/行号差
(_gap)漏算, 实盘与回测结果不一致。

- repository.py get_enriched_history: 从数据实际交易日序列取最后 N 个交易日
- screener.py _load_enriched_history: 同上(慢路径)
- 不动 warmup 裁剪(×2 放宽, 多读无害)和 run_all cap=30(性能保护, 独立问题)
- 回测路径本就正确(传完整 panel, 无裁剪), 不受影响

验证: lookback=10 旧逻辑返回 7 交易日 → 新逻辑返回 11 交易日(含target当天)
129 后端测试全过, 无回归
This commit is contained in:
wshy
2026-07-09 16:35:43 +08:00
committed by GitHub
parent e0badf1458
commit 000fcba0ed
2 changed files with 18 additions and 5 deletions
+8 -2
View File
@@ -428,9 +428,15 @@ class ScreenerService:
if "name" not in df_full.columns:
df_full = df_full.join(instruments.select(inst_cols), on="symbol", how="left")
# 裁剪掉 warmup 部分, 只保留 lookback 范围 (减少 group_by 开销)
lookback_start = target_date - timedelta(days=lookback_days)
# 裁剪掉 warmup 部分, 只保留 lookback 范围 (减少 group_by 开销)
# 按交易日计数: 从数据里实际存在的交易日序列取最后 lookback_days 个交易日,
# 不能用 timedelta(days=N) (自然日), 否则周末/节假日会让窗口偏少, 与回测不一致。
if "date" in df_full.columns:
trading_dates = df_full["date"].unique().sort()
if len(trading_dates) > lookback_days:
lookback_start = trading_dates[-(lookback_days + 1)]
else:
lookback_start = trading_dates[0]
df_full = df_full.filter(pl.col("date") >= lookback_start)
df_full = df_full.sort(["symbol", "date"])
+10 -3
View File
@@ -942,12 +942,19 @@ class KlineRepository:
cache_max = cache["date"].max()
cache_min = cache["date"].min()
from datetime import timedelta
# 验证缓存覆盖完整范围 (含 warmup)
# 验证缓存覆盖完整范围 (含 warmup)。lookback_days 是交易日语义, 用 ×2 日历日
# 放宽确保覆盖 (节假日/周末), 与 warmup 60 一起留足余量。
warmup_start = target_date - timedelta(days=(lookback_days + 60) * 2)
if cache_min > warmup_start or cache_max < target_date:
return None
# 只返回 lookback 范围 (日历天数 ≈ 2/3 交易日, 足够覆盖)
lookback_start = target_date - timedelta(days=lookback_days)
# 按交易日计数裁剪: 从数据里实际存在的交易日序列取最后 lookback_days 个交易日。
# 不能用 timedelta(days=N) (自然日), 否则周末/节假日会让窗口只有 ~N×5/7 个交易日,
# 导致 filter_history 策略的滚动窗口/行号差(_gap)漏算, 与回测结果不一致。
trading_dates = cache["date"].unique().sort()
if len(trading_dates) > lookback_days:
lookback_start = trading_dates[-(lookback_days + 1)]
else:
lookback_start = trading_dates[0]
return cache.filter((pl.col("date") >= lookback_start) & (pl.col("date") <= target_date))
def get_enriched_range(