Files
easy_tdx_max/tests/unit/test_baostock_source.py
T
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 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 全绿。
2026-09-06 22:16:48 +08:00

471 lines
18 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.
"""baostock 自动兜底数据源单测(离线,注入假 baostock 模块)。
覆盖:参数映射(代码/周期/复权)、offset 切片语义、停牌日剔除、
可用性门控(环境变量 / 未安装)、/bars 与 /bars/index 的端到端兜底、
TDX 正常时绝不触发兜底。
"""
from __future__ import annotations
import sys
import types
import pandas as pd
import pytest
# ---------------------------------------------------------------------------
# 假 baostock 模块
# ---------------------------------------------------------------------------
class _FakeLoginResult:
error_code = "0"
error_msg = ""
class _FakeResultData:
def __init__(self, rows: list[list[str]]):
self._rows = rows
self._i = 0
self.error_code = "0"
self.error_msg = ""
def next(self) -> bool:
if self._i < len(self._rows):
self._i += 1
return True
return False
def get_row_data(self) -> list[str]:
return self._rows[self._i - 1]
def _fake_rows(n: int, end: str = "2026-09-04") -> list[list[str]]:
"""n 个交易日的日线行:date, open, high, low, close, volume, amount, tradestatus。"""
dates = pd.bdate_range(end=end, periods=n).strftime("%Y-%m-%d")
return [[d, "10.0", "11.0", "9.5", "10.5", "100000", "1050000.0", "1"] for d in dates]
def _weekly_rows(n: int, end: str = "2026-09-04") -> list[list[str]]:
"""n 个周线行(无 tradestatus 列,与真实 W/M 返回一致)。"""
dates = pd.date_range(end=end, periods=n, freq="W-FRI").strftime("%Y-%m-%d")
return [[d, "10.0", "11.0", "9.5", "10.5", "500000", "5250000.0"] for d in dates]
def _install_fake_bs(
rows: list[list[str]] | None,
captured: dict,
*,
query_error: bool = False,
login_error: bool = False,
) -> types.ModuleType:
mod = types.ModuleType("baostock")
def _login(): # type: ignore[no-untyped-def]
captured["login"] = captured.get("login", 0) + 1
if login_error:
result = _FakeLoginResult()
result.error_code = "10001"
result.error_msg = "用户登录失败"
return result
return _FakeLoginResult()
mod.login = _login # type: ignore[attr-defined]
mod.logout = lambda: None # type: ignore[attr-defined]
def query_history_k_data_plus(**kwargs): # type: ignore[no-untyped-def]
captured.update(kwargs)
captured["calls"] = captured.get("calls", 0) + 1
if query_error:
result = _FakeResultData([])
result.error_code = "10001"
result.error_msg = "网络异常"
return result
return _FakeResultData(rows or [])
mod.query_history_k_data_plus = query_history_k_data_plus # type: ignore[attr-defined]
sys.modules["baostock"] = mod
return mod
@pytest.fixture()
def fake_bs(monkeypatch: pytest.MonkeyPatch):
"""注入假模块 + 复位模块级登录态;测试结束移除。"""
from easy_tdx.sources import baostock as bs_source
captured: dict = {}
monkeypatch.setattr(bs_source, "_logged_in", False)
monkeypatch.delenv(bs_source.BAOSTOCK_DISABLE_ENV, raising=False)
_install_fake_bs(_fake_rows(10), captured)
yield captured
sys.modules.pop("baostock", None)
# ---------------------------------------------------------------------------
# 源模块行为
# ---------------------------------------------------------------------------
def test_fetch_maps_args_and_matches_contract(fake_bs):
"""代码/周期/复权映射正确;输出列序与 vol 单位(股,不换算)符合 /bars 契约。"""
from easy_tdx.sources import baostock as bs_source
df = bs_source.fetch_bars("SH", "600519", "DAY", 0, 5, "QFQ")
assert df is not None and len(df) == 5
assert list(df.columns) == ["date", "open", "close", "high", "low", "vol", "amount"]
assert fake_bs["code"] == "sh.600519"
assert fake_bs["frequency"] == "d"
assert fake_bs["adjustflag"] == "2" # QFQ
# 时间升序,最后一根是最新交易日
assert df["date"].iloc[-1] == pd.Timestamp("2026-09-04")
assert (df["vol"] == 100000).all() # baostock volume=股,与 /bars 契约一致,不换算
def test_offset_slice_matches_tdx_semantics(fake_bs):
"""start=跳过最新 N 根:30 根里 start=5, count=10 → 返回第 16~25 根。"""
from easy_tdx.sources import baostock as bs_source
_install_fake_bs(_fake_rows(30), fake_bs)
df = bs_source.fetch_bars("SZ", "000001", "DAY", 5, 10, "QFQ")
assert df is not None and len(df) == 10
dates = df["date"].dt.strftime("%Y-%m-%d").tolist()
expected = pd.bdate_range(end="2026-09-04", periods=30).strftime("%Y-%m-%d").tolist()
assert dates[0] == expected[15]
assert dates[-1] == expected[24]
def test_suspension_rows_dropped(fake_bs):
"""停牌日(tradestatus=0 / volume=0)剔除,对齐通达信 K 线口径。"""
rows = _fake_rows(6)
rows[2] = [rows[2][0], "0", "0", "0", "0", "0", "0", "0"] # 停牌日
_install_fake_bs(rows, fake_bs)
from easy_tdx.sources import baostock as bs_source
df = bs_source.fetch_bars("SZ", "000001", "DAY", 0, 10, "QFQ")
assert df is not None and len(df) == 5
assert (df["vol"] > 0).all()
def test_disabled_via_env(fake_bs, monkeypatch: pytest.MonkeyPatch):
"""EASY_TDX_BAOSTOCK=0 显式关闭:不安装也不调用。"""
from easy_tdx.sources import baostock as bs_source
monkeypatch.setenv(bs_source.BAOSTOCK_DISABLE_ENV, "0")
assert bs_source.is_enabled() is False
assert bs_source.fetch_bars("SH", "600519", "DAY", 0, 5, "QFQ") is None
assert "login" not in fake_bs
def test_missing_module_returns_none(monkeypatch: pytest.MonkeyPatch):
"""未安装 baostock:静默返回 None(兜底环自动关闭)。"""
monkeypatch.delenv("EASY_TDX_BAOSTOCK", raising=False)
monkeypatch.setitem(sys.modules, "baostock", None) # import 时抛 ImportError
from easy_tdx.sources import baostock as bs_source
assert bs_source.is_enabled() is False
assert bs_source.fetch_bars("SH", "600519", "DAY", 0, 5, "QFQ") is None
def test_unsupported_inputs(fake_bs):
"""BJ 市场 / 分钟线周期 / 非法复权 / 超大窗口:不适用即 None。"""
from easy_tdx.sources import baostock as bs_source
assert bs_source.fetch_bars("BJ", "430047", "DAY", 0, 5, "QFQ") is None
assert bs_source.fetch_bars("SH", "600519", "MIN_5", 0, 5, "QFQ") is None
assert bs_source.fetch_bars("SH", "600519", "SEASON", 0, 5, "QFQ") is None
assert bs_source.fetch_bars("SH", "600519", "DAY", 0, 5, "FOO") is None
assert bs_source.fetch_bars("SH", "600519", "DAY", 99999, 800, "QFQ") is None
assert "calls" not in fake_bs
def test_query_error_raises_runtimeerror_and_logs(fake_bs, caplog):
"""baostock 查询失败(error_code≠0):记 warning 并抛 RuntimeError。
回归:旧实现吞掉所有异常静默返回 None——`--source baostock` 显式使用时
故障被伪装成"无数据"sync 记 skipped 而非 failed)。auto 兜底路径
web/routers/bars.py)以 except Exception 包裹调用,不受影响。
"""
_install_fake_bs([], fake_bs, query_error=True)
from easy_tdx.sources import baostock as bs_source
with pytest.raises(RuntimeError, match="baostock 拉取失败"):
bs_source.fetch_bars("SH", "600519", "DAY", 0, 5, "QFQ")
assert "baostock 拉取失败" in caplog.text
def test_login_failure_raises_runtimeerror(fake_bs, monkeypatch: pytest.MonkeyPatch):
"""baostock 登录失败:同样 warning + RuntimeError(不再静默)。"""
_install_fake_bs([], fake_bs, login_error=True)
from easy_tdx.sources import baostock as bs_source
with pytest.raises(RuntimeError, match="拉取失败"):
bs_source.fetch_bars("SZ", "000001", "DAY", 0, 5, "QFQ")
def test_weekly_monthly_fields_exclude_tradestatus(fake_bs):
"""W/M 请求不传 tradestatusbaostock 实测 error_code=10004012 报错),
日线保留。"""
from easy_tdx.sources import baostock as bs_source
weekly_rows = _weekly_rows(6)
_install_fake_bs(weekly_rows, fake_bs)
df = bs_source.fetch_bars("SH", "600519", "WEEK", 0, 5, "QFQ")
assert df is not None and len(df) == 5
assert fake_bs["frequency"] == "w"
assert "tradestatus" not in fake_bs["fields"]
assert list(df.columns) == ["date", "open", "close", "high", "low", "vol", "amount"]
_install_fake_bs(_weekly_rows(6), fake_bs)
df = bs_source.fetch_bars("SH", "600519", "MONTH", 0, 5, "QFQ")
assert df is not None and len(df) == 5
assert fake_bs["frequency"] == "m"
assert "tradestatus" not in fake_bs["fields"]
# 日线仍保留 tradestatus(停牌剔除依赖它)
_install_fake_bs(_fake_rows(6), fake_bs)
bs_source.fetch_bars("SH", "600519", "DAY", 0, 5, "QFQ")
assert "tradestatus" in fake_bs["fields"]
def test_weekly_suspension_dropped_by_volume(fake_bs):
"""WEEK 无 tradestatus 列时,停牌/无成交周(volume=0)按 vol>0 兜底剔除。"""
from easy_tdx.sources import baostock as bs_source
rows = _weekly_rows(6)
rows[2][5] = "0" # volume=0 的停牌周
_install_fake_bs(rows, fake_bs)
df = bs_source.fetch_bars("SZ", "000001", "WEEK", 0, 10, "QFQ")
assert df is not None and len(df) == 5
assert (df["vol"] > 0).all()
def test_index_volume_converted_to_lots(fake_bs):
"""is_index=True:指数 vol 股→手(÷100),对齐 /bars/index 契约。
实测 sh.000001 2026-09-04baostock volume=53,728,616,100(股),
÷100 = 537,286,161 手(TDX 指数日线口径为手)。
"""
from easy_tdx.sources import baostock as bs_source
df = bs_source.fetch_bars("SH", "000001", "DAY", 0, 5, "NONE", is_index=True)
assert df is not None
assert (df["vol"] == 1000.0).all() # 100000 股 ÷100 = 1000 手
# 默认(个股路径)不换算
_install_fake_bs(_fake_rows(6), fake_bs)
df = bs_source.fetch_bars("SH", "600519", "DAY", 0, 5, "QFQ")
assert (df["vol"] == 100000).all()
# ---------------------------------------------------------------------------
# /bars 与 /bars/index 端到端兜底
# ---------------------------------------------------------------------------
def _bars_app(mac_client, tdx_client):
from fastapi import FastAPI
from easy_tdx.web.errors import register_exception_handlers
from easy_tdx.web.routers import bars
app = FastAPI()
register_exception_handlers(app)
app.include_router(bars.router, prefix="/api/v1")
app.state.tdx_client = tdx_client
app.state.mac_client = mac_client
return app
class _RaisingMac:
async def get_stock_kline(self, *args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("MAC 连接失败")
class _RaisingTdx:
async def get_security_bars(self, *args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("标准协议连接失败")
async def get_index_bars(self, *args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("标准协议连接失败")
class _OkMac:
async def get_stock_kline(self, *args, **kwargs): # noqa: ANN002, ANN003
return pd.DataFrame(
{
"datetime": pd.bdate_range(end="2026-09-04", periods=5),
"open": [10.0] * 5,
"close": [10.5] * 5,
"high": [11.0] * 5,
"low": [9.5] * 5,
"vol": [100000] * 5,
"amount": [1050000.0] * 5,
"float_shares": [0.0] * 5,
}
)
def test_bars_endpoint_falls_back_to_baostock(fake_bs, monkeypatch: pytest.MonkeyPatch):
"""MAC 与标准协议都失败 → baostock 兜底命中,响应带 source 字段。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
_install_fake_bs(_fake_rows(10), fake_bs)
with TestClient(_bars_app(_RaisingMac(), _RaisingTdx())) as client:
resp = client.get("/api/v1/bars", params={"market": "SH", "code": "600519"})
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "baostock"
assert body["count"] == 10
assert "date" in body["data"][0]
assert "change_pct" in body["data"][0]
def test_bars_endpoint_tdx_ok_never_calls_baostock(fake_bs):
"""TDX 正常出数时兜底绝不触发:source 为 Nonebaostock 零调用。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
with TestClient(_bars_app(_OkMac(), _RaisingTdx())) as client:
resp = client.get("/api/v1/bars", params={"market": "SH", "code": "600519"})
assert resp.status_code == 200
body = resp.json()
assert body["source"] is None
assert body["count"] == 5
assert "login" not in fake_bs
def test_bars_endpoint_no_fallback_available_keeps_error(fake_bs, monkeypatch: pytest.MonkeyPatch):
"""TDX 全败且兜底不可用:维持原错误语义(500),不返回空数据伪装成功。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
monkeypatch.delenv("EASY_TDX_BAOSTOCK", raising=False)
monkeypatch.setitem(sys.modules, "baostock", None)
# raise_server_exceptions=False:模拟生产环境由服务端中间件返回 500
with TestClient(
_bars_app(_RaisingMac(), _RaisingTdx()), raise_server_exceptions=False
) as client:
resp = client.get("/api/v1/bars", params={"market": "SH", "code": "600519"})
assert resp.status_code == 500
assert "连接失败" in resp.json()["detail"]
def test_index_endpoint_falls_back_to_baostock(fake_bs):
"""/bars/indexTDX 失败 → baostock 兜底(指数代码同格式)。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
_install_fake_bs(_fake_rows(10), fake_bs)
with TestClient(_bars_app(None, _RaisingTdx())) as client:
resp = client.get(
"/api/v1/bars/index", params={"market": "SH", "code": "000001", "category": "DAY"}
)
assert resp.status_code == 200
body = resp.json()
assert body["source"] == "baostock"
assert fake_bs["code"] == "sh.000001"
# ---------------------------------------------------------------------------
# Warehouse 适配(BaostockClient / AutoKlineClient
# ---------------------------------------------------------------------------
def test_baostock_client_maps_and_returns_datetime(fake_bs):
"""适配器满足 WarehouseSyncer 协议:market/period 数字与名称映射正确,
输出 datetime 列(仓库 schema)。"""
from easy_tdx.sources.baostock import BaostockClient
df = BaostockClient().get_stock_kline(
1, "600519", period="DAILY", start=0, count=5, adjust="QFQ"
)
assert len(df) == 5
assert "datetime" in df.columns
assert fake_bs["code"] == "sh.600519"
assert fake_bs["frequency"] == "d"
def test_baostock_client_unsupported_market_returns_empty(fake_bs):
"""BJmarket=2)等不覆盖范围:返回空表(上层按无数据跳过),不报错。"""
from easy_tdx.sources.baostock import BaostockClient
df = BaostockClient().get_stock_kline(2, "430047", period="DAILY")
assert len(df) == 0
assert "calls" not in fake_bs
def test_baostock_client_no_data_returns_empty_not_raise(fake_bs):
"""无数据(如超出上市范围)返回空表而非异常。"""
from easy_tdx.sources.baostock import BaostockClient
_install_fake_bs([], fake_bs)
df = BaostockClient().get_stock_kline(0, "000001", period="DAILY")
assert len(df) == 0
def test_baostock_client_not_installed_raises_with_hint(monkeypatch: pytest.MonkeyPatch):
"""显式 --source baostock 但未安装:报错且信息带安装提示。"""
monkeypatch.delenv("EASY_TDX_BAOSTOCK", raising=False)
monkeypatch.setitem(sys.modules, "baostock", None)
from easy_tdx.sources.baostock import BaostockClient
with pytest.raises(RuntimeError, match="easy-tdx\[baostock\]"):
BaostockClient().get_stock_kline(1, "600519", period="DAILY")
class _OkClient:
def __init__(self) -> None:
self.calls = 0
def get_stock_kline(self, market, code, **kwargs): # noqa: ANN001, ANN003
self.calls += 1
return pd.DataFrame({"datetime": [1], "close": [10.0]})
class _EmptyThenOkClient(_OkClient):
def get_stock_kline(self, market, code, **kwargs): # noqa: ANN001, ANN003
self.calls += 1
return pd.DataFrame()
class _RaisingClient(_OkClient):
def get_stock_kline(self, market, code, **kwargs): # noqa: ANN001, ANN003
self.calls += 1
raise RuntimeError("主源失败")
def test_auto_kline_client_primary_ok_skips_fallback():
from easy_tdx.sources import AutoKlineClient
primary, fallback = _OkClient(), _OkClient()
df = AutoKlineClient(primary, fallback).get_stock_kline(1, "600519", period="DAILY")
assert len(df) == 1
assert primary.calls == 1
assert fallback.calls == 0
def test_auto_kline_client_primary_empty_falls_back():
from easy_tdx.sources import AutoKlineClient
primary, fallback = _EmptyThenOkClient(), _OkClient()
df = AutoKlineClient(primary, fallback).get_stock_kline(1, "600519", period="DAILY")
assert len(df) == 1
assert fallback.calls == 1
def test_auto_kline_client_primary_error_falls_back():
from easy_tdx.sources import AutoKlineClient
fallback = _OkClient()
df = AutoKlineClient(_RaisingClient(), fallback).get_stock_kline(1, "600519")
assert len(df) == 1
assert fallback.calls == 1
def test_auto_kline_client_fallback_error_propagates():
from easy_tdx.sources import AutoKlineClient
with pytest.raises(RuntimeError, match="主源失败"):
AutoKlineClient(_RaisingClient(), _RaisingClient()).get_stock_kline(1, "600519")