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,93 @@
|
||||
"""Failover 审计测试:主源失败 → 备用源兜底,每次尝试留 SyncLog。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from app.domain.entities.market import SyncLog
|
||||
from app.infrastructure.data_sources.errors import (
|
||||
DataSourceError,
|
||||
DataSourceNotSupported,
|
||||
)
|
||||
from app.infrastructure.data_sources.failover import FailoverProvider
|
||||
|
||||
|
||||
class PrimaryProvider:
|
||||
name = "tushare"
|
||||
|
||||
def __init__(self, *, fail: bool = False, fail_message: str = "boom") -> None:
|
||||
self.fail = fail
|
||||
self.fail_message = fail_message
|
||||
|
||||
def get_daily(self, symbol, start, end):
|
||||
if self.fail:
|
||||
raise DataSourceError(self.fail_message)
|
||||
return ["bar-ok"]
|
||||
|
||||
|
||||
class FallbackProvider:
|
||||
name = "sina"
|
||||
|
||||
def __init__(self, *, fail: bool = False, unsupported: bool = False) -> None:
|
||||
self.fail = fail
|
||||
self.unsupported = unsupported
|
||||
|
||||
def get_daily(self, symbol, start, end):
|
||||
if self.unsupported:
|
||||
raise DataSourceNotSupported("新浪不支持日线兜底")
|
||||
if self.fail:
|
||||
raise DataSourceError("sina down")
|
||||
return ["bar-sina"]
|
||||
|
||||
|
||||
def _logs() -> list[SyncLog]:
|
||||
collected: list[SyncLog] = []
|
||||
return collected
|
||||
|
||||
|
||||
def _make(primary, fallback, collected) -> FailoverProvider:
|
||||
return FailoverProvider(primary, fallback, audit=collected.append)
|
||||
|
||||
|
||||
class TestFailover:
|
||||
def test_primary_success_no_fallback(self) -> None:
|
||||
collected: list[SyncLog] = []
|
||||
f = _make(PrimaryProvider(), FallbackProvider(), collected)
|
||||
assert f.get_daily("600519.SH", date(2024, 1, 1), date(2024, 1, 5)) == ["bar-ok"]
|
||||
assert len(collected) == 1
|
||||
assert collected[0].source == "tushare"
|
||||
assert collected[0].success is True
|
||||
assert collected[0].row_count == 1
|
||||
assert collected[0].data_start == date(2024, 1, 1)
|
||||
|
||||
def test_primary_fails_fallback_succeeds(self) -> None:
|
||||
collected: list[SyncLog] = []
|
||||
f = _make(PrimaryProvider(fail=True), FallbackProvider(), collected)
|
||||
assert f.get_daily("600519.SH", date(2024, 1, 1), date(2024, 1, 5)) == ["bar-sina"]
|
||||
assert [log.source for log in collected] == ["tushare", "sina"]
|
||||
assert collected[0].success is False
|
||||
assert "boom" in (collected[0].failure_reason or "")
|
||||
assert collected[1].success is True
|
||||
|
||||
def test_fallback_unsupported_raises_with_audit(self) -> None:
|
||||
collected: list[SyncLog] = []
|
||||
f = _make(PrimaryProvider(fail=True), FallbackProvider(unsupported=True), collected)
|
||||
with pytest.raises(DataSourceError, match="备用源不支持"):
|
||||
f.get_daily("600519.SH", date(2024, 1, 1), date(2024, 1, 5))
|
||||
assert len(collected) == 2
|
||||
assert collected[1].success is False
|
||||
|
||||
def test_both_fail_raises_with_audit(self) -> None:
|
||||
collected: list[SyncLog] = []
|
||||
f = _make(PrimaryProvider(fail=True), FallbackProvider(fail=True), collected)
|
||||
with pytest.raises(DataSourceError, match="主备数据源均失败"):
|
||||
f.get_daily("600519.SH", date(2024, 1, 1), date(2024, 1, 5))
|
||||
assert len(collected) == 2
|
||||
|
||||
def test_no_fallback_raises(self) -> None:
|
||||
collected: list[SyncLog] = []
|
||||
f = FailoverProvider(PrimaryProvider(fail=True), None, audit=collected.append)
|
||||
with pytest.raises(DataSourceError, match="无备用源"):
|
||||
f.get_daily("600519.SH", date(2024, 1, 1), date(2024, 1, 5))
|
||||
assert len(collected) == 1
|
||||
Reference in New Issue
Block a user