Files
easy-tdx/tests/unit/test_sina.py
T

527 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""新浪财经财报模块离线测试 —— mock HTTP,零网络依赖。
覆盖:响应解析(三表)、数值转换(字符串→float)、报告期格式化、
同比键、paperCode 推导、错误转换、模块导出。
"""
from __future__ import annotations
import json
from typing import Any
import pandas as pd
import pytest
# ---------------------------------------------------------------------------
# 导出
# ---------------------------------------------------------------------------
def test_public_exports() -> None:
"""模块应导出 SinaClient / ReportType / SinaError。"""
from easy_tdx import sina
assert hasattr(sina, "SinaClient")
assert hasattr(sina, "ReportType")
assert hasattr(sina, "SinaError")
def test_sina_error_subclasses_tdx_error() -> None:
"""SinaError 必须继承 TdxError,保证全局 except TdxError 覆盖。"""
from easy_tdx.exceptions import TdxError
from easy_tdx.sina import SinaError
assert issubclass(SinaError, TdxError)
assert issubclass(SinaError, Exception)
# ---------------------------------------------------------------------------
# report_type 归一化
# ---------------------------------------------------------------------------
def test_normalize_report_type_standard() -> None:
from easy_tdx.sina import normalize_report_type
assert normalize_report_type("lrb") == "lrb"
assert normalize_report_type("fzb") == "fzb"
assert normalize_report_type("llb") == "llb"
def test_normalize_report_type_uppercase() -> None:
from easy_tdx.sina import normalize_report_type
assert normalize_report_type("LRB") == "lrb"
assert normalize_report_type("FZB") == "fzb"
def test_normalize_report_type_chinese_alias() -> None:
from easy_tdx.sina import normalize_report_type
assert normalize_report_type("利润表") == "lrb"
assert normalize_report_type("资产负债表") == "fzb"
assert normalize_report_type("现金流量表") == "llb"
def test_normalize_report_type_english_alias() -> None:
from easy_tdx.sina import normalize_report_type
assert normalize_report_type("income") == "lrb"
assert normalize_report_type("balance") == "fzb"
assert normalize_report_type("cashflow") == "llb"
def test_normalize_report_type_invalid_raises() -> None:
from easy_tdx.sina import normalize_report_type
with pytest.raises(ValueError, match="无法识别"):
normalize_report_type("xyz")
# ---------------------------------------------------------------------------
# _to_float / _format_period
# ---------------------------------------------------------------------------
def test_to_float_numeric_string() -> None:
from easy_tdx.sina.client import _to_float
assert _to_float("54702912385.230000") == 54702912385.23
assert _to_float("0") == 0.0
assert _to_float("-123.45") == -123.45
def test_to_float_empty_and_none() -> None:
from easy_tdx.sina.client import _to_float
assert _to_float("") is None
assert _to_float(None) is None
def test_to_float_non_numeric() -> None:
from easy_tdx.sina.client import _to_float
assert _to_float("N/A") is None
assert _to_float("--") is None
assert _to_float("abc") is None
def test_format_period() -> None:
from easy_tdx.sina.client import _format_period
assert _format_period("20260331") == "2026-03-31"
assert _format_period("20251231") == "2025-12-31"
# 非 8 位数字原样返回
assert _format_period("2026Q1") == "2026Q1"
# ---------------------------------------------------------------------------
# paperCode 推导
# ---------------------------------------------------------------------------
def test_build_paper_code_sh() -> None:
"""6 开头 → sh 前缀。"""
from easy_tdx.sina import SinaClient
assert SinaClient()._build_paper_code("600519") == "sh600519"
assert SinaClient()._build_paper_code("601318") == "sh601318"
def test_build_paper_code_sz() -> None:
"""非 6 开头 → sz 前缀(含北交所 8/4)。"""
from easy_tdx.sina import SinaClient
assert SinaClient()._build_paper_code("000001") == "sz000001"
assert SinaClient()._build_paper_code("002594") == "sz002594"
assert SinaClient()._build_paper_code("300750") == "sz300750"
assert SinaClient()._build_paper_code("830799") == "sz830799"
# ---------------------------------------------------------------------------
# 财报查询与解析(mock HTTP)
# ---------------------------------------------------------------------------
def _make_response(periods: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
"""构造新浪 API 响应。periods = {period: [行项...]}"""
report_list = {
period: {
"data": items,
"publish_date": "2026-04-30",
"rType": "lrb",
}
for period, items in periods.items()
}
return {
"result": {
"status": {"code": 0},
"data": {
"report_count": len(periods),
"report_date": sorted(periods.keys(), reverse=True),
"report_list": report_list,
},
}
}
# 典型利润表行项
_LRB_ITEMS_2026Q1 = [
{
"item_field": "BIZTOTINCO",
"item_title": "营业总收入",
"item_value": "54702912385.230000",
"item_display": "大类",
"item_tongbi": 0.06336,
},
{
"item_field": "BIZINCO",
"item_title": "营业收入",
"item_value": "53909252220.510000",
"item_display": "小类",
"item_tongbi": 0.06538,
},
]
_LRB_ITEMS_2025 = [
{
"item_field": "BIZTOTINCO",
"item_title": "营业总收入",
"item_value": "174000000000.000000",
"item_display": "大类",
"item_tongbi": 0.10,
},
]
# 资产负债表(含大类标题行,item_value 为空)
_FZB_ITEMS = [
{
"item_field": "",
"item_title": "流动资产",
"item_value": "",
"item_display": "大类",
"item_tongbi": "",
},
{
"item_field": "CURFDS",
"item_title": "货币资金",
"item_value": "48786691397.550000",
"item_display": "小类",
"item_tongbi": -0.06538,
},
]
def _patch_http(monkeypatch: pytest.MonkeyPatch, response: dict[str, Any]) -> None:
"""让 _http_get_json 返回固定响应(不触网)。"""
monkeypatch.setattr(
"easy_tdx.sina.client._http_get_json",
lambda url, params, timeout=15.0: response,
)
def test_get_financial_report_returns_dataframe(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""应返回 DataFrame,第一列是报告期,其余是科目(float)。"""
resp = _make_response({"20260331": _LRB_ITEMS_2026Q1, "20251231": _LRB_ITEMS_2025})
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519", report_type="lrb")
assert isinstance(df, pd.DataFrame)
# 第一列报告期
assert df.columns[0] == "报告期"
# 最新期在前
assert df.iloc[0]["报告期"] == "2026-03-31"
assert df.iloc[1]["报告期"] == "2025-12-31"
# 科目名作为列
assert "营业总收入" in df.columns
assert "营业收入" in df.columns
# 值已转 float(非 object 字符串)
assert df.iloc[0]["营业总收入"] == 54702912385.23
assert isinstance(df.iloc[0]["营业总收入"], float)
def test_get_financial_report_tongbi_columns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""有同比的科目应附加 {科目}_同比 列。"""
resp = _make_response({"20260331": _LRB_ITEMS_2026Q1})
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519", report_type="lrb")
assert "营业总收入_同比" in df.columns
assert df.iloc[0]["营业总收入_同比"] == pytest.approx(0.06336)
def test_get_financial_report_no_tongbi_omits_column(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""tongbi 为 None/空时不应生成 _同比 列。"""
items = [
{
"item_field": "NETPROFIT",
"item_title": "净利润",
"item_value": "100000000.00",
"item_tongbi": None,
}
]
resp = _make_response({"20260331": items})
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519")
assert "净利润" in df.columns
assert "净利润_同比" not in df.columns
def test_get_financial_report_dalei_header_row_preserved(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""大类标题行(item_value="")应保留,值为 None(不跳过)。"""
resp = _make_response({"20260331": _FZB_ITEMS})
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519", report_type="fzb")
assert "流动资产" in df.columns
# 大类标题行的值为 None(空字符串转换)
assert df.iloc[0]["流动资产"] is None or pd.isna(df.iloc[0]["流动资产"])
# 小类行有值
assert df.iloc[0]["货币资金"] == 48786691397.55
def test_get_financial_report_dalei_no_tongbi_column(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""大类标题行 tongbi="" 不生成 _同比 列。"""
resp = _make_response({"20260331": _FZB_ITEMS})
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519", report_type="fzb")
assert "流动资产_同比" not in df.columns
def test_get_financial_report_non_numeric_value_to_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""非数值 item_value(如 'N/A')应转 None,不抛错。"""
items = [
{
"item_field": "X",
"item_title": "某科目",
"item_value": "N/A",
"item_tongbi": None,
}
]
resp = _make_response({"20260331": items})
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519")
assert "某科目" in df.columns
assert df.iloc[0]["某科目"] is None or pd.isna(df.iloc[0]["某科目"])
def test_get_financial_report_skips_no_title(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""item_title 为空应跳过该行项。"""
items = [
{"item_field": "", "item_title": "", "item_value": "123.45"},
{"item_field": "X", "item_title": "有效科目", "item_value": "1.00"},
]
resp = _make_response({"20260331": items})
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519")
assert "有效科目" in df.columns
# 空 title 的行不应产生列(pandas 会把无名列丢进一个奇怪的列名,确认它不是有效科目)
def test_get_financial_report_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""空 report_list → 带「报告期」列名的空 DataFrame。"""
resp = {"result": {"status": {"code": 0}, "data": {"report_list": {}}}}
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519")
assert isinstance(df, pd.DataFrame)
assert df.empty
assert list(df.columns) == ["报告期"]
def test_get_financial_report_missing_keys(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""响应缺少 result/data/report_list 链应视为空结果,不抛错。"""
_patch_http(monkeypatch, {"unexpected": True})
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519")
assert df.empty
def test_get_financial_report_request_failure_raises_sina_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""HTTP 异常应转为 SinaError。"""
def _boom(url: str, params: dict[str, str], timeout: float = 15.0) -> Any:
raise OSError("connection refused")
monkeypatch.setattr("easy_tdx.sina.client._http_get_json", _boom)
from easy_tdx.sina import SinaClient, SinaError
with pytest.raises(SinaError):
SinaClient().get_financial_report("600519")
def test_get_financial_report_num_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""num 应限制返回期数(sorted reverse 后取前 num)。"""
resp = _make_response(
{
"20260331": _LRB_ITEMS_2026Q1,
"20251231": _LRB_ITEMS_2025,
"20250930": _LRB_ITEMS_2025,
"20250630": _LRB_ITEMS_2025,
}
)
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519", num=2)
assert len(df) == 2
assert df.iloc[0]["报告期"] == "2026-03-31"
assert df.iloc[1]["报告期"] == "2025-12-31"
def test_get_financial_report_params_passed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""paperCode/source/num 应正确传入 params。"""
captured: dict[str, Any] = {}
def _capture(url: str, params: dict[str, str], timeout: float = 15.0) -> Any:
captured.update(params)
return {"result": {"data": {"report_list": {}}}}
monkeypatch.setattr("easy_tdx.sina.client._http_get_json", _capture)
from easy_tdx.sina import SinaClient
SinaClient().get_financial_report("600519", report_type="fzb", num=4)
assert captured["paperCode"] == "sh600519"
assert captured["source"] == "fzb"
assert captured["num"] == "4"
assert captured["type"] == "0"
def test_get_financial_report_report_type_alias(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""中文别名(利润表)应归一化为 lrb 传入 source。"""
captured: dict[str, Any] = {}
def _capture(url: str, params: dict[str, str], timeout: float = 15.0) -> Any:
captured.update(params)
return {"result": {"data": {"report_list": {}}}}
monkeypatch.setattr("easy_tdx.sina.client._http_get_json", _capture)
from easy_tdx.sina import SinaClient
SinaClient().get_financial_report("000001", report_type="现金流量表")
assert captured["source"] == "llb"
assert captured["paperCode"] == "sz000001"
def test_get_financial_report_invalid_report_type_raises() -> None:
"""无法识别的 report_type 应抛 ValueErrornormalize 阶段,不触网)。"""
from easy_tdx.sina import SinaClient
with pytest.raises(ValueError, match="无法识别"):
SinaClient().get_financial_report("600519", report_type="xyz")
def test_get_financial_report_skips_non_dict_period(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""report_list 中某期值非 dict(如 list)应跳过该期。"""
resp = {
"result": {
"data": {
"report_list": {
"20260331": {"data": _LRB_ITEMS_2026Q1}, # 正常(含 data 键)
"20251231": ["not", "a", "dict"], # 异常,跳过
}
}
}
}
_patch_http(monkeypatch, resp)
from easy_tdx.sina import SinaClient
df = SinaClient().get_financial_report("600519")
assert len(df) == 1
assert df.iloc[0]["报告期"] == "2026-03-31"
# ---------------------------------------------------------------------------
# urllib helper 烟雾测试(不触网,验证 query 拼接 + headers
# ---------------------------------------------------------------------------
def test_http_get_json_builds_query_string(monkeypatch: pytest.MonkeyPatch) -> None:
"""_http_get_json 应把 params urlencode 到 URL。"""
import easy_tdx.sina.client as mod
captured: dict[str, Any] = {}
class _FakeResp:
def read(self) -> bytes:
return json.dumps({"ok": 1}).encode("utf-8")
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
def _fake_urlopen(req: Any, timeout: float = 15.0) -> _FakeResp:
captured["url"] = req.full_url
captured["headers"] = {k.lower(): v for k, v in req.header_items()}
return _FakeResp()
monkeypatch.setattr(mod.urlrequest, "urlopen", _fake_urlopen)
result = mod._http_get_json(
"https://example.com/api", {"paperCode": "sh600519", "source": "lrb"}
)
assert result == {"ok": 1}
assert "paperCode=sh600519" in captured["url"]
assert "source=lrb" in captured["url"]
assert "user-agent" in captured["headers"]
def test_http_get_json_no_params(monkeypatch: pytest.MonkeyPatch) -> None:
"""params 为空时 URL 不附加 ?。"""
import easy_tdx.sina.client as mod
class _FakeResp:
def read(self) -> bytes:
return b'{"ok": 1}'
def __enter__(self) -> _FakeResp:
return self
def __exit__(self, *args: Any) -> None:
pass
monkeypatch.setattr(mod.urlrequest, "urlopen", lambda req, timeout=15.0: _FakeResp())
assert mod._http_get_json("https://example.com/x", {}) == {"ok": 1}