Files
market_sync/app/sources/mairui.py
T
gao 7a985dddd5 refactor: PG-only 迁移 + 龙虎榜/tick/moneyflow 同步 + stock_code 统一 + mairui 编码修复
## 1. PG-only 重构
- 删 app/core/db/connection.py + schema.py (MySQL 路径)
- 新 app/core/db/{orm,models,pg_bootstrap}.py — SQLAlchemy 2.x ORM 一键建表
- 16 张业务表全在 market_data schema,原生 TIMESTAMPTZ / JSONB / Float / TEXT
- requirements.txt 删 PyMySQL 路径,加 psycopg2

## 2. 同步任务扩展(3 个新 task)
- **tick_trade** (mairui hsrl/zbjy):当天逐笔交易,21:00 发布
- **moneyflow** (mairui hsstock/history/transaction):个股资金流,21:30 发布
- **longhubang** (akshare):龙虎榜聚合层 + 席位层(2 张新表)
  - 长虎榜放宽 akshare 政策:仅"无替代源 + 烟测通过"场景允许
  - data_eastmoney 私有 API 不需要(akshare 烟测通过)
- 3-timer 设计:
  - 15:30 market-sync.service (8 base tasks via runall_once)
  - 21:05 market-sync-tick.service (tick_trade)
  - 21:35 market-sync-moneyflow.service (moneyflow)
  - 22:00 market-sync-lhb.service (longhubang,新加)
- bin/systemd/ 新增 tick / moneyflow / lhb 各 1 对 service+timer
- bin/market_sync_*_run.sh wrapper 脚本(不做法定节假日过滤,fail-open)

## 3. stock_code 统一为带 SH/SZ/BJ 前缀
- 历史 bug:stocks.code 用 SH600519,但 kline/moneyflow/tick_trade/kline_5min
  /stock_sector_map/industry 6 张表用纯 6 位 600519,跨表 JOIN 全部 0 行
- 新增 to_hermes() 工具:6位 / 9位(mairui `000001.SZ` 格式)→ 统一 SH000001
- 5 个 task 改写:用 to_hermes(code6) 写入 stock_code
- 一次性迁移 6 张表存量 154M 行(CASE WHEN 探测 + 去重 + 加前缀)
- ORM: stock_sector_map.stock_code / industry.code String(6)→String(10)

## 4. Bug 修复
- **share table stock_code 格式**:之前写 6 位不带前缀,与 stocks 不一致
  → 修 task_share_snapshot + 一次性 UPDATE 63,417 行加前缀
- **share_snapshot warning 状态错填 last_error**:
  → 加 mark_sync_warning() 走专用路径,不写 last_failure_at / last_error
- **schedule config lastRun 不同步**:
  → 加 update_job_status_for_dataset(),SyncTask.run() 完成后自动镜像
  → cli/runall 触发的 task 也能更新 schedule config

## 5. mairui UTF-8 编码修复
- 历史 bug:mairui.py:_fetch 用 latin-1 兜底解码,把所有 UTF-8 中文名
  double-encoded 写入 stocks.name(如 `歌华有线` 变成 `æ\xad\x8cå\x8d\x8e...`)
- 加 _decode_response():UTF-8 → GBK → latin-1 兜底
- 一次性修复 stocks.name 5,213 行:
  - 4,370 行 (encode('latin-1').decode('utf-8') 反向解码)
  - 616 行 (含 fullwidth A,宽松 printable 检查)
  - 820 行 (mid-character 截断,重新从 mairui 拉)

## 6. 测试
- tests/test_smoke.py: TASKS 10→11, SYNC_DEFINITIONS 10→11
- tests/test_schema_models.py: 16→18 张表,新增 longhubang_daily/seat
- pytest 11/11 passed

## 验证
- 6 张表 0 残留无前缀行
- stocks JOIN kline_stock / kline_5min / moneyflow / tick_trade / stock_sector_map:88-100% 命中
- 5,213 stocks.name 全部正确 UTF-8 中文
- pytest 11/11 passed
2026-07-01 22:00:58 +08:00

