diff --git a/backend/app/backtest/fundamentals.py b/backend/app/backtest/fundamentals.py index 0b0e8aa..940d927 100644 --- a/backend/app/backtest/fundamentals.py +++ b/backend/app/backtest/fundamentals.py @@ -181,9 +181,11 @@ def build_fundamental_matrices( continue for column, target in raw_columns.items(): value = snapshot[column][row_index] - if value is None or not np.isfinite(float(value)): - continue - target[start:, column_index] = float(value) + numeric = float("nan") if value is None else float(value) + # 新一期该指标为空时必须覆盖旧值为 NaN: 跳过写入会让同一行混用两期 + # 报告 (bps 取新期、roe 停在上一期), 与 polars 侧 join_asof 只认 + # 最新一期整行的口径不一致。 + target[start:, column_index] = numeric if np.isfinite(numeric) else np.nan for name in requested: spec = FUNDAMENTAL_FACTORS[name] diff --git a/backend/tests/test_fundamental_factors.py b/backend/tests/test_fundamental_factors.py index 9e4bc8f..de52f1f 100644 --- a/backend/tests/test_fundamental_factors.py +++ b/backend/tests/test_fundamental_factors.py @@ -140,6 +140,36 @@ def test_matrix_field_matches_polars_attach_across_two_announcements(): np.testing.assert_allclose(actual, value, rtol=1e-6) +def test_matrix_field_clears_value_when_newer_report_lacks_metric(): + """新一期财报缺该指标时不得继续沿用上一期值, 否则同一行混用两期报告。 + + 矩阵路径逐列前向填充, 若跳过空值写入, 4-16 起 pb 已换到新期 bps=6, + roe 却仍停在上一期的 20 —— polars 侧 join_asof 只认最新一期整行 (roe 为 + null), 两条路径给出不同的因子值。 + """ + panel = _daily_panel(date(2026, 4, 1), 20, ("600000.SH",)) + snapshot = _snapshot_frame([ + {"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0, "bps": 4.0}, + {"symbol": "600000.SH", "announce": "2026-04-15", "roe": None, "bps": 6.0}, + ]) + attached = attach_fundamental_factors(panel, snapshot, ["roe_latest", "pb_latest"]).sort("date") + market = build_market_data_matrix(panel) + matrices = build_fundamental_matrices(market, snapshot, ["roe_latest", "pb_latest"]) + column = market.symbols.index("600000.SH") + + # polars 口径: 新公告生效 (4-16) 后 roe 无值, pb 走新一期 bps + assert attached["roe_latest"].to_list()[15:] == [None] * 5 + assert all(value is not None for value in attached["pb_latest"].to_list()[15:]) + + for name in ("roe_latest", "pb_latest"): + for row_index, value in enumerate(attached[name].to_list()): + actual = matrices[name][row_index, column] + if value is None: + assert np.isnan(actual), (name, 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([