- 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
102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
"""新浪财经 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
|