Files
market_sync/app/sources/mairui.py
T
gao 8f016f25df chore: baseline — ORM migration + systemd deploy + MCP server + daily check
- ORM migration: models/ops 重构
- deploy: 标准化 systemd timer/service 部署体系
- MCP server: 5 个只读工具(任务/状态/数据集)
- daily check: 数据一致性巡检
- watch: task_watch 后台监控
- kline_daily: 麦蕊优先,时间门禁15:00
- 删除: task_industry_sector, task_sector_features
2026-07-21 16:37:00 +08:00

614 lines
26 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", "stock_node", "indicator_daily"]
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://a.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
# ── 技术指标 fetchMACD / KDJ / BOLL)─────────────────────
# mairui 端点:/hsstock/history/{indicator}/{symbol}/d/n/{licence}
# indicator ∈ {macd, kdj, boll};日 K 级别 "/d/";不复权 "/n/"
# 各指标字段(除公共 "t" 外):
# macd → diff, dea, macd, ema12, ema26
# kdj → k, d, j
# boll → u(上轨), d(下轨), m(中轨) ← 注意 mairui 用 u/d/m
_INDICATOR_FIELDS = {
"macd": ["diff", "dea", "macd", "ema12", "ema26"],
"kdj": ["k", "d", "j"],
"boll": ["u", "d", "m"],
}
def fetch_indicator_daily(
self, indicator: str, code6: str, start: str = "", end: str = ""
) -> pd.DataFrame:
"""拉单只股票某个技术指标的日 K 序列。
返回列:trade_date + 该指标字段(macd/kdj/boll 各自的原始字段名)。
start/end 为 "YYYY-MM-DD"(可空 → 全历史)。
"""
indicator = indicator.lower()
fields = self._INDICATOR_FIELDS.get(indicator)
if fields is None:
raise ValueError(f"未知指标: {indicator!r},可选 {list(self._INDICATOR_FIELDS)}")
lic = self._get_licence()
if not lic:
return pd.DataFrame()
symbol = code6_to_mairui(code6)
path = f"/hsstock/history/{indicator}/{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 = item.get("t", "")
d = t.split(" ")[0] if isinstance(t, str) else ""
if not d:
continue
row = {"trade_date": d}
for f in fields:
v = item.get(f)
row[f] = None if v is None else float(v)
records.append(row)
except (KeyError, ValueError, TypeError):
continue
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce")
df = df.dropna(subset=["trade_date"]).sort_values("trade_date").reset_index(drop=True)
if start:
df = df[df["trade_date"] >= pd.Timestamp(start)]
if end:
df = df[df["trade_date"] <= pd.Timestamp(end)]
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' + ttz=Asia/Shanghaimairui 返回北京时间 UTC+8
Source 层契约(2026-07-07 强化): 保证返回的 trade_time
一定是 tz-aware datetime,Task 层无需再 strftime 转换。
SQLAlchemy 存到 TIMESTAMPTZ 时自动转 UTC。
direction = 0/1/2 → 'neutral'/'buy'/'sell'
amount = price * volume(元)
stock_code = 外部传入的 6 位 code(保持与其它表一致)
历史 bug2026-07-02 修复):之前 trade_time 是 naive datetime,被 PG 当 UTC 存,
导致查询时差 8 小时(如 SH600519 第一条 tick 实际是 09:15:08 BJT = 01:15:08 UTC,
之前显示为 09:15:08 UTC = 17:15:08 BJT,跟实际交易时段不符)。
2026-07-07 再加固: 若 mairui 某行 d/t 已带 tz-offset,不做 tz_localize(避免
"Already tz-aware" 报错),保持原 tz。
"""
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" —— 是北京时间(UTC+8)
iso = f"{d}T{t}"
ts_val = pd.to_datetime(iso, errors="coerce")
if pd.isna(ts_val):
continue
# Source 层保证 tz-aware: 已有 tz 就保留,没有就标 Asia/Shanghai。
if ts_val.tzinfo is None:
ts_val = ts_val.tz_localize("Asia/Shanghai")
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)
# ── 指数/行业/概念 树 (mairui /hszg) ───────────────────────────
def fetch_node_tree(self) -> pd.DataFrame:
"""mairui /hszg/list/{licence} 指数/行业/概念树。
API: GET https://a.mairuiapi.com/hszg/list/{licence}
文档: https://mairui.club/hsdata → 指数/行业/概念 → /hszg/list
更新: 每周六 03:05。
返回 DataFrame 列:
name, code, type1, type2, level, pcode, pname, isleaf
1464 节点覆盖:A 股(1131) + 港股(31) + 基金/债券/美股/外汇/期货/黄金 等。
type1 标识市场(0=A股),type2 标识子类(0=申万一级, 2=热门概念, 3=概念板块,
5=证监会行业, 7=指数成分 等),isleaf=1 是可直接喂给 /hszg/gg 的叶子节点。
"""
lic = self._get_licence()
if not lic:
return pd.DataFrame()
path = f"/hszg/list/{lic}"
data = self._fetch(path, {})
return pd.DataFrame(data) if isinstance(data, list) else pd.DataFrame()
def fetch_stock_nodes(self, code6: str) -> pd.DataFrame:
"""mairui /hszg/zg/{code6}/{licence} 股票→相关节点。
API: GET https://a.mairuiapi.com/hszg/zg/{code6}/{licence}
文档: https://mairui.club/hsdata → 指数/行业/概念 → /hszg/zg
更新: 每周六 11:00。
实测: SH600000 约 30 行(code + name, code 喂给 /hszg/gg 拿成分股)。
返回 DataFrame 列: code, name
"""
lic = self._get_licence()
if not lic:
return pd.DataFrame()
path = f"/hszg/zg/{code6}/{lic}"
data = self._fetch(path, {})
return pd.DataFrame(data) if isinstance(data, list) else pd.DataFrame()
def fetch_node_stocks(self, node_code: str) -> pd.DataFrame:
"""mairui /hszg/gg/{code}/{licence} 节点→成分股。
API: GET https://a.mairuiapi.com/hszg/gg/{code}/{licence}
文档: https://mairui.club/hsdata → 指数/行业/概念 → /hszg/gg
更新: 每周六 11:00。
实测: sw_sysh(申万银行)约 48 行,概念节点约 30 行,沪深300 约 300 行。
返回 DataFrame 列: dm(6 位代码), mc(名称), jys(交易所 sh/sz/bj)
任务层需 dm → hermes 转换。
"""
lic = self._get_licence()
if not lic:
return pd.DataFrame()
path = f"/hszg/gg/{node_code}/{lic}"
data = self._fetch(path, {})
return pd.DataFrame(data) if isinstance(data, list) else pd.DataFrame()