Files
claude_dev f074a6b420 fix(portfolio): provider download加threading超时防御+Layer1链路闭环
xtdata.download_financial_data 阻塞无超时,休市/服务不响应卡死整个回测
(实证:周六休市HS300全成分首次download卡死,已缓存股票0.5s)。
包threading+join(120s)超时跳过读缓存(数据不全但回测不卡死)。
Layer1 MVP链路完整闭环:runner→BulletTrade engine→AllWeather策略→miniQMT→JSON
(7.49%收益/37交易日/选股5只ETF/净值37点/指标total_return+sharpe+max_drawdown全有)。
休市致股票财务空走ETF兜底,策略数值无意义但链路100%通;周一download恢复走真实选股。
2026-07-18 22:31:58 +08:00

675 lines
27 KiB
Python
Raw Permalink 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.
"""SanguoMiniQmtProvider:继承 MiniQMTProvider,补齐 ``get_fundamentals``。
BulletTrade 的 MiniQMTProvider 实现了行情/成分/涨跌停/证券信息,**唯一缺口**是
base.py:159 的 ``get_fundamentals``(默认抛 NotImplementedError)。本子类填这个缺口。
数据源映射(miniQMT 实证 2026-07-18,见 docs/portfolio_backtest_result.md):
- ``xtdata.get_financial_data(stock_list)`` → dict[stock][table_name] → DataFrame
table_name: 'PershareIndex' / 'Capital' / 'Balance' / 'Income' / 'CashFlow'
- ``xtdata.get_market_data_ex`` → close 行情(**end_time 必须 YYYYMMDD,带 dash 报错**)
- ``Capital[total_capital/circulating_capital]`` 单位 = **股**(不是万股),不需 ×10000
字段名差异(miniQMT 实际 vs 聚宽/本 provider 历史 alias):
- PershareIndex: du_return_on_equity→roe, s_fa_eps_basic→eps,
sales_gross_profit→gross_profit_margin, du_profit_rate→net_profit_margin,
inc_revenue_rate→inc_revenue_year_on_year,
inc_net_profit_rate→inc_operation_profit_year_on_year,
inc_total_revenue_annual→inc_total_revenue_year_on_year
- Balance: tot_liab→total_liability,
tot_shrhldr_eqy_excl_min_int→total_sheet_owner_equities,
undistributed_profit→retained_profit,
shortterm_loan→short_loan, long_term_loans→long_loan
- ROA:PershareIndex 没有,用 ROE × (equity / total_assets) 自算
聚宽 query(...) ORM 太复杂,这里支持两种入参:
1. dict: ``{'stocks': [...], 'date': 'YYYY-MM-DD'}`` 直接返合并 DataFrame(推荐,策略用)
2. jq-style query_object: 支持 ``filter(==/>/</between/in_)``/``order_by``/``limit``
复杂 filter 抛 NotImplementedError(标注清楚)
Mac 没有 xtquant,所有 xtquant 调用通过 ``self._ensure_xtdata()``,可被 mock 注入。
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
# bullet-trade 可能正在装,容错 import
try:
from bullet_trade.data.providers.miniqmt import MiniQMTProvider # type: ignore
_HAS_BT_BASE = True
_BT_IMPORT_ERROR: Optional[Exception] = None
except ImportError as _e: # Mac dev 环境可能未装,允许模块加载
MiniQMTProvider = object # type: ignore[misc,assignment]
_HAS_BT_BASE = False
_BT_IMPORT_ERROR = _e
# 聚宽 valuation/indicator/balance 列名 → 我们合并 DataFrame 的列名
# (统一用聚宽列名,方便策略层直接 pandas 筛选)
JQ_COLUMN_ALIASES: Dict[str, str] = {
# valuation 表(自算)
"market_cap": "market_cap",
"circulating_market_cap": "circulating_market_cap",
"pe_ratio": "pe_ratio",
"pb_ratio": "pb_ratio",
"ps_ratio": "ps_ratio",
"pcf_ratio": "pcf_ratio",
"code": "code",
# indicator(PershareIndex 直接拿)
"roe": "roe",
"roa": "roa",
"eps": "eps",
"gross_profit_margin": "gross_profit_margin",
"net_profit_margin": "net_profit_margin",
"inc_revenue_year_on_year": "inc_revenue_year_on_year",
"inc_operation_profit_year_on_year": "inc_operation_profit_year_on_year",
"inc_total_revenue_year_on_year": "inc_total_revenue_year_on_year",
# balance(合并表)
"total_liability": "total_liability",
"total_sheet_owner_equities": "total_sheet_owner_equities",
"retained_profit": "retained_profit",
}
class SanguoMiniQmtProvider(MiniQMTProvider): # type: ignore[misc]
"""miniQMT + 自算估值/ROIC 的 fundamentals provider。
继承 MiniQMTProvider 的行情/成分/证券信息/涨跌停能力,**只补 get_fundamentals**。
策略层用 ``get_fundamentals_df(stocks, date)`` 直接拿合并 DataFrame 做 pandas 筛选,
避开解析聚宽 query ORM。
"""
name: str = "sanguo_miniqmt"
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
if not _HAS_BT_BASE:
raise RuntimeError(
f"bullet-trade 未安装,SanguoMiniQmtProvider 无法继承 MiniQMTProvider: "
f"{_BT_IMPORT_ERROR}"
)
super().__init__(config or {})
# ------------------------ 主入口 ------------------------
def get_fundamentals(
self,
query_object: Any,
date: Optional[Union[str, datetime]] = None,
statDate: Optional[str] = None,
) -> pd.DataFrame:
"""聚宽风格 query + 直接 dict 两种入参。
Args:
query_object:
- dict: ``{'stocks': [...], 'date': 'YYYY-MM-DD'}`` 直接返合并 DataFrame
- 其它: 当作 jq query ORM 解析(支持 filter/order_by/limit 子集)
date: 查询日期(YYYY-MM-DD 或 datetime),用于取 close 和报告期
statDate: 聚宽 statDate 风格('2023Q3'/'2023'),暂不支持,传则忽略并告警
Returns:
聚宽 get_fundamentals 语义的 DataFrame:列见 ``JQ_COLUMN_ALIASES``。
"""
if statDate is not None:
logger.warning("statDate=%s 暂不支持,忽略,用最近报告期", statDate)
# 入参分支 1: dict 直接给 stocks
if isinstance(query_object, dict):
stocks = list(query_object.get("stocks") or [])
query_date = query_object.get("date") or date
df = self.get_fundamentals_df(stocks, query_date)
extra_filter = query_object.get("filter")
if extra_filter and callable(extra_filter):
df = df[extra_filter(df)]
order_by = query_object.get("order_by")
if order_by:
df = _apply_order_by(df, order_by)
limit = query_object.get("limit")
if isinstance(limit, int) and limit > 0:
df = df.head(limit)
return df
# 入参分支 2: 当作 jq-style query ORM
df = self._resolve_stocks_from_query(query_object, date)
return df
# ------------------------ 策略层便捷方法 ------------------------
def get_fundamentals_df(
self,
stocks: List[str],
date: Optional[Union[str, datetime]] = None,
) -> pd.DataFrame:
"""合并 PershareIndex + Balance + Income + CashFlow + Capital + close。
返回 DataFrame 每行一只股票,index 是 jq-style code(如 ``600519.XSHG``)。
列含:code/market_cap/circulating_market_cap/pe_ratio/pb_ratio/ps_ratio/pcf_ratio
+ roe/roa/eps/gross_profit_margin/net_profit_margin/inc_revenue_year_on_year/
inc_operation_profit_year_on_year/inc_total_revenue_year_on_year
+ total_liability/total_sheet_owner_equities/retained_profit
+ roic(自算)+归母净利润/营收/经营现金流(供策略再算其它因子)
"""
if not stocks:
return pd.DataFrame(columns=list(JQ_COLUMN_ALIASES.values()))
xt = self._ensure_xtdata()
date_str = _to_date_str(date)
# Step 1: 拉财务数据(miniQMT 返回 dict[stock_code] -> dict[table] -> DataFrame)
# 成分股用 jq code,xtdata 要 QMT 风格("600519.SH"),通过 _normalize_security_code
qmt_stocks = [self._normalize_security_code(s) for s in stocks]
try:
if self.auto_download:
# download_financial_data 阻塞无超时,休市/服务不响应会卡死整个回测;
# 包 threading 超时,超时则跳过读缓存(数据可能不全但回测不卡死)
self._download_financial_safe(
qmt_stocks, ["PershareIndex", "Balance", "Capital"]
)
fin_data = xt.get_financial_data(qmt_stocks)
except Exception as exc:
logger.warning("get_financial_data 失败,返空表: %s", exc)
return pd.DataFrame(columns=list(JQ_COLUMN_ALIASES.values()))
# Step 2: 拉当日收盘(用 date_str 或最近一日)
close_map = self._fetch_close(qmt_stocks, date_str)
# Step 3: 组装每行
rows: List[Dict[str, Any]] = []
for jq_code, qmt_code in zip(stocks, qmt_stocks):
stock_fin = fin_data.get(qmt_code) or fin_data.get(jq_code) or {}
close = close_map.get(qmt_code) or close_map.get(jq_code)
row = self._build_row(jq_code, qmt_code, stock_fin, close)
rows.append(row)
df = pd.DataFrame(rows)
if "code" in df.columns:
df = df.set_index("code", drop=False)
return df
def _download_financial_safe(
self, qmt_stocks: List[str], tables: List[str], timeout: float = 120.0
) -> None:
"""download_financial_data 包 threading 超时。
xtdata.download_financial_data 阻塞且无超时参数,休市/服务不响应时卡死
整个回测。用线程 + join(timeout):超时则放弃(读缓存),daemon 线程随进程退出清理。
"""
import threading
xt = self._ensure_xtdata()
err: List[str] = []
def _worker() -> None:
try:
xt.download_financial_data(qmt_stocks, tables)
except Exception as exc:
err.append(str(exc))
t = threading.Thread(target=_worker, daemon=True)
t.start()
t.join(timeout)
if t.is_alive():
logger.warning(
"download_financial_data 超时 %.0fs (%d 只),跳过读缓存",
timeout, len(qmt_stocks),
)
elif err:
logger.debug("download_financial_data 失败(继续读缓存): %s", err[0])
# ------------------------ 内部组装 ------------------------
def _build_row(
self,
jq_code: str,
qmt_code: str,
stock_fin: Dict[str, Any],
close: Optional[float],
) -> Dict[str, Any]:
"""合并 PershareIndex(最新一行) + Balance + Income + CashFlow + Capital + close。"""
from .. import factors # lazy import,避免循环
row: Dict[str, Any] = {"code": jq_code}
# Capital(股本,实证单位 = 股,不再 ×10000)
capital = _latest_row(stock_fin.get("Capital"))
total_capital = _to_float(_get(capital, "total_capital"))
circulating_capital = _to_float(_get(capital, "circulating_capital"))
# Balance(实测字段名 tot_liab / tot_shrhldr_eqy_excl_min_int / undistributed_profit /
# shortterm_loan / long_term_loans;历史 alias 用 total_liability / retained_profit /
# short_loan / long_loan,_get_multi 兼容两套)
balance = _latest_row(stock_fin.get("Balance"))
tot_shrhldr_eqy = _to_float(_get_multi(balance, ["tot_shrhldr_eqy_excl_min_int", "total_sheet_owner_equities"]))
total_liability = _to_float(_get_multi(balance, ["total_liability", "tot_liab"]))
total_sheet_owner_equities = _to_float(_get_multi(balance, ["total_sheet_owner_equities", "tot_shrhldr_eqy_excl_min_int", "total_equity"]))
retained_profit = _to_float(_get_multi(balance, ["retained_profit", "undistributed_profit"]))
cash_equivalents = _to_float(_get_multi(balance, ["cash_equivalents", "monetary_funds"]))
short_loan = _to_float(_get_multi(balance, ["short_loan", "shortterm_loan"])) or 0.0
long_loan = _to_float(_get_multi(balance, ["long_loan", "long_term_loans"])) or 0.0
bonds_payable = _to_float(_get(balance, "bonds_payable")) or 0.0
total_assets = _to_float(_get_multi(balance, ["tot_assets", "total_assets"]))
interest_bearing_debt = short_loan + long_loan + bonds_payable
# Income(miniQMT 实证 trading hours 常下载超时,空表时用 PershareIndex EPS 反推)
income = _latest_row(stock_fin.get("Income"))
net_profit = _to_float(_get_multi(income, ["net_profit_excl_min_int", "n_income", "net_profit_incl_min_int"]))
revenue = _to_float(_get_multi(income, ["operating_revenue", "revenue", "total_revenue", "operating revenue"]))
oper_profit = _to_float(_get_multi(income, ["oper_profit", "operating_profit"]))
inc_tax = _to_float(_get_multi(income, ["inc_tax", "income_tax"]))
profit_before_tax = _to_float(_get_multi(income, ["profit_before_tax"]))
# CashFlow(同 Income,trading hours 常空)
cashflow = _latest_row(stock_fin.get("CashFlow"))
net_oper_cash_flow = _to_float(_get_multi(cashflow, ["n_cashflow_act", "net_operate_cash_flow", "net_cash_flow_oper"]))
# PershareIndex(实测字段名 du_return_on_equity/s_fa_eps_basic/sales_gross_profit 等)
psh = _latest_row(stock_fin.get("PershareIndex"))
roe = _to_float(_get_multi(psh, ["roe", "du_return_on_equity", "equity_roe"]))
roa = _to_float(_get_multi(psh, ["roa", "return_on_assets", "total_roe"]))
eps = _to_float(_get_multi(psh, ["eps", "s_fa_eps_basic", "s_fa_eps_diluted"]))
gross_profit_margin = _to_float(_get_multi(psh, ["gross_profit_margin", "sales_gross_profit", "gross_profit"]))
net_profit_margin = _to_float(_get_multi(psh, ["net_profit_margin", "du_profit_rate", "net_profit"]))
inc_revenue_yoy = _to_float(_get_multi(psh, ["inc_revenue_year_on_year", "inc_revenue_rate", "inc_revenue"]))
inc_operation_profit_yoy = _to_float(_get_multi(psh, [
"inc_operation_profit_year_on_year", "inc_net_profit_rate", "inc_net_profit",
]))
# 营业总收入同比(annual 口径优先,回退 rate)
inc_total_revenue_yoy = _to_float(_get_multi(psh, [
"inc_total_revenue_year_on_year", "inc_total_revenue_annual", "inc_revenue_rate",
]))
actual_tax_rate = _to_float(_get(psh, "actual_tax_rate"))
# Income 空表兜底:用 EPS × total_capital 得单季净利润(calc_pe 内部 ×4 近似 TTM)
# 注意:不能在这里 ×4,calc_pe 已经 ×4,重复 ×4 会致 PE 偏小 4 倍
if (net_profit is None) and eps is not None and total_capital and total_capital > 0:
net_profit = eps * total_capital
logger.debug("%s Income 空,用 EPS×股本 近似单季 net_profit=%s", jq_code, net_profit)
# ROA 兜底:PershareIndex 没有 roa 字段,用 ROE × (归母权益/总资产) 自算
if (roa is None) and roe is not None and tot_shrhldr_eqy and total_assets and total_assets > 0:
# ROE 是百分数(如 10.57),保持百分数口径
roa = roe * (tot_shrhldr_eqy / total_assets)
# 估值(自算,close None 时全置 NaN)
if close is not None and total_capital and total_capital > 0:
# 聚宽口径:市值亿元
row["market_cap"] = factors.valuation.to_yi(close * total_capital)
row["circulating_market_cap"] = factors.valuation.to_yi(
close * (circulating_capital or total_capital)
)
if net_profit is not None:
row["pe_ratio"] = factors.valuation.calc_pe(close, net_profit, total_capital)
else:
row["pe_ratio"] = float("nan")
if tot_shrhldr_eqy:
row["pb_ratio"] = factors.valuation.calc_pb(close, tot_shrhldr_eqy, total_capital)
else:
row["pb_ratio"] = float("nan")
if revenue is not None:
row["ps_ratio"] = factors.valuation.calc_ps(close, revenue, total_capital)
else:
row["ps_ratio"] = float("nan")
if net_oper_cash_flow is not None:
row["pcf_ratio"] = factors.valuation.calc_pcf(close, net_oper_cash_flow, total_capital)
else:
row["pcf_ratio"] = float("nan")
else:
row["market_cap"] = float("nan")
row["circulating_market_cap"] = float("nan")
row["pe_ratio"] = float("nan")
row["pb_ratio"] = float("nan")
row["ps_ratio"] = float("nan")
row["pcf_ratio"] = float("nan")
# indicator
# 实证 miniQMT PershareIndex 返回的是百分数(茅台 ROE=10.57 表示 10.57%),
# 聚宽 valuation/indicator 是小数(0.1057)。策略层阈值 roe>0.15 按聚宽口径,
# 这里统一归一到小数,×0.01。NaN 透传。
row["roe"] = _pct_to_decimal(roe)
row["roa"] = _pct_to_decimal(roa)
row["eps"] = _or_nan(eps)
row["gross_profit_margin"] = _pct_to_decimal(gross_profit_margin)
row["net_profit_margin"] = _pct_to_decimal(net_profit_margin)
row["inc_revenue_year_on_year"] = _pct_to_decimal(inc_revenue_yoy)
row["inc_operation_profit_year_on_year"] = _pct_to_decimal(inc_operation_profit_yoy)
row["inc_total_revenue_year_on_year"] = _pct_to_decimal(inc_total_revenue_yoy)
# balance(策略层会算 total_liability/total_sheet_owner_equities 比率)
row["total_liability"] = _or_nan(total_liability)
row["total_sheet_owner_equities"] = _or_nan(total_sheet_owner_equities)
row["retained_profit"] = _or_nan(retained_profit)
# ROIC 自算
if oper_profit is not None and tot_shrhldr_eqy and interest_bearing_debt is not None and cash_equivalents is not None:
row["roic"] = factors.roic.calc_roic(
oper_profit, actual_tax_rate, tot_shrhldr_eqy,
interest_bearing_debt, cash_equivalents,
inc_tax=inc_tax, profit_before_tax=profit_before_tax,
)
else:
row["roic"] = float("nan")
# 留原始字段给策略做更多自算
row["_net_profit"] = _or_nan(net_profit)
row["_revenue"] = _or_nan(revenue)
row["_oper_profit"] = _or_nan(oper_profit)
row["_total_capital"] = total_capital
row["_close"] = close if close is not None else float("nan")
return row
# ------------------------ close 行情 ------------------------
def _fetch_close(self, qmt_stocks: List[str], date_str: Optional[str]) -> Dict[str, float]:
"""取 ``date`` 当日(或最近一日)收盘价。失败返空 dict。
实证 miniQMT ``get_market_data_ex`` 的 ``end_time`` 必须 YYYYMMDD(带 dash 报
"结束时间错误"),这里归一。
"""
xt = self._ensure_xtdata()
try:
# get_market_data_ex([], stock_list, period='1d', end_time=date, count=1)
end = _to_yyyymmdd(date_str) or ""
result = xt.get_market_data_ex(
[], qmt_stocks, period="1d", start_time="", end_time=end, count=1
)
except Exception as exc:
logger.debug("get_market_data_ex 失败: %s", exc)
return {}
if not result:
return {}
out: Dict[str, float] = {}
for code, df in result.items():
if df is None or len(df) == 0:
continue
try:
close = float(df["close"].iloc[-1])
out[code] = close
except Exception:
continue
return out
# ------------------------ jq query 解析(最小子集) ------------------------
def _resolve_stocks_from_query(
self, query_object: Any, date: Optional[Union[str, datetime]]
) -> pd.DataFrame:
"""解析 jq-style query 的 stocks 列表 + filter/order_by/limit。
支持:
- ``query(valuation, indicator).filter(valuation.code.in_(...), indicator.roe > 0.15)``
- ``...order_by(valuation.market_cap.asc()/.desc())``
- ``...limit(N)``
复杂 filter(OR / 跨表 join / 自定义函数)抛 NotImplementedError。
"""
# 解析 stocks:从 filter 的 code.in_(...) 提取
stocks = _extract_stocks_from_query(query_object)
if not stocks:
logger.warning("无法从 query 提取 stocks(可能用了不支持的 filter),返空表")
return pd.DataFrame(columns=list(JQ_COLUMN_ALIASES.values()))
df = self.get_fundamentals_df(stocks, date)
# 应用 filter(简单比较/范围)
df = _apply_query_filters(df, query_object)
# 应用 order_by
order_by = _extract_order_by(query_object)
if order_by:
df = _apply_order_by(df, order_by)
# 应用 limit
limit = _extract_limit(query_object)
if isinstance(limit, int) and limit > 0:
df = df.head(limit)
return df
# ======================== jq query ORM 解析辅助 ========================
# bullet-trade 的 query 是轻量 ORM(filter/order_by/limit 返回 self)。
# 我们不依赖它的具体类型,鸭子取属性即可。
def _extract_stocks_from_query(query_object: Any) -> List[str]:
"""从 query 的 code.in_(...) 子句提取股票池。
bullet-trade 的 query 对象上 filter 条件可能存在 ``_filters`` / ``_wheres`` 等内部字段,
我们一律用鸭子反射 + 字符串模式提取 in_(...)。
"""
text = _stringify_query(query_object)
if not text:
return []
import re
# 匹配 in_('600519.XSHG', '000001.XSHE', ...)
matches = re.findall(r"in_\(\s*\[([^\]]*)\]\s*\)", text)
if not matches:
matches = re.findall(r"in_\(([^)]+)\)", text)
if not matches:
return []
found: List[str] = []
for chunk in matches:
for code in re.findall(r"['\"]([0-9A-Za-z]+\.[A-Z]+)['\"]", chunk):
found.append(code)
return _dedup(found)
def _stringify_query(query_object: Any) -> str:
"""把 query 对象转字符串,各种失败容错。"""
try:
return str(query_object)
except Exception:
return ""
def _apply_query_filters(df: pd.DataFrame, query_object: Any) -> pd.DataFrame:
"""从 query 提取 filter 条件,逐条应用到 df。
支持:col == v / > v / < v / >= v / <= v / between(a,b)。
不支持 OR / 跨表 / 自定义函数 → NotImplementedError(标注 v2)。
"""
text = _stringify_query(query_object)
if not text:
return df
import re
# 简单比较: col > 0.15 / col < 30 / col == 1 / col >= 0 / col <= 0
# 字段名带点(valuation.market_cap / indicator.roe),取点后那段
patterns = [
(r"(\w+\.)?(\w+)\s*(>=|<=|==|!=|>|<)\s*([0-9eE.+-]+)", "compare"),
(r"(\w+\.)?(\w+)\.between\(\s*([0-9eE.+-]+)\s*,\s*([0-9eE.+-]+)\s*\)", "between"),
]
for pat, kind in patterns:
for m in re.finditer(pat, text):
col = m.group(2)
if col not in df.columns:
continue
if kind == "compare":
op, val = m.group(3), float(m.group(4))
df = _apply_compare(df, col, op, val)
else:
lo, hi = float(m.group(3)), float(m.group(4))
df = df[(df[col] >= lo) & (df[col] <= hi)]
return df
def _apply_compare(df: pd.DataFrame, col: str, op: str, val: float) -> pd.DataFrame:
if op == ">":
return df[df[col] > val]
if op == "<":
return df[df[col] < val]
if op == ">=":
return df[df[col] >= val]
if op == "<=":
return df[df[col] <= val]
if op == "==":
return df[df[col] == val]
if op == "!=":
return df[df[col] != val]
return df
def _extract_order_by(query_object: Any) -> List[tuple]:
"""提取 order_by 子句 → [(col, 'asc'|'desc'), ...]。
用字符串解析,匹配 valuation.market_cap.asc() / .desc()。
"""
text = _stringify_query(query_object)
if not text:
return []
import re
out = []
for m in re.finditer(r"(\w+\.)?(\w+)\.(asc|desc)\(\)", text):
out.append((m.group(2), m.group(3)))
return out
def _apply_order_by(df: pd.DataFrame, order_by: List[tuple]) -> pd.DataFrame:
if not order_by:
return df
for col, direction in reversed(order_by):
if col not in df.columns:
continue
ascending = direction != "desc"
df = df.sort_values(col, ascending=ascending, na_position="last")
return df
def _extract_limit(query_object: Any) -> Optional[int]:
text = _stringify_query(query_object)
if not text:
return None
import re
m = re.search(r"\.limit\(\s*(\d+)\s*\)", text)
return int(m.group(1)) if m else None
# ======================== 工具 ========================
def _latest_row(table: Any) -> Optional[pd.Series]:
"""取财务表 DataFrame 的最新一行(按报告期 date 倒序)。
xtquant 返回的 PershareIndex/Balance 等是 DataFrame,index 通常为报告期。
"""
if table is None:
return None
if isinstance(table, pd.DataFrame):
if len(table) == 0:
return None
# 尝试按 index(报告期)降序取最新
try:
return table.sort_index(ascending=False).iloc[0]
except Exception:
return table.iloc[-1]
if isinstance(table, dict):
return pd.Series(table)
return None
def _get(series_or_dict: Any, key: str) -> Any:
"""从 Series/dict 取 key,容错 key 不存在/大小写差异。"""
if series_or_dict is None:
return None
if isinstance(series_or_dict, pd.Series):
if key in series_or_dict:
return series_or_dict[key]
# case-insensitive fallback
lower_map = {k.lower(): k for k in series_or_dict.index}
if key.lower() in lower_map:
return series_or_dict[lower_map[key.lower()]]
return None
if isinstance(series_or_dict, dict):
if key in series_or_dict:
return series_or_dict[key]
for k, v in series_or_dict.items():
if k.lower() == key.lower():
return v
return None
return None
def _get_multi(series_or_dict: Any, keys: List[str]) -> Any:
"""按 keys 顺序尝试取,第一个非 None(且非 NaN)的值返回。
用于字段 alias 兼容(如 roe/du_return_on_equity/equity_roe 三套名字)。
"""
for k in keys:
v = _get(series_or_dict, k)
if v is None:
continue
# NaN 透传到下一个 key
try:
fv = float(v)
if np.isnan(fv):
continue
return v
except (TypeError, ValueError):
return v
return None
def _to_yyyymmdd(value: Optional[str]) -> Optional[str]:
"""YYYY-MM-DD → YYYYMMDD(miniQMT get_market_data_ex 要求)。已 YYYYMMDD 直接返。"""
if value is None:
return None
s = str(value).strip()
if not s:
return None
if "-" in s:
return s.replace("-", "")[:8]
return s[:8]
def _to_float(value: Any) -> Optional[float]:
"""Any → float,None/NaN/异常 → None。"""
if value is None:
return None
try:
out = float(value)
if np.isnan(out):
return None
return out
except (TypeError, ValueError):
return None
def _or_nan(value: Optional[float]) -> float:
if value is None:
return float("nan")
return float(value)
def _pct_to_decimal(value: Optional[float]) -> float:
"""百分数 → 小数(10.57 → 0.1057)对齐聚宽 indicator 口径。
NaN/None 透传。绝对值 < 1 时认为已经是小数,不转换(防御)。
"""
if value is None:
return float("nan")
try:
v = float(value)
except (TypeError, ValueError):
return float("nan")
if np.isnan(v):
return float("nan")
if abs(v) < 1:
return v
return v / 100.0
def _to_date_str(value: Optional[Union[str, datetime]]) -> Optional[str]:
if value is None:
return None
if isinstance(value, str):
return value[:10]
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
try:
return str(value)[:10]
except Exception:
return None
def _dedup(seq: List[str]) -> List[str]:
seen = set()
out = []
for x in seq:
if x not in seen:
seen.add(x)
out.append(x)
return out
__all__ = ["SanguoMiniQmtProvider", "JQ_COLUMN_ALIASES"]