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:
@@ -0,0 +1,379 @@
|
||||
"""麦蕊智数(mairui)数据源。
|
||||
|
||||
提供:
|
||||
- 日 K 线(`hsstock/history/{code}.{ex}/d/n/{licence}`)
|
||||
- 5 分钟 K 线(`hsstock/history/{code}.{ex}/5/n/{licence}`)
|
||||
- 指数日 K 线(`hsindex/history/{code}.{ex}/d/{licence}`)
|
||||
- 资金流向(`hsstock/history/transaction/{code}.{ex}/{licence}`)
|
||||
|
||||
限速:1分钟300次(默认保守到 5 RPS = 1分钟300次)
|
||||
凭证:licence(无需登录态,从环境变量 MAIRUI_LICENCE 读取)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.core.datasource.base import DataSource
|
||||
from app.core.datasource.utils import (
|
||||
code6_to_mairui,
|
||||
filter_date_range,
|
||||
normalize_kline,
|
||||
)
|
||||
|
||||
|
||||
class MairuiSource(DataSource):
|
||||
key = "datasource_mairui"
|
||||
name = "麦蕊智数(mairui.club)"
|
||||
provides = ["kline_daily", "kline_5min", "index_daily", "stock_basic", "moneyflow"]
|
||||
requires_credential = True
|
||||
credential_key = "MAIRUI_LICENCE"
|
||||
|
||||
_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
_BASE_URL = "https://api.mairuiapi.com"
|
||||
|
||||
# mairui 免费 licence 1 分钟 300 次 = 5 RPS(**单 licence**硬上限)。
|
||||
# 5 workers × 1 RPS/worker = 5 RPS 总 = 刚好踩满上限,避免风控。
|
||||
# 用 thread-local 计时:每个 worker 独立计自己的 last_ts,
|
||||
# 避免 N 个 worker 串行抢一把锁退化成 1 worker。
|
||||
_RPS_LIMIT = 1.0
|
||||
_rps_local = threading.local()
|
||||
|
||||
@classmethod
|
||||
def _wait_rps(cls):
|
||||
last = getattr(cls._rps_local, "last_ts", 0.0)
|
||||
now = time.time()
|
||||
elapsed = now - last
|
||||
min_interval = 1.0 / cls._RPS_LIMIT
|
||||
if elapsed < min_interval:
|
||||
time.sleep(min_interval - elapsed)
|
||||
now = time.time()
|
||||
cls._rps_local.last_ts = now
|
||||
|
||||
def _get_licence(self) -> Optional[str]:
|
||||
return os.environ.get(self.credential_key, "").strip() or None
|
||||
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
if not self._get_licence():
|
||||
return False, f"未配置 {self.credential_key}"
|
||||
return True, "ok"
|
||||
|
||||
def health_check(self) -> dict[str, Any]:
|
||||
lic = self._get_licence()
|
||||
if not lic:
|
||||
return {"success": False, "message": f"{self.credential_key} 未配置"}
|
||||
try:
|
||||
# 拿 1 条日线数据作为连通性 + 凭证测试
|
||||
url = f"{self._BASE_URL}/hsstock/history/600519.SH/d/n/{lic}?st=20260609&et=20260609"
|
||||
req = urllib.request.Request(url, headers=self._HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, list) and data:
|
||||
return {"success": True, "message": f"连接成功,获取到 {len(data)} 条 K 线"}
|
||||
if isinstance(data, dict) and data.get("error"):
|
||||
return {"success": False, "message": f"麦蕊返回: {data['error']}"}
|
||||
return {"success": False, "message": f"麦蕊返回空/异常: {raw[:200]}"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"麦蕊连接失败: {e}"}
|
||||
|
||||
def _fetch(self, path: str, params: dict[str, Any], retry: int = 2) -> Any:
|
||||
"""通用 GET,含限速 + 重试。"""
|
||||
self._wait_rps()
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items() if v)
|
||||
url = f"{self._BASE_URL}{path}"
|
||||
if qs:
|
||||
url = f"{url}?{qs}"
|
||||
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:
|
||||
# 先按 gbk 试(中文乱码返回),再 fallback utf-8
|
||||
raw_bytes = resp.read()
|
||||
# 用 latin-1 永不失败地把 bytes 转成字符串(每字节 1 字符)
|
||||
raw = raw_bytes.decode("latin-1")
|
||||
# mairui 风控时返回 "接收数据异常,请稍后再试"(gbk 编码)
|
||||
if "请稍后再试" in raw or "codec can't decode" in raw or raw.startswith("'utf-8'") or "Error -3 while decompressing" in raw:
|
||||
raise RuntimeError(f"mairui 返回风控提示: {raw[:80]}")
|
||||
# 试 JSON parse;如果是 gbk 编码的中文提示,要先解码
|
||||
if raw.startswith("'") and raw.endswith("'"):
|
||||
# gbk 编码的 Python repr 字符串,如 "'接收数据异常,请稍后再试'"
|
||||
try:
|
||||
decoded = raw[1:-1].encode("latin-1").decode("gbk")
|
||||
if "请稍后再试" in decoded:
|
||||
raise RuntimeError(f"mairui 返回风控: {decoded}")
|
||||
except Exception:
|
||||
pass
|
||||
return json.loads(raw)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < retry:
|
||||
time.sleep(1.0 * (attempt + 1)) # 风控重试退避长一点
|
||||
from app.core.utils.logging import get_logger
|
||||
get_logger("mairui").debug(f"mairui fetch {url} failed: {last_err}")
|
||||
return []
|
||||
|
||||
# ── 股票列表 ───────────────────────────────────────────
|
||||
|
||||
def fetch_stock_list(self) -> list[dict]:
|
||||
"""全市场沪深 A 股基础列表。
|
||||
|
||||
API: GET /hslt/list/{licence}
|
||||
返回字段:dm (代码.交易所,如 "000001.SZ"), mc (名称), jys (交易所 SZ/SH)
|
||||
"""
|
||||
lic = self._get_licence()
|
||||
if not lic:
|
||||
return []
|
||||
data = self._fetch(f"/hslt/list/{lic}", {})
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
rows = []
|
||||
for item in data:
|
||||
try:
|
||||
dm = str(item.get("dm", "")).strip()
|
||||
mc = str(item.get("mc", "")).strip()
|
||||
# Mairui 在 2 字简称中间填了空格(如 "万 科A"),去掉多余空格
|
||||
mc = "".join(mc.split())
|
||||
jys = str(item.get("jys", "")).strip().upper()
|
||||
# dm 格式: "000001.SZ" → code6="000001", exchange="SZ"
|
||||
if "." in dm:
|
||||
code6, exch = dm.split(".", 1)
|
||||
else:
|
||||
code6, exch = dm, jys
|
||||
if not code6 or len(code6) != 6 or not code6.isdigit():
|
||||
continue
|
||||
exchange = exch.upper() or jys
|
||||
if exchange not in ("SH", "SZ"):
|
||||
continue
|
||||
rows.append({
|
||||
"code": f"{exchange}{code6}",
|
||||
"name": mc,
|
||||
"exchange": exchange,
|
||||
"list_date": "", # hslt/list 不返回 IPO 日期
|
||||
"listing_status": "st" if (mc.startswith("ST") or mc.startswith("*ST")) else "normal",
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return rows
|
||||
|
||||
# ── K 线 fetch ─────────────────────────────────────────
|
||||
|
||||
def fetch_kline_daily(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||||
lic = self._get_licence()
|
||||
if not lic:
|
||||
return pd.DataFrame()
|
||||
symbol = code6_to_mairui(code6)
|
||||
path = f"/hsstock/history/{symbol}/d/n/{lic}"
|
||||
params = {
|
||||
"st": (start or "").replace("-", ""),
|
||||
"et": (end or "").replace("-", ""),
|
||||
}
|
||||
data = self._fetch(path, params)
|
||||
if not isinstance(data, list):
|
||||
return pd.DataFrame()
|
||||
records = []
|
||||
for item in data:
|
||||
try:
|
||||
# t 字段:日线 "2026-06-09 00:00:00",取日期部分
|
||||
t = item.get("t", "")
|
||||
d = t.split(" ")[0] if isinstance(t, str) else ""
|
||||
if not d:
|
||||
continue
|
||||
records.append({
|
||||
"trade_date": d,
|
||||
"open": float(item["o"]),
|
||||
"high": float(item["h"]),
|
||||
"low": float(item["l"]),
|
||||
"close": float(item["c"]),
|
||||
"volume": float(item.get("v") or 0),
|
||||
})
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
return filter_date_range(normalize_kline(pd.DataFrame(records)), start, end)
|
||||
|
||||
def fetch_kline_5min(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""5 分钟 K 线。返回标准列:bar_time, open, high, low, close, volume, amount。
|
||||
|
||||
mairui 用 level=5(数字,不是 "5m")。
|
||||
"""
|
||||
lic = self._get_licence()
|
||||
if not lic:
|
||||
return pd.DataFrame()
|
||||
symbol = code6_to_mairui(code6)
|
||||
path = f"/hsstock/history/{symbol}/5/n/{lic}"
|
||||
params = {
|
||||
"st": (start or "").replace("-", ""),
|
||||
"et": (end or "").replace("-", ""),
|
||||
}
|
||||
data = self._fetch(path, params)
|
||||
if not isinstance(data, list):
|
||||
return pd.DataFrame()
|
||||
records = []
|
||||
for item in data:
|
||||
try:
|
||||
t = item.get("t", "")
|
||||
# 分钟级时间格式 "2026-06-09 14:50:00" → 替换空格为 T 让 pandas 识别
|
||||
bar_time = t.replace(" ", "T") if isinstance(t, str) else None
|
||||
if not bar_time:
|
||||
continue
|
||||
records.append({
|
||||
"bar_time": bar_time,
|
||||
"open": float(item["o"]),
|
||||
"high": float(item["h"]),
|
||||
"low": float(item["l"]),
|
||||
"close": float(item["c"]),
|
||||
"volume": float(item.get("v") or 0),
|
||||
"amount": float(item.get("a") or 0),
|
||||
})
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
if not records:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(records)
|
||||
df["bar_time"] = pd.to_datetime(df["bar_time"], errors="coerce")
|
||||
df = df.dropna(subset=["bar_time"]).sort_values("bar_time").reset_index(drop=True)
|
||||
# 过滤日期范围(按日期部分,不含时分)
|
||||
if start:
|
||||
df = df[df["bar_time"] >= pd.Timestamp(start)]
|
||||
if end:
|
||||
# end 包含整天
|
||||
df = df[df["bar_time"] < pd.Timestamp(end) + pd.Timedelta(days=1)]
|
||||
return df
|
||||
|
||||
def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""指数日 K。index_code 用 mairui 格式(如 '000300.SH')。"""
|
||||
lic = self._get_licence()
|
||||
if not lic:
|
||||
return pd.DataFrame()
|
||||
path = f"/hsindex/history/{index_code}/d/{lic}"
|
||||
params = {
|
||||
"st": (start or "").replace("-", ""),
|
||||
"et": (end or "").replace("-", ""),
|
||||
}
|
||||
data = self._fetch(path, params)
|
||||
if not isinstance(data, list):
|
||||
return pd.DataFrame()
|
||||
records = []
|
||||
for item in data:
|
||||
try:
|
||||
t = item.get("t", "")
|
||||
d = t.split(" ")[0] if isinstance(t, str) else ""
|
||||
if not d:
|
||||
continue
|
||||
records.append({
|
||||
"trade_date": d,
|
||||
"open": float(item["o"]),
|
||||
"high": float(item["h"]),
|
||||
"low": float(item["l"]),
|
||||
"close": float(item["c"]),
|
||||
"volume": float(item.get("v") or 0),
|
||||
})
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
return filter_date_range(normalize_kline(pd.DataFrame(records)), start, end)
|
||||
|
||||
# ── 资金流向 ───────────────────────────────────────────
|
||||
|
||||
def fetch_moneyflow(self, code6: str, start: str, end: str) -> pd.DataFrame:
|
||||
"""个股资金流向(主力/大/中/小单 净额)。
|
||||
|
||||
API: GET /hsstock/history/transaction/{code}.{ex}/{licence}?st=YYYYMMDD&et=YYYYMMDD
|
||||
返回字段(按买卖方向 × 单型 4×4 矩阵 + 主买/主卖/被动买/被动卖):
|
||||
zmbtdcje 主买特大单成交额
|
||||
zmbddcje 主买大单成交额
|
||||
zmbzdcje 主买中单成交额
|
||||
zmbxdcje 主买小单成交额
|
||||
zmstdcje 主卖特大单成交额
|
||||
zmsddcje 主卖大单成交额
|
||||
zmszdcje 主卖中单成交额
|
||||
zmsxdcje 主卖小单成交额
|
||||
bdmbtdcje 被动买特大单成交额
|
||||
bdmbddcje 被动买大单成交额
|
||||
bdmbzdcje 被动买中单成交额
|
||||
bdmbxdcje 被动买小单成交额
|
||||
bdmstdcje 被动卖特大单成交额
|
||||
bdmsddcje 被动卖大单成交额
|
||||
bdmszdcje 被动卖中单成交额
|
||||
bdmsxdcje 被动卖小单成交额
|
||||
... 以及对应的成交量/笔数字段(zmbtdcjl 等),本接口暂只取成交额
|
||||
|
||||
单型口径(mairui 文档):
|
||||
特大单:成交额 ≥ 100 万 或 成交量 ≥ 5000 手
|
||||
大单 :成交额 ≥ 20 万 或 成交量 ≥ 1000 手
|
||||
中单 :成交额 ≥ 4 万 或 成交量 ≥ 200 手
|
||||
小单 :其他
|
||||
|
||||
输出字段:trade_date, main_net_inflow, large_net_inflow, medium_net_inflow, small_net_inflow
|
||||
净额口径(与 akshare stock_individual_fund_flow 保持一致):
|
||||
主力净流入 = Σ主买四型 - Σ主卖四型 (zmb{t,d,z,x} - zms{t,d,z,x})
|
||||
大单净额 = 主买大 - 主卖大 (zmbddcje - zmsddcje)
|
||||
中单净额 = 主买中 - 主卖中 (zmbzdcje - zmszdcje)
|
||||
小单净额 = 主买小 - 主卖小 (zmbxdcje - zmsxdcje)
|
||||
注:不能加被动买/卖 —— 主动买 ≡ 被动卖、主动卖 ≡ 被动买
|
||||
(同一笔成交记在两边),加起来恒等 0。
|
||||
"""
|
||||
lic = self._get_licence()
|
||||
if not lic:
|
||||
return pd.DataFrame()
|
||||
symbol = code6_to_mairui(code6)
|
||||
path = f"/hsstock/history/transaction/{symbol}/{lic}"
|
||||
params = {
|
||||
"st": (start or "").replace("-", ""),
|
||||
"et": (end or "").replace("-", ""),
|
||||
}
|
||||
data = self._fetch(path, params)
|
||||
if not isinstance(data, list):
|
||||
return pd.DataFrame()
|
||||
|
||||
def f(item, k) -> float:
|
||||
try:
|
||||
v = item.get(k)
|
||||
return float(v) if v not in (None, "", "-") else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
records = []
|
||||
for item in data:
|
||||
t = item.get("t", "")
|
||||
d = t.split(" ")[0] if isinstance(t, str) else ""
|
||||
if not d:
|
||||
continue
|
||||
# 主买四型 / 主卖四型
|
||||
main_buy = (
|
||||
f(item, "zmbtdcje") + f(item, "zmbddcje")
|
||||
+ f(item, "zmbzdcje") + f(item, "zmbxdcje")
|
||||
)
|
||||
main_sell = (
|
||||
f(item, "zmstdcje") + f(item, "zmsddcje")
|
||||
+ f(item, "zmszdcje") + f(item, "zmsxdcje")
|
||||
)
|
||||
# 大/中/小 净额:只看主动方(不包含被动方,否则恒等 0)
|
||||
large_net = f(item, "zmbddcje") - f(item, "zmsddcje")
|
||||
medium_net = f(item, "zmbzdcje") - f(item, "zmszdcje")
|
||||
small_net = f(item, "zmbxdcje") - f(item, "zmsxdcje")
|
||||
records.append({
|
||||
"trade_date": d,
|
||||
"main_net_inflow": round(main_buy - main_sell, 2),
|
||||
"large_net_inflow": round(large_net, 2),
|
||||
"medium_net_inflow": round(medium_net, 2),
|
||||
"small_net_inflow": round(small_net, 2),
|
||||
})
|
||||
if not records:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(records)
|
||||
# 日期过滤
|
||||
if start:
|
||||
df = df[df["trade_date"] >= start]
|
||||
if end:
|
||||
df = df[df["trade_date"] <= end]
|
||||
return df.sort_values("trade_date").reset_index(drop=True)
|
||||
Reference in New Issue
Block a user