Files
easy_tdx_max/tests/unit/test_backtest_rotation.py
T
GitHub 917295edaf release: v1.27.0 — 通达信公式解析器三通道 + 轮动组合引擎 + 回测页WF/评估开关 + Docker 部署
升级计划 P3 + P4(部分)。全量 1252 单测、ruff/mypy strict、前端 vue-tsc+vite build 全绿。

- 通达信公式解析器(formula.py):自建 tokenizer + 递归下降 AST + 30+ 函数白名单求值
  (不走 Python eval);命名布尔输出=信号列、数值输出=排名列;除零→NaN、预热期不出信号
- 公式三通道:CLI easy-tdx formula compute|screen|backtest;REST /formula/validate|compute|
  backtest|screen(run/async);Python API run_formula_backtest(买/卖列自动挑选)
- 轮动组合引擎(rotation.py):排名定期换仓(打分只用截至当日数据)、槽位等额、
  跌出排名自动补位、日/周/月刷新、槽内止盈止损;momentum_score/formula_score 打分;
  REST /backtest/rotation/run/async
- 回测页附加分析开关(Web UI):勾选后随回测并行跑 WF(逐窗红涨绿跌柱状图+汇总卡,
  窗口数 2~12)与一条龙评估(评分分项条/高适配徽标/买入持有对比/8 项适配检查);
  新增 WalkForwardPanel/EvaluatePanel 组件与 store runWalkforward/runEvaluate;
  WF 端点 ?n_windows= 透传;修复报告 numpy 标量 REST 400(源头清洗)
- Docker 部署(Dockerfile + docker-compose.yml,/data 卷 + 健康检查)与
  scripts/verify_ci.sh 一键门禁
- 升级计划文档 docs/upgrade-plan-2026H2.md(四阶段全部完成 + 诚实实测数据)
- 未做(独立排期):Playwright E2E、WebSocket 实时联动、引擎逐 bar 向量化
2026-09-01 22:17:57 +08:00

160 lines
6.3 KiB
Python

"""轮动组合引擎测试(排名换仓 / 槽位等额 / 止盈止损 / 刷新频率 / 绩效)。"""
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