mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 20:24:19 +08:00
对 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 全绿。
250 lines
9.9 KiB
Python
250 lines
9.9 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
|
||
|
||
|
||
# ── 回归:停牌/初始调仓/历史不足(审查修复) ─────────────────────────────────
|
||
|
||
|
||
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)])
|
||
# B:01-01..01-07 有 bar(01-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)
|