summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:24:32 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 18:24:32 +0900
commit7bfdf07dccb09a613f66f63d1513b80f167a3881 (patch)
tree1b184c9353178a4ad1c34db3d83d0ab6a0d5212b
parent9e82c51dfde3941189db1b2d62dcc239442b9dc6 (diff)
feat(backtester): add slippage, fees, stop-loss/take-profit, and short selling
-rw-r--r--services/backtester/src/backtester/config.py4
-rw-r--r--services/backtester/src/backtester/engine.py27
-rw-r--r--services/backtester/src/backtester/simulator.py208
-rw-r--r--services/backtester/tests/test_engine.py8
-rw-r--r--services/backtester/tests/test_simulator.py157
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