"""新浪财经数据源。 提供: - 日 K 线(主源,免费,无 token) - 指数日 K 线 接口:https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData """ from __future__ import annotations import json import threading import time import urllib.request from typing import Any import pandas as pd from app.core.datasource.base import DataSource from app.core.datasource.utils import ( code6_to_sina, filter_date_range, normalize_kline, ) class SinaSource(DataSource): key = "datasource_xinlang" name = "新浪财经" provides = ["kline_daily", "index_daily"] requires_credential = False _HEADERS = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "Referer": "https://finance.sina.com.cn", } _BASE_URL = ( "https://money.finance.sina.com.cn/quotes_service/api/json_v2.php" "/CN_MarketData.getKLineData" ) # RPS 限流(线程安全) # 设为 3 防止触发新浪反爬(单 IP 频率过高会被临时封禁) _RPS_LIMIT = 3.0 _rate_lock = threading.Lock() _last_request_ts = 0.0 @classmethod def _wait_rps(cls): """全局 RPS 限流:所有线程共享同一个速率限制。""" with cls._rate_lock: now = time.time() elapsed = now - cls._last_request_ts min_interval = 1.0 / cls._RPS_LIMIT if elapsed < min_interval: time.sleep(min_interval - elapsed) cls._last_request_ts = time.time() def is_available(self) -> tuple[bool, str]: return True, "ok" def health_check(self) -> dict[str, Any]: try: url = ( f"{self._BASE_URL}?symbol=sh600036&scale=240&ma=no&datalen=5" ) req = urllib.request.Request(url, headers=self._HEADERS) with urllib.request.urlopen(req, timeout=15) as resp: data = json.loads(resp.read().decode("utf-8")) if not data or not isinstance(data, list): return {"success": False, "message": "新浪财经返回数据格式异常"} return {"success": True, "message": f"连接成功,获取到 {len(data)} 条 K 线数据"} except Exception as e: return {"success": False, "message": f"新浪财经连接失败: {e}"} def _fetch(self, symbol: str, datalen: int = 5000, retry: int = 2) -> list: self._wait_rps() url = f"{self._BASE_URL}?symbol={symbol}&scale=240&ma=no&datalen={int(max(datalen, 260))}" last_err = None for attempt in range(retry + 1): try: req = urllib.request.Request(url, headers=self._HEADERS) with urllib.request.urlopen(req, timeout=20) as resp: raw = resp.read().decode("gbk", errors="replace") return json.loads(raw) except Exception as e: last_err = e if attempt < retry: time.sleep(0.5 * (attempt + 1)) # 短暂退避后重试 continue if last_err: from app.core.utils.logging import get_logger get_logger("sina").debug(f"sina fetch {symbol} failed after {retry+1} attempts: {last_err}") return [] def fetch_kline_daily(self, code6: str, start: str, end: str) -> pd.DataFrame: symbol = code6_to_sina(code6) data = self._fetch(symbol) if not data or not isinstance(data, list): return pd.DataFrame() records = [] for item in data: try: records.append({ "trade_date": item["day"], "open": item["open"], "high": item["high"], "low": item["low"], "close": item["close"], "volume": item["volume"], }) except KeyError: continue return filter_date_range(normalize_kline(pd.DataFrame(records)), start, end) def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame: if index_code.startswith(("5", "6", "9")): symbol = f"sh{index_code}" else: symbol = f"sz{index_code}" data = self._fetch(symbol) if not data or not isinstance(data, list): return pd.DataFrame() records = [] for item in data: try: records.append({ "trade_date": item["day"], "open": float(item["open"]), "high": float(item["high"]), "low": float(item["low"]), "close": float(item["close"]), "volume": float(item["volume"]), }) except (KeyError, ValueError, TypeError): continue return filter_date_range(normalize_kline(pd.DataFrame(records)), start, end)