- financial 默认增量:按 A 股披露节奏判断已最新并跳过;--full 强制全量重拉 - Tushare fina_indicator 增加报告期窗口与 100 条/请求自动分页(修复老报告期静默截断) - 新浪兜底收紧为校验兜底:两源重叠历史一致才导入缺失键,行标记 source=sina; 财务可比字段取 eps/销售毛利率(ROE 两端口径不同不作依据),日线只比较最近重叠交易日 - CLI 输出逐只进度与导入内容描述(来源/行数/报告期与公告区间),失败股票留待重跑 - financial_indicator 增 source 列(迁移 d3f6c9a21b04);新增一致性/分页/服务测试
184 lines
5.7 KiB
Python
184 lines
5.7 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, start: date | None = None, end: date | None = None
|
||
) -> list:
|
||
return self._with_failover(
|
||
"get_financial",
|
||
start=start,
|
||
end=end,
|
||
primary_call=lambda: self.primary.get_financial(symbol, start, end),
|
||
fallback_call=lambda: self.fallback.get_financial(symbol, start, end),
|
||
)
|
||
|
||
# ---- 内部 ----
|
||
|
||
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
|