Files
easy_tdx_max/tests/unit/test_web_bars_fallback.py
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

220 lines
8.0 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.
"""/bars、/bars/index 的 baostock 兜底集成测试(v1.32.6 修复项)。
覆盖:
- 数字周期字符串(category="4")归一后也能走兜底(旧实现直接透传原串,
baostock 频率查表落空 → 兜底静默失效,维持原错误);
- 指数兜底必须传 is_index=Truebaostock 指数 vol 股→手),个股路径不传;
- fetch_bars 真故障抛 RuntimeError 时按"兜底不可用"处理,维持原 TDX 错误。
"""
from __future__ import annotations
import sys
import types
import pandas as pd
import pytest
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
# ── 测试替身 ─────────────────────────────────────────────────────────────────
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("标准协议连接失败")
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
def _fallback_df(n: int = 5) -> pd.DataFrame:
dates = pd.bdate_range(end="2026-09-04", periods=n)
return pd.DataFrame(
{
"date": dates.normalize(),
"open": [10.0] * n,
"close": [10.5] * n,
"high": [11.0] * n,
"low": [9.5] * n,
"vol": [100000.0] * n,
"amount": [1050000.0] * n,
}
)
def _install_fake_bs_module(monkeypatch: pytest.MonkeyPatch, rows: int = 10) -> dict:
"""装一个最小可用的 baostock 模块替身,返回 captured 观测点。"""
from easy_tdx.sources import baostock as bs_source
captured: dict = {}
def _login():
lg = types.SimpleNamespace()
lg.error_code = "0"
lg.error_msg = "ok"
return lg
def query_history_k_data_plus(**kwargs): # noqa: ANN003
captured.update(kwargs)
captured["calls"] = captured.get("calls", 0) + 1
data = [
[f"2026-08-{d:02d}", "10.0", "10.5", "11.0", "9.5", "100000", "1050000", "1"]
for d in range(1, rows + 1)
]
rs = types.SimpleNamespace()
rs.error_code = "0"
rs.error_msg = "ok"
rs._rows = data
rs._i = 0
rs.next = lambda: rs._i < len(rs._rows) # type: ignore[method-assign]
rs.get_row_data = lambda: rs._rows[rs._i] # type: ignore[method-assign]
def _advance():
row = rs._rows[rs._i]
rs._i += 1
return row
rs.get_row_data = _advance # type: ignore[method-assign]
return rs
mod = types.ModuleType("baostock")
mod.login = _login # type: ignore[attr-defined]
mod.logout = lambda: None # type: ignore[attr-defined]
mod.query_history_k_data_plus = query_history_k_data_plus # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "baostock", mod)
monkeypatch.delenv("EASY_TDX_BAOSTOCK", raising=False)
monkeypatch.setattr(bs_source, "_logged_in", False)
return captured
# ── 项11:数字周期字符串归一后再兜底 ──────────────────────────────────────────
def test_bars_numeric_category_still_falls_back(monkeypatch):
"""category="4"=DAY 的数字形式)TDX 全败时也应命中 baostock 兜底。
旧实现把原串 "4" 透传给 fetch_bars_FREQ_BY_CATEGORY.get("4") 落空
返回 None → 兜底静默失效,客户端拿到 500。
"""
captured = _install_fake_bs_module(monkeypatch)
with TestClient(
_bars_app(_RaisingMac(), _RaisingTdx()), raise_server_exceptions=False
) as client:
resp = client.get(
"/api/v1/bars", params={"market": "SH", "code": "600519", "category": "4"}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["source"] == "baostock"
assert body["count"] > 0
assert captured["frequency"] == "d" # 归一成 DAY 后映射到日线
# ── 项12:is_index 传递与异常语义 ────────────────────────────────────────────
def test_index_fallback_passes_is_index_true(monkeypatch):
"""/bars/index 兜底必须带 is_index=True(指数 vol 股→手 ÷100)。"""
from easy_tdx.sources import baostock as bs_source
calls: dict = {}
def fake_fetch(market, code, category, start, count, adjust, is_index=False):
calls["is_index"] = is_index
return _fallback_df()
monkeypatch.setattr(bs_source, "is_enabled", lambda: True)
monkeypatch.setattr(bs_source, "fetch_bars", fake_fetch)
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
assert resp.json()["source"] == "baostock"
assert calls["is_index"] is True
def test_bars_stock_fallback_keeps_is_index_false(monkeypatch):
"""个股路径兜底 is_index=Falsevol 保持股口径)。"""
from easy_tdx.sources import baostock as bs_source
calls: dict = {}
def fake_fetch(market, code, category, start, count, adjust, is_index=False):
calls["is_index"] = is_index
return _fallback_df()
monkeypatch.setattr(bs_source, "is_enabled", lambda: True)
monkeypatch.setattr(bs_source, "fetch_bars", fake_fetch)
with TestClient(_bars_app(_RaisingMac(), _RaisingTdx())) as client:
resp = client.get("/api/v1/bars", params={"market": "SH", "code": "600519"})
assert resp.status_code == 200
assert calls["is_index"] is False
def test_bars_fallback_exception_keeps_original_tdx_error(monkeypatch):
"""fetch_bars 真故障抛 RuntimeError → 按"兜底不可用"处理,重抛原 TDX 异常。
响应错误详情须是标准协议的失败原因,而非 baostock 的失败原因(baostock
的失败只记日志),且不返回空数据伪装成功。
"""
from easy_tdx.sources import baostock as bs_source
def boom(*args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("baostock 拉取失败: 网络异常")
monkeypatch.setattr(bs_source, "is_enabled", lambda: True)
monkeypatch.setattr(bs_source, "fetch_bars", boom)
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"]
assert "baostock" not in resp.json()["detail"]
def test_index_fallback_exception_keeps_original_tdx_error(monkeypatch):
"""/bars/index 同语义:baostock 异常不吞掉原 TDX 错误。"""
from easy_tdx.sources import baostock as bs_source
def boom(*args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("baostock 拉取失败: 网络异常")
monkeypatch.setattr(bs_source, "is_enabled", lambda: True)
monkeypatch.setattr(bs_source, "fetch_bars", boom)
with TestClient(_bars_app(None, _RaisingTdx()), raise_server_exceptions=False) as client:
resp = client.get(
"/api/v1/bars/index", params={"market": "SH", "code": "000001", "category": "DAY"}
)
assert resp.status_code == 500
assert "标准协议连接失败" in resp.json()["detail"]