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
This commit is contained in:
Simon
2026-09-06 16:59:28 +08:00
parent 7a89d97c0b
commit 2da234220a
23 changed files with 2666 additions and 1 deletions
@@ -0,0 +1,179 @@
"""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