From 03f8966856a8de677b3ef1aa95082ea9f3622ffa Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:50:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(backtest):=20=E8=B4=A2=E5=8A=A1=E5=9B=A0?= =?UTF-8?q?=E5=AD=90=E7=9F=A9=E9=98=B5=E8=B7=AF=E5=BE=84=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=B2=BF=E7=94=A8=E4=B8=8A=E4=B8=80=E6=9C=9F=E7=9A=84=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 矩阵侧逐列前向填充时跳过空值写入, 新一期财报缺某指标就继续沿用上一期, 同一行会混用两期报告 (pb 取新期净资产、roe 停在上一期); polars 侧 join_asof 只认最新一期整行, 该指标为 null。同一份配置两条路径给出不同 因子值。改为空值同样覆盖为 NaN, 与 attach_fundamental_factors 口径一致。 --- backend/app/backtest/fundamentals.py | 8 +++--- backend/tests/test_fundamental_factors.py | 30 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) 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([