Files
qlib/backend/app/infrastructure/data_sources/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

180 lines
5.6 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.
"""Tushare → Sina Failover 包装(AGENT.md §7)。
规则:
- 优先 primary;primary 抛错时才尝试 fallback(避免对空结果做无谓兜底请求)
- fallback 不支持该 API(DataSourceNotSupported)或自身失败 → 抛 DataSourceError
- 每次尝试都写 SyncLog(source / 成功与否 / 行数 / 区间),禁止静默切换
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import date
from typing import Any
from app.domain.entities.market import SyncLog
from app.domain.providers import MarketDataProvider
from app.infrastructure.data_sources.errors import DataSourceError, DataSourceNotSupported
class FailoverProvider:
"""以 primary 为主、fallback 为辅的 MarketDataProvider 实现。"""
name = "failover"
def __init__(
self,
primary: MarketDataProvider,
fallback: MarketDataProvider | None = None,
*,
audit: Callable[[SyncLog], None] | None = None,
) -> None:
self.primary = primary
self.fallback = fallback
self._audit = audit or (lambda _log: None)
# ---- 各 API 代理 ----
def get_stock_basic(self) -> list:
return self._with_failover(
"get_stock_basic",
primary_call=lambda: self.primary.get_stock_basic(),
fallback_call=lambda: self.fallback.get_stock_basic(),
)
def get_trade_cal(self, start: date, end: date) -> list:
return self._with_failover(
"get_trade_cal",
start=start,
end=end,
primary_call=lambda: self.primary.get_trade_cal(start, end),
fallback_call=lambda: self.fallback.get_trade_cal(start, end),
)
def get_daily(self, symbol: str, start: date, end: date) -> list:
return self._with_failover(
"get_daily",
start=start,
end=end,
primary_call=lambda: self.primary.get_daily(symbol, start, end),
fallback_call=lambda: self.fallback.get_daily(symbol, start, end),
)
def get_adjust_factor(self, symbol: str, start: date, end: date) -> list:
return self._with_failover(
"get_adjust_factor",
start=start,
end=end,
primary_call=lambda: self.primary.get_adjust_factor(symbol, start, end),
fallback_call=lambda: self.fallback.get_adjust_factor(symbol, start, end),
)
def get_financial(self, symbol: str) -> list:
return self._with_failover(
"get_financial",
primary_call=lambda: self.primary.get_financial(symbol),
fallback_call=lambda: self.fallback.get_financial(symbol),
)
# ---- 内部 ----
def _with_failover(
self,
api: str,
*,
primary_call: Callable[[], list],
fallback_call: Callable[[], list] | None = None,
start: date | None = None,
end: date | None = None,
) -> list:
try:
rows = primary_call()
except Exception as exc: # noqa: BLE001 —— 统一走审计
self._log(
source=self.primary.name,
api=api,
success=False,
reason=str(exc),
start=start,
end=end,
)
return self._try_fallback(api, fallback_call, start=start, end=end, primary_error=exc)
self._log(
source=self.primary.name,
api=api,
success=True,
row_count=_len(rows),
start=start,
end=end,
)
return rows
def _try_fallback(self, api, fallback_call, *, start, end, primary_error):
if fallback_call is None or self.fallback is None:
raise DataSourceError(
f"{self.primary.name}.{api} 失败且无备用源: {primary_error}"
) from primary_error
try:
rows = fallback_call()
except DataSourceNotSupported as exc:
self._log(
source=self.fallback.name,
api=api,
success=False,
reason=f"不支持: {exc}",
start=start,
end=end,
)
raise DataSourceError(
f"{self.primary.name}.{api} 失败,备用源不支持: {primary_error}"
) from primary_error
except Exception as exc: # noqa: BLE001
self._log(
source=self.fallback.name,
api=api,
success=False,
reason=str(exc),
start=start,
end=end,
)
raise DataSourceError(
f"主备数据源均失败: primary[{self.primary.name}]={primary_error} "
f"fallback[{self.fallback.name}]={exc}"
) from exc
self._log(
source=self.fallback.name,
api=api,
success=True,
row_count=_len(rows),
start=start,
end=end,
)
return rows
def _log(
self,
*,
source: str,
api: str,
success: bool,
reason: str | None = None,
row_count: int = 0,
start: date | None = None,
end: date | None = None,
) -> None:
self._audit(
SyncLog(
source=source,
api=api,
success=success,
failure_reason=reason,
row_count=row_count,
data_start=start,
data_end=end,
)
)
def _len(rows: Any) -> int:
return len(rows) if rows is not None else 0