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

173 lines
6.1 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.
"""count>800 的分页取数测试(离线)。
TDX 协议单次 get_security_bars 最多返回 800 根:旧实现里 multiseed /
rotation / formula 的单次调用在 count>800 时被服务器静默截断。本文件钉死
"分页取全量 + 页序正确 + 数据起点提前停止"行为。
"""
from __future__ import annotations
import asyncio
import pandas as pd
import pytest
pytest.importorskip("fastapi")
_PAGE_CAP = 800
class _CappedBarsClient:
"""模拟 TDX 服务器:单次最多返回 _PAGE_CAP 根,start 为回看偏移。"""
def __init__(self, total_bars: int = 3000):
self.total_bars = total_bars
self.calls: list[tuple[int, int]] = [] # (start, count)
def _make_page(self, start: int, n: int) -> pd.DataFrame:
"""start 偏移处往前 n 根(升序页);越过数据起点则截断为 0 根。"""
hi = self.total_bars - start # 本页最旧一根的全局序号(0 起)
lo = max(0, hi - n)
if hi <= 0:
return pd.DataFrame()
dates = pd.date_range("2020-01-01", periods=self.total_bars, freq="B")
idx = dates[lo:hi]
return pd.DataFrame(
{
"date": idx,
"open": 10.0,
"high": 11.0,
"low": 9.0,
"close": 10.5,
"vol": 1000.0,
"amount": 10000.0,
}
)
async def get_security_bars(self, market, code, category, start, count, **kw):
self.calls.append((int(start), int(count)))
return self._make_page(int(start), int(count))
# ── 共享分页辅助 ───────────────────────────────────────────────────────────────
def test_fetch_bars_paged_requests_multiple_pages():
"""count=2000 → 3 次请求(800/800/400),拼齐 2000 根且时间升序。"""
from easy_tdx.web.routers.backtest import _fetch_bars_paged
fake = _CappedBarsClient(total_bars=3000)
df = asyncio.run(_fetch_bars_paged(fake, "SZ:000001", "DAY", 2000))
assert fake.calls == [(0, 800), (800, 800), (1600, 400)]
assert len(df) == 2000
dates = pd.to_datetime(df["date"])
assert dates.is_monotonic_increasing # 页序拼接后必须升序
def test_fetch_bars_paged_stops_at_data_start():
"""数据起点不足一页时提前停止,不多发请求。"""
from easy_tdx.web.routers.backtest import _fetch_bars_paged
fake = _CappedBarsClient(total_bars=1000)
df = asyncio.run(_fetch_bars_paged(fake, "SH:600519", "DAY", 2000))
# 第二页只回 200 根(不足一页)= 数据起点,循环不再发第三笔请求
assert fake.calls == [(0, 800), (800, 800)]
assert len(df) == 1000
def test_fetch_bars_paged_small_count_single_call():
"""count≤800 仍单页取齐(不多打请求)。"""
from easy_tdx.web.routers.backtest import _fetch_bars_paged
fake = _CappedBarsClient(total_bars=3000)
df = asyncio.run(_fetch_bars_paged(fake, "SZ:000001", "DAY", 250))
assert fake.calls == [(0, 250)]
assert len(df) == 250
def test_fetch_bars_paged_empty_returns_empty_df():
from easy_tdx.web.routers.backtest import _fetch_bars_paged
fake = _CappedBarsClient(total_bars=0)
df = asyncio.run(_fetch_bars_paged(fake, "SZ:000001", "DAY", 800))
assert df.empty
# ── multiseed / rotation 端点(取数在 handler 内完成,POST 返回即可断言)──────
def _app_with(fake_client):
from fastapi import FastAPI
from easy_tdx.web.errors import register_exception_handlers
from easy_tdx.web.routers import backtest as backtest_mod
app = FastAPI()
register_exception_handlers(app)
app.include_router(backtest_mod.router, prefix="/api/v1")
app.state.tdx_client = fake_client
app.state.mac_client = None
app.state.ex_client = None
return app
def test_multiseed_fetches_full_count_via_paging():
"""multiseed count=900>800)→ 每标的 2 次请求,不再被 800 截断。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _CappedBarsClient(total_bars=3000)
with TestClient(_app_with(fake)) as client:
resp = client.post(
"/api/v1/backtest/multiseed/run/async",
json={
"strategy": "ma_cross",
"params": {"fast": 3, "slow": 6},
"stocks": ["SZ:000001", "SH:600519"],
"count": 900,
},
)
assert resp.status_code == 202, resp.text
# 2 标的 × 2 页
assert fake.calls == [(0, 800), (800, 100), (0, 800), (800, 100)]
def test_rotation_fetches_full_count_via_paging():
"""rotation count=900>800)→ 每标的 2 次请求。"""
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
fake = _CappedBarsClient(total_bars=3000)
with TestClient(_app_with(fake)) as client:
resp = client.post(
"/api/v1/backtest/rotation/run/async",
json={
"stocks": ["SZ:000001", "SH:600519"],
"count": 900,
},
)
assert resp.status_code == 202, resp.text
assert fake.calls == [(0, 800), (800, 100), (0, 800), (800, 100)]
# ── formula 取数路径 ──────────────────────────────────────────────────────────
def test_formula_resolve_df_pages_full_count():
"""formula _resolve_df symbol 路径 count=2000 → 3 页拼齐且升序。"""
from easy_tdx.web.routers.backtest import _fetch_bars_paged # noqa: F401 需已存在
from easy_tdx.web.routers.formula import FormulaComputeRequest, _resolve_df
fake = _CappedBarsClient(total_bars=3000)
df = asyncio.run(
_resolve_df(fake, FormulaComputeRequest(text="C", symbol="SZ:000001", count=2000))
)
assert fake.calls == [(0, 800), (800, 800), (1600, 400)]
assert len(df) == 2000
dates = pd.to_datetime(df["date"])
assert dates.is_monotonic_increasing