
Backtrader
- 240 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
backtrader is a Claude Code skill guiding use of the Backtrader Python event-driven backtesting framework for trading strategies.
About
This skill is a guide to Backtrader, a Python event-driven backtesting framework that walks through market history bar-by-bar with a built-in broker. It covers Cerebro, strategies, complex order types like brackets and stop-limits, analyzers, and custom indicators, and explains when to use it versus vectorized frameworks. A developer uses it to build and run realistic trading-strategy backtests in Python.
- Guide to Backtrader, a Python event-driven backtesting framework
- Covers bar-by-bar execution, complex order types, analyzers and custom indicators
- Contrasts event-driven backtrader with vectorized frameworks like vectorbt
Backtrader by the numbers
- 240 all-time installs (skills.sh)
- Ranked #376 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
backtrader capabilities & compatibility
- Capabilities
- strategy backtesting · order simulation · quant analysis
- Use cases
- trading · data analysis
What backtrader says it does
Backtrader is a Python event-driven backtesting framework that processes data bar-by-bar, simulating realistic execution with a built-in broker, order management, and position tracking.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill backtraderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 240 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Build and run an event-driven trading-strategy backtest in Python with Backtrader, including bracket orders and analyzers.
Who is it for?
Developers backtesting complex trading strategies that need bracket orders and realistic execution.
Skip if: Fast parameter sweeps over simple signals, where a vectorized framework like vectorbt fits better.
When should I use this skill?
You are building an event-driven trading backtest with complex order types or multi-timeframe logic in Python.
What you get
A Backtrader strategy that backtests bar-by-bar with a broker, analyzers and custom indicators.
- A Backtrader Strategy and Cerebro backtest setup
By the numbers
- 5 core Backtrader objects (Cerebro, Strategy, Data, Analyzers, Sizers)
Files
Backtrader
Backtrader is a Python event-driven backtesting framework that processes data bar-by-bar, simulating realistic execution with a built-in broker, order management, and position tracking. Unlike vectorized frameworks (vectorbt, pandas), backtrader walks through history one bar at a time, firing callbacks that let you implement complex order logic that depends on previous fills, partial executions, and conditional brackets.
Event-Driven vs Vectorized
| Aspect | Backtrader (event-driven) | vectorbt (vectorized) |
|---|---|---|
| Execution model | Bar-by-bar callbacks | Whole-array operations |
| Speed | Slower (Python loop) | Fast (NumPy/Numba) |
| Order types | Market, limit, stop, stop-limit, bracket, OCO | Market only (native) |
| Realism | Built-in broker with commission, slippage, margin | Manual slippage modeling |
| Multi-timeframe | Native resampledata | Manual alignment |
| Best for | Complex strategies, bracket orders, portfolio | Fast parameter sweeps, simple signals |
Use backtrader when you need:
- Bracket orders (entry + stop loss + take profit as a unit)
- Stop-limit or trailing stop orders
- Order-dependent logic (scale in after first fill, cancel if not filled in N bars)
- Multi-timeframe strategies (daily signals, hourly execution)
- Realistic commission and slippage modeling
Use vectorbt when you need:
- Fast parameter optimization over thousands of combinations
- Simple long/short signals without complex order management
- Quick prototyping and statistical analysis of results
---
Core Concepts
Backtrader has five core objects that interact through an event loop:
1. Cerebro (the engine)
The central orchestrator. You add strategies, data feeds, analyzers, and sizers to Cerebro, then call run().
import backtrader as bt
cerebro = bt.Cerebro()
cerebro.addstrategy(MyStrategy, fast_period=10, slow_period=30)
cerebro.adddata(data_feed)
cerebro.broker.setcash(100_000)
cerebro.broker.setcommission(commission=0.003) # 0.3%
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe")
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
cerebro.run()2. Strategy (your logic)
A Strategy subclass contains all trading logic. Key methods:
__init__()— Define indicators. Runs once before backtesting starts.next()— Called on every bar. Place orders here.notify_order(order)— Called when order status changes (submitted, accepted, completed, canceled, margin, expired).notify_trade(trade)— Called when a trade opens or closes. Access P&L here.
class EMACrossover(bt.Strategy):
params = (
("fast_period", 10),
("slow_period", 30),
)
def __init__(self) -> None:
self.ema_fast = bt.ind.EMA(period=self.p.fast_period)
self.ema_slow = bt.ind.EMA(period=self.p.slow_period)
self.crossover = bt.ind.CrossOver(self.ema_fast, self.ema_slow)
def next(self) -> None:
if not self.position:
if self.crossover > 0:
self.buy()
elif self.crossover < 0:
self.close()3. Data Feed
Backtrader data feeds provide OHLCV lines. The most common approach is loading from a pandas DataFrame:
import pandas as pd
df = pd.DataFrame({
"open": [...], "high": [...], "low": [...],
"close": [...], "volume": [...],
}, index=pd.DatetimeIndex([...]))
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)For CSV files:
data = bt.feeds.GenericCSVData(
dataname="ohlcv.csv",
dtformat="%Y-%m-%d",
openinterest=-1, # no open interest column
)4. Broker
The built-in broker simulates order execution with configurable cash, commission, and slippage.
cerebro.broker.setcash(100_000)
cerebro.broker.setcommission(commission=0.003) # 0.3% per trade
# Cheat-on-open: execute at the open of the signal bar (avoids lookahead)
cerebro.broker.set_coo(True)5. Analyzers
Analyzers compute performance metrics after the backtest completes.
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe",
riskfreerate=0.0, annualize=True, timeframe=bt.TimeFrame.Days)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
results = cerebro.run()
strat = results[0]
sharpe = strat.analyzers.sharpe.get_analysis()
dd = strat.analyzers.drawdown.get_analysis()
trades = strat.analyzers.trades.get_analysis()---
Order Types
Backtrader supports complex order types critical for realistic crypto backtesting.
Market Order
self.buy() # market buy
self.sell() # market sell
self.close() # close current positionLimit Order
self.buy(exectype=bt.Order.Limit, price=95.0)
self.sell(exectype=bt.Order.Limit, price=105.0)Stop Order
Triggers a market order when price reaches the stop level:
self.sell(exectype=bt.Order.Stop, price=90.0) # stop lossStop-Limit Order
Triggers a limit order when price reaches the stop level:
self.buy(exectype=bt.Order.StopLimit, price=100.0, plimit=101.0)Bracket Order
Entry + stop loss + take profit as an atomic unit. If the stop fills, the take profit is canceled (and vice versa).
self.buy_bracket(
price=100.0, # entry limit
stopprice=95.0, # stop loss
limitprice=110.0, # take profit
exectype=bt.Order.Limit,
stopexec=bt.Order.Stop,
limitexec=bt.Order.Limit,
)See references/strategy_patterns.md for bracket order patterns with ATR-based stops.
---
Position Sizing (Sizers)
Sizers determine how many units to buy/sell per order.
# Fixed size
cerebro.addsizer(bt.sizers.FixedSize, stake=100)
# Percent of portfolio
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
# All available cash
cerebro.addsizer(bt.sizers.AllInSizer, percents=95)Custom sizer:
class RiskSizer(bt.Sizer):
params = (("risk_pct", 0.02),)
def _getsizing(self, comminfo, cash, data, isbuy):
risk_amount = cash * self.p.risk_pct
atr = self.strategy.atr[0]
if atr <= 0:
return 0
size = risk_amount / atr
return int(size)---
Crypto Considerations
24/7 Markets
Crypto trades around the clock. When using daily bars, there are no weekends to skip. Set the session times or use sessionstart/sessionend if analyzing specific windows.
High Fees
DEX swaps on Solana typically cost 0.25-0.30% per trade. Set commission accordingly:
cerebro.broker.setcommission(commission=0.003) # 0.3% round trip per sideFractional Sizing
Crypto allows fractional units. Backtrader supports this natively -- no special config needed.
Slippage
For realistic simulation, enable cheat-on-open and add slippage:
cerebro.broker.set_coo(True)
cerebro.broker.set_slippage_perc(0.001) # 0.1% slippageVolatile Data
Crypto OHLCV data often has extreme wicks. Use ATR-based stops rather than fixed percentage stops to adapt to volatility.
---
Multi-Timeframe
Backtrader can resample data to multiple timeframes within a single strategy:
data_1h = bt.feeds.PandasData(dataname=df_1h)
cerebro.adddata(data_1h)
# Resample 1h to daily
cerebro.resampledata(data_1h, timeframe=bt.TimeFrame.Days, compression=1)Access in strategy:
def __init__(self):
self.ema_1h = bt.ind.EMA(self.datas[0], period=20) # hourly
self.ema_daily = bt.ind.EMA(self.datas[1], period=20) # daily---
Custom Indicators
class SpreadIndicator(bt.Indicator):
lines = ("spread", "zscore",)
params = (("period", 20),)
def __init__(self):
mean = bt.ind.SMA(self.data, period=self.p.period)
std = bt.ind.StdDev(self.data, period=self.p.period)
self.lines.spread = self.data - mean
self.lines.zscore = self.lines.spread / std---
Plotting
Backtrader includes matplotlib-based plotting:
cerebro.plot(style="candlestick", volume=True)For headless environments, save to file:
import matplotlib
matplotlib.use("Agg")
figs = cerebro.plot(style="candlestick")
figs[0][0].savefig("backtest_result.png", dpi=150)---
Integration with Other Skills
- pandas-ta: Compute indicators externally, add as data feed columns. See
references/api_guide.mdfor adding extra lines. - trading-visualization: Export trade log from
notify_tradeand plot with the visualization skill. - position-sizing: Use the
position-sizingskill for Kelly or volatility-targeting sizers. - risk-management: Apply portfolio-level guardrails from the
risk-managementskill as strategy filters. - slippage-modeling: Use slippage estimates from the
slippage-modelingskill to configureset_slippage_perc.
---
Files
References
references/api_guide.md— Cerebro, Strategy, Broker, Analyzer, Data Feed API referencereferences/strategy_patterns.md— Reusable strategy patterns: crossover, mean reversion, multi-timeframe, custom indicators
Scripts
scripts/backtest_strategy.py— Complete EMA crossover backtest with analyzers and synthetic datascripts/bracket_orders.py— Bracket order demonstration with RSI entry and ATR-based stops
---
Quick Start
uv pip install backtrader pandas numpy matplotlib
python scripts/backtest_strategy.py --demo
python scripts/bracket_orders.py --demoBacktrader API Guide
Cerebro
The engine that ties everything together.
Constructor & Configuration
import backtrader as bt
cerebro = bt.Cerebro(
preload=True, # preload data feeds (default True)
runonce=True, # vectorized indicator calc (default True)
optreturn=True, # return lightweight results in optimize mode
stdstats=True, # add default observers (Broker, Trades, BuySell)
cheat_on_open=False, # execute orders at next bar open
)Key Methods
| Method | Description |
|---|---|
addstrategy(cls, **kwargs) | Add a strategy class with parameters |
adddata(data, name=None) | Add a data feed |
resampledata(data, timeframe, compression) | Resample data to a higher timeframe |
addanalyzer(cls, _name=str) | Attach an analyzer |
addsizer(cls, **kwargs) | Set a position sizer |
addwriter(cls, **kwargs) | Add output writer (CSV) |
optstrategy(cls, **kwargs) | Add strategy for parameter optimization (pass lists) |
run(**kwargs) | Execute the backtest; returns list of strategy instances |
plot(style, volume, numfigs) | Plot results with matplotlib |
Broker Configuration
cerebro.broker.setcash(100_000.0)
cerebro.broker.setcommission(commission=0.003) # 0.3%
cerebro.broker.set_coo(True) # cheat on open
cerebro.broker.set_slippage_perc(0.001) # 0.1% slippage
cerebro.broker.getvalue() # current portfolio value
cerebro.broker.getcash() # current cash
cerebro.broker.getposition(data) # position for data feedCommission models:
# Percentage-based (crypto default)
cerebro.broker.setcommission(commission=0.003)
# Fixed per-unit (equities)
cerebro.broker.setcommission(commission=0.005, commtype=bt.CommInfoBase.COMM_FIXED)
# Custom CommissionInfo
class CryptoCommission(bt.CommInfoBase):
params = (("commission", 0.003), ("mult", 1.0), ("margin", None),
("commtype", bt.CommInfoBase.COMM_PERC),
("stocklike", True),)
cerebro.broker.addcommissioninfo(CryptoCommission())---
Strategy
Class Structure
class MyStrategy(bt.Strategy):
params = (
("fast_period", 10),
("slow_period", 30),
("risk_pct", 0.02),
)
def __init__(self) -> None:
"""Define indicators. Runs once before backtesting."""
self.ema_fast = bt.ind.EMA(period=self.p.fast_period)
self.ema_slow = bt.ind.EMA(period=self.p.slow_period)
def next(self) -> None:
"""Called on every bar. Place orders here."""
pass
def notify_order(self, order: bt.Order) -> None:
"""Called when order status changes."""
if order.status in [order.Completed]:
if order.isbuy():
self.log(f"BUY @ {order.executed.price:.2f}")
else:
self.log(f"SELL @ {order.executed.price:.2f}")
def notify_trade(self, trade: bt.Trade) -> None:
"""Called when trade opens or closes."""
if trade.isclosed:
self.log(f"TRADE P&L: gross={trade.pnl:.2f} net={trade.pnlcomm:.2f}")
def log(self, txt: str) -> None:
dt = self.datas[0].datetime.date(0)
print(f"{dt} | {txt}")Accessing Data Lines
# Current bar values
self.data.open[0] # current open
self.data.high[0] # current high
self.data.low[0] # current low
self.data.close[0] # current close (alias: self.data[0])
self.data.volume[0] # current volume
# Previous bars
self.data.close[-1] # previous close
self.data.close[-2] # two bars ago
# Named data feeds
self.datas[0] # first data feed
self.datas[1] # second data feed (e.g., resampled)Order Methods
# Market orders
order = self.buy(size=100)
order = self.sell(size=100)
self.close() # close entire position
# Limit order
self.buy(exectype=bt.Order.Limit, price=95.0, size=50)
# Stop order
self.sell(exectype=bt.Order.Stop, price=90.0, size=50)
# Stop-limit order
self.buy(exectype=bt.Order.StopLimit, price=100.0, plimit=101.0, size=50)
# Bracket order (entry + stop loss + take profit)
orders = self.buy_bracket(
price=100.0, # entry price (limit)
stopprice=95.0, # stop loss trigger
limitprice=110.0, # take profit
size=50,
)
# returns (main_order, stop_order, limit_order)
# Cancel an order
self.cancel(order)
# Order valid for N bars
self.buy(exectype=bt.Order.Limit, price=95.0,
valid=self.data.datetime.date(0) + datetime.timedelta(days=3))Order Status Values
| Status | Meaning |
|---|---|
Order.Created | Order created but not yet submitted |
Order.Submitted | Sent to broker |
Order.Accepted | Accepted by broker |
Order.Partial | Partially filled |
Order.Completed | Fully filled |
Order.Canceled | Canceled (by user or broker) |
Order.Expired | Validity period expired |
Order.Margin | Insufficient margin/cash |
Order.Rejected | Rejected by broker |
---
Built-in Analyzers
| Analyzer | Key Output Fields |
|---|---|
SharpeRatio | sharperatio |
DrawDown | max.drawdown, max.len, max.moneydown |
TradeAnalyzer | total.total, won.total, lost.total, pnl.net.total |
Returns | rtot, ravg, rnorm, rnorm100 |
SQN | sqn, trades |
TimeReturn | Dict of {datetime: return} |
AnnualReturn | Dict of {year: return} |
Calmar | calmar |
VWR | vwr |
PeriodStats | average, stddev, positive, negative, best, worst |
Accessing Analyzer Results
results = cerebro.run()
strat = results[0]
sharpe_dict = strat.analyzers.sharpe.get_analysis()
sharpe_value = sharpe_dict.get("sharperatio", 0.0)
dd_dict = strat.analyzers.drawdown.get_analysis()
max_dd = dd_dict.max.drawdown # percentage
trades_dict = strat.analyzers.trades.get_analysis()
total_trades = trades_dict.total.total
won = trades_dict.won.total
lost = trades_dict.lost.total---
Data Feeds
From pandas DataFrame
import pandas as pd
df = pd.read_csv("ohlcv.csv", parse_dates=["date"], index_col="date")
# Columns must include: open, high, low, close, volume
# Index must be DatetimeIndex
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)Adding Extra Lines (e.g., external indicators)
class PandasDataWithSignal(bt.feeds.PandasData):
lines = ("signal",)
params = (("signal", -1),) # -1 = column index or column name
# DataFrame must have a 'signal' column
data = PandasDataWithSignal(dataname=df)From CSV
data = bt.feeds.GenericCSVData(
dataname="ohlcv.csv",
dtformat="%Y-%m-%d %H:%M:%S",
datetime=0, open=1, high=2, low=3, close=4, volume=5,
openinterest=-1,
)---
Sizers
| Sizer | Description |
|---|---|
FixedSize(stake=N) | Buy exactly N units |
PercentSizer(percents=P) | Use P% of portfolio value |
AllInSizer(percents=P) | Use P% of available cash |
FixedReverser(stake=N) | Reverse position with fixed size |
Custom Sizer
class ATRSizer(bt.Sizer):
params = (("risk_pct", 0.02),)
def _getsizing(self, comminfo, cash, data, isbuy) -> int:
atr = self.strategy.atr[0]
if atr <= 0:
return 0
risk_amount = self.broker.getvalue() * self.p.risk_pct
return int(risk_amount / atr)---
Parameter Optimization
cerebro.optstrategy(MyStrategy,
fast_period=range(5, 20, 5),
slow_period=range(20, 60, 10),
)
results = cerebro.run(maxcpus=4)
for run in results:
for strat in run:
sharpe = strat.analyzers.sharpe.get_analysis().get("sharperatio", 0)
params = strat.params._getkwargs()
print(f" {params} -> Sharpe: {sharpe}")Backtrader Strategy Patterns
1. EMA Crossover with Bracket Orders
Entry on EMA crossover, with ATR-based stop loss and risk-reward take profit.
class EMABracket(bt.Strategy):
params = (
("fast", 10), ("slow", 30),
("atr_period", 14), ("atr_mult", 2.0),
("rr_ratio", 2.0), # risk:reward
)
def __init__(self) -> None:
self.ema_fast = bt.ind.EMA(period=self.p.fast)
self.ema_slow = bt.ind.EMA(period=self.p.slow)
self.crossover = bt.ind.CrossOver(self.ema_fast, self.ema_slow)
self.atr = bt.ind.ATR(period=self.p.atr_period)
def next(self) -> None:
if self.position:
return
if self.crossover > 0:
entry = self.data.close[0]
stop_dist = self.atr[0] * self.p.atr_mult
stop_price = entry - stop_dist
tp_price = entry + stop_dist * self.p.rr_ratio
self.buy_bracket(
limitprice=tp_price,
stopprice=stop_price,
exectype=bt.Order.Market,
)
def notify_order(self, order: bt.Order) -> None:
if order.status == order.Completed:
action = "BUY" if order.isbuy() else "SELL"
dt = self.data.datetime.date(0)
print(f"{dt} | {action} @ {order.executed.price:.4f}")Key points:
buy_bracketwithexectype=bt.Order.Marketenters immediately; the stop and limit are placed as child orders.- When the stop fills, the take-profit is auto-canceled (and vice versa).
- ATR-based distances adapt to current volatility.
---
2. RSI Mean Reversion with Stop-Limit
Enter on RSI oversold, exit on RSI overbought or stop loss.
class RSIMeanReversion(bt.Strategy):
params = (
("rsi_period", 14),
("oversold", 30), ("overbought", 70),
("stop_pct", 0.05),
)
def __init__(self) -> None:
self.rsi = bt.ind.RSI(period=self.p.rsi_period)
self.order = None
def next(self) -> None:
if self.order:
return
if not self.position:
if self.rsi[0] < self.p.oversold:
entry = self.data.close[0]
stop = entry * (1 - self.p.stop_pct)
self.order = self.buy()
self.sell(exectype=bt.Order.Stop, price=stop)
else:
if self.rsi[0] > self.p.overbought:
self.order = self.close()
def notify_order(self, order: bt.Order) -> None:
if order.status in [order.Completed, order.Canceled, order.Margin]:
self.order = NoneKey points:
- Tracks pending order to avoid duplicate entries.
- Stop loss placed immediately after entry fill.
- Exits on RSI overbought or stop hit.
---
3. Multi-Timeframe Strategy
Use daily trend direction with hourly entry timing.
class MultiTimeframe(bt.Strategy):
params = (("ema_period", 20),)
def __init__(self) -> None:
# datas[0] = hourly, datas[1] = daily (resampled)
self.ema_hourly = bt.ind.EMA(self.datas[0], period=self.p.ema_period)
self.ema_daily = bt.ind.EMA(self.datas[1], period=self.p.ema_period)
def next(self) -> None:
daily_trend_up = self.datas[1].close[0] > self.ema_daily[0]
if not self.position:
# Only buy when daily trend is up and hourly pulls back to EMA
if daily_trend_up and self.datas[0].close[0] > self.ema_hourly[0]:
if self.datas[0].close[-1] <= self.ema_hourly[-1]:
self.buy()
else:
if not daily_trend_up:
self.close()Setup in Cerebro:
data_1h = bt.feeds.PandasData(dataname=df_1h)
cerebro.adddata(data_1h)
cerebro.resampledata(data_1h, timeframe=bt.TimeFrame.Days, compression=1)
cerebro.addstrategy(MultiTimeframe)---
4. Custom Indicator
Creating a Bollinger Band Width indicator:
class BollingerWidth(bt.Indicator):
lines = ("bbwidth", "bbpctb",)
params = (("period", 20), ("devfactor", 2.0),)
def __init__(self) -> None:
bb = bt.ind.BollingerBands(
self.data, period=self.p.period, devfactor=self.p.devfactor
)
self.lines.bbwidth = (bb.top - bb.bot) / bb.mid * 100.0
self.lines.bbpctb = (self.data - bb.bot) / (bb.top - bb.bot)Usage in strategy:
def __init__(self):
self.bbw = BollingerWidth(self.data, period=20)
def next(self):
if self.bbw.bbwidth[0] < 5.0: # squeeze
if self.bbw.bbpctb[0] > 0.8: # near upper band
self.buy()---
5. Position Management: Pyramiding
Scale into a position across multiple bars:
class PyramidStrategy(bt.Strategy):
params = (("max_entries", 3), ("entry_spacing_pct", 0.02),)
def __init__(self) -> None:
self.ema = bt.ind.EMA(period=20)
self.entry_count = 0
self.last_entry_price = 0.0
def next(self) -> None:
price = self.data.close[0]
if price > self.ema[0] and self.entry_count < self.p.max_entries:
if self.entry_count == 0:
self.buy(size=100)
self.last_entry_price = price
self.entry_count += 1
elif price > self.last_entry_price * (1 + self.p.entry_spacing_pct):
self.buy(size=100)
self.last_entry_price = price
self.entry_count += 1
elif price < self.ema[0] and self.position:
self.close()
self.entry_count = 0
self.last_entry_price = 0.0---
6. Scaling Out (Partial Exits)
Take partial profits at targets:
class ScaleOut(bt.Strategy):
params = (("tp1_pct", 0.03), ("tp2_pct", 0.06),)
def __init__(self) -> None:
self.entry_price = 0.0
self.took_tp1 = False
def next(self) -> None:
if not self.position:
if self.some_entry_signal():
self.buy(size=200)
self.entry_price = self.data.close[0]
self.took_tp1 = False
return
pnl_pct = (self.data.close[0] - self.entry_price) / self.entry_price
if not self.took_tp1 and pnl_pct >= self.p.tp1_pct:
self.sell(size=100) # sell half
self.took_tp1 = True
elif pnl_pct >= self.p.tp2_pct:
self.close() # sell remainder
def some_entry_signal(self) -> bool:
return False # replace with actual logic---
Common Pitfalls
1. Lookahead Bias
Using self.data.close[0] in next() is the current bar's close, which is only known at bar close. If you place a market order based on close, it executes at the next bar's open. Use cheat_on_open to execute at the current bar's open instead.
2. Forgetting to Check Position
Always check self.position before entering. Without this check, you will stack orders every bar.
3. Not Handling Order Rejections
Orders can be rejected due to insufficient cash (Margin status). Always handle this in notify_order:
def notify_order(self, order):
if order.status == order.Margin:
print("WARNING: Order rejected - insufficient margin")4. Indicator Warm-up
Indicators need period bars to produce values. Backtrader handles this automatically -- next() is not called until all indicators are ready. But if you manually check len(self), be aware that it starts at period, not 0.
5. Multiple Data Feed Alignment
When using multiple data feeds (multi-timeframe), the slower feed may not have data for every bar. Always check len(self.datas[1]) before accessing its values.
6. Parameter Optimization Overfitting
Using optstrategy to find optimal parameters on in-sample data will overfit. Always reserve an out-of-sample period:
# Split data: first 80% for optimization, last 20% for validation
split_idx = int(len(df) * 0.8)
df_train = df.iloc[:split_idx]
df_test = df.iloc[split_idx:]7. Commission Double-Counting
setcommission(commission=0.003) applies per trade side. A round trip (buy + sell) costs 0.6% total. Make sure you are not doubling this.
#!/usr/bin/env python3
"""Complete EMA crossover backtest using backtrader with synthetic OHLCV data.
Demonstrates:
- Strategy definition with EMA crossover signals
- Broker configuration (cash, commission, slippage)
- Multiple analyzers (Sharpe, DrawDown, TradeAnalyzer, Returns)
- Order and trade notification logging
- Comprehensive results printing
Usage:
python scripts/backtest_strategy.py --demo
python scripts/backtest_strategy.py --fast 8 --slow 21 --cash 50000
Dependencies:
uv pip install backtrader pandas numpy
"""
import argparse
import datetime
import sys
from typing import Optional
import backtrader as bt
import numpy as np
import pandas as pd
# ── Synthetic Data Generator ────────────────────────────────────────
def generate_synthetic_ohlcv(
days: int = 500,
start_price: float = 100.0,
volatility: float = 0.02,
trend: float = 0.0003,
seed: int = 42,
) -> pd.DataFrame:
"""Generate synthetic OHLCV data with realistic price dynamics.
Uses geometric Brownian motion with a slight upward drift and
generates intraday OHLC from the simulated path.
Args:
days: Number of trading days to simulate.
start_price: Initial price.
volatility: Daily volatility (standard deviation of returns).
trend: Daily drift (positive = uptrend).
seed: Random seed for reproducibility.
Returns:
DataFrame with DatetimeIndex and columns: open, high, low, close, volume.
"""
rng = np.random.default_rng(seed)
# Simulate daily returns with GBM
returns = rng.normal(trend, volatility, days)
prices = start_price * np.exp(np.cumsum(returns))
# Generate OHLC from close prices
opens = np.roll(prices, 1)
opens[0] = start_price
# Intraday range scaled by volatility
intraday_range = prices * volatility * rng.uniform(0.5, 2.0, days)
highs = np.maximum(opens, prices) + intraday_range * 0.5
lows = np.minimum(opens, prices) - intraday_range * 0.5
# Ensure OHLC consistency
lows = np.minimum(lows, np.minimum(opens, prices))
highs = np.maximum(highs, np.maximum(opens, prices))
# Volume with some randomness
base_volume = 1_000_000
volume = (base_volume * rng.uniform(0.5, 2.0, days)).astype(int)
dates = pd.date_range(
start=datetime.datetime(2024, 1, 1),
periods=days,
freq="D",
)
df = pd.DataFrame(
{
"open": opens,
"high": highs,
"low": lows,
"close": prices,
"volume": volume,
},
index=dates,
)
return df
# ── Strategy ────────────────────────────────────────────────────────
class EMACrossoverStrategy(bt.Strategy):
"""EMA crossover strategy with order and trade logging.
Buys when fast EMA crosses above slow EMA, sells when it crosses below.
Tracks all orders and trades for detailed reporting.
Params:
fast_period: Fast EMA lookback period.
slow_period: Slow EMA lookback period.
printlog: Whether to print log messages.
"""
params = (
("fast_period", 10),
("slow_period", 30),
("printlog", True),
)
def __init__(self) -> None:
"""Initialize indicators and tracking variables."""
self.ema_fast = bt.ind.EMA(period=self.p.fast_period)
self.ema_slow = bt.ind.EMA(period=self.p.slow_period)
self.crossover = bt.ind.CrossOver(self.ema_fast, self.ema_slow)
# Order tracking
self.order: Optional[bt.Order] = None
self.trade_count: int = 0
self.trade_log: list[dict] = []
def log(self, txt: str, dt: Optional[datetime.date] = None) -> None:
"""Log a message with the current date.
Args:
txt: Message to log.
dt: Optional date override.
"""
if self.p.printlog:
dt = dt or self.datas[0].datetime.date(0)
print(f" {dt} | {txt}")
def next(self) -> None:
"""Process each bar: check crossover signals and manage orders."""
if self.order:
return # waiting for pending order
if not self.position:
if self.crossover[0] > 0:
self.log(
f"SIGNAL BUY | Close={self.data.close[0]:.4f} "
f"EMA_fast={self.ema_fast[0]:.4f} EMA_slow={self.ema_slow[0]:.4f}"
)
self.order = self.buy()
else:
if self.crossover[0] < 0:
self.log(
f"SIGNAL SELL | Close={self.data.close[0]:.4f} "
f"EMA_fast={self.ema_fast[0]:.4f} EMA_slow={self.ema_slow[0]:.4f}"
)
self.order = self.close()
def notify_order(self, order: bt.Order) -> None:
"""Handle order status changes.
Args:
order: The order whose status changed.
"""
if order.status in [order.Submitted, order.Accepted]:
return
if order.status == order.Completed:
if order.isbuy():
self.log(
f"BUY EXECUTED | Price={order.executed.price:.4f} "
f"Size={order.executed.size:.2f} "
f"Commission={order.executed.comm:.4f}"
)
else:
self.log(
f"SELL EXECUTED | Price={order.executed.price:.4f} "
f"Size={abs(order.executed.size):.2f} "
f"Commission={order.executed.comm:.4f}"
)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f"ORDER FAILED | Status={order.getstatusname()}")
self.order = None
def notify_trade(self, trade: bt.Trade) -> None:
"""Handle trade notifications for P&L tracking.
Args:
trade: The trade that was opened or closed.
"""
if not trade.isclosed:
return
self.trade_count += 1
self.trade_log.append(
{
"trade_num": self.trade_count,
"pnl_gross": trade.pnl,
"pnl_net": trade.pnlcomm,
"bars_held": trade.barlen,
}
)
self.log(
f"TRADE CLOSED #{self.trade_count} | "
f"Gross P&L={trade.pnl:.2f} Net P&L={trade.pnlcomm:.2f} "
f"Bars held={trade.barlen}"
)
# ── Results Printer ─────────────────────────────────────────────────
def print_results(
strat: bt.Strategy,
initial_cash: float,
final_value: float,
) -> None:
"""Print comprehensive backtest results from analyzers.
Args:
strat: The completed strategy instance.
initial_cash: Starting portfolio value.
final_value: Ending portfolio value.
"""
print("\n" + "=" * 70)
print("BACKTEST RESULTS")
print("=" * 70)
# Portfolio summary
total_return = (final_value - initial_cash) / initial_cash * 100
print(f"\n Initial Cash: ${initial_cash:>12,.2f}")
print(f" Final Value: ${final_value:>12,.2f}")
print(f" Total Return: {total_return:>12.2f}%")
# Sharpe ratio
sharpe_dict = strat.analyzers.sharpe.get_analysis()
sharpe_val = sharpe_dict.get("sharperatio")
sharpe_str = f"{sharpe_val:.4f}" if sharpe_val is not None else "N/A"
print(f"\n Sharpe Ratio: {sharpe_str:>12}")
# Drawdown
dd_dict = strat.analyzers.drawdown.get_analysis()
max_dd = dd_dict.get("max", {})
max_dd_pct = max_dd.get("drawdown", 0.0)
max_dd_money = max_dd.get("moneydown", 0.0)
max_dd_len = max_dd.get("len", 0)
print(f" Max Drawdown: {max_dd_pct:>11.2f}%")
print(f" Max DD ($): ${max_dd_money:>12,.2f}")
print(f" Max DD Length: {max_dd_len:>12} bars")
# Returns
ret_dict = strat.analyzers.returns.get_analysis()
rnorm = ret_dict.get("rnorm100", 0.0)
print(f" Ann. Return: {rnorm:>11.2f}%")
# Trade analysis
trade_dict = strat.analyzers.trades.get_analysis()
total_trades = trade_dict.get("total", {}).get("total", 0)
total_open = trade_dict.get("total", {}).get("open", 0)
total_closed = trade_dict.get("total", {}).get("closed", 0)
print(f"\n Total Trades: {total_trades:>12}")
print(f" Open Trades: {total_open:>12}")
print(f" Closed Trades: {total_closed:>12}")
if total_closed > 0:
won = trade_dict.get("won", {}).get("total", 0)
lost = trade_dict.get("lost", {}).get("total", 0)
win_rate = won / total_closed * 100 if total_closed > 0 else 0.0
print(f" Winners: {won:>12}")
print(f" Losers: {lost:>12}")
print(f" Win Rate: {win_rate:>11.1f}%")
# P&L
pnl_net = trade_dict.get("pnl", {}).get("net", {})
total_pnl = pnl_net.get("total", 0.0)
avg_pnl = pnl_net.get("average", 0.0)
print(f"\n Total Net P&L: ${total_pnl:>12,.2f}")
print(f" Avg Trade P&L: ${avg_pnl:>12,.2f}")
# Won/lost breakdown
won_pnl = trade_dict.get("won", {}).get("pnl", {})
lost_pnl = trade_dict.get("lost", {}).get("pnl", {})
avg_win = won_pnl.get("average", 0.0)
avg_loss = lost_pnl.get("average", 0.0)
max_win = won_pnl.get("max", 0.0)
max_loss = lost_pnl.get("max", 0.0)
print(f" Avg Win: ${avg_win:>12,.2f}")
print(f" Avg Loss: ${avg_loss:>12,.2f}")
print(f" Max Win: ${max_win:>12,.2f}")
print(f" Max Loss: ${max_loss:>12,.2f}")
if avg_loss != 0:
profit_factor = abs(avg_win * won / (avg_loss * lost))
print(f" Profit Factor: {profit_factor:>12.2f}")
# Streak
streak = trade_dict.get("streak", {})
won_streak = streak.get("won", {}).get("longest", 0)
lost_streak = streak.get("lost", {}).get("longest", 0)
print(f" Win Streak: {won_streak:>12}")
print(f" Loss Streak: {lost_streak:>12}")
# Avg bars in trade
trade_len = trade_dict.get("len", {})
avg_bars = trade_len.get("average", 0.0)
print(f" Avg Bars/Trade: {avg_bars:>12.1f}")
# Trade log
if strat.trade_log:
print(f"\n {'#':>4} {'Gross P&L':>12} {'Net P&L':>12} {'Bars':>6}")
print(f" {'-' * 4} {'-' * 12} {'-' * 12} {'-' * 6}")
for t in strat.trade_log:
print(
f" {t['trade_num']:>4} "
f"${t['pnl_gross']:>11,.2f} "
f"${t['pnl_net']:>11,.2f} "
f"{t['bars_held']:>6}"
)
print("\n" + "=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments.
Returns:
Parsed arguments namespace.
"""
parser = argparse.ArgumentParser(
description="Backtrader EMA Crossover Backtest (analysis only, not financial advice)"
)
parser.add_argument(
"--demo",
action="store_true",
default=True,
help="Run with synthetic data (default: True)",
)
parser.add_argument("--fast", type=int, default=10, help="Fast EMA period (default: 10)")
parser.add_argument("--slow", type=int, default=30, help="Slow EMA period (default: 30)")
parser.add_argument(
"--cash", type=float, default=100_000.0, help="Starting cash (default: 100000)"
)
parser.add_argument(
"--commission",
type=float,
default=0.003,
help="Commission per trade side (default: 0.003 = 0.3%%)",
)
parser.add_argument("--days", type=int, default=500, help="Days of synthetic data (default: 500)")
parser.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
parser.add_argument("--plot", action="store_true", help="Show matplotlib plot after backtest")
parser.add_argument("--quiet", action="store_true", help="Suppress per-trade logging")
return parser.parse_args()
def main() -> None:
"""Run the EMA crossover backtest."""
args = parse_args()
if args.fast >= args.slow:
print(f"Error: fast period ({args.fast}) must be less than slow period ({args.slow})")
sys.exit(1)
# Generate data
print(f"Generating {args.days} days of synthetic OHLCV data (seed={args.seed})...")
df = generate_synthetic_ohlcv(days=args.days, seed=args.seed)
print(f" Date range: {df.index[0].date()} to {df.index[-1].date()}")
print(f" Price range: {df['low'].min():.2f} to {df['high'].max():.2f}")
# Configure cerebro
cerebro = bt.Cerebro()
# Add data
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)
# Add strategy
cerebro.addstrategy(
EMACrossoverStrategy,
fast_period=args.fast,
slow_period=args.slow,
printlog=not args.quiet,
)
# Broker settings
cerebro.broker.setcash(args.cash)
cerebro.broker.setcommission(commission=args.commission)
cerebro.broker.set_coo(True) # cheat on open for realistic execution
cerebro.broker.set_slippage_perc(0.001) # 0.1% slippage
# Sizer: use 95% of available cash
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
# Analyzers
cerebro.addanalyzer(
bt.analyzers.SharpeRatio,
_name="sharpe",
riskfreerate=0.0,
annualize=True,
timeframe=bt.TimeFrame.Days,
)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trades")
cerebro.addanalyzer(bt.analyzers.Returns, _name="returns")
# Run
print(f"\nRunning EMA({args.fast}/{args.slow}) crossover backtest...")
print(f" Cash: ${args.cash:,.2f} | Commission: {args.commission * 100:.1f}%")
print(f" Slippage: 0.1% | Cheat-on-open: enabled\n")
results = cerebro.run()
strat = results[0]
# Print results
final_value = cerebro.broker.getvalue()
print_results(strat, args.cash, final_value)
# Optional plot
if args.plot:
try:
cerebro.plot(style="candlestick", volume=True)
except Exception as exc:
print(f"\nPlot failed (headless environment?): {exc}")
print("Install matplotlib and run in a GUI environment for plotting.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Bracket order demonstration with RSI-based entry and ATR-based stops.
Demonstrates:
- Bracket orders (entry + stop loss + take profit as atomic unit)
- RSI oversold entry signals
- ATR-based dynamic stop loss and take profit distances
- Detailed order notification and trade tracking
- Trade log with entry/exit prices and P&L
Usage:
python scripts/bracket_orders.py --demo
python scripts/bracket_orders.py --rsi-period 14 --rsi-entry 30 --atr-mult 2.0
Dependencies:
uv pip install backtrader pandas numpy
"""
import argparse
import datetime
import sys
from typing import Optional
import backtrader as bt
import numpy as np
import pandas as pd
# ── Synthetic Data Generator ────────────────────────────────────────
def generate_mean_reverting_ohlcv(
days: int = 600,
start_price: float = 100.0,
volatility: float = 0.025,
mean_reversion_strength: float = 0.01,
seed: int = 123,
) -> pd.DataFrame:
"""Generate synthetic OHLCV data with mean-reverting characteristics.
Mean-reverting data produces more RSI oversold/overbought signals,
which is better suited for demonstrating bracket orders with RSI entry.
Args:
days: Number of trading days to simulate.
start_price: Initial price and mean to revert toward.
volatility: Daily volatility.
mean_reversion_strength: Pull toward the mean (higher = faster reversion).
seed: Random seed for reproducibility.
Returns:
DataFrame with DatetimeIndex and OHLCV columns.
"""
rng = np.random.default_rng(seed)
prices = np.zeros(days)
prices[0] = start_price
for i in range(1, days):
# Mean-reverting drift
drift = -mean_reversion_strength * (prices[i - 1] - start_price) / start_price
shock = rng.normal(0, volatility)
log_return = drift + shock
prices[i] = prices[i - 1] * np.exp(log_return)
opens = np.roll(prices, 1)
opens[0] = start_price
intraday_range = prices * volatility * rng.uniform(0.5, 2.5, days)
highs = np.maximum(opens, prices) + intraday_range * 0.5
lows = np.minimum(opens, prices) - intraday_range * 0.5
# Ensure OHLC consistency
lows = np.minimum(lows, np.minimum(opens, prices))
highs = np.maximum(highs, np.maximum(opens, prices))
volume = (1_000_000 * rng.uniform(0.5, 2.0, days)).astype(int)
dates = pd.date_range(
start=datetime.datetime(2024, 1, 1),
periods=days,
freq="D",
)
return pd.DataFrame(
{
"open": opens,
"high": highs,
"low": lows,
"close": prices,
"volume": volume,
},
index=dates,
)
# ── Strategy ────────────────────────────────────────────────────────
class RSIBracketStrategy(bt.Strategy):
"""RSI-based entry with ATR bracket orders.
Entry: Market buy when RSI crosses below oversold threshold.
Exit: Bracket order with ATR-based stop loss and take profit.
The stop and take-profit are placed as OCO children of the entry.
When one fills, the other is automatically canceled.
Params:
rsi_period: RSI calculation period.
rsi_entry: RSI threshold for entry (buy when RSI < this).
atr_period: ATR calculation period.
atr_stop_mult: ATR multiplier for stop loss distance.
atr_tp_mult: ATR multiplier for take profit distance.
printlog: Whether to print trade-by-trade logs.
"""
params = (
("rsi_period", 14),
("rsi_entry", 30),
("atr_period", 14),
("atr_stop_mult", 2.0),
("atr_tp_mult", 3.0),
("printlog", True),
)
def __init__(self) -> None:
"""Initialize indicators and order tracking."""
self.rsi = bt.ind.RSI(period=self.p.rsi_period)
self.atr = bt.ind.ATR(period=self.p.atr_period)
# Order tracking
self.entry_order: Optional[bt.Order] = None
self.stop_order: Optional[bt.Order] = None
self.tp_order: Optional[bt.Order] = None
# Trade log
self.trade_log: list[dict] = []
self.trade_count: int = 0
# Pending bracket flag
self.has_pending_bracket: bool = False
def log(self, txt: str, dt: Optional[datetime.date] = None) -> None:
"""Log a message with date prefix.
Args:
txt: Message text.
dt: Optional date override.
"""
if self.p.printlog:
dt = dt or self.datas[0].datetime.date(0)
print(f" {dt} | {txt}")
def next(self) -> None:
"""Check for RSI entry signal and place bracket orders."""
if self.position or self.has_pending_bracket:
return
# Entry signal: RSI below oversold threshold
if self.rsi[0] < self.p.rsi_entry:
price = self.data.close[0]
atr_val = self.atr[0]
if atr_val <= 0:
return
stop_dist = atr_val * self.p.atr_stop_mult
tp_dist = atr_val * self.p.atr_tp_mult
stop_price = price - stop_dist
tp_price = price + tp_dist
self.log(
f"RSI ENTRY SIGNAL | RSI={self.rsi[0]:.1f} "
f"Price={price:.4f} ATR={atr_val:.4f}"
)
self.log(
f" BRACKET | Stop={stop_price:.4f} "
f"(dist={stop_dist:.4f}) TP={tp_price:.4f} "
f"(dist={tp_dist:.4f})"
)
# Place bracket order
orders = self.buy_bracket(
limitprice=tp_price,
stopprice=stop_price,
exectype=bt.Order.Market,
stopexec=bt.Order.Stop,
limitexec=bt.Order.Limit,
)
self.entry_order = orders[0]
self.stop_order = orders[1]
self.tp_order = orders[2]
self.has_pending_bracket = True
def notify_order(self, order: bt.Order) -> None:
"""Handle order status changes for all bracket components.
Args:
order: The order whose status changed.
"""
if order.status in [order.Submitted, order.Accepted]:
return
if order.status == order.Completed:
order_type = self._identify_order(order)
action = "BUY" if order.isbuy() else "SELL"
self.log(
f"{order_type} {action} FILLED | "
f"Price={order.executed.price:.4f} "
f"Size={abs(order.executed.size):.2f} "
f"Comm={order.executed.comm:.4f}"
)
# If the entry order filled, we now have a position with bracket
if order is self.entry_order:
self.has_pending_bracket = False
# If stop or TP filled, the other is auto-canceled by bracket
if order is self.stop_order:
self.log(" >> STOP LOSS triggered")
self._clear_orders()
elif order is self.tp_order:
self.log(" >> TAKE PROFIT triggered")
self._clear_orders()
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
order_type = self._identify_order(order)
self.log(f"{order_type} {order.getstatusname()}")
# If entry was rejected, clear everything
if order is self.entry_order:
self.has_pending_bracket = False
self._clear_orders()
def _identify_order(self, order: bt.Order) -> str:
"""Identify which bracket component this order is.
Args:
order: Order to identify.
Returns:
String label for the order type.
"""
if order is self.entry_order:
return "ENTRY"
elif order is self.stop_order:
return "STOP"
elif order is self.tp_order:
return "TP"
return "UNKNOWN"
def _clear_orders(self) -> None:
"""Reset all order references."""
self.entry_order = None
self.stop_order = None
self.tp_order = None
self.has_pending_bracket = False
def notify_trade(self, trade: bt.Trade) -> None:
"""Track completed trades for the final summary.
Args:
trade: Trade that opened or closed.
"""
if not trade.isclosed:
return
self.trade_count += 1
exit_type = "UNKNOWN"
if trade.pnl < 0:
exit_type = "STOP LOSS"
elif trade.pnl > 0:
exit_type = "TAKE PROFIT"
record = {
"num": self.trade_count,
"entry_price": trade.price,
"pnl_gross": trade.pnl,
"pnl_net": trade.pnlcomm,
"bars": trade.barlen,
"exit_type": exit_type,
}
self.trade_log.append(record)
self.log(
f"TRADE #{self.trade_count} CLOSED | "
f"Exit={exit_type} Gross={trade.pnl:.2f} "
f"Net={trade.pnlcomm:.2f} Bars={trade.barlen}"
)
# ── Results Printer ─────────────────────────────────────────────────
def print_results(
strat: RSIBracketStrategy,
initial_cash: float,
final_value: float,
) -> None:
"""Print bracket order backtest results.
Args:
strat: Completed strategy instance.
initial_cash: Starting cash.
final_value: Ending portfolio value.
"""
print("\n" + "=" * 70)
print("BRACKET ORDER BACKTEST RESULTS")
print("=" * 70)
total_return = (final_value - initial_cash) / initial_cash * 100
print(f"\n Initial Cash: ${initial_cash:>12,.2f}")
print(f" Final Value: ${final_value:>12,.2f}")
print(f" Total Return: {total_return:>12.2f}%")
# Sharpe
sharpe_dict = strat.analyzers.sharpe.get_analysis()
sharpe_val = sharpe_dict.get("sharperatio")
sharpe_str = f"{sharpe_val:.4f}" if sharpe_val is not None else "N/A"
print(f" Sharpe Ratio: {sharpe_str:>12}")
# Drawdown
dd_dict = strat.analyzers.drawdown.get_analysis()
max_dd = dd_dict.get("max", {})
print(f" Max Drawdown: {max_dd.get('drawdown', 0.0):>11.2f}%")
# Trade summary
trades = strat.trade_log
if not trades:
print("\n No completed trades.")
print("=" * 70)
return
total = len(trades)
winners = [t for t in trades if t["pnl_net"] > 0]
losers = [t for t in trades if t["pnl_net"] <= 0]
stops = [t for t in trades if t["exit_type"] == "STOP LOSS"]
tps = [t for t in trades if t["exit_type"] == "TAKE PROFIT"]
print(f"\n Total Trades: {total:>12}")
print(f" Winners: {len(winners):>12}")
print(f" Losers: {len(losers):>12}")
print(f" Win Rate: {len(winners) / total * 100:>11.1f}%")
print(f" Stop Outs: {len(stops):>12}")
print(f" TP Hits: {len(tps):>12}")
total_pnl = sum(t["pnl_net"] for t in trades)
avg_pnl = total_pnl / total
avg_win = sum(t["pnl_net"] for t in winners) / len(winners) if winners else 0
avg_loss = sum(t["pnl_net"] for t in losers) / len(losers) if losers else 0
avg_bars = sum(t["bars"] for t in trades) / total
print(f"\n Total Net P&L: ${total_pnl:>12,.2f}")
print(f" Avg Trade P&L: ${avg_pnl:>12,.2f}")
print(f" Avg Win: ${avg_win:>12,.2f}")
print(f" Avg Loss: ${avg_loss:>12,.2f}")
print(f" Avg Bars/Trade: {avg_bars:>12.1f}")
if avg_loss != 0:
pf = abs(sum(t["pnl_net"] for t in winners) / sum(t["pnl_net"] for t in losers))
print(f" Profit Factor: {pf:>12.2f}")
# Detailed trade log
print(f"\n {'#':>4} {'Entry':>10} {'Exit Type':>12} {'Gross P&L':>12} {'Net P&L':>12} {'Bars':>6}")
print(f" {'-' * 4} {'-' * 10} {'-' * 12} {'-' * 12} {'-' * 12} {'-' * 6}")
for t in trades:
print(
f" {t['num']:>4} "
f"{t['entry_price']:>10.4f} "
f"{t['exit_type']:>12} "
f"${t['pnl_gross']:>11,.2f} "
f"${t['pnl_net']:>11,.2f} "
f"{t['bars']:>6}"
)
print("\n" + "=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments.
Returns:
Parsed arguments namespace.
"""
parser = argparse.ArgumentParser(
description="Bracket Order Backtest (analysis only, not financial advice)"
)
parser.add_argument(
"--demo", action="store_true", default=True, help="Run with synthetic data (default)"
)
parser.add_argument("--rsi-period", type=int, default=14, help="RSI period (default: 14)")
parser.add_argument("--rsi-entry", type=int, default=30, help="RSI entry threshold (default: 30)")
parser.add_argument(
"--atr-mult", type=float, default=2.0, help="ATR multiplier for stop loss (default: 2.0)"
)
parser.add_argument(
"--tp-mult", type=float, default=3.0, help="ATR multiplier for take profit (default: 3.0)"
)
parser.add_argument(
"--cash", type=float, default=100_000.0, help="Starting cash (default: 100000)"
)
parser.add_argument(
"--commission", type=float, default=0.003, help="Commission per side (default: 0.003)"
)
parser.add_argument("--days", type=int, default=600, help="Days of synthetic data (default: 600)")
parser.add_argument("--seed", type=int, default=123, help="Random seed (default: 123)")
parser.add_argument("--plot", action="store_true", help="Show matplotlib plot")
parser.add_argument("--quiet", action="store_true", help="Suppress per-trade logging")
return parser.parse_args()
def main() -> None:
"""Run the RSI bracket order backtest."""
args = parse_args()
# Generate mean-reverting data (better for RSI signals)
print(f"Generating {args.days} days of mean-reverting synthetic OHLCV data (seed={args.seed})...")
df = generate_mean_reverting_ohlcv(days=args.days, seed=args.seed)
print(f" Date range: {df.index[0].date()} to {df.index[-1].date()}")
print(f" Price range: {df['low'].min():.2f} to {df['high'].max():.2f}")
# Configure cerebro
cerebro = bt.Cerebro()
data = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data)
cerebro.addstrategy(
RSIBracketStrategy,
rsi_period=args.rsi_period,
rsi_entry=args.rsi_entry,
atr_period=14,
atr_stop_mult=args.atr_mult,
atr_tp_mult=args.tp_mult,
printlog=not args.quiet,
)
# Broker
cerebro.broker.setcash(args.cash)
cerebro.broker.setcommission(commission=args.commission)
cerebro.broker.set_coo(True)
cerebro.broker.set_slippage_perc(0.001)
# Size: 95% of portfolio
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
# Analyzers
cerebro.addanalyzer(
bt.analyzers.SharpeRatio,
_name="sharpe",
riskfreerate=0.0,
annualize=True,
timeframe=bt.TimeFrame.Days,
)
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
# Run
print(
f"\nRunning RSI({args.rsi_period}) bracket backtest "
f"(entry<{args.rsi_entry}, stop={args.atr_mult}xATR, tp={args.tp_mult}xATR)..."
)
print(f" Cash: ${args.cash:,.2f} | Commission: {args.commission * 100:.1f}%\n")
results = cerebro.run()
strat = results[0]
final_value = cerebro.broker.getvalue()
print_results(strat, args.cash, final_value)
if args.plot:
try:
cerebro.plot(style="candlestick", volume=True)
except Exception as exc:
print(f"\nPlot failed: {exc}")
if __name__ == "__main__":
main()
Related skills
FAQ
When should I use Backtrader over vectorbt?
Use Backtrader for bracket orders, stop-limit/trailing stops, order-dependent logic, multi-timeframe strategies and realistic commission/slippage; use vectorbt for fast parameter sweeps and simple signals.
What is Cerebro?
The central engine to which you add strategies, data feeds, analyzers and sizers before calling run().