mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现: 回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标 被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、 组合体检品种费率、寻优端点费率透传。 安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、 错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。 数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/ provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、 baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作) + 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。 Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、 submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。 公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。 前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、 空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。 CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、 CI 超时与缓存、spec 补 baostock 前提。 约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
239 lines
10 KiB
Python
239 lines
10 KiB
Python
"""baostock 自动兜底数据源(仅日线及以上,TDX 全部路径失败时的最后一级回退)。
|
||
|
||
定位与边界:
|
||
- baostock 是 EOD 数据源,当日数据约 17:30 后才可查,**盘中实时能力为零**;
|
||
因此本模块只承接 DAY/WEEK/MONTH 的历史 K 线,分钟线/分时/逐笔/板块/实时
|
||
报价一律返回不可用(None),由上层维持原错误。
|
||
- 启用条件自动判断:已安装 baostock 且未设置环境变量 ``EASY_TDX_BAOSTOCK=0``
|
||
即启用;未安装时本模块整体静默关闭,核心功能零影响。
|
||
- baostock 客户端是单条全局连接且非线程安全,本模块内部全程持锁串行,
|
||
供 async 调用方经 ``asyncio.to_thread`` 使用。
|
||
- 数据口径:个股 volume 为股(与 /bars 输出契约一致,无需换算);指数
|
||
经 ``is_index=True`` 显式声明后 vol ÷100 转为手(baostock 指数 volume
|
||
单位为股,而 /bars/index 契约为手);停牌日(tradestatus=0 或
|
||
volume=0)剔除,与通达信 K 线不含停牌日的口径对齐;复权经 adjustflag
|
||
原生支持(QFQ/HFQ/NONE),North Exchange(BJ)不覆盖。
|
||
- 拉取失败(登录失败 / 查询 error_code≠0)记 warning 日志并抛
|
||
``RuntimeError``——auto 兜底路径以 except 包裹调用不受影响,
|
||
``--source baostock`` 显式使用时不会被伪装成"无数据"。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib
|
||
import logging
|
||
import os
|
||
import threading
|
||
from datetime import datetime, timedelta
|
||
|
||
import pandas as pd
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
BAOSTOCK_DISABLE_ENV = "EASY_TDX_BAOSTOCK"
|
||
|
||
# baostock 的全局连接锁(该库单连接、非线程安全)
|
||
_bs_lock = threading.Lock()
|
||
_logged_in = False
|
||
# 兜底路径的锁等待上限:拿不到锁说明另一个兜底请求正在进行,
|
||
# 与其排队不如放弃本次兜底(回退路径宁快勿堵)。
|
||
_LOCK_TIMEOUT_SECONDS = 30.0
|
||
|
||
# 支持兜底的周期(baostock frequency):日线及以上;分钟线/季年线不兜
|
||
_FREQ_BY_CATEGORY: dict[str, str] = {"DAY": "d", "WEEK": "w", "MONTH": "m"}
|
||
# 请求字段:baostock 周线/月线不支持 tradestatus(实测 error_code=10004012
|
||
# 「周线指标参数传入错误:tradestatus」,2026-09-06),仅日线可传。
|
||
_FIELDS_DAILY = "date,open,high,low,close,volume,amount,tradestatus"
|
||
_FIELDS_WEEKLY = "date,open,high,low,close,volume,amount"
|
||
# 复权映射:baostock adjustflag — 1=后复权 2=前复权 3=不复权
|
||
_ADJUST_FLAG = {"NONE": "3", "QFQ": "2", "HFQ": "1"}
|
||
_MARKET_PREFIX = {"SZ": "sz", "SH": "sh"} # BJ baostock 不覆盖
|
||
|
||
# 拉取窗口的日历天数系数(start+count 根 × 周期占的日历天 + 节假日缓冲)
|
||
_WINDOW_DAYS = {"d": (1.6, 30), "w": (7.2, 40), "m": (32.0, 100)}
|
||
# 偏移窗口规模上限(/bars 的 start 无上界,防极端参数把兜底源拖死)
|
||
_MAX_TOTAL_BARS = 10_000
|
||
|
||
|
||
def is_enabled() -> bool:
|
||
"""自动判断兜底是否可用:未禁用(环境变量)且 baostock 已安装。"""
|
||
disabled = os.environ.get(BAOSTOCK_DISABLE_ENV, "").strip().lower() in {"0", "false", "off"}
|
||
if disabled:
|
||
return False
|
||
try:
|
||
importlib.import_module("baostock")
|
||
except ImportError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _login_if_needed(bs: object) -> None:
|
||
"""确保 baostock 已登录(匿名账户;调用方需已持有 _bs_lock)。"""
|
||
global _logged_in
|
||
if _logged_in:
|
||
return
|
||
lg = bs.login() # type: ignore[attr-defined]
|
||
error_code = str(getattr(lg, "error_code", ""))
|
||
if error_code != "0":
|
||
raise RuntimeError(f"baostock 登录失败: {getattr(lg, 'error_msg', error_code)}")
|
||
_logged_in = True
|
||
|
||
|
||
def _query_rows(bs: object, **kwargs: str) -> list[list[str]]:
|
||
"""执行 query_history_k_data_plus 并取回全部行(调用方需已持锁)。"""
|
||
rs = bs.query_history_k_data_plus(**kwargs) # type: ignore[attr-defined]
|
||
if str(getattr(rs, "error_code", "")) != "0":
|
||
raise RuntimeError(f"baostock 查询失败: {getattr(rs, 'error_msg', '')}")
|
||
rows: list[list[str]] = []
|
||
while rs.next() or False:
|
||
rows.append(rs.get_row_data())
|
||
return rows
|
||
|
||
|
||
def fetch_bars(
|
||
market: str,
|
||
code: str,
|
||
category: str,
|
||
start: int,
|
||
count: int,
|
||
adjust: str,
|
||
is_index: bool = False,
|
||
) -> pd.DataFrame | None:
|
||
"""拉取日线及以上 K 线,输出对齐 /bars 契约的 DataFrame。
|
||
|
||
Args:
|
||
market: "SZ" / "SH"(BJ 不支持,返回 None)。
|
||
code: 6 位代码。
|
||
category: 周期名(DAY/WEEK/MONTH 之外返回 None)。
|
||
start: 跳过最新 start 根(与 TDX offset 语义一致)。
|
||
count: 最多返回 count 根。
|
||
adjust: "NONE" / "QFQ" / "HFQ"。
|
||
is_index: 标的是指数(如 sh.000001)。baostock 指数 volume 单位为
|
||
股,而 /bars/index 输出契约为手(通达信指数日线原样、周/月
|
||
×100 还原后均为手)——True 时 vol ÷100 转手。实测
|
||
sh.000001 2026-09-04:volume=53,728,616,100 股
|
||
÷100 = 537,286,161 手。
|
||
|
||
Returns:
|
||
按 [date, open, close, high, low, vol, amount] 列序、时间升序的
|
||
DataFrame;兜底不可用 / 不适用 / 无数据时返回 None。
|
||
|
||
Raises:
|
||
RuntimeError: baostock 登录或查询失败(已记 warning 日志)。auto
|
||
兜底调用方以 except 包裹即可维持原错误路径。
|
||
"""
|
||
global _logged_in
|
||
if not is_enabled():
|
||
return None
|
||
prefix = _MARKET_PREFIX.get(market.upper())
|
||
frequency = _FREQ_BY_CATEGORY.get(category.upper())
|
||
adjustflag = _ADJUST_FLAG.get(adjust.upper())
|
||
if prefix is None or frequency is None or adjustflag is None:
|
||
return None
|
||
|
||
total = start + count
|
||
if total <= 0 or total > _MAX_TOTAL_BARS:
|
||
return None
|
||
coef, buffer_days = _WINDOW_DAYS[frequency]
|
||
end_date = datetime.now()
|
||
start_date = end_date - timedelta(days=total * coef + buffer_days)
|
||
fields = _FIELDS_DAILY if frequency == "d" else _FIELDS_WEEKLY
|
||
|
||
# baostock 全局单连接:持锁串行;等待超时则放弃本次兜底
|
||
if not _bs_lock.acquire(timeout=_LOCK_TIMEOUT_SECONDS):
|
||
return None
|
||
try:
|
||
bs = importlib.import_module("baostock")
|
||
_login_if_needed(bs)
|
||
rows = _query_rows(
|
||
bs,
|
||
code=f"{prefix}.{code}",
|
||
fields=fields,
|
||
start_date=start_date.strftime("%Y-%m-%d"),
|
||
end_date=end_date.strftime("%Y-%m-%d"),
|
||
frequency=frequency,
|
||
adjustflag=adjustflag,
|
||
)
|
||
except Exception as exc:
|
||
# 连接可能中途断开:重置登录态,下次兜底重新登录。
|
||
# 真故障记日志并上抛——auto 兜底调用方(/bars 的 except 分支)接住
|
||
# 后维持原错误;显式 --source baostock 不会被伪装成"无数据"。
|
||
_logged_in = False
|
||
logger.warning("baostock 拉取失败(%s.%s %s):%s", prefix, code, frequency, exc)
|
||
raise RuntimeError(f"baostock 拉取失败: {exc}") from exc
|
||
finally:
|
||
_bs_lock.release()
|
||
|
||
if not rows:
|
||
return None
|
||
df = pd.DataFrame(rows, columns=fields.split(",")).rename(columns={"volume": "vol"})
|
||
for col in ("open", "high", "low", "close", "vol", "amount"):
|
||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||
# 停牌日剔除(tradestatus=0 或无成交),对齐通达信 K 线不含停牌日的口径。
|
||
# W/M 无 tradestatus 列(baostock 不支持),停牌周/月靠 vol>0 兜底剔除。
|
||
if "tradestatus" in df.columns:
|
||
df = df[df["tradestatus"] != "0"]
|
||
df = df.dropna(subset=["close"])
|
||
df = df[df["close"] > 0]
|
||
df = df[df["vol"] > 0]
|
||
if is_index:
|
||
df["vol"] = df["vol"] / 100.0 # 股 → 手,见 docstring is_index 说明
|
||
if df.empty:
|
||
return None
|
||
df["date"] = pd.to_datetime(df["date"]).dt.normalize()
|
||
|
||
# TDX offset 语义:跳过最新 start 根,再取至多 count 根(时间升序)
|
||
end_pos = len(df) - start
|
||
if end_pos <= 0:
|
||
return None
|
||
df = df.iloc[max(0, end_pos - count) : end_pos]
|
||
if df.empty:
|
||
return None
|
||
|
||
return df[["date", "open", "close", "high", "low", "vol", "amount"]].reset_index(drop=True)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Warehouse 适配:满足 WarehouseSyncer 客户端协议(get_stock_kline)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# TDX Market 枚举值 → baostock 市场前缀(0=SZ 1=SH;2=BJ 不覆盖)
|
||
_TDX_TO_MARKET_STR = {0: "SZ", 1: "SH"}
|
||
# 仓库周期名(Period 名)→ 本模块 category 键
|
||
_PERIOD_TO_CATEGORY = {"DAILY": "DAY", "WEEKLY": "WEEK", "MONTHLY": "MONTH"}
|
||
|
||
|
||
class BaostockClient:
|
||
"""把 baostock 包装成 ``WarehouseSyncer`` 可直接使用的行情客户端。
|
||
|
||
只需实现 ``get_stock_kline(market:int, code, period=, start=, count=,
|
||
adjust=)`` 签名(``MacClient`` 同款)。语义差异:
|
||
- 数据缺失(未上市 / 超出范围)返回**空 DataFrame**——上层按"无数据"处理;
|
||
- baostock 未安装 / 已禁用 / 查询失败抛 ``RuntimeError``——错误信息带
|
||
安装提示,便于 ``--source baostock`` 显式使用时定位。
|
||
"""
|
||
|
||
def get_stock_kline(
|
||
self,
|
||
market: int,
|
||
code: str,
|
||
period: str = "DAILY",
|
||
start: int = 0,
|
||
count: int = 8000,
|
||
adjust: str = "QFQ",
|
||
) -> pd.DataFrame:
|
||
market_str = _TDX_TO_MARKET_STR.get(int(market))
|
||
category = _PERIOD_TO_CATEGORY.get(str(period).upper())
|
||
if market_str is None or category is None:
|
||
# 不覆盖的市场/周期:按"无数据"处理而非报错(调用方可跳过)
|
||
return pd.DataFrame()
|
||
if not is_enabled():
|
||
raise RuntimeError(
|
||
"baostock 未安装或已禁用(EASY_TDX_BAOSTOCK=0)。"
|
||
"安装: pip install easy-tdx[baostock]"
|
||
)
|
||
df = fetch_bars(market_str, code, category, start, count, adjust)
|
||
if df is None or df.empty:
|
||
return pd.DataFrame()
|
||
return df.rename(columns={"date": "datetime"})
|