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,101 @@
"""新浪财经 Provider —— 备用数据源。
能力边界(AGENT.md §5.2):
- 新浪日 K 接口返回**前复权**数据,口径与 Tushare 不复权不同,
因此本 Provider 只用于「缺失/不可用时的行情参考与交叉验证」,
不得把结果直接并入不复权主时序库(禁止静默混口径)。
- 新浪不提供复权因子 / 财务指标 → 相应方法抛 DataSourceNotSupported。
"""
from __future__ import annotations
import json
import re
import urllib.parse
import urllib.request
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from app.domain.entities.market import DailyBar
from app.infrastructure.data_sources.errors import (
DataSourceError,
DataSourceNotSupported,
)
_KLINE_JSONP = (
"https://quotes.sina.cn/cn/api/jsonp_v2.php/var%20data=/CN_MarketDataService"
".getKLineData?symbol={sina_symbol}&scale=240&ma=no&datalen={datalen}"
)
def _to_sina_symbol(symbol: str) -> str:
"""600519.SH -> sh600519;000001.SZ -> sz000001。"""
code, _, suffix = symbol.partition(".")
prefix = {"SH": "sh", "SZ": "sz", "BJ": "bj"}.get(suffix.upper(), "sh")
return f"{prefix}{code}"
def _extract_jsonp(payload: str) -> list[dict[str, Any]]:
match = re.search(r"=\s*(\[.*\])\s*$", payload.strip(), flags=re.DOTALL)
if not match:
raise DataSourceError("新浪行情返回格式无法解析")
return json.loads(match.group(1))
class SinaProvider:
"""新浪财经备用数据源(仅日线参考 / 交叉验证)。"""
name = "sina"
def __init__(self, *, timeout: float = 10.0, urlopen=urllib.request.urlopen) -> None:
self._timeout = timeout
self._urlopen = urlopen
def get_daily(self, symbol: str, start: date, end: date, datalen: int = 320) -> list[DailyBar]:
"""拉取前复权日 K(新浪仅支持最近 datalen 个自然日窗口)。"""
url = _KLINE_JSONP.format(sina_symbol=_to_sina_symbol(symbol), datalen=datalen)
try:
with self._urlopen(url, timeout=self._timeout) as resp:
payload = resp.read().decode("utf-8", errors="replace")
except Exception as exc: # noqa: BLE001
raise DataSourceError(f"sina 请求失败: {exc}") from exc
bars: list[DailyBar] = []
for rec in _extract_jsonp(payload):
day = datetime.strptime(rec["day"], "%Y-%m-%d").date()
if day < start or day > end:
continue
bars.append(
DailyBar(
symbol=symbol,
trade_date=day,
open=_d(rec.get("open")),
high=_d(rec.get("high")),
low=_d(rec.get("low")),
close=_d(rec.get("close")),
volume=_d(rec.get("volume")),
)
)
return bars
def get_stock_basic(self):
raise DataSourceNotSupported("新浪不提供股票基础信息列表")
def get_trade_cal(self, start, end):
raise DataSourceNotSupported("新浪不提供交易日历")
def get_adjust_factor(self, symbol, start, end):
raise DataSourceNotSupported("新浪不提供复权因子(返回数据为前复权口径)")
def get_financial(self, symbol):
raise DataSourceNotSupported("新浪不提供财务指标")
def _d(value) -> Decimal | None:
if value is None:
return None
try:
return Decimal(str(value))
except (ValueError, TypeError):
return None