chore: 重构前基线 — 9 个 sync task 全部 ok + akshare 移除 + mairui 资金流接入

状态:
- 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(独立项目)
This commit is contained in:
gao
2026-06-15 15:36:09 +08:00
commit 05635b76b9
55 changed files with 6307 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
"""新浪财经数据源。
提供:
- 日 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)