Files
easy-tdx/tests/unit/test_backtest_engine.py
Justin Gu f6ae69845d feat(backtest): 成交价精简 + 一键寻优全策略 + 预设网格
- 成交价精简为 next_open/next_close,删除 this_close/worst/best 三种
  非真实模式(this_close 有未来函数偏差,worst/best 为压力测试边界)
- 初始资金默认统一为 1,000,000(原 10万/20万)
- 新增 presets.py:18 策略各配 1-2 参数的预设寻优网格(笛卡尔积≤200)
- registry.to_schema() 返回 preset_grid 字段供前端自动填充
- 新增 POST /backtest/optimize-all/run/async:逐策略预设网格寻优 +
  全局排名(OptimizeAllResult:ranking/best/per_strategy/total_grid_points)
- 新增 optimize-all 端到端单测 2 例(838 单测全绿)
2026-07-04 00:08:38 +08:00

729 lines
24 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.
"""Test BacktestEngine orchestration."""
from __future__ import annotations
import numpy as np
import pandas as pd
from easy_tdx import MyTT
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.execution import TWAPExecution, VWAPExecution
from easy_tdx.backtest.slippage import FixedSlippage, SquareRootSlippage
from easy_tdx.backtest.strategy import Strategy
def _make_df(n: int = 100, seed: int = 42) -> pd.DataFrame:
"""Generate synthetic OHLCV data."""
rng = np.random.default_rng(seed)
close = 100.0 + np.cumsum(rng.normal(0, 1, n))
high = close + rng.uniform(0, 1, n)
low = close - rng.uniform(0, 1, n)
open_ = low + rng.uniform(0, high - low, n)
volume = rng.integers(1000000, 10000000, n)
dates = pd.date_range("2024-01-01", periods=n, freq="D")
return pd.DataFrame(
{
"datetime": dates,
"open": open_,
"high": high,
"low": low,
"close": close,
"volume": volume,
}
)
class MACrossStrategy(Strategy):
"""Simple MA crossover strategy."""
def init(self):
self.ma5 = self.I(MyTT.MA, self.data.close, 5)
self.ma20 = self.I(MyTT.MA, self.data.close, 20)
self.cross_up = False
self.cross_down = False
def next(self):
# Check if crossing happened on this bar
if self._bar_index > 0:
prev_ma5 = self.ma5[self._bar_index - 1]
prev_ma20 = self.ma20[self._bar_index - 1]
curr_ma5 = self.ma5[self._bar_index]
curr_ma20 = self.ma20[self._bar_index]
# Golden cross: ma5 crosses above ma20
if prev_ma5 <= prev_ma20 and curr_ma5 > curr_ma20:
self.buy(size=0)
# Death cross: ma5 crosses below ma20
elif prev_ma5 >= prev_ma20 and curr_ma5 < curr_ma20:
self.sell(size=0)
class FixedBuyStrategy(Strategy):
"""Strategy with fixed buy/sell at specific bars."""
def init(self):
pass
def next(self):
if self._bar_index == 5:
self.buy(size=100)
if self._bar_index == 50:
self.sell(size=100)
class ChanlunStrategy(Strategy):
"""Strategy that uses chanlun result."""
def init(self):
pass
def next(self):
if self._bar_index == 10 and hasattr(self, "chanlun"):
# Access chanlun result
_ = self.chanlun
self.buy(size=50)
class PrecomputedIndicatorStrategy(Strategy):
"""Strategy that uses precomputed indicator columns."""
def init(self):
# Assume BOLL_UPPER already exists in df
if hasattr(self.data, "BOLL_UPPER"):
self.boll_upper = self.data.BOLL_UPPER
else:
self.boll_upper = None
def next(self):
if self.boll_upper is not None and self._bar_index == 20:
_ = self.boll_upper[self._bar_index]
self.buy(size=10)
def test_basic_run():
"""Test basic engine run with MACrossStrategy."""
df = _make_df(n=200)
engine = BacktestEngine(MACrossStrategy, cash=100000)
result = engine.run(df)
# Check performance metrics
assert result.performance is not None
assert "total_return" in result.performance
# Check equity curve length
assert len(result.equity_curve) == 200
# Check columns
assert "datetime" in result.equity_curve.columns
assert "total" in result.equity_curve.columns
def test_fixed_strategy():
"""Test FixedBuyStrategy produces trades."""
df = _make_df(n=100)
engine = BacktestEngine(FixedBuyStrategy, cash=100000)
result = engine.run(df)
# Should have at least 2 trades
assert len(result.trades) >= 2, f"Expected at least 2 trades, got {len(result.trades)}"
# Check buy at bar 5
buy_trades = result.trades[result.trades["direction"] == "BUY"]
assert len(buy_trades) >= 1, "No buy trades found"
# Check sell at bar 50
sell_trades = result.trades[result.trades["direction"] == "SELL"]
assert len(sell_trades) >= 1, "No sell trades found"
def test_result_columns():
"""Test BacktestResult has correct columns."""
df = _make_df(n=100)
engine = BacktestEngine(MACrossStrategy)
result = engine.run(df)
# Equity curve columns
expected_ec_cols = ["datetime", "cash", "position_value", "total"]
for col in expected_ec_cols:
assert col in result.equity_curve.columns
# Trades columns
expected_trade_cols = ["datetime", "direction", "size", "price", "pnl"]
for col in expected_trade_cols:
assert col in result.trades.columns
def test_to_dict():
"""Test BacktestResult is serializable."""
df = _make_df(n=50)
engine = BacktestEngine(MACrossStrategy)
result = engine.run(df)
# to_dict should not raise
d = result.to_dict()
assert "performance" in d
assert "equity_curve" in d
assert "trades" in d
# to_json should not raise
json_str = result.to_json()
assert len(json_str) > 0
def test_chanlun_injection():
"""Test chanlun result injection."""
df = _make_df(n=50)
# Mock chanlun result
chanlun_result = {"test": "data"}
engine = BacktestEngine(ChanlunStrategy)
result = engine.run(df, chanlun_result=chanlun_result)
# Should have trades
assert len(result.trades) >= 1
def test_config_snapshot():
"""Test config contains correct cash and commission."""
df = _make_df(n=50)
engine = BacktestEngine(MACrossStrategy, cash=50000, commission=0.0005, execution="next_open")
result = engine.run(df)
# Check config
assert result.config["cash"] == 50000
assert result.config["commission"] == 0.0005
assert result.config["execution"] == "next_open"
def test_precomputed_indicator_columns():
"""Test strategy works with precomputed indicator columns."""
df = _make_df(n=50)
# Add precomputed BOLL_UPPER column
df["BOLL_UPPER"] = df["close"] * 1.05
engine = BacktestEngine(PrecomputedIndicatorStrategy)
result = engine.run(df)
# Should not crash and should have trades
assert len(result.equity_curve) == 50
def test_empty_df():
"""Test engine with empty DataFrame."""
df = pd.DataFrame(columns=["datetime", "open", "high", "low", "close", "volume"])
engine = BacktestEngine(MACrossStrategy)
result = engine.run(df)
# Should return empty result
assert len(result.equity_curve) == 0
assert len(result.trades) == 0
def test_strategy_instance_vs_class():
"""Test engine accepts both strategy class and instance."""
df = _make_df(n=50)
# Test with class
engine1 = BacktestEngine(MACrossStrategy)
result1 = engine1.run(df)
assert len(result1.equity_curve) == 50
# Test with instance
strat = MACrossStrategy()
engine2 = BacktestEngine(strat)
result2 = engine2.run(df)
assert len(result2.equity_curve) == 50
def test_commission_calculation():
"""Test commission is correctly applied."""
df = _make_df(n=100)
engine = BacktestEngine(
FixedBuyStrategy,
cash=100000,
commission=0.001,
min_commission=10.0,
)
result = engine.run(df)
# Should have trades
assert len(result.trades) >= 2
# Check trades have commission
assert (result.trades["commission"] > 0).all()
def test_pnl_calculation():
"""Test PnL is calculated for sell trades."""
df = _make_df(n=100, seed=123) # Use specific seed for predictable prices
engine = BacktestEngine(FixedBuyStrategy, cash=100000, commission=0.0)
result = engine.run(df)
# Should have trades
assert len(result.trades) >= 2
# Get trades - should have at least one BUY and one SELL
buy_trades = result.trades[result.trades["direction"] == "BUY"]
sell_trades = result.trades[result.trades["direction"] == "SELL"]
assert len(buy_trades) >= 1
assert len(sell_trades) >= 1
# PnL is calculated for sell trades
# Check that sell trades have PnL computed
assert (sell_trades["pnl"] != 0).any() or len(sell_trades) == 0
# For buy trades, PnL should be 0
assert (buy_trades["pnl"] == 0).all()
class PositionAwareStrategy(Strategy):
"""Strategy that checks position before trading (the common pattern)."""
def init(self):
self.ma5 = self.I(MyTT.MA, self.data.close, 5)
self.ma20 = self.I(MyTT.MA, self.data.close, 20)
self.cross_up = False
self.cross_down = False
def next(self):
if self._bar_index > 0:
prev_ma5 = self.ma5[self._bar_index - 1]
prev_ma20 = self.ma20[self._bar_index - 1]
curr_ma5 = self.ma5[self._bar_index]
curr_ma20 = self.ma20[self._bar_index]
if prev_ma5 <= prev_ma20 and curr_ma5 > curr_ma20:
if self.position["size"] == 0:
self.buy(size=0)
elif prev_ma5 >= prev_ma20 and curr_ma5 < curr_ma20:
if self.position["size"] > 0:
self.sell(size=0)
def test_position_aware_buy_sell_alternation():
"""Regression: strategy that checks position must produce alternating BUY/SELL."""
df = _make_df(n=300, seed=42)
engine = BacktestEngine(PositionAwareStrategy, cash=100000)
result = engine.run(df)
trades = result.trades[~result.trades["rejected"]]
directions = trades["direction"].tolist()
# Must have both BUYs and SELLs
assert "BUY" in directions, "No BUY trades generated"
assert "SELL" in directions, "No SELL trades generated — position feedback broken"
# Trades must alternate: no two consecutive BUYs or SELLs
for i in range(1, len(directions)):
assert directions[i] != directions[i - 1], (
f"Consecutive same-direction trades at index {i}: "
f"{directions[i - 1]} -> {directions[i]}"
)
def test_position_aware_no_duplicate_buys():
"""After a BUY, position['size'] > 0 so strategy should not buy again."""
df = _make_df(n=300, seed=42)
engine = BacktestEngine(PositionAwareStrategy, cash=100000)
result = engine.run(df)
buy_trades = result.trades[(result.trades["direction"] == "BUY") & (~result.trades["rejected"])]
# Each BUY's size should be reasonable (not tiny leftover from exhausted cash)
if len(buy_trades) > 1:
# No consecutive buys where the second is tiny (cash leftover artifact)
sizes = buy_trades["size"].tolist()
for i in range(1, len(sizes)):
# Second buy in a pair should not be tiny compared to first
# (would indicate position wasn't tracked between bars)
if i >= 1:
prev_size = sizes[i - 1]
cur_size = sizes[i]
# Allow some variance but not orders-of-magnitude difference
if prev_size > 0:
assert cur_size > prev_size * 0.1, (
f"Suspicious tiny buy {cur_size} after {prev_size} — "
f"position feedback may be broken"
)
# ── Stop-Loss / Take-Profit ──────────────────────────────────────────────────
def _make_flat_df(n: int = 30, base_price: float = 100.0) -> pd.DataFrame:
"""Generate flat OHLCV data at constant price for SL/TP testing."""
dates = pd.date_range("2024-01-01", periods=n, freq="D")
return pd.DataFrame(
{
"datetime": dates,
"open": [base_price] * n,
"high": [base_price + 2.0] * n,
"low": [base_price - 2.0] * n,
"close": [base_price] * n,
"vol": [1000000] * n,
"amount": [100000000] * n,
}
)
class StopLossStrategy(Strategy):
"""Strategy that buys with stop-loss."""
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 5 and self.position["size"] == 0:
self.buy(size=0, stop_loss=95.0)
class TakeProfitStrategy(Strategy):
"""Strategy that buys with take-profit."""
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 5 and self.position["size"] == 0:
self.buy(size=0, take_profit=110.0)
class StopLossAndTakeProfitStrategy(Strategy):
"""Strategy that buys with both stop-loss and take-profit."""
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 5 and self.position["size"] == 0:
self.buy(size=0, stop_loss=95.0, take_profit=110.0)
def test_stop_loss_triggers_sell():
"""Test stop-loss triggers auto SELL when price drops below stop."""
df = _make_flat_df(n=30)
# Bar 12 drops low below stop_loss=95.0
df.loc[12, "low"] = 93.0
df.loc[12, "high"] = 96.0
df.loc[12, "close"] = 94.0
df.loc[12, "open"] = 97.0
engine = BacktestEngine(StopLossStrategy, cash=100000)
result = engine.run(df)
trades = result.trades[~result.trades["rejected"]]
sell_trades = trades[trades["direction"] == "SELL"]
# Should have at least one SELL triggered by stop-loss
assert len(sell_trades) >= 1, "Expected stop-loss sell"
# Sell price should be at stop_loss price (95.0)
assert sell_trades.iloc[0]["price"] == 95.0
def test_take_profit_triggers_sell():
"""Test take-profit triggers auto SELL when price rises above target.
注意(审计 #4):止盈信号延迟到下一根开盘成交(消除前视偏差)。
当下一根开盘价低于触发价时(跳空回落),SELL 取更不利的实际开盘价。
"""
df = _make_flat_df(n=30)
# Bar 12 rises above take_profit=110.0
df.loc[12, "high"] = 112.0
df.loc[12, "low"] = 108.0
df.loc[12, "close"] = 111.0
df.loc[12, "open"] = 109.0
# Bar 13 开盘回落到 100(跳空),止盈延迟成交应取更不利的 100 而非触发价 110
df.loc[13, "open"] = 100.0
engine = BacktestEngine(TakeProfitStrategy, cash=100000)
result = engine.run(df)
trades = result.trades[~result.trades["rejected"]]
sell_trades = trades[trades["direction"] == "SELL"]
# Should have at least one SELL triggered by take-profit
assert len(sell_trades) >= 1, "Expected take-profit sell"
# 延迟到下一根(bar 13)开盘成交,跳空回落取更不利的实际价 100(非触发价 110)
assert sell_trades.iloc[0]["price"] == 100.0
def test_stop_loss_gap_down_fills_at_worse_price():
"""SL 信号延迟到下一根开盘成交;若跳空下跌,取更不利的开盘价(审计 #4)。
构造当根触及止损、但下一根开盘远低于止损价的跳空场景,
断言实际成交价取更不利的开盘价,回测净值低于"触发价成交"基线。
"""
df = _make_flat_df(n=30)
# Bar 12 触及 stop_loss=95low=93
df.loc[12, "low"] = 93.0
df.loc[12, "high"] = 96.0
df.loc[12, "close"] = 94.0
df.loc[12, "open"] = 97.0
# Bar 13 跳空低开到 90(远低于止损价 95),应取 90 而非 95
df.loc[13, "open"] = 90.0
df.loc[13, "low"] = 89.0
df.loc[13, "high"] = 91.0
df.loc[13, "close"] = 90.5
engine = BacktestEngine(StopLossStrategy, cash=100000)
result = engine.run(df)
trades = result.trades[~result.trades["rejected"]]
sell_trades = trades[trades["direction"] == "SELL"]
assert len(sell_trades) >= 1, "Expected stop-loss sell"
# 跳空下跌:SELL 取 min(next_open=90, trigger=95) = 90(更不利)
assert sell_trades.iloc[0]["price"] == 90.0
def test_stop_loss_not_triggered_when_price_stays_above():
"""Test no SL sell when price never drops to stop level."""
df = _make_flat_df(n=30, base_price=100.0)
# low is always 98.0 (> stop_loss=95.0), so SL never triggers
engine = BacktestEngine(StopLossStrategy, cash=100000)
result = engine.run(df)
trades = result.trades[~result.trades["rejected"]]
sell_trades = trades[trades["direction"] == "SELL"]
# No SELL should be triggered by SL (low=98 > stop_loss=95)
assert len(sell_trades) == 0, "SL should not trigger when price stays above"
def test_stop_loss_takes_priority_over_strategy_sell():
"""SL-triggered sell prevents duplicate strategy sell."""
df = _make_flat_df(n=30)
class SLThenManualSell(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 5 and self.position["size"] == 0:
self.buy(size=0, stop_loss=95.0)
# Manual sell at bar 15 — but SL should have fired first
if self._bar_index == 15 and self.position["size"] > 0:
self.sell(size=0)
# Bar 10 triggers stop-loss
df.loc[10, "low"] = 93.0
df.loc[10, "close"] = 94.0
engine = BacktestEngine(SLThenManualSell, cash=100000)
result = engine.run(df)
trades = result.trades[~result.trades["rejected"]]
sell_trades = trades[trades["direction"] == "SELL"]
# Should have exactly 1 SELL (from SL, not the manual one at bar 15)
assert len(sell_trades) == 1, f"Expected 1 SL sell, got {len(sell_trades)}"
assert sell_trades.iloc[0]["price"] == 95.0
# ── Chanlun Auto-Bridge ──────────────────────────────────────────────────────
class ChanlunAwareStrategy(Strategy):
"""Strategy that buys when chanlun analysis has at least one bi."""
def init(self) -> None:
pass
def next(self) -> None:
if self.chanlun is not None and self._bar_index == 15 and self.position["size"] == 0:
# Strategy uses chanlun result to make trading decisions
bis = self.chanlun.bis if hasattr(self.chanlun, "bis") else []
if len(bis) > 0:
self.buy(size=0)
def test_chanlun_auto_bridge():
"""Test chanlun_level auto-computes and injects analysis into strategy."""
df = _make_df(n=100)
engine = BacktestEngine(ChanlunAwareStrategy, cash=100000, chanlun_level="DAILY")
result = engine.run(df)
# Strategy should have received chanlun result (100 bars → at least some bis)
trades = result.trades[~result.trades["rejected"]]
buy_trades = trades[trades["direction"] == "BUY"]
# With 100 bars of random data, ChanlunAnalyser should produce bis,
# so the strategy should trigger a BUY at bar 15
assert len(buy_trades) >= 1, "Expected chanlun-aware BUY"
def test_chanlun_manual_result_overrides_auto():
"""Test explicit chanlun_result takes priority over chanlun_level."""
df = _make_df(n=50)
class CheckerStrategy(Strategy):
received: object = None
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 10:
CheckerStrategy.received = self.chanlun
self.buy(size=10)
# Pass explicit result — should NOT auto-compute
manual_result = {"manual": True}
engine = BacktestEngine(CheckerStrategy, cash=100000, chanlun_level="DAILY")
engine.run(df, chanlun_result=manual_result)
# Strategy should have received the manual result, not auto-computed one
assert CheckerStrategy.received == manual_result
# ── SlippageModel + ExecutionModel Integration ───────────────────────────────
class TestEngineSlippageModel:
"""BacktestEngine with SlippageModel integration."""
def test_engine_with_slippage_model(self) -> None:
"""Engine uses SlippageModel."""
class SimpleBuy(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=100)
df = _make_df(20)
engine = BacktestEngine(
SimpleBuy,
cash=100000,
slippage_model=FixedSlippage(per_share=0.05),
)
result = engine.run(df)
buy_trades = result.trades[result.trades["direction"] == "BUY"]
if len(buy_trades) > 0:
assert buy_trades.iloc[0]["slippage"] > 0
class TestEngineExecutionModel:
"""BacktestEngine with ExecutionModel integration."""
def test_engine_with_twap(self) -> None:
"""Engine uses TWAP execution."""
class SimpleBuy(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=300)
df = _make_df(20)
engine = BacktestEngine(
SimpleBuy,
cash=100000,
execution_model=TWAPExecution(n_bars=3),
)
result = engine.run(df)
buy_trades = result.trades[result.trades["direction"] == "BUY"]
assert len(buy_trades) >= 1
def test_execution_model_affects_equity(self) -> None:
"""ExecutionModel 路径的交易必须真正进入 PortfolioTracker。
回归 datetime 类型分歧 bugExecutionModel 曾把 Trade.datetime 转成
int(YYYYMMDD),而 PortfolioTracker 用 df 原始 Timestamp 作为 trade_map
的 key,导致 ExecutionModel 路径(TWAP/VWAP/Limit)的交易全部被静默
跳过、权益曲线恒定、收益归零。
"""
class BuyAndHold(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=0) # 全仓
df = _make_df(30) # Timestamp datetime(真实行情场景)
engine = BacktestEngine(
BuyAndHold,
cash=100000,
execution_model=TWAPExecution(n_bars=3),
)
result = engine.run(df)
# 交易必须影响持仓与权益曲线(不能全程空仓 / 恒定)
assert result.positions["size"].max() > 0
assert result.equity_curve["total"].nunique() > 1
def test_execution_model_with_vol_column(self) -> None:
"""真实行情数据使用 vol 列名,VWAP/方根滑点应能读到成交量。
回归 volume 列名分歧 bug:回测代码曾只认 "volume",真实数据列为
"vol",导致滑点模型 volume 恒为 0、退化到百分比模式,VWAP 退化为等权。
"""
class BuyOnce(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=0)
df = _make_df(30).rename(columns={"volume": "vol"})
engine = BacktestEngine(
BuyOnce,
cash=100000,
execution_model=VWAPExecution(n_bars=3),
slippage_model=SquareRootSlippage(),
)
result = engine.run(df)
buy = result.trades[result.trades["direction"] == "BUY"]
assert len(buy) > 0
# volume 读到非 0 → 方根冲击未退化 → 滑点 > 0
assert (buy["slippage"] > 0).all()
def test_engine_backward_compatible(self) -> None:
"""No new params: behavior unchanged."""
class SimpleBuy(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=100)
df = _make_df(20)
engine = BacktestEngine(SimpleBuy, cash=100000)
result = engine.run(df)
assert len(result.trades) >= 1
def test_engine_accepts_date_column(self) -> None:
"""引擎应直接接受真实行情日线的 date 列(而非 datetime)。
get_security_bars 日线返回 date 列,引擎在 run() 入口由 date 派生
datetime,下游无感兼容。回归此前用户必须手动重命名才能跑日线回测的问题。
"""
class SimpleBuy(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=0)
# 仅 date 列、无 datetime 列 —— 模拟 get_security_bars 日线输出
df = _make_df(30).rename(columns={"datetime": "date", "volume": "vol"})
engine = BacktestEngine(SimpleBuy, cash=100000)
result = engine.run(df)
assert result.positions["size"].max() > 0