Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backtrader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,5 @@
# Load contributed indicators and studies
import backtrader.indicators.contrib
import backtrader.studies.contrib

from backtrader.executionmodels.realistic import RealisticExecutionModel
1 change: 1 addition & 0 deletions backtrader/executionmodels/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from backtrader.executionmodels.realistic import RealisticExecutionModel
35 changes: 35 additions & 0 deletions backtrader/executionmodels/realistic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import absolute_import, division, print_function, unicode_literals


class RealisticExecutionModel:
"""
Adjusts the fill price of buy/sell orders to account for
bid-ask spread and market slippage — both applied adversely.

Parameters
----------
spread : float — half bid-ask spread as decimal (default 5 bps)
slippage : float — adverse price movement on fill (default 5 bps)
"""

def __init__(self, spread: float = 0.0005, slippage: float = 0.0005):
if spread < 0 or slippage < 0:
raise ValueError("spread and slippage must be non-negative")
self.spread = spread
self.slippage = slippage

def adjust_price(self, price: float, is_buy: bool = True) -> float:
direction = 1 if is_buy else -1
price *= 1 + direction * self.spread
price *= 1 + direction * self.slippage
return round(price, 4)

def total_cost_bps(self) -> float:
return (self.spread + self.slippage) * 10_000

def __repr__(self) -> str:
return (
f"RealisticExecutionModel("
f"spread={self.spread * 10_000:.1f}bps, "
f"slippage={self.slippage * 10_000:.1f}bps)"
)
18 changes: 18 additions & 0 deletions samples/execution-model-realistic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Realistic Execution & Cost Engine for Backtrader

Most backtests assume perfect execution. Every trade has hidden costs:

- Bid-ask spread: 5 bps
- Market slippage: 5 bps
- Commission: 10 bps

## Real results (SPY, SMA-20, 2020-2024)

No costs: +8.46% return, Sharpe -0.50, Max DD 8.10%
With costs: +1.22% return, Sharpe -8.84, Max DD 1.23%
Cost drag: -7.24% return

## How to run

pip install -r requirements.txt
python run.py
41 changes: 41 additions & 0 deletions samples/execution-model-realistic/analyzers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
def print_results(label: str, strat) -> dict:
returns = strat.analyzers.returns.get_analysis()
sharpe = strat.analyzers.sharpe.get_analysis()
drawdown = strat.analyzers.drawdown.get_analysis()

total_return = returns.get("rtot", 0) * 100
sharpe_ratio = sharpe.get("sharperatio") or 0.0
max_dd = drawdown.max.drawdown

print(f"\n{'='*40}")
print(f" {label}")
print(f"{'='*40}")
print(f" Total Return : {total_return:+.2f}%")
print(f" Sharpe Ratio : {sharpe_ratio:.4f}")
print(f" Max Drawdown : {max_dd:.2f}%")
print(f"{'='*40}")

return {
"label": label,
"return_pct": total_return,
"sharpe": sharpe_ratio,
"max_dd_pct": max_dd,
}


def print_comparison(no_cost: dict, with_cost: dict):
return_drag = with_cost["return_pct"] - no_cost["return_pct"]
sharpe_drag = with_cost["sharpe"] - no_cost["sharpe"]
dd_change = with_cost["max_dd_pct"] - no_cost["max_dd_pct"]

print(f"\n{'='*40}")
print(" COST DRAG SUMMARY")
print(f"{'='*40}")
print(f" Return drag : {return_drag:+.2f}%")
print(f" Sharpe drag : {sharpe_drag:+.4f}")
print(f" Drawdown Δ : {dd_change:+.2f}%")
print(f"{'='*40}")
print("\n Interpretation:")
print(f" Every percentage point of drag is real money")
print(f" left on the table due to spread, slippage,")
print(f" and commission — costs most backtests ignore.\n")
12 changes: 12 additions & 0 deletions samples/execution-model-realistic/cost_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import backtrader as bt


class CommissionModel(bt.CommInfoBase):
"""
Charges 10 bps commission on every trade.
Applied to both buys and sells.
"""
params = (("commission", 0.001),)

def _getcommission(self, size, price, pseudoexec):
return abs(size) * price * self.p.commission
4 changes: 4 additions & 0 deletions samples/execution-model-realistic/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
backtrader
yfinance
pandas
matplotlib
45 changes: 45 additions & 0 deletions samples/execution-model-realistic/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import backtrader as bt
import yfinance as yf
import pandas as pd

from strategy import SMAStrategy
from cost_model import CommissionModel
from analyzers import print_results, print_comparison


def get_data(ticker="SPY", start="2020-01-01", end="2024-01-01"):
df = yf.download(ticker, start=start, end=end, auto_adjust=True, progress=False)

if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)

df.columns = [c.lower() for c in df.columns]
df.dropna(inplace=True)

return bt.feeds.PandasData(dataname=df)


def run_backtest(use_costs=False, starting_cash=100_000):
cerebro = bt.Cerebro()
cerebro.adddata(get_data())
cerebro.addstrategy(SMAStrategy, use_execution_model=use_costs)
cerebro.broker.set_cash(starting_cash)

if use_costs:
cerebro.broker.addcommissioninfo(CommissionModel())

cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe",
riskfreerate=0.05, annualize=True)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")

results = cerebro.run()
label = "WITH REALISTIC COSTS" if use_costs else "NO COSTS (ideal world)"
return print_results(label, results[0])


if __name__ == "__main__":
print("\nRunning backtest — SPY | SMA-20 | 2020-2024\n")
no_cost_results = run_backtest(use_costs=False)
cost_results = run_backtest(use_costs=True)
print_comparison(no_cost_results, cost_results)
60 changes: 60 additions & 0 deletions samples/execution-model-realistic/strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import backtrader as bt
from backtrader.executionmodels.realistic import RealisticExecutionModel


class SMAStrategy(bt.Strategy):
"""
Simple 20-day SMA crossover strategy.

Buy when price crosses above the SMA.
Sell when price crosses below the SMA.

When use_execution_model=True, fill price is adjusted
by RealisticExecutionModel before the order is placed.
"""

params = (
("sma_period", 20),
("size", 100),
("use_execution_model", False),
)

def __init__(self):
self.sma = bt.indicators.SimpleMovingAverage(
self.data.close, period=self.p.sma_period
)
self.exec_model = RealisticExecutionModel() if self.p.use_execution_model else None
self.order = None

def notify_order(self, order):
if order.status in [order.Completed, order.Canceled, order.Margin]:
self.order = None

def next(self):
if self.order:
return

price = self.data.close[0]

if not self.position:
if price > self.sma[0]:
fill_price = (
self.exec_model.adjust_price(price, is_buy=True)
if self.exec_model else None
)
self.order = self.buy(
size=self.p.size,
price=fill_price,
exectype=bt.Order.Limit if fill_price else bt.Order.Market
)
else:
if price < self.sma[0]:
fill_price = (
self.exec_model.adjust_price(price, is_buy=False)
if self.exec_model else None
)
self.order = self.sell(
size=self.p.size,
price=fill_price,
exectype=bt.Order.Limit if fill_price else bt.Order.Market
)