From 6bf8bcd22ed3801e960a560f6937eacb80cb32e8 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:20:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(repository):=20=E5=8E=86=E5=8F=B2=E6=97=A5?= =?UTF-8?q?=E6=9C=9F=E7=9A=84=20enriched=20=E5=8E=86=E5=8F=B2=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=8F=AA=E6=95=B0=E7=9B=AE=E6=A0=87=E6=97=A5=E5=8F=8A?= =?UTF-8?q?=E4=B9=8B=E5=89=8D=E7=9A=84=E4=BA=A4=E6=98=93=E6=97=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_enriched_history 按交易日裁剪 lookback 窗口时, 交易日序列取的是整份缓存, 起点 = 倒数第 lookback+1 个交易日, 返回时再过滤 date <= target_date。选股页选 历史日期时缓存里还有更晚的交易日, 它们也被计入: as_of 早 5 个交易日, lookback=20 的窗口只剩 16 天; 早于 lookback 时直接是空表 (退到慢路径现算)。 内置策略都是 matrix_native (required_history_bars 21~75), 窗口不够时 MA60 等特征 在 as_of 当天为空。合成数据上 as_of 往前 5 个交易日, 25 个内置策略中 7 个结果与 「缓存截止到 as_of」不同 (bullish_alignment 0 只 vs 89 只)。 慢路径 _load_enriched_history 只读 date <= target_date, 本来就对; 缓存路径补上同样 的过滤。 Closes #345 Co-Authored-By: Claude Opus 5 --- backend/app/tickflow/repository.py | 3 +- .../test_enriched_history_past_target.py | 71 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_enriched_history_past_target.py diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index e1cc023..3436d17 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -1222,7 +1222,8 @@ class KlineRepository: # 按交易日计数裁剪: 从数据里实际存在的交易日序列取最后 lookback_days 个交易日。 # 不能用 timedelta(days=N) (自然日), 否则周末/节假日会让窗口只有 ~N×5/7 个交易日, # 导致 filter_history 策略的滚动窗口/行号差(_gap)漏算, 与回测结果不一致。 - trading_dates = cache["date"].unique().sort() + # 只数目标日及之前的交易日: 历史日期选股时缓存里还有更晚的交易日 + trading_dates = cache["date"].filter(cache["date"] <= target_date).unique().sort() if len(trading_dates) > lookback_days: lookback_start = trading_dates[-(lookback_days + 1)] else: diff --git a/backend/tests/test_enriched_history_past_target.py b/backend/tests/test_enriched_history_past_target.py new file mode 100644 index 0000000..37716c8 --- /dev/null +++ b/backend/tests/test_enriched_history_past_target.py @@ -0,0 +1,71 @@ +"""get_enriched_history 对历史日期也必须按「目标日及之前」的交易日计数取窗口。 + +选股页可以选历史日期 (DatePicker), run_all / 单策略运行都会以 as_of 调 +_load_enriched_history(as_of, required_history_bars)。缓存命中路径 +(repo.get_enriched_history) 从整份缓存的交易日序列取最后 N+1 个交易日作起点, +缓存里晚于 as_of 的交易日也被算进去: as_of 越早窗口越短, 早于 N 个交易日时 +直接是空窗口。慢路径只读 date <= as_of, 本来就是按目标日计数的。 +""" +from __future__ import annotations + +import math +from datetime import date, timedelta +from pathlib import Path + +import polars as pl +import pytest + +from app.services.screener import ScreenerService +from app.tickflow.repository import DataStore, KlineRepository + +LOOKBACK = 20 + + +def _trading_days(n: int = 220) -> list[date]: + days: list[date] = [] + d = date(2025, 11, 3) + while len(days) < n: + if d.weekday() < 5: + days.append(d) + d += timedelta(days=1) + return days + + +def _cache(days: list[date]) -> pl.DataFrame: + rows = [] + for symbol, phase in (("600001.SH", 0.0), ("000002.SZ", 1.0)): + for i, day in enumerate(days): + rows.append((symbol, day, round(10 + math.sin(i * 0.2 + phase), 2))) + return pl.DataFrame(rows, schema=["symbol", "date", "close"], orient="row").sort(["symbol", "date"]) + + +def _repo(tmp_path: Path, cache: pl.DataFrame) -> KlineRepository: + repo = KlineRepository(DataStore(tmp_path)) + repo._enriched_history_cache = cache + return repo + + +@pytest.mark.parametrize("back", [0, 1, 5, 15], ids=["latest", "back1", "back5", "back15"]) +def test_history_window_counts_trading_days_up_to_target(tmp_path, back): + days = _trading_days() + target = days[-1 - back] + out = _repo(tmp_path, _cache(days)).get_enriched_history(target, LOOKBACK) + + assert out is not None + want = [d for d in days if d <= target][-(LOOKBACK + 1):] + assert out["date"].unique().sort().to_list() == want + + +def test_screener_history_does_not_depend_on_later_cached_days(tmp_path): + """同一个 as_of, 缓存是否已包含更晚的交易日不应改变策略拿到的历史窗口。""" + days = _trading_days() + as_of = days[-6] + full = _cache(days) + upto = full.filter(pl.col("date") <= as_of) + + with_later = ScreenerService(_repo(tmp_path / "a", full))._load_enriched_history(as_of, LOOKBACK) + without_later = ScreenerService(_repo(tmp_path / "b", upto))._load_enriched_history(as_of, LOOKBACK) + + assert without_later["date"].n_unique() == LOOKBACK + 1 + assert with_later["date"].n_unique() == without_later["date"].n_unique() + assert with_later.sort(["symbol", "date"]).equals(without_later.sort(["symbol", "date"]))