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([