- 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
180 lines
5.6 KiB
Python
180 lines
5.6 KiB
Python
"""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
|