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

250 lines
9.9 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.
"""轮动组合引擎测试(排名换仓 / 槽位等额 / 止盈止损 / 刷新频率 / 绩效)。"""
from __future__ import annotations
import json
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.rotation import RotationEngine, RotationResult, formula_score, momentum_score
def _stock(
n: int = 250, seed: int = 1, drift: float = 0.001, start: str = "2024-01-01"
) -> pd.DataFrame:
rng = np.random.default_rng(seed)
close = 10.0 * np.cumprod(1.0 + drift + rng.normal(0, 0.01, n))
return pd.DataFrame(
{
"datetime": pd.date_range(start, periods=n, freq="B"),
"open": close * 0.999,
"high": close * 1.02,
"low": close * 0.98,
"close": close,
"vol": 1e6,
"amount": close * 1e6,
}
)
def _pool(drifts: dict[str, float], n: int = 250) -> dict[str, pd.DataFrame]:
return {sym: _stock(n, seed=i, drift=drift) for i, (sym, drift) in enumerate(drifts.items())}
# ── 基础结构 ─────────────────────────────────────────────────────────────────
def test_rotation_basic_run_and_structure():
pool = _pool({"SH:600519": 0.002, "SZ:000001": 0.001, "SZ:000858": 0.0005, "SH:601318": 0.0})
engine = RotationEngine(pool, momentum_score(20), slots=2, refresh="weekly")
result = engine.run()
assert isinstance(result, RotationResult)
assert len(result.equity_curve) >= 200
assert result.performance.get("total_return") is not None
assert result.config["slots"] == 2
# 净值曲线字段完整(可喂组合评级)
first = result.equity_curve[0]
assert {"datetime", "cash", "position_value", "total", "drawdown_pct"} <= set(first)
def test_rotation_strong_pool_makes_money():
"""普涨池 + 动量排名 → 正收益。"""
pool = _pool({f"SH:60000{i}": 0.004 for i in range(5)})
result = RotationEngine(pool, momentum_score(20), slots=3, refresh="monthly").run()
assert result.performance["total_return"] > 0
def test_rotation_weak_pool_loses_less_than_buyhold():
"""普跌池 → 负收益(动量轮动不做空)。"""
pool = _pool({f"SH:60000{i}": -0.004 for i in range(5)})
result = RotationEngine(pool, momentum_score(20), slots=2).run()
assert result.performance["total_return"] < 0
def test_rotation_trades_have_reasons():
pool = _pool({f"SH:60000{i}": 0.002 if i % 2 else -0.001 for i in range(6)})
result = RotationEngine(pool, momentum_score(10), slots=2, refresh="weekly").run()
reasons = {t["reason"] for t in result.trades}
assert "rotation" in reasons # 买入
assert "rank_exit" in reasons # 跌出排名的卖出
def test_rotation_respects_slots():
"""持仓数永远 ≤ slots。"""
pool = _pool({f"SH:60000{i}": 0.001 + 0.0005 * i for i in range(8)})
engine = RotationEngine(pool, momentum_score(10), slots=3, refresh="weekly")
# 用逐日持仓推断:trades 序列重放
holdings = 0
peak_holdings = 0
for t in result_trades_sorted(engine):
if t["direction"] == "BUY":
holdings += 1
peak_holdings = max(peak_holdings, holdings)
else:
holdings -= 1
assert peak_holdings <= 3
def result_trades_sorted(engine: RotationEngine) -> list[dict]:
result = engine.run()
return result.trades
def test_rotation_stop_loss_triggers():
"""深跌池 + 10% 止损 → 出现 stop_loss 卖出。"""
pool = _pool({f"SH:60000{i}": -0.006 for i in range(4)})
result = RotationEngine(
pool, momentum_score(5), slots=2, refresh="monthly", stop_loss=0.05
).run()
reasons = {t["reason"] for t in result.trades}
assert "stop_loss" in reasons
def test_rotation_refresh_frequencies():
pool = _pool({f"SH:60000{i}": 0.001 * (i + 1) for i in range(4)})
r_daily = RotationEngine(pool, momentum_score(10), slots=2, refresh="daily").run()
r_monthly = RotationEngine(pool, momentum_score(10), slots=2, refresh="monthly").run()
# 月调仓的调仓日数 ≤ 日调仓
assert len(r_monthly.rebalance_dates) <= len(r_daily.rebalance_dates)
# 月调仓约 12 次/年(250 交易日)
assert 3 <= len(r_monthly.rebalance_dates) <= 15
def test_rotation_formula_score_synergy():
"""公式打分与轮动联动:数值输出作为排名分。"""
pool = _pool({f"SH:60000{i}": 0.001 * (i + 1) for i in range(4)})
score = formula_score("动量分: C / REF(C, 20) * 100;")
result = RotationEngine(pool, score, slots=2, refresh="monthly").run()
assert result.performance["total_return"] is not None
def test_rotation_result_serializable():
pool = _pool({f"SH:60000{i}": 0.001 * (i + 1) for i in range(4)})
result = RotationEngine(pool, momentum_score(10), slots=2).run()
d = result.to_dict()
text = json.dumps(d, ensure_ascii=False)
assert "equity_curve" in text
assert d["n_rebalances"] >= 1
def test_rotation_rejects_bad_config():
pool = _pool({"SH:600519": 0.001, "SZ:000001": 0.001})
with pytest.raises(ValueError, match="refresh"):
RotationEngine(pool, momentum_score(5), refresh="yearly")
with pytest.raises(ValueError, match="stock_dfs"):
RotationEngine({}, momentum_score(5))
with pytest.raises(ValueError, match="slots"):
RotationEngine(pool, momentum_score(5), slots=0)
def test_rotation_equal_weight_no_allin_single_stock():
"""首日建仓是等额分批,不是一把全买一只(槽位预算 = 净值/槽数)。"""
pool = _pool({f"SH:60000{i}": 0.001 * (i + 1) for i in range(6)})
result = RotationEngine(pool, momentum_score(10), slots=3, refresh="monthly").run()
first_day_buys = [
t for t in result.trades if t["direction"] == "BUY" and t["reason"] == "rotation"
][:3]
if len(first_day_buys) >= 2:
values = [t["size"] * t["price"] for t in first_day_buys]
# 同日买入的各笔金额接近(等额),差异 < 25%(价格整百取整的摩擦)
assert max(values) / max(min(values), 1) < 1.25
def test_momentum_score_helper():
df = _stock(30, seed=1, drift=0.01)
score = momentum_score(10)(df)
assert score > 0
assert momentum_score(10)(_stock(5)) == 0.0 # 数据不足 → 0
# ── 回归:停牌/初始调仓/历史不足(审查修复) ─────────────────────────────────
def _bar_frame(dates: pd.DatetimeIndex, prices: list[float]) -> pd.DataFrame:
closes = np.asarray(prices, dtype=float)
return pd.DataFrame(
{
"datetime": dates[: len(closes)],
"open": closes * 0.999,
"high": closes * 1.01,
"low": closes * 0.98,
"close": closes,
"vol": 1e6,
}
)
def test_rotation_suspension_defers_fill_to_resume_open():
"""停牌日挂单顺延:成交日=复牌日、成交价=复牌开盘(旧码在停牌日按停牌前价格成交)。"""
dates = pd.date_range("2024-01-01", periods=12, freq="D")
a = _bar_frame(dates, [10 + 0.05 * i for i in range(12)])
# B01-01..01-07 有 bar01-07 收盘崩盘跌出排名),01-08 停牌(下标 7 无 bar),
# 01-09 复牌开盘 -30%(下标 8 = 4.2
b_prices = [10, 10.1, 10.2, 10.3, 10.4, 10.5, 6.0, 4.9, 4.2, 4.3, 4.3, 4.3]
b = pd.DataFrame(
[
{
"datetime": dates[i],
"open": p * 0.999,
"high": p * 1.01,
"low": p * 0.98,
"close": p,
"vol": 1e6,
}
for i, p in enumerate(b_prices)
if i != 7 # 01-08 停牌,无 bar
]
)
engine = RotationEngine(
{"SH:600001": a, "SZ:000002": b},
momentum_score(2),
slots=1,
refresh="daily",
keep_rank=1,
)
res = engine.run()
sells_b = [t for t in res.trades if t["symbol"] == "SZ:000002" and t["direction"] == "SELL"]
assert len(sells_b) == 1
sell = sells_b[0]
assert sell["datetime"] == "2024-01-09" # 旧码记 2024-01-08(停牌日)
assert sell["price"] == pytest.approx(4.2 * 0.999) # 旧码记 6.0 * 0.999(停牌前开盘)
# 复牌前净值按最后已知收盘估值,不应把持仓价值清零
eq_by_date = {r["datetime"]: r for r in res.equity_curve}
assert eq_by_date["2024-01-08"]["position_value"] > 0
def test_rotation_day0_counts_as_first_rebalance():
"""day0 即为首个调仓日(排名只用 ≤day0 数据,次日开盘执行),不再人为空仓一天。"""
pool = _pool({f"SH:60000{i}": 0.002 for i in range(5)}, n=40)
res = RotationEngine(pool, momentum_score(5), slots=3, refresh="weekly").run()
# 旧码首个调仓日是下一 ISO 周 2024-01-08
assert res.rebalance_dates[0] == "2024-01-01"
# next_open 语义:任何成交不早于第二个交易日(day0 信号次日执行)
if res.trades:
assert min(t["datetime"] for t in res.trades) > res.rebalance_dates[0]
def test_rotation_new_listing_not_bought_on_zero_score():
"""历史不足(<5 根)从买入候选剔除:次新股 0 分不得排在负动量标的之前被买入。"""
n = 60
dates = pd.date_range("2024-01-01", periods=n, freq="B")
declining = 100.0 * np.cumprod(np.full(n, 1.0 - 0.005))
a = _bar_frame(dates, list(declining)) # 长历史持续阴跌,动量为负
b = _bar_frame(dates, [10.0, 10.0, 10.0]) # 末段才上市,全程 idx<5
res = RotationEngine(
{"SH:600001": a, "SZ:000002": b},
momentum_score(5),
slots=1,
refresh="daily",
).run()
buys_b = [t for t in res.trades if t["symbol"] == "SZ:000002" and t["direction"] == "BUY"]
assert buys_b == [] # 旧码 B 以 0 分登顶被买入
# A 作为唯一有效候选被正常买入
assert any(t["symbol"] == "SH:600001" and t["direction"] == "BUY" for t in res.trades)