From 000fcba0eddb4646642e2d4b7cd0e9e6856807d7 Mon Sep 17 00:00:00 2001 From: wshy Date: Thu, 9 Jul 2026 16:35:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(strategy):=20=E5=AE=9E=E7=9B=98=E9=80=89?= =?UTF-8?q?=E8=82=A1=20lookback=20=E7=AA=97=E5=8F=A3=E7=94=A8=E8=87=AA?= =?UTF-8?q?=E7=84=B6=E6=97=A5=E8=A3=81=E5=89=AA=E5=AF=BC=E8=87=B4=E5=81=8F?= =?UTF-8?q?=E5=B0=91=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 策略 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 后端测试全过, 无回归 --- backend/app/services/screener.py | 10 ++++++++-- backend/app/tickflow/repository.py | 13 ++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/app/services/screener.py b/backend/app/services/screener.py index fac6731..cbb82a9 100644 --- a/backend/app/services/screener.py +++ b/backend/app/services/screener.py @@ -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"]) diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index d24f224..f54ffbe 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -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(