feat(data): Tushare 限速退避 + 新浪兜底(财务 getFinanceReport2022 / 日K 前复权),source+adjust 口径标记

- TushareProvider:频率超限按指数退避重试(不再一次 200/min 即中断),最长等待 30s
- SinaProvider 重构(参考 cc-cursor 公开接口实现):
  · 新增财务通道 CompanyFinanceService.getFinanceReport2022(source=gjzb) → FinancialIndicator
    (report_date / announce_date=publish_date),与 Tushare fina_indicator schema 一致
  · 日 K 保留 jsonp(前复权),统一 UA + 重试
  · 不支持方法仍抛 DataSourceNotSupported(复权因子/交易日历/基础信息)
- FailoverProvider 现在可对 daily 与 financial 兜底(CLI _failover_provider 接 SinaProvider)
- DailyBar + stock_daily 表新增 source/adjust 列:新浪兜底行标记 sina/qfq,
  Tushare 恢复后 --resume 按同键覆盖回不复权 → 两源格式一致且可追溯
- 迁移 91c4e27a03fb 已生成;执行需在全市场同步结束后:uv run alembic upgrade head
- 测试 34+ 项(新浪财务解析/格式一致/限速退避等)通过
This commit is contained in:
Simon
2026-09-06 20:52:13 +08:00
parent e2741a0236
commit 56254172b3
8 changed files with 314 additions and 35 deletions
+80 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
from datetime import date
import pytest
@@ -72,6 +73,84 @@ class TestCapabilities:
with pytest.raises(DataSourceNotSupported):
provider.get_stock_basic()
with pytest.raises(DataSourceNotSupported):
provider.get_adjust_factor("600519.SH", date(2024, 1, 1), date(2024, 1, 31))
provider.get_trade_cal(date(2024, 1, 1), date(2024, 1, 31))
with pytest.raises(DataSourceNotSupported):
provider.get_adjust_factor("600519.SH", date(2024, 1, 1), date(2024, 1, 31))
_FIN_PAYLOAD = json.dumps(
{
"result": {
"data": {
"report_date": [{"date_value": "2024-06-30"}, {"date_value": "2024-03-31"}],
"report_list": {
"2024-06-30": {
"publish_date": "2024-08-31",
"data": [
{"item_title": "基本每股收益", "item_value": "1.23"},
{"item_title": "净资产收益率(ROE)", "item_value": "15.5"},
{"item_title": "净利润", "item_value": "1230000000"},
],
},
"2024-03-31": {"publish_date": "", "data": []},
},
}
}
},
ensure_ascii=False,
)
class _FinResp:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return _FIN_PAYLOAD.encode("utf-8")
class TestFinancial:
def test_parse_schema_parity_with_tushare(self) -> None:
"""财务 schema 与 Tushare 对齐:report_date / announce_date / 指标。"""
provider = SinaProvider(urlopen=lambda _req, **kw: _FinResp())
rows = provider.get_financial("600519.SH")
assert len(rows) == 1 # 无 publish_date 的行被跳过(防未来函数)
row = rows[0]
assert row.symbol == "600519.SH"
assert row.report_date == date(2024, 6, 30)
assert row.announce_date == date(2024, 8, 31)
assert float(row.eps) == 1.23
assert float(row.roe) == 15.5
assert float(row.net_profit) == 1230000000
# FinancialIndicator 校验能通过 → 格式与 Tushare 一致
assert row.model_dump()["eps"] is not None
def test_bad_structure_raises(self) -> None:
class _BadResp(_FinResp):
def read(self):
return b'{"result": {}}'
provider = SinaProvider(urlopen=lambda _req, **kw: _BadResp())
try:
provider.get_financial("600519.SH")
except DataSourceError:
return
raise AssertionError("应抛 DataSourceError")
class TestFormatParity:
"""两源格式一致:统一 DailyBar schema,新浪兜底行带 source/adjust 标记。"""
def test_sina_bar_marks_source_and_adjust(self) -> None:
provider = SinaProvider(urlopen=lambda _url, **kw: _FakeResp())
bars = provider.get_daily("600519.SH", date(2024, 8, 30), date(2024, 8, 30))
assert len(bars) == 1
bar = bars[0]
assert bar.source == "sina"
assert bar.adjust == "qfq" # 新浪为前复权,与 Tushare 不复权主口径区分
# 与 Tushare 同 schema:必备字段齐全
assert bar.symbol == "600519.SH"
assert bar.close is not None
+21
View File
@@ -148,3 +148,24 @@ class TestCall:
def test_missing_token_rejected(self) -> None:
with pytest.raises(DataSourceAuthenticationError, match="TUSHARE_TOKEN"):
TushareProvider(token="")
class TestRateLimitBackoff:
def test_rate_limit_retries_with_sleep(self) -> None:
"""频率超限:按退避等待后重试,最终抛错带原始信息(不当作鉴权错误)。"""
import app.infrastructure.data_sources.tushare as ts_mod
orig_sleep = ts_mod.time.sleep
sleeps: list[float] = []
ts_mod.time.sleep = lambda w: sleeps.append(w) # noqa: SLF001 —— 测试桩
try:
provider = _pro(
error=RuntimeError("抱歉,您访问接口(adj_factor)频率超限(200次/分钟)"), retries=3
)
provider._rate_limit_wait = 0.1 # noqa: SLF001
with pytest.raises(DataSourceError, match="频率超限"):
provider.get_stock_basic()
finally:
ts_mod.time.sleep = orig_sleep
assert len(provider._pro.calls) == 3 # noqa: SLF001 —— 完整重试 3 次
assert len(sleeps) >= 2