From 7a13713903d17482b27ec51b40202c41a8ffa8be Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:19:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(fundamentals):=20=E6=8D=A2=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=9C=9F=E5=85=AC=E5=91=8A=E5=BD=93=E6=97=A5=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=B8=A2=E5=A4=B1=E4=B8=8A=E4=B8=80=E6=9C=9F=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E5=9B=A0=E5=AD=90=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach_fundamental_factors 用公告日做 asof 回看再按「严格大于」门控: 日期正好等于新一期公告日时, join_asof 已匹配到「当天还不能用」的新记录, 门控随即置 null, 打断上一期的前向填充。矩阵路径 build_fundamental_matrices 按 searchsorted(side="right") 逐期覆盖, 公告当日保留上一期, 两条路径因此不一致 (其 docstring 声明与 attach_fundamental_factors 同口径)。 asof 键改用生效日 (公告日次日), 只命中已生效的报告期; 公告前仍为 null 的门控不变。 补 tests/test_fundamental_factors.py 的两条公告一致性用例。 --- backend/app/backtest/fundamentals.py | 13 ++++++++++--- backend/tests/test_fundamental_factors.py | 21 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/backend/app/backtest/fundamentals.py b/backend/app/backtest/fundamentals.py index d69f52a..0b0e8aa 100644 --- a/backend/app/backtest/fundamentals.py +++ b/backend/app/backtest/fundamentals.py @@ -103,11 +103,18 @@ def attach_fundamental_factors( columns = sorted( {FUNDAMENTAL_FACTORS[name]["column"] for name in missing_columns} ) - right = snapshot.select(["symbol", "_announce", *columns]).sort(["symbol", "_announce"]) + # asof 键取生效日 (公告日次日) 而非公告日: 直接用公告日回看会在换报告期的 + # 公告当日取到「尚未生效」的新一期并被门控置 null, 打断上一期的前向填充, + # 与矩阵路径 (searchsorted side="right") 不一致。 + right = ( + snapshot.select(["symbol", "_announce", *columns]) + .with_columns(pl.col("_announce").dt.offset_by("1d").alias("_effective")) + .sort(["symbol", "_effective"]) + ) joined = panel.join_asof( right, left_on="date", - right_on="_announce", + right_on="_effective", by="symbol", strategy="backward", check_sortedness=False, # 双侧均已按 (symbol, key) 排序, 免除逐组检查开销 @@ -128,7 +135,7 @@ def attach_fundamental_factors( expressions.append( pl.when(announced).then(value).otherwise(None).alias(name) ) - return joined.with_columns(expressions) + return joined.with_columns(expressions).drop("_effective") def build_fundamental_matrices( diff --git a/backend/tests/test_fundamental_factors.py b/backend/tests/test_fundamental_factors.py index 861a4c9..c148678 100644 --- a/backend/tests/test_fundamental_factors.py +++ b/backend/tests/test_fundamental_factors.py @@ -85,7 +85,7 @@ def test_attach_replaces_with_newer_announcement(): assert roe[4] is None # 4-5 公告日 assert roe[5] == 20.0 # 4-6 起 20.0 assert roe[13] == 20.0 # 4-14 - assert roe[14] is None # 4-15 二次公告日, 当天仍不可用 (严格大于) + assert roe[14] == 20.0 # 4-15 二次公告日: 新一期尚未生效, 仍保留上一期 assert roe[15] == 33.0 # 4-16 起新公告生效 @@ -121,6 +121,25 @@ def test_matrix_field_matches_polars_attach(): np.testing.assert_allclose(actual, expected, rtol=1e-6) +def test_matrix_field_matches_polars_attach_across_two_announcements(): + """换报告期时两条路径仍须一致: 新公告当日应保留上一期值(前向填充不断档)。""" + panel = _daily_panel(date(2026, 4, 1), 20, ("600000.SH",)) + snapshot = _snapshot_frame([ + {"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0}, + {"symbol": "600000.SH", "announce": "2026-04-15", "roe": 33.0}, + ]) + attached = attach_fundamental_factors(panel, snapshot, ["roe_latest"]).sort("date") + market = build_market_data_matrix(panel) + matrix = build_fundamental_matrices(market, snapshot, ["roe_latest"])["roe_latest"] + column = market.symbols.index("600000.SH") + for row_index, value in enumerate(attached["roe_latest"].to_list()): + actual = matrix[row_index, column] + if value is None: + assert np.isnan(actual), (row_index, actual) + else: + np.testing.assert_allclose(actual, value, rtol=1e-6) + + def test_bps_nonpositive_gives_null_pb(): panel = _daily_panel(date(2026, 4, 1), 8, ("000001.SZ",)) snapshot = _snapshot_frame([