"""新浪财经 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