mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
attach_fundamental_factors 用公告日做 asof 回看再按「严格大于」门控: 日期正好等于新一期公告日时, join_asof 已匹配到「当天还不能用」的新记录, 门控随即置 null, 打断上一期的前向填充。矩阵路径 build_fundamental_matrices 按 searchsorted(side="right") 逐期覆盖, 公告当日保留上一期, 两条路径因此不一致 (其 docstring 声明与 attach_fundamental_factors 同口径)。 asof 键改用生效日 (公告日次日), 只命中已生效的报告期; 公告前仍为 null 的门控不变。 补 tests/test_fundamental_factors.py 的两条公告一致性用例。
213 lines
8.3 KiB
Python
213 lines
8.3 KiB
Python
"""财务因子点时接入测试: 公告日门控 / 无数据 null 安全 / 双路径一致 / 历史累积同步。"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, timedelta
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
|
|
from app.backtest.fundamentals import (
|
|
FUNDAMENTAL_FACTOR_NAMES,
|
|
attach_fundamental_factors,
|
|
build_fundamental_matrices,
|
|
load_fundamental_snapshot,
|
|
)
|
|
from app.backtest.matrix import build_market_data_matrix
|
|
|
|
|
|
def _snapshot_frame(rows: list[dict]) -> pl.DataFrame:
|
|
frame = pl.DataFrame({
|
|
"period_end": ["2026-03-31"] * len(rows),
|
|
"symbol": [r["symbol"] for r in rows],
|
|
"announce_date": [r["announce"] for r in rows],
|
|
"roe": [r.get("roe", 10.0) for r in rows],
|
|
"bps": [r.get("bps", 5.0) for r in rows],
|
|
"revenue_yoy": [r.get("revenue_yoy", 8.0) for r in rows],
|
|
})
|
|
return (
|
|
frame.with_columns(
|
|
pl.col("announce_date").str.slice(0, 10).str.to_date().alias("_announce")
|
|
)
|
|
.sort(["symbol", "_announce"])
|
|
)
|
|
|
|
|
|
def _daily_panel(start: date, days: int, symbols: tuple[str, ...]) -> pl.DataFrame:
|
|
rows = []
|
|
for offset in range(days):
|
|
for symbol in symbols:
|
|
rows.append({
|
|
"symbol": symbol,
|
|
"date": start + timedelta(days=offset),
|
|
"open": 10.0,
|
|
"high": 10.5,
|
|
"low": 9.5,
|
|
"close": 10.0 + offset * 0.1,
|
|
"volume": 1000.0,
|
|
})
|
|
return pl.DataFrame(rows).sort(["symbol", "date"])
|
|
|
|
|
|
def test_attach_gates_on_announce_date_strictly():
|
|
panel = _daily_panel(date(2026, 4, 1), 10, ("600000.SH", "000001.SZ"))
|
|
snapshot = _snapshot_frame([
|
|
# 公告日 4-5: 4-5 当天不可用, 4-6 起 roe=20
|
|
{"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0, "bps": 4.0},
|
|
# 无财务数据的标的
|
|
])
|
|
attached = attach_fundamental_factors(panel, snapshot, ["roe_latest", "pb_latest"])
|
|
|
|
values = attached.filter(pl.col("symbol") == "600000.SH").sort("date")
|
|
roe = values["roe_latest"].to_list()
|
|
assert roe[:5] == [None] * 5 # 4-1 ~ 4-5 均不可用 (含公告当日)
|
|
assert roe[5:] == [20.0] * 5 # 4-6 起生效
|
|
|
|
# 无财务数据标的全 null, 绝不填 0
|
|
other = attached.filter(pl.col("symbol") == "000001.SZ")["roe_latest"]
|
|
assert other.null_count() == other.len()
|
|
|
|
# pb = close / bps, 公告前 null
|
|
pb = values["pb_latest"].to_list()
|
|
assert pb[:5] == [None] * 5
|
|
close_6 = values.filter(pl.col("date") == date(2026, 4, 6))["close"].item()
|
|
assert abs(pb[5] - close_6 / 4.0) < 1e-12
|
|
|
|
|
|
def test_attach_replaces_with_newer_announcement():
|
|
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")
|
|
roe = attached["roe_latest"].to_list()
|
|
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] == 20.0 # 4-15 二次公告日: 新一期尚未生效, 仍保留上一期
|
|
assert roe[15] == 33.0 # 4-16 起新公告生效
|
|
|
|
|
|
def test_attach_without_snapshot_keeps_null_columns():
|
|
panel = _daily_panel(date(2026, 4, 1), 5, ("600000.SH",))
|
|
attached = attach_fundamental_factors(panel, None, ["roe_latest", "pb_latest"])
|
|
for name in ("roe_latest", "pb_latest"):
|
|
assert name in attached.columns
|
|
assert attached[name].null_count() == attached.height
|
|
|
|
|
|
def test_matrix_field_matches_polars_attach():
|
|
panel = _daily_panel(date(2026, 4, 1), 12, ("600000.SH", "000001.SZ"))
|
|
snapshot = _snapshot_frame([
|
|
{"symbol": "600000.SH", "announce": "2026-04-05", "roe": 20.0, "bps": 4.0},
|
|
{"symbol": "000001.SZ", "announce": "2026-04-08", "roe": -5.0, "bps": -1.0},
|
|
])
|
|
attached = attach_fundamental_factors(panel, snapshot, ["roe_latest", "pb_latest"])
|
|
market = build_market_data_matrix(panel)
|
|
matrices = build_fundamental_matrices(market, snapshot, ["roe_latest", "pb_latest"])
|
|
symbols = {s: i for i, s in enumerate(market.symbols)}
|
|
dates = {str(d): t for t, d in enumerate(
|
|
sorted({row["date"] for row in panel.iter_rows(named=True)})
|
|
)}
|
|
for name in ("roe_latest", "pb_latest"):
|
|
matrix = matrices[name]
|
|
for row in attached.iter_rows(named=True):
|
|
expected = row[name]
|
|
actual = matrix[dates[str(row["date"])], symbols[row["symbol"]]]
|
|
if expected is None:
|
|
assert np.isnan(actual), (name, row["date"], row["symbol"], actual)
|
|
else:
|
|
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([
|
|
{"symbol": "000001.SZ", "announce": "2026-04-02", "bps": -1.0},
|
|
])
|
|
attached = attach_fundamental_factors(panel, snapshot, ["pb_latest"])
|
|
assert attached["pb_latest"].null_count() == attached.height
|
|
|
|
|
|
def test_load_snapshot_from_missing_dir_returns_none(tmp_path: Path):
|
|
assert load_fundamental_snapshot(tmp_path) is None
|
|
assert load_fundamental_snapshot(None) is None
|
|
|
|
|
|
def test_snapshot_requires_announce_date(tmp_path: Path):
|
|
out = tmp_path / "financials" / "metrics"
|
|
out.mkdir(parents=True)
|
|
pl.DataFrame({
|
|
"symbol": ["600000.SH"],
|
|
"announce_date": [None],
|
|
"roe": [10.0],
|
|
"gross_margin": [30.0],
|
|
"net_margin": [5.0],
|
|
"revenue_yoy": [8.0],
|
|
"net_income_yoy": [6.0],
|
|
"debt_to_asset_ratio": [40.0],
|
|
"bps": [5.0],
|
|
}).write_parquet(out / "part.parquet")
|
|
# 公告日缺失的行无法做点时门控, 视为无有效快照
|
|
assert load_fundamental_snapshot(tmp_path) is None
|
|
|
|
|
|
def test_fundamental_factor_names_are_catalogued():
|
|
from app.backtest.factor import FACTOR_COLUMNS
|
|
|
|
catalog_ids = {item["id"] for item in FACTOR_COLUMNS}
|
|
assert catalog_ids >= FUNDAMENTAL_FACTOR_NAMES
|
|
|
|
|
|
def test_financial_sync_merges_history(tmp_path: Path):
|
|
from app.services import financial_sync as fs
|
|
|
|
old = pl.DataFrame({
|
|
"symbol": ["600000.SH", "600000.SH"],
|
|
"period_end": ["2025-09-30", "2025-12-31"],
|
|
"announce_date": ["2025-10-28", "2026-01-20"],
|
|
"roe": [8.0, 9.0],
|
|
})
|
|
latest = pl.DataFrame({
|
|
"symbol": ["600000.SH", "000001.SZ"],
|
|
"period_end": ["2026-03-31", "2026-03-31"],
|
|
"announce_date": ["2026-04-25", "2026-04-24"],
|
|
"roe": [10.0, 5.0],
|
|
})
|
|
merged = fs._merge_report_history(old, latest)
|
|
assert merged.height == 4 # 旧各期保留 + 新一期并入
|
|
# 同期修正: 旧 2025-12-31 公告 2026-01-20 vs 更晚的修正公告
|
|
revised = pl.DataFrame({
|
|
"symbol": ["600000.SH"],
|
|
"period_end": ["2025-12-31"],
|
|
"announce_date": ["2026-02-01"],
|
|
"roe": [9.5],
|
|
})
|
|
merged2 = fs._merge_report_history(old, revised)
|
|
row = merged2.filter(
|
|
(pl.col("symbol") == "600000.SH") & (pl.col("period_end") == "2025-12-31")
|
|
)
|
|
assert row.height == 1
|
|
assert row["roe"].item() == 9.5
|
|
assert merged2.height == 2 # 修正不增加行数
|