feat: implement stop-loss/take-profit execution in backtest engine

- Track SL/TP conditions from BUY signals in _generate_signals loop
- Check active conditions against each bar's high/low price range
- Auto-generate SELL signal at trigger price when condition is met
- Modify OrderSimulator to respect signal.price for direct execution
  (previously signal.price was stored but never used in execution)
- SL/TP activates on bar AFTER BUY signal (consistent with next_open)
- Stop-loss checked before take-profit (conservative for holder)
- Add 4 tests: SL trigger, TP trigger, no-trigger, priority over manual sell
This commit is contained in:
Justin Gu
2026-06-11 01:53:11 +08:00
parent 06b2617ebc
commit 815b3ddf7c
3 changed files with 268 additions and 11 deletions
+113 -3
View File
@@ -5,6 +5,7 @@ Coordinates Strategy → OrderSimulator → PortfolioTracker → PerformanceAnal
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import pandas as pd
@@ -16,6 +17,19 @@ from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.types import BacktestResult, Signal, Trade
@dataclass
class _StopCondition:
"""Active stop-loss / take-profit condition tied to an open position.
Attributes:
stop_loss: Price below which a SELL is triggered (None = disabled)
take_profit: Price above which a SELL is triggered (None = disabled)
"""
stop_loss: float | None
take_profit: float | None
class BacktestEngine:
"""Orchestrate backtest execution pipeline.
@@ -171,18 +185,114 @@ class BacktestEngine:
# Generate signals bar by bar
all_signals: list[Signal] = []
active_stops: list[_StopCondition] = []
high_arr = df["high"].to_numpy()
low_arr = df["low"].to_numpy()
for i in range(len(df)):
strat._set_bar_index(i)
strat._call_next()
bar_signals = strat._clear_signals()
# Update strategy position state so next bar sees current holdings
self._update_strategy_position(strat, bar_signals, close_arr[i])
# Check existing SL/TP conditions against current bar's price range
sl_tp_signals = self._check_stop_conditions(
active_stops, high_arr[i], low_arr[i], close_arr[i], i, df
)
all_signals.extend(bar_signals)
# Combine strategy signals with SL/TP signals
combined = bar_signals + sl_tp_signals
# Register new SL/TP from BUY signals (after checking, so they
# activate on the NEXT bar — consistent with next_open execution)
for sig in bar_signals:
if sig.direction == "BUY" and (
sig.stop_loss is not None or sig.take_profit is not None
):
active_stops.append(
_StopCondition(stop_loss=sig.stop_loss, take_profit=sig.take_profit)
)
# Clear conditions when a SELL occurs (strategy or SL/TP triggered)
for sig in combined:
if sig.direction == "SELL" and active_stops:
active_stops.clear()
# Update strategy position state so next bar sees current holdings
self._update_strategy_position(strat, combined, close_arr[i])
all_signals.extend(combined)
return all_signals
def _check_stop_conditions(
self,
active_stops: list[_StopCondition],
bar_high: float,
bar_low: float,
bar_close: float,
bar_index: int,
df: pd.DataFrame,
) -> list[Signal]:
"""Check active SL/TP conditions against current bar's price range.
If triggered, generates a SELL signal at the trigger price and removes
the condition. Stop-loss is checked first (conservative: assume the
worst case for the holder).
Args:
active_stops: List of active stop conditions
bar_high: Current bar's high price
bar_low: Current bar's low price
bar_close: Current bar's close price
bar_index: Current bar index
df: Price DataFrame (for datetime extraction)
Returns:
List of SELL signals triggered by SL/TP conditions
"""
if not active_stops:
return []
signals: list[Signal] = []
remaining: list[_StopCondition] = []
for cond in active_stops:
triggered = False
trigger_price = 0.0
# Check stop-loss first (worst case for holder)
if cond.stop_loss is not None and bar_low <= cond.stop_loss:
triggered = True
trigger_price = cond.stop_loss
# Then check take-profit
elif cond.take_profit is not None and bar_high >= cond.take_profit:
triggered = True
trigger_price = cond.take_profit
if triggered:
# Get datetime for this bar
dt_val = df["datetime"].iloc[bar_index]
if hasattr(dt_val, "strftime"):
dt_int = int(dt_val.strftime("%Y%m%d"))
else:
dt_int = int(dt_val)
signals.append(
Signal(
datetime=dt_int,
direction="SELL",
size=0, # full position close
price=trigger_price,
)
)
else:
remaining.append(cond)
active_stops.clear()
active_stops.extend(remaining)
return signals
def _update_strategy_position(
self, strat: Strategy, signals: list[Signal], est_price: float
) -> None:
+16 -8
View File
@@ -72,15 +72,23 @@ class OrderSimulator:
if bar_idx is None:
continue
# 确定成交的 K 线索引
exec_idx = self._resolve_exec_index(bar_idx)
if exec_idx is None or exec_idx >= len(self.df):
continue
# 当信号指定了价格(止损/止盈/限价单),
# 直接在信号所在 bar 以信号价格成交
if signal.price is not None:
exec_idx: int = bar_idx
price: float = signal.price
else:
# 确定成交的 K 线索引
exec_idx_raw = self._resolve_exec_index(bar_idx)
if exec_idx_raw is None or exec_idx_raw >= len(self.df):
continue
exec_idx = exec_idx_raw
# 获取成交价
price = self._get_price(exec_idx, signal.direction)
if price is None:
continue
# 获取成交价
price_raw = self._get_price(exec_idx, signal.direction)
if price_raw is None:
continue
price = price_raw
# 执行交易
if signal.direction == "BUY":
+139
View File
@@ -357,3 +357,142 @@ def test_position_aware_no_duplicate_buys():
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."""
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
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"
# Sell price should be at take_profit price (110.0)
assert sell_trades.iloc[0]["price"] == 110.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