05635b76b9
状态: - 9 个 sync task(stock_basic / kline_daily / kline_index / kline_5min / moneyflow / industry_sector / sector_features / share_snapshot / market_regime) - 数据源:baostock + mairui + 雪球(pysnowball) + 新浪(4 个) - 项目级约束:永远不用 akshare(已落实) - kline_5min 改用 DB 快照统一全量/增量逻辑 - 零后端 Chrome 扩展 xueqiu_sync(独立项目)
138 lines
4.9 KiB
Python
138 lines
4.9 KiB
Python
"""新浪财经数据源。
|
|
|
|
提供:
|
|
- 日 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)
|