Files
qlib/backend/tests/test_failover.py
T
Simon 2da234220a 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
2026-09-06 16:59:28 +08:00

94 lines
3.4 KiB
Python

"""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