Files
tick-stock-panel/backend/tests/test_fuyao_financial.py
T
shy3130 d70a960750 feat(fuyao): 日K dump 链路强化与财务数据集适配
- 财务接入(股本除外): 三大报表多期 + 指标单期, 字段映射至项目 canonical 列
- metrics: bps 由估值 pb_mrq 反推, eps_basic 取自利润表, report 期由最新披露期反查
- financial_sync 报告期合并升级为逐列填空: 多源数据并集共存, 新值覆盖缺列保留
- 前序未入库工作一并提交: 10 年/10d 日K dump、除权因子事件推导与涨跌停自检
2026-08-30 19:05:26 +08:00

243 lines
10 KiB
Python

"""fuyao 财务适配测试 (不依赖真实网络)。
覆盖: 三大报表字段映射 (canonical 列名 + 扩展列透传 + ISO 日期口径)、
latest_only 分档 (limit 1 vs 8)、metrics 组装 (eps_basic 顺带 / bps 估值反推 /
指标 index_id 映射与未知 id 透传 / 单股指标失败不弃行)、shares 恒空、
报告期合并写入的逐列填空语义 (并集共存, 新行缺列不覆盖旧值)。
"""
from __future__ import annotations
import polars as pl
import pytest
from app.plugins.fuyao import client as fc
from app.plugins.fuyao import provider as fp
from app.plugins.fuyao.provider import FuyaoProvider
from app.services.financial_sync import _merge_report_history
class _FakeFinClient:
"""财务端点假客户端: 记录调用入参, 按表返回预置行。"""
def __init__(
self,
statements: dict[str, list[dict]] | None = None,
indicators: dict[str, list[dict]] | None = None,
indicator_error: Exception | None = None,
valuations: list[dict] | None = None,
prices: list[dict] | None = None,
):
self.statements = statements or {}
self.indicators = indicators or {}
self.indicator_error = indicator_error
self.valuations = valuations or []
self.prices = prices or []
self.stmt_calls: list[tuple] = []
self.ind_calls: list[str] = []
def financial_statements(self, stmt, thscode, limit=1):
self.stmt_calls.append((stmt, thscode, limit))
return [dict(r, thscode=thscode) for r in self.statements.get(stmt, [])]
def financial_indicators(self, thscode, report):
self.ind_calls.append(f"{thscode}@{report}")
if self.indicator_error:
raise self.indicator_error
return self.indicators.get(report, [])
def valuations_snapshot(self, thscodes):
return [r for r in self.valuations if r.get("thscode") in thscodes]
def price_snapshot_batch(self, thscodes):
return [r for r in self.prices if r.get("thscode") in thscodes]
def _provider_with(monkeypatch, fake):
monkeypatch.setattr(
fp, "fuyao_client", type("M", (), {"FuyaoClient": lambda **kw: fake})
)
monkeypatch.setattr(fp, "get_api_key", lambda: "test-key")
monkeypatch.setattr(fp, "_HIST_INTERVAL_S", 0)
return FuyaoProvider()
# period_end_ms: 2026-06-30 上海零点; report_date_ms: 2026-08-15 上海零点
_INCOME_ROW = {
"period": "quarterly",
"fiscal_year": 2026,
"fiscal_period": "Q2",
"report_date_ms": 1786723200000,
"period_end_ms": 1782748800000,
"currency": "CNY",
"operating_income": 90703260964.48,
"net_profit": 46033330566.78,
"parent_holder_net_profit": 44516880421.86,
"basic_eps": 35.57,
"operating_expenses": 50000000000.0, # 扶摇独有 → 扩展列
}
def test_income_mapping_canonical_columns(monkeypatch):
fake = _FakeFinClient(statements={"income": [_INCOME_ROW]})
provider = _provider_with(monkeypatch, fake)
df = provider.get_financials("income", ["600519.SH"], latest_only=True)
row = df.to_dicts()[0]
assert row["symbol"] == "600519.SH"
assert row["period_end"] == "2026-06-30"
assert row["announce_date"] == "2026-08-15"
assert row["revenue"] == pytest.approx(_INCOME_ROW["operating_income"])
assert row["net_income"] == pytest.approx(_INCOME_ROW["net_profit"])
assert row["net_income_attributable"] == pytest.approx(
_INCOME_ROW["parent_holder_net_profit"]
)
assert row["basic_eps"] == 35.57
# 扩展列: 扶摇独有数字字段原名透传; 字符串元数据不透传
assert row["operating_expenses"] == 50000000000.0
assert "thscode" not in df.columns and "ticker" not in df.columns
# 原始名不残留 (已映射字段)
assert "operating_income" not in df.columns and "net_profit" not in df.columns
def test_statements_limit_latest_vs_history(monkeypatch):
fake = _FakeFinClient(statements={"income": [_INCOME_ROW]})
provider = _provider_with(monkeypatch, fake)
provider.get_financials("income", ["600519.SH"], latest_only=True)
assert fake.stmt_calls == [("income", "600519.SH", 1)]
provider.get_financials("income", ["600519.SH"], latest_only=False)
assert fake.stmt_calls[-1] == ("income", "600519.SH", fp._FINANCIAL_HISTORY_PERIODS)
def test_balance_and_cashflow_mapping(monkeypatch):
balance = {
"period_end_ms": 1782748800000,
"report_date_ms": 1786723200000,
"assets_total": 309050784569.31,
"total_debt": 46954432394.95,
"holder_equity_total": 262096352174.36,
}
cashflow = {
"period_end_ms": 1782748800000,
"report_date_ms": 1786723200000,
"act_cash_flow_net": 92000000000.0,
"invest_cash_flow_net": -3000000000.0,
"pay_dividends_profits_interest_cash": 64000000000.0, # 扶摇独有 → 扩展列
}
fake = _FakeFinClient(
statements={"balance_sheet": [balance], "cash_flow": [cashflow]}
)
provider = _provider_with(monkeypatch, fake)
bal = provider.get_financials("balance_sheet", ["600519.SH"]).to_dicts()[0]
assert bal["total_assets"] == pytest.approx(balance["assets_total"])
assert bal["total_liabilities"] == pytest.approx(balance["total_debt"])
assert bal["total_equity"] == pytest.approx(balance["holder_equity_total"])
cf = provider.get_financials("cash_flow", ["600519.SH"]).to_dicts()[0]
assert cf["net_operating_cash_flow"] == pytest.approx(92000000000.0)
assert cf["net_investing_cash_flow"] == pytest.approx(-3000000000.0)
assert cf["pay_dividends_profits_interest_cash"] == pytest.approx(64000000000.0)
_METRICS_ABILITIES = [
{
"ability": "profitability",
"indicators": [
{"index_id": "index_weighted_avg_roe", "value": "16.7500"},
{"index_id": "sale_gross_margin", "value": "89.5552"},
],
},
{
"ability": "growth",
# 实测 index_id 与文档有出入; 未映射 id 原名透传
"indicators": [{"index_id": "fixed_asset_invest_expansion_ratio", "value": "2.12587300"}],
},
{
"ability": "solvency",
"indicators": [{"index_id": "earned_interest_multiple", "value": None}], # null → 不写列
},
]
def test_metrics_assembly(monkeypatch):
fake = _FakeFinClient(
statements={"income": [_INCOME_ROW]},
indicators={"2026-2": _METRICS_ABILITIES},
valuations=[{"thscode": "600519.SH", "pb_mrq": 6.455055}],
prices=[{"thscode": "600519.SH", "last_price": 1297.4}],
)
provider = _provider_with(monkeypatch, fake)
df = provider.get_financials("metrics", ["600519.SH"], latest_only=True)
row = df.to_dicts()[0]
assert row["period_end"] == "2026-06-30"
assert row["announce_date"] == "2026-08-15"
assert row["eps_basic"] == 35.57 # 顺带取自利润表
assert row["bps"] == pytest.approx(1297.4 / 6.455055) # 估值反推
assert row["roe"] == pytest.approx(16.75) # 字符串 → float
assert row["gross_margin"] == pytest.approx(89.5552)
assert row["fixed_asset_invest_expansion_ratio"] == pytest.approx(2.125873)
assert "earned_interest_multiple" not in df.columns # 全空指标不成列
assert fake.ind_calls == ["600519.SH@2026-2"] # report 由利润表最新期反推
def test_metrics_indicator_failure_keeps_row(monkeypatch):
"""指标端点单股失败 (如未披露期 code=5003) → 行仍写入 (eps/bps 保留)。"""
fake = _FakeFinClient(
statements={"income": [_INCOME_ROW]},
indicator_error=fc.FuyaoError("code=5003"),
valuations=[{"thscode": "600519.SH", "pb_mrq": 6.455055}],
prices=[{"thscode": "600519.SH", "last_price": 1297.4}],
)
provider = _provider_with(monkeypatch, fake)
df = provider.get_financials("metrics", ["600519.SH"], latest_only=True)
row = df.to_dicts()[0]
assert row["symbol"] == "600519.SH"
assert row["eps_basic"] == 35.57
assert "roe" not in df.columns
def test_metrics_skips_symbol_without_income(monkeypatch):
fake = _FakeFinClient(statements={"income": []})
provider = _provider_with(monkeypatch, fake)
assert provider.get_financials("metrics", ["600519.SH"]).is_empty()
def test_shares_returns_empty(monkeypatch):
provider = _provider_with(monkeypatch, _FakeFinClient())
assert provider.get_financials("shares", ["600519.SH"]).is_empty()
def test_merge_fills_missing_cells_from_old_rows():
"""逐列填空: 同报告期新行缺的列由旧行补齐, 有值则覆盖 (并集共存语义)。"""
old = pl.DataFrame({
"symbol": ["600519.SH", "600519.SH"],
"period_end": ["2026-03-31", "2026-06-30"],
"announce_date": ["2026-04-20", "2026-08-10"],
"diluted_eps": [68.1, 70.2], # tickflow 提供, fuyao 没有
"net_income": [280.0, 460.0],
})
new = pl.DataFrame({
"symbol": ["600519.SH"],
"period_end": ["2026-06-30"],
"announce_date": ["2026-08-15"], # 更晚公告 → 该期以新行为基准
"net_income": [461.5], # 修正值覆盖
# diluted_eps 缺失 → 由旧行 70.2 补齐
"bps": [200.99], # fuyao 扩展列, 旧行没有
})
merged = _merge_report_history(old, new).to_dicts()
assert len(merged) == 2
q2 = next(r for r in merged if r["period_end"] == "2026-06-30")
assert q2["net_income"] == pytest.approx(461.5) # 新值覆盖
assert q2["diluted_eps"] == pytest.approx(70.2) # 旧行补齐
assert q2["bps"] == pytest.approx(200.99) # 扩展列并入
q1 = next(r for r in merged if r["period_end"] == "2026-03-31")
assert q1["diluted_eps"] == pytest.approx(68.1) # 未触碰期原样保留
# 旧公告覆盖新公告的倒序场景: announce 早的行不覆盖晚的
reversed_new = pl.DataFrame({
"symbol": ["600519.SH"],
"period_end": ["2026-06-30"],
"announce_date": ["2026-08-01"],
"net_income": [999.0],
})
q2b = _merge_report_history(old, reversed_new).to_dicts()[1]
# 公告更晚的 old 行 (08-10) 胜出, 早公告的新行不覆盖 → 业绩修正以最新公告为准
assert q2b["net_income"] == pytest.approx(460.0)