feat(backend): Phase 1 数据层 — Domain / Provider / Failover 审计 + 持久化 + 同步 CLI
- domain:市场数据实体(Stock / 交易日历 / 日线 / 复权 / 财务含 announce_date)+ Repository 与 MarketDataProvider Protocol
- 数据源:TushareProvider(归一化、重试、鉴权错误归类)、SinaProvider(备用,明确前复权口径与能力边界)、FailoverProvider + SyncLog 审计(禁止静默切换)
- 持久化:SQLAlchemy 2.x Models + Repository 实现(按业务键幂等 upsert、as_of_date 防未来函数过滤)+ Alembic 迁移
- CLI:uv run python -m app.cli.sync {basic|calendar|daily|financial|verify},支持 --resume 断点续传
- 真实 Tushare 验证:stock 5556 / 交易日历 366 / daily+factor 242 / 财务 55;sync_log 审计完整
- 测试:38 passed(domain / provider / failover / repository / 未来函数 / 迁移),ruff clean
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""新浪 Provider 测试:代码转换、JSONP 解析、能力边界(不触网)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from app.infrastructure.data_sources.errors import (
|
||||
DataSourceError,
|
||||
DataSourceNotSupported,
|
||||
)
|
||||
from app.infrastructure.data_sources.sina import SinaProvider, _extract_jsonp, _to_sina_symbol
|
||||
|
||||
_KLINE_OK = (
|
||||
'var data=[{"day":"2024-08-30","open":"1700.0","high":"1720.0","low":"1690.0",'
|
||||
'"close":"1710.0","volume":"20000"},{"day":"2024-08-31","open":"1710.0",'
|
||||
'"high":"1725.0","low":"1705.0","close":"1720.0","volume":"18000"}]'
|
||||
)
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return _KLINE_OK.encode("utf-8")
|
||||
|
||||
|
||||
class _BoomResp(_FakeResp):
|
||||
def read(self):
|
||||
raise OSError("socket timeout")
|
||||
|
||||
|
||||
class TestSymbolMap:
|
||||
def test_mapping(self) -> None:
|
||||
assert _to_sina_symbol("600519.SH") == "sh600519"
|
||||
assert _to_sina_symbol("000001.SZ") == "sz000001"
|
||||
assert _to_sina_symbol("830001.BJ") == "bj830001"
|
||||
|
||||
|
||||
class TestJsonp:
|
||||
def test_extract(self) -> None:
|
||||
rows = _extract_jsonp(_KLINE_OK)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["close"] == "1710.0"
|
||||
|
||||
def test_bad_payload_raises(self) -> None:
|
||||
with pytest.raises(DataSourceError, match="无法解析"):
|
||||
_extract_jsonp("not jsonp")
|
||||
|
||||
|
||||
class TestGetDaily:
|
||||
def test_ok_with_date_filter(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
|
||||
assert bars[0].close is not None
|
||||
assert bars[0].symbol == "600519.SH"
|
||||
|
||||
def test_network_error_wrapped(self) -> None:
|
||||
provider = SinaProvider(urlopen=lambda _url, **kw: _BoomResp())
|
||||
with pytest.raises(DataSourceError, match="sina 请求失败"):
|
||||
provider.get_daily("600519.SH", date(2024, 8, 1), date(2024, 8, 31))
|
||||
|
||||
|
||||
class TestCapabilities:
|
||||
def test_not_supported(self) -> None:
|
||||
provider = SinaProvider()
|
||||
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))
|
||||
with pytest.raises(DataSourceNotSupported):
|
||||
provider.get_financial("600519.SH")
|
||||
Reference in New Issue
Block a user