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