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:
@@ -0,0 +1,219 @@
|
||||
"""Tushare Provider —— 首选数据源实现。
|
||||
|
||||
依赖注入:pro 客户端(tushare.pro.client 或测试 Fake)。真实运行时惰性加载
|
||||
tushare 库(pyproject optional:uv sync --extra datasource-tushare)。
|
||||
归一化函数只依赖 list[dict],便于无 pandas 环境下单测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.domain.entities.market import (
|
||||
AdjustFactor,
|
||||
DailyBar,
|
||||
FinancialIndicator,
|
||||
Stock,
|
||||
TradingCalendar,
|
||||
)
|
||||
from app.infrastructure.data_sources.errors import (
|
||||
DataSourceAuthenticationError,
|
||||
DataSourceError,
|
||||
)
|
||||
|
||||
_TS_DATE = "%Y%m%d"
|
||||
|
||||
|
||||
def _to_date(value: str | None) -> date | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return datetime.strptime(str(value)[:10], _TS_DATE).date()
|
||||
|
||||
|
||||
def _to_decimal(value) -> Decimal | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
num = float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if num != num: # NaN
|
||||
return None
|
||||
return Decimal(str(num))
|
||||
|
||||
|
||||
class TushareProvider:
|
||||
"""封装 Tushare Pro(ts.pro_api)。所有输出已归一化为领域实体。"""
|
||||
|
||||
name = "tushare"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str = "",
|
||||
*,
|
||||
pro: object | None = None,
|
||||
max_retries: int = 3,
|
||||
) -> None:
|
||||
self._pro = pro if pro is not None else _build_pro(token)
|
||||
self._max_retries = max_retries
|
||||
|
||||
# ---- 归一化(纯函数,输入 list[dict],可单测) ----
|
||||
|
||||
@staticmethod
|
||||
def normalize_stock(records: list[dict[str, Any]]) -> list[Stock]:
|
||||
stocks: list[Stock] = []
|
||||
for rec in records:
|
||||
stocks.append(
|
||||
Stock(
|
||||
symbol=str(rec.get("ts_code") or rec.get("symbol") or ""),
|
||||
name=str(rec.get("name") or ""),
|
||||
industry=rec.get("industry"),
|
||||
area=rec.get("area"),
|
||||
market=rec.get("market"),
|
||||
exchange=rec.get("exchange"),
|
||||
list_date=_to_date(rec.get("list_date")) or date.min,
|
||||
delist_date=_to_date(rec.get("delist_date")),
|
||||
status=str(rec.get("status") or "L"),
|
||||
)
|
||||
)
|
||||
return stocks
|
||||
|
||||
@staticmethod
|
||||
def normalize_calendar(records: list[dict[str, Any]]) -> list[TradingCalendar]:
|
||||
return [
|
||||
TradingCalendar(
|
||||
calendar_date=_to_date(rec.get("cal_date")) or date.min,
|
||||
is_open=bool(rec.get("is_open")),
|
||||
)
|
||||
for rec in records
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def normalize_daily(records: list[dict[str, Any]]) -> list[DailyBar]:
|
||||
bars: list[DailyBar] = []
|
||||
for rec in records:
|
||||
vol = _to_decimal(rec.get("vol"))
|
||||
amount = _to_decimal(rec.get("amount"))
|
||||
bars.append(
|
||||
DailyBar(
|
||||
symbol=str(rec.get("ts_code") or ""),
|
||||
trade_date=_to_date(rec.get("trade_date")) or date.min,
|
||||
open=_to_decimal(rec.get("open")),
|
||||
high=_to_decimal(rec.get("high")),
|
||||
low=_to_decimal(rec.get("low")),
|
||||
close=_to_decimal(rec.get("close")),
|
||||
volume=vol * 100 if vol is not None else None,
|
||||
amount=amount * 1000 if amount is not None else None,
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
@staticmethod
|
||||
def normalize_adj_factor(records: list[dict[str, Any]]) -> list[AdjustFactor]:
|
||||
return [
|
||||
AdjustFactor(
|
||||
symbol=str(rec.get("ts_code") or ""),
|
||||
trade_date=_to_date(rec.get("trade_date")) or date.min,
|
||||
factor=_to_decimal(rec.get("adj_factor")) or Decimal(1),
|
||||
)
|
||||
for rec in records
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def normalize_financial(records: list[dict[str, Any]]) -> list[FinancialIndicator]:
|
||||
rows: list[FinancialIndicator] = []
|
||||
for rec in records:
|
||||
rows.append(
|
||||
FinancialIndicator(
|
||||
symbol=str(rec.get("ts_code") or ""),
|
||||
report_date=_to_date(rec.get("end_date")) or date.min,
|
||||
announce_date=_to_date(rec.get("ann_date")) or date.min,
|
||||
eps=_to_decimal(rec.get("eps")),
|
||||
roe=_to_decimal(rec.get("roe")),
|
||||
net_profit=_to_decimal(rec.get("n_income_attr_p")),
|
||||
gross_margin=_to_decimal(rec.get("grossprofit_margin")),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
# ---- 接口调用 ----
|
||||
|
||||
def get_stock_basic(self) -> list[Stock]:
|
||||
records = self._call(
|
||||
"stock_basic",
|
||||
fields="ts_code,symbol,name,area,industry,market,exchange,list_date,delist_date,status",
|
||||
)
|
||||
return self.normalize_stock(records)
|
||||
|
||||
def get_trade_cal(self, start: date, end: date) -> list[TradingCalendar]:
|
||||
records = self._call(
|
||||
"trade_cal",
|
||||
exchange="SSE",
|
||||
start_date=start.strftime(_TS_DATE),
|
||||
end_date=end.strftime(_TS_DATE),
|
||||
is_open="",
|
||||
)
|
||||
return self.normalize_calendar(records)
|
||||
|
||||
def get_daily(self, symbol: str, start: date, end: date) -> list[DailyBar]:
|
||||
records = self._call(
|
||||
"daily",
|
||||
ts_code=symbol,
|
||||
start_date=start.strftime(_TS_DATE),
|
||||
end_date=end.strftime(_TS_DATE),
|
||||
)
|
||||
return self.normalize_daily(records)
|
||||
|
||||
def get_adjust_factor(self, symbol: str, start: date, end: date) -> list[AdjustFactor]:
|
||||
records = self._call(
|
||||
"adj_factor",
|
||||
ts_code=symbol,
|
||||
start_date=start.strftime(_TS_DATE),
|
||||
end_date=end.strftime(_TS_DATE),
|
||||
)
|
||||
return self.normalize_adj_factor(records)
|
||||
|
||||
def get_financial(self, symbol: str) -> list[FinancialIndicator]:
|
||||
records = self._call("fina_indicator", ts_code=symbol)
|
||||
return self.normalize_financial(records)
|
||||
|
||||
# ---- 内部 ----
|
||||
|
||||
def _call(self, api: str, **kwargs) -> list[dict[str, Any]]:
|
||||
last_error: Exception | None = None
|
||||
for _ in range(self._max_retries):
|
||||
try:
|
||||
fn = getattr(self._pro, api)
|
||||
result = fn(**kwargs)
|
||||
if result is None:
|
||||
return []
|
||||
if hasattr(result, "to_dict"):
|
||||
return result.to_dict("records")
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
return []
|
||||
except Exception as exc: # noqa: BLE001 —— tushare 异常无统一类型,逐一归类
|
||||
last_error = exc
|
||||
msg = str(exc)
|
||||
if "权限" in msg or "积分" in msg or "token" in msg.lower():
|
||||
raise DataSourceAuthenticationError(msg) from exc
|
||||
raise DataSourceError(
|
||||
f"tushare.{api} 重试 {self._max_retries} 次仍失败: {last_error}"
|
||||
) from last_error
|
||||
|
||||
|
||||
def _build_pro(token: str):
|
||||
if not token:
|
||||
raise DataSourceAuthenticationError(
|
||||
"缺少 TUSHARE_TOKEN:请 cp .env.example .env 并填入 Tushare Pro token"
|
||||
)
|
||||
try:
|
||||
ts = importlib.import_module("tushare")
|
||||
except ImportError as exc: # pragma: no cover —— 环境相关
|
||||
raise DataSourceError(
|
||||
"未安装 tushare 客户端:cd backend && uv sync --extra datasource-tushare"
|
||||
) from exc
|
||||
return ts.pro_api(token)
|
||||
Reference in New Issue
Block a user