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
+203
View File
@@ -0,0 +1,203 @@
"""雪球数据源(pysnowball,非官方 SDK)。
提供:
- 日 K 线复核(需要 token,免费但有限速)
- 最新股本快照(quote_detail,单位原始股,需 ÷1e8 转为亿股)
没有 token 时降级:is_available() 返回 False。"""
from __future__ import annotations
import logging
import os
import time
from datetime import datetime
from typing import Any, Optional
import pandas as pd
from app.core.datasource.base import DataSource
from app.core.datasource.utils import code6_to_xueqiu
logger = logging.getLogger("sync.xueqiu")
class XueqiuSource(DataSource):
key = "datasource_xueqiu"
name = "雪球"
provides = ["kline_daily", "share"]
requires_credential = True
credential_key = "XUEQIU_TOKEN"
# 进程内只 set_token 一次
_token_lock = __import__("threading").Lock()
_token_set = False
# RPS 限流(线程安全)
# 雪球 token 限速 10 RPS(用户实测),用满 10 才能在合理时间内跑完全市场
_RPS_LIMIT = 10.0
_rate_lock = __import__("threading").Lock()
_last_request = 0.0
def _read_token(self) -> str:
return os.environ.get(self.credential_key, "").strip()
def _import_ball(self):
"""延迟 + 显式 import。失败时打 warning(之前全被 except 吞了)。"""
try:
import pysnowball as ball
return ball
except ImportError as e:
logger.error(
"pysnowball 未安装,雪球数据源不可用。pip install pysnowball。错误: %s", e,
)
raise
def _set_token_once(self) -> None:
if self._token_set:
return
with self._token_lock:
if self._token_set:
return
token = self._read_token()
if not token:
return
try:
ball = self._import_ball()
ball.set_token(token)
self._token_set = True
except ImportError:
pass # _import_ball 已经打日志了
def _wait_rps(self) -> None:
with self._rate_lock:
now = time.time()
elapsed = now - self._last_request
min_interval = 1.0 / self._RPS_LIMIT
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
self._last_request = time.time()
def is_available(self) -> tuple[bool, str]:
token = self._read_token()
if not token:
return False, f"未配置 {self.credential_key}"
# 顺便检查 pysnowball 是否装了
try:
import pysnowball # noqa: F401
except ImportError:
return False, "pysnowball 未安装,pip install pysnowball"
return True, "ok"
def health_check(self) -> dict[str, Any]:
if not self._read_token():
return {"success": False, "message": f"未配置 {self.credential_key}"}
try:
ball = self._import_ball()
self._set_token_once()
result = ball.quote_detail("SH600036")
if result is None or result.get("error_code") != 0:
return {
"success": False,
"message": f"雪球返回异常: error_code={result.get('error_code') if result else 'None'} "
f"desc={result.get('error_description') if result else 'None'}",
}
quote = (result or {}).get("data", {}).get("quote", {})
if not quote.get("total_shares") or not quote.get("float_shares"):
return {"success": False, "message": "雪球返回成功,但缺少 total_shares/float_shares 字段"}
return {"success": True, "message": "雪球连接成功,可读取股本快照"}
except Exception as e:
logger.exception("雪球 health_check 异常: %s", e)
return {"success": False, "message": f"雪球连接失败: {e}"}
def fetch_kline_daily(self, code6: str, start: str, end: str) -> pd.DataFrame:
if not self._read_token():
return pd.DataFrame()
try:
ball = self._import_ball()
self._set_token_once()
self._wait_rps()
symbol = code6_to_xueqiu(code6)
start_dt = datetime.strptime(start, "%Y-%m-%d")
end_dt = datetime.strptime(end, "%Y-%m-%d")
count = min(max((end_dt - start_dt).days + 60, 10), 5000)
# 加重试:雪球风控偶尔返回空/错误
result = None
for attempt in range(3):
result = ball.kline(symbol, period="day", count=count)
if result and result.get("error_code") == 0:
break
time.sleep(0.3 * (attempt + 1))
if not result or result.get("error_code") != 0:
logger.warning(
"[kline %s] 雪球 kline 失败: error_code=%s desc=%s",
code6,
result.get("error_code") if result else "None",
result.get("error_description") if result else "None",
)
return pd.DataFrame()
data = result.get("data", {})
columns = data.get("column", [])
items = data.get("item", [])
if not columns or not items:
return pd.DataFrame()
needed = {"timestamp": 0, "volume": 1, "open": 2, "high": 3, "low": 4, "close": 5}
records = []
for row in items:
try:
ts = row[needed["timestamp"]] / 1000
records.append({
"trade_date": datetime.fromtimestamp(ts).strftime("%Y-%m-%d"),
"open": float(row[needed["open"]]),
"high": float(row[needed["high"]]),
"low": float(row[needed["low"]]),
"close": float(row[needed["close"]]),
"volume": float(row[needed["volume"]]),
})
except (IndexError, ValueError, TypeError, OSError):
continue
from app.core.datasource.utils import filter_date_range, normalize_kline
return filter_date_range(normalize_kline(pd.DataFrame(records)), start, end)
except Exception as e:
logger.exception("[kline %s] 雪球 kline 异常: %s", code6, e)
return pd.DataFrame()
def fetch_share_snapshot(self, code6: str) -> Optional[dict[str, Any]]:
"""单只股票的最新股本快照。
返回:
{total_share, float_share, trade_date} —— 单位:亿股
"""
if not self._read_token():
return None
try:
ball = self._import_ball()
self._set_token_once()
self._wait_rps()
symbol = code6_to_xueqiu(code6)
result = ball.quote_detail(symbol)
if not result or result.get("error_code") != 0:
logger.warning(
"[share %s] 雪球 quote_detail 失败: error_code=%s desc=%s",
code6,
result.get("error_code") if result else "None",
result.get("error_description") if result else "None",
)
return None
quote = result.get("data", {}).get("quote", {})
today_str = datetime.now().strftime("%Y-%m-%d")
total = round(float(quote.get("total_shares") or 0) / 1e8, 4)
flt = round(float(quote.get("float_shares") or 0) / 1e8, 4)
if total <= 0 or flt <= 0:
logger.warning("[share %s] 雪球返回成功但 total/float 为 0", code6)
return None
return {
"trade_date": today_str,
"total_share": total,
"float_share": flt,
}
except Exception as e:
logger.exception("[share %s] 雪球 quote_detail 异常: %s", code6, e)
return None