diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:24:32 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 18:24:32 +0900 |
| commit | 7bfdf07dccb09a613f66f63d1513b80f167a3881 (patch) | |
| tree | 1b184c9353178a4ad1c34db3d83d0ab6a0d5212b | |
| parent | 9e82c51dfde3941189db1b2d62dcc239442b9dc6 (diff) | |
feat(backtester): add slippage, fees, stop-loss/take-profit, and short selling
| -rw-r--r-- | services/backtester/src/backtester/config.py | 4 | ||||
| -rw-r--r-- | services/backtester/src/backtester/engine.py | 27 | ||||
| -rw-r--r-- | services/backtester/src/backtester/simulator.py | 208 | ||||
| -rw-r--r-- | services/backtester/tests/test_engine.py | 8 | ||||
| -rw-r--r-- | services/backtester/tests/test_simulator.py | 157 |
5 files changed, 372 insertions, 32 deletions
diff --git a/services/backtester/src/backtester/config.py b/services/backtester/src/backtester/config.py index 0d759f8..f7897da 100644 --- a/services/backtester/src/backtester/config.py +++ b/services/backtester/src/backtester/config.py @@ -9,5 +9,9 @@ class BacktestConfig(Settings): timeframe: str = "1h" strategy_name: str = "rsi_strategy" candle_limit: int = 500 + slippage_pct: float = 0.001 # 0.1% default + maker_fee_pct: float = 0.0005 # 0.05% + taker_fee_pct: float = 0.001 # 0.1% + allow_short: bool = False model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} diff --git a/services/backtester/src/backtester/engine.py b/services/backtester/src/backtester/engine.py index 0441011..8854b17 100644 --- a/services/backtester/src/backtester/engine.py +++ b/services/backtester/src/backtester/engine.py @@ -61,15 +61,33 @@ class BacktestResult: class BacktestEngine: """Runs a strategy against historical candles using a simulated order executor.""" - def __init__(self, strategy: StrategyProtocol, initial_balance: Decimal) -> None: + def __init__( + self, + strategy: StrategyProtocol, + initial_balance: Decimal, + slippage_pct: float = 0.001, + taker_fee_pct: float = 0.001, + allow_short: bool = False, + ) -> None: self._strategy = strategy self._initial_balance = initial_balance + self._slippage_pct = slippage_pct + self._taker_fee_pct = taker_fee_pct + self._allow_short = allow_short def run(self, candles: list[Candle]) -> BacktestResult: """Run the backtest over a list of candles and return a result.""" - simulator = OrderSimulator(self._initial_balance) + simulator = OrderSimulator( + self._initial_balance, + slippage_pct=self._slippage_pct, + taker_fee_pct=self._taker_fee_pct, + allow_short=self._allow_short, + ) for candle in candles: + # Check stops first + simulator.check_stops(candle.high, candle.low, candle.open_time) + signal = self._strategy.on_candle(candle) if signal is not None: simulator.execute(signal, timestamp=candle.open_time) @@ -81,6 +99,10 @@ class BacktestEngine: for symbol, qty in simulator.positions.items(): if qty > Decimal("0"): final_balance += qty * last_price + elif qty < Decimal("0"): + # Short position: profit = entry_price * |qty| - last_price * |qty| + # The balance already has the margin; value the liability + final_balance += qty * last_price # qty is negative, so this subtracts profit = final_balance - self._initial_balance if self._initial_balance != Decimal("0"): @@ -96,6 +118,7 @@ class BacktestEngine: side=t.side.value, price=t.price, quantity=t.quantity, + fee=t.fee, ) for t in simulator.trades ] diff --git a/services/backtester/src/backtester/simulator.py b/services/backtester/src/backtester/simulator.py index 33eeb76..1e37740 100644 --- a/services/backtester/src/backtester/simulator.py +++ b/services/backtester/src/backtester/simulator.py @@ -12,48 +12,206 @@ from shared.models import OrderSide, Signal class SimulatedTrade: symbol: str side: OrderSide - price: Decimal + price: Decimal # actual execution price (after slippage) quantity: Decimal balance_after: Decimal + fee: Decimal = Decimal("0") timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) +@dataclass +class OpenPosition: + """Tracks an open position with optional stop-loss and take-profit.""" + + symbol: str + side: OrderSide # BUY = long, SELL = short + entry_price: Decimal + quantity: Decimal + stop_loss: Optional[Decimal] = None + take_profit: Optional[Decimal] = None + + class OrderSimulator: - """Simulates order execution against a paper balance.""" + """Simulates order execution with slippage, fees, stops, and short selling.""" - def __init__(self, initial_balance: Decimal) -> None: + def __init__( + self, + initial_balance: Decimal, + slippage_pct: float = 0.0, + taker_fee_pct: float = 0.0, + allow_short: bool = False, + ) -> None: self.balance: Decimal = initial_balance - self.positions: dict[str, Decimal] = {} + self.positions: dict[str, Decimal] = {} # symbol -> quantity (negative = short) self.trades: list[SimulatedTrade] = [] + self.open_positions: list[OpenPosition] = [] + self._slippage_pct = Decimal(str(slippage_pct)) + self._fee_pct = Decimal(str(taker_fee_pct)) + self._allow_short = allow_short + + def _apply_slippage(self, price: Decimal, side: OrderSide) -> Decimal: + """Apply slippage: buy higher, sell lower.""" + if side == OrderSide.BUY: + return price * (1 + self._slippage_pct) + return price * (1 - self._slippage_pct) + + def _calculate_fee(self, price: Decimal, quantity: Decimal) -> Decimal: + return price * quantity * self._fee_pct + + def check_stops( + self, candle_high: Decimal, candle_low: Decimal, timestamp: datetime + ) -> list[SimulatedTrade]: + """Check all open positions for stop-loss/take-profit triggers. + + Call this for each candle BEFORE processing strategy signals. + Returns list of auto-closed trades. + """ + closed: list[SimulatedTrade] = [] + remaining: list[OpenPosition] = [] + for pos in self.open_positions: + triggered = False + exit_price: Optional[Decimal] = None + + if pos.side == OrderSide.BUY: # Long position + if pos.stop_loss is not None and candle_low <= pos.stop_loss: + exit_price = pos.stop_loss + triggered = True + elif pos.take_profit is not None and candle_high >= pos.take_profit: + exit_price = pos.take_profit + triggered = True + else: # Short position + if pos.stop_loss is not None and candle_high >= pos.stop_loss: + exit_price = pos.stop_loss + triggered = True + elif pos.take_profit is not None and candle_low <= pos.take_profit: + exit_price = pos.take_profit + triggered = True + + if triggered and exit_price is not None: + # Close the position + close_side = ( + OrderSide.SELL if pos.side == OrderSide.BUY else OrderSide.BUY + ) + fee = self._calculate_fee(exit_price, pos.quantity) + + if pos.side == OrderSide.BUY: + proceeds = exit_price * pos.quantity - fee + self.balance += proceeds + self.positions[pos.symbol] = ( + self.positions.get(pos.symbol, Decimal("0")) - pos.quantity + ) + else: # Close short + cost = exit_price * pos.quantity + fee + self.balance -= cost + self.positions[pos.symbol] = ( + self.positions.get(pos.symbol, Decimal("0")) + pos.quantity + ) + + trade = SimulatedTrade( + symbol=pos.symbol, + side=close_side, + price=exit_price, + quantity=pos.quantity, + fee=fee, + balance_after=self.balance, + timestamp=timestamp, + ) + self.trades.append(trade) + closed.append(trade) + else: + remaining.append(pos) + + self.open_positions = remaining + return closed + + def execute( + self, + signal: Signal, + timestamp: Optional[datetime] = None, + stop_loss: Optional[Decimal] = None, + take_profit: Optional[Decimal] = None, + ) -> bool: + """Execute a signal with slippage and fees. Returns True if accepted.""" + ts = timestamp or datetime.now(timezone.utc) + exec_price = self._apply_slippage(signal.price, signal.side) + fee = self._calculate_fee(exec_price, signal.quantity) - def execute(self, signal: Signal, timestamp: Optional[datetime] = None) -> bool: - """Execute a signal. Returns True if the trade was accepted, False otherwise.""" if signal.side == OrderSide.BUY: - cost = signal.price * signal.quantity + cost = exec_price * signal.quantity + fee if cost > self.balance: return False self.balance -= cost self.positions[signal.symbol] = ( self.positions.get(signal.symbol, Decimal("0")) + signal.quantity ) - trade_quantity = signal.quantity - else: # SELL - current_position = self.positions.get(signal.symbol, Decimal("0")) - if current_position <= Decimal("0"): + # Track open position for stop management + self.open_positions.append( + OpenPosition( + symbol=signal.symbol, + side=OrderSide.BUY, + entry_price=exec_price, + quantity=signal.quantity, + stop_loss=stop_loss, + take_profit=take_profit, + ) + ) + elif signal.side == OrderSide.SELL: + current = self.positions.get(signal.symbol, Decimal("0")) + if current > Decimal("0"): + # Close long position + trade_qty = min(signal.quantity, current) + proceeds = exec_price * trade_qty - fee + self.balance += proceeds + self.positions[signal.symbol] = current - trade_qty + # Remove from open_positions + self._close_open_position(signal.symbol, OrderSide.BUY, trade_qty) + elif self._allow_short and current <= Decimal("0"): + # Open short position + margin = exec_price * signal.quantity # simplified margin + if margin + fee > self.balance: + return False + self.balance -= fee # Fee on entry + self.positions[signal.symbol] = current - signal.quantity + self.open_positions.append( + OpenPosition( + symbol=signal.symbol, + side=OrderSide.SELL, + entry_price=exec_price, + quantity=signal.quantity, + stop_loss=stop_loss, + take_profit=take_profit, + ) + ) + else: return False - trade_quantity = min(signal.quantity, current_position) - proceeds = signal.price * trade_quantity - self.balance += proceeds - self.positions[signal.symbol] = current_position - trade_quantity - - trade_kwargs: dict = dict( - symbol=signal.symbol, - side=signal.side, - price=signal.price, - quantity=trade_quantity, - balance_after=self.balance, + + self.trades.append( + SimulatedTrade( + symbol=signal.symbol, + side=signal.side, + price=exec_price, + quantity=signal.quantity, + fee=fee, + balance_after=self.balance, + timestamp=ts, + ) ) - if timestamp is not None: - trade_kwargs["timestamp"] = timestamp - self.trades.append(SimulatedTrade(**trade_kwargs)) return True + + def _close_open_position( + self, symbol: str, side: OrderSide, quantity: Decimal + ) -> None: + """Remove closed quantity from open positions (FIFO).""" + remaining_qty = quantity + new_positions: list[OpenPosition] = [] + for pos in self.open_positions: + if pos.symbol == symbol and pos.side == side and remaining_qty > 0: + if pos.quantity <= remaining_qty: + remaining_qty -= pos.quantity + else: + pos.quantity -= remaining_qty + remaining_qty = Decimal("0") + new_positions.append(pos) + else: + new_positions.append(pos) + self.open_positions = new_positions diff --git a/services/backtester/tests/test_engine.py b/services/backtester/tests/test_engine.py index 6962477..003e951 100644 --- a/services/backtester/tests/test_engine.py +++ b/services/backtester/tests/test_engine.py @@ -44,7 +44,9 @@ def test_backtest_engine_runs_strategy_over_candles(): strategy.on_candle.return_value = None candles = make_candles([50000.0, 51000.0, 52000.0]) - engine = BacktestEngine(strategy, Decimal("10000")) + engine = BacktestEngine( + strategy, Decimal("10000"), slippage_pct=0.0, taker_fee_pct=0.0 + ) result = engine.run(candles) assert strategy.on_candle.call_count == 3 @@ -62,7 +64,9 @@ def test_backtest_engine_executes_signals(): strategy.on_candle.side_effect = [buy_signal, None, sell_signal] candles = make_candles([50000.0, 52000.0, 55000.0]) - engine = BacktestEngine(strategy, Decimal("10000")) + engine = BacktestEngine( + strategy, Decimal("10000"), slippage_pct=0.0, taker_fee_pct=0.0 + ) result = engine.run(candles) assert result.total_trades == 2 diff --git a/services/backtester/tests/test_simulator.py b/services/backtester/tests/test_simulator.py index e8c80ec..a407c21 100644 --- a/services/backtester/tests/test_simulator.py +++ b/services/backtester/tests/test_simulator.py @@ -1,9 +1,9 @@ """Tests for the OrderSimulator.""" +from datetime import datetime, timezone from decimal import Decimal - -from shared.models import Signal, OrderSide +from shared.models import OrderSide, Signal from backtester.simulator import OrderSimulator @@ -24,6 +24,11 @@ def make_signal( ) +# --------------------------------------------------------------------------- +# Existing tests (backward compat: defaults slippage=0, fee=0) +# --------------------------------------------------------------------------- + + def test_simulator_initial_balance(): sim = OrderSimulator(Decimal("10000")) assert sim.balance == Decimal("10000") @@ -48,7 +53,7 @@ def test_simulator_sell_increases_balance(): result = sim.execute(sell_signal) assert result is True assert sim.balance > balance_after_buy - # Profit: sold at 55000, bought at 50000 → gain 500 + # Profit: sold at 55000, bought at 50000 -> gain 500 assert sim.balance == Decimal("10000") - Decimal("5000") + Decimal("5500") @@ -71,3 +76,149 @@ def test_simulator_trade_history(): assert trade.side == OrderSide.BUY assert trade.price == Decimal("50000") assert trade.quantity == Decimal("0.1") + + +# --------------------------------------------------------------------------- +# Slippage tests +# --------------------------------------------------------------------------- + + +def test_slippage_on_buy(): + """Buy price should increase by slippage_pct.""" + sim = OrderSimulator(Decimal("100000"), slippage_pct=0.01) # 1% + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(signal) + trade = sim.trades[0] + expected_price = Decimal("50000") * Decimal("1.01") # 50500 + assert trade.price == expected_price + + +def test_slippage_on_sell(): + """Sell price should decrease by slippage_pct.""" + sim = OrderSimulator(Decimal("100000"), slippage_pct=0.01) + # Buy first (no slippage check here, just need a position) + buy = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(buy) + # Sell + sell = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + sim.execute(sell) + trade = sim.trades[1] + expected_price = Decimal("50000") * Decimal("0.99") # 49500 + assert trade.price == expected_price + + +# --------------------------------------------------------------------------- +# Fee tests +# --------------------------------------------------------------------------- + + +def test_fee_deducted_from_balance(): + """Fees should reduce balance beyond the raw cost.""" + fee_pct = 0.001 # 0.1% + sim = OrderSimulator(Decimal("100000"), taker_fee_pct=fee_pct) + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(signal) + # cost = 50000 * 0.1 = 5000, fee = 5000 * 0.001 = 5 + expected_balance = Decimal("100000") - Decimal("5000") - Decimal("5") + assert sim.balance == expected_balance + assert sim.trades[0].fee == Decimal("5") + + +# --------------------------------------------------------------------------- +# Stop-loss / take-profit tests +# --------------------------------------------------------------------------- + + +def test_stop_loss_triggers(): + """Long position auto-closed when candle_low <= stop_loss.""" + sim = OrderSimulator(Decimal("100000")) + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(signal, stop_loss=Decimal("48000")) + + ts = datetime(2025, 1, 1, tzinfo=timezone.utc) + closed = sim.check_stops( + candle_high=Decimal("50500"), + candle_low=Decimal("47500"), # below stop_loss + timestamp=ts, + ) + assert len(closed) == 1 + assert closed[0].side == OrderSide.SELL + assert closed[0].price == Decimal("48000") + assert len(sim.open_positions) == 0 + + +def test_take_profit_triggers(): + """Long position auto-closed when candle_high >= take_profit.""" + sim = OrderSimulator(Decimal("100000")) + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(signal, take_profit=Decimal("55000")) + + ts = datetime(2025, 1, 1, tzinfo=timezone.utc) + closed = sim.check_stops( + candle_high=Decimal("56000"), # above take_profit + candle_low=Decimal("50000"), + timestamp=ts, + ) + assert len(closed) == 1 + assert closed[0].side == OrderSide.SELL + assert closed[0].price == Decimal("55000") + assert len(sim.open_positions) == 0 + + +def test_stop_not_triggered_within_range(): + """No auto-close when price stays within stop/tp range.""" + sim = OrderSimulator(Decimal("100000")) + signal = make_signal("BTCUSDT", OrderSide.BUY, "50000", "0.1") + sim.execute(signal, stop_loss=Decimal("48000"), take_profit=Decimal("55000")) + + ts = datetime(2025, 1, 1, tzinfo=timezone.utc) + closed = sim.check_stops( + candle_high=Decimal("52000"), + candle_low=Decimal("49000"), + timestamp=ts, + ) + assert len(closed) == 0 + assert len(sim.open_positions) == 1 + + +# --------------------------------------------------------------------------- +# Short selling tests +# --------------------------------------------------------------------------- + + +def test_short_sell_allowed(): + """Can open short position with allow_short=True.""" + sim = OrderSimulator(Decimal("100000"), allow_short=True) + signal = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + result = sim.execute(signal) + assert result is True + assert sim.positions["BTCUSDT"] == Decimal("-0.1") + assert len(sim.open_positions) == 1 + assert sim.open_positions[0].side == OrderSide.SELL + + +def test_short_sell_rejected(): + """Short rejected when allow_short=False (default).""" + sim = OrderSimulator(Decimal("100000"), allow_short=False) + signal = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + result = sim.execute(signal) + assert result is False + assert sim.positions.get("BTCUSDT", Decimal("0")) == Decimal("0") + + +def test_short_stop_loss(): + """Short position stop-loss triggers on candle high >= stop_loss.""" + sim = OrderSimulator(Decimal("100000"), allow_short=True) + signal = make_signal("BTCUSDT", OrderSide.SELL, "50000", "0.1") + sim.execute(signal, stop_loss=Decimal("52000")) + + ts = datetime(2025, 1, 1, tzinfo=timezone.utc) + closed = sim.check_stops( + candle_high=Decimal("53000"), # above stop_loss + candle_low=Decimal("49000"), + timestamp=ts, + ) + assert len(closed) == 1 + assert closed[0].side == OrderSide.BUY # closing a short = buy + assert closed[0].price == Decimal("52000") + assert len(sim.open_positions) == 0 |