482 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""麦蕊智数(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}`
- 当天逐笔交易(`hsrl/zbjy/{code6}/{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", "tick_trade"]
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 限速(按 tier):
# 免费版: 1 min/300 次 = 5 RPS
# 体验版: 1 min/1千次 = 16.67 RPS
# 包年版: 1 min/3千次 = 50 RPS
# 钻石版: 1 min/6千次 = 100 RPS ← 当前项目用
#
# 用法:5 workers × _RPS_LIMIT RPS/worker = 总 RPS。
# 默认 10.0 → 5 workers = 50 RPS(钻石 100 RPS 的一半,留 50% buffer 防风控)。
# 如需调:env `MAIRUI_RPS_LIMIT=20` → 100 RPS (踩满钻石);
# `MAIRUI_RPS_LIMIT=1` → 5 RPS (回退到免费档兼容)。
#
# 用 thread-local 计时:每个 worker 独立计自己的 last_ts
# 避免 N 个 worker 串行抢一把锁退化成 1 worker。
_RPS_LIMIT = float(os.environ.get("MAIRUI_RPS_LIMIT", "10"))
_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,含限速 + 重试。
编码探测顺序 (2026-07-01 修复):
1) UTF-8 — 正常 JSON 响应
2) GBK — mairui 风控时的中文错误消息 ("接收数据异常,请稍后再试")
3) latin-1 兜底 — 防御性 fallback (历史 Python repr'd 字符串)
历史 bug (2026-06-16 之前): 无条件 `raw_bytes.decode("latin-1")`
把 UTF-8 中文名全部 double-encoded (Latin-1 → UTF-8) → 写入 DB
后 stock_name 看起来正常但 hex 是错的双倍长度字节。
修复后正常 JSON 走 UTF-8,错误消息走 GBKLatin-1 仅作兜底。
"""
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:
raw_bytes = resp.read()
# 编码探测: 优先 UTF-8, fallback GBK, 兜底 latin-1
raw = self._decode_response(raw_bytes)
# 风控提示 (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]}")
# Python repr'd 字符串: "'接收数据异常,请稍后再试'" (GBK bytes 用 latin-1 解码后又被 Python repr)
if raw.startswith("'") and raw.endswith("'"):
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 []
@staticmethod
def _decode_response(raw_bytes: bytes) -> str:
"""探测响应字节流的编码,优先 UTF-8,fallback GBK,latin-1 兜底。"""
# 1) UTF-8: 99% 的情况 (正常 JSON 响应)
try:
return raw_bytes.decode("utf-8")
except UnicodeDecodeError:
pass
# 2) GBK: mairui 风控时返 GBK 编码的中文错误消息
try:
return raw_bytes.decode("gbk")
except UnicodeDecodeError:
pass
# 3) latin-1 兜底: 永不失败,但可能产生错乱字符
return raw_bytes.decode("latin-1")
# ── 股票列表 ───────────────────────────────────────────
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)
# ── 当天逐笔交易 ───────────────────────────────────────
def fetch_tick_trade(self, code6: str) -> pd.DataFrame:
"""当天逐笔交易(mairui 不支持历史回溯,仅当天数据)。
API: GET /hsrl/zbjy/{code6}/{licence} (无 st/et 参数)
文档:https://mairui.club/hsdata → "当天逐笔交易"
更新:每日 21:00。
排序:按时间倒序。
限速:1 min/300 次(与其它接口共享 licence 配额)。
返回字段:
d string 数据归属日期(yyyy-MM-dd
t string 时间(HH:mm:dd,文档笔误,应为 HH:mm:ss
v number 成交量(股)
p number 成交价(元)
ts number 交易方向 0=中性盘 / 1=买入 / 2=卖出
派生字段:
trade_time = d + 'T' + tdatetime64[ns, UTC],无 tz 直接当本地时区)
direction = 0/1/2 → 'neutral'/'buy'/'sell'
amount = price * volume(元)
stock_code = 外部传入的 6 位 code(保持与其它表一致)
"""
lic = self._get_licence()
if not lic:
return pd.DataFrame()
path = f"/hsrl/zbjy/{code6}/{lic}"
data = self._fetch(path, {}) # 200/200 —— 不需要 start/end
if not isinstance(data, list) or not data:
return pd.DataFrame()
direction_map = {0: "neutral", 1: "buy", 2: "sell"}
records = []
for item in data:
try:
d = str(item.get("d", "") or "").strip()
t = str(item.get("t", "") or "").strip()
if not d or not t:
continue
# mairui `t` 形如 "14:53:21",拼成 ISO 字符串让 pandas 解析
iso = f"{d}T{t}"
ts_val = pd.to_datetime(iso, errors="coerce")
if pd.isna(ts_val):
continue
price = float(item.get("p") or 0)
volume = float(item.get("v") or 0)
if price <= 0 or volume <= 0:
continue
ts_code = int(item.get("ts") or 0)
records.append({
"stock_code": code6,
"trade_date": d,
"trade_time": ts_val.to_pydatetime(),
"price": price,
"volume": volume,
"direction_code": ts_code,
"direction": direction_map.get(ts_code, ""),
"amount": round(price * volume, 2),
})
except (KeyError, ValueError, TypeError):
continue
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
# mairui 按时间倒序,统一升序便于入库
return df.sort_values("trade_time").reset_index(drop=True)