In the world of quantitative trading, choosing the right indicators and combining them effectively is paramount. While Simple Moving Averages (SMAs) and Exponential Moving Averages (EMAs) are foundational, they often suffer from lag, causing delayed entry and exit signals. The Triple Exponential Moving Average (TEMA) is designed to address this lag, offering a more responsive trend-following indicator.
However, even the most responsive indicators can generate false signals in choppy markets. This is where volume confirmation comes into play. By validating price movements with significant trading activity, we can filter out less reliable signals and potentially improve strategy performance.
This tutorial will guide you through creating a
backtrader
strategy that implements a TEMA crossover
system, reinforced with volume confirmation, and integrated with
essential risk management through a stop-loss mechanism. We will use
cryptocurrency data (ETH-USD
) to demonstrate its
application in a highly dynamic market.
Developed by Patrick Mulloy, TEMA aims to reduce the inherent lag of traditional moving averages. Unlike a simple EMA, TEMA applies a complex calculation involving multiple EMAs to achieve faster responsiveness without sacrificing smoothness. This makes TEMA particularly valuable for traders who need earlier identification of trend changes, which can be critical in fast-moving markets like cryptocurrencies.
The formula for TEMA is:
Where:
Price movements are more significant when backed by substantial trading volume.
By requiring a TEMA crossover to be confirmed by above-average volume, we aim to:
No trading strategy is foolproof. A stop-loss is a critical risk management tool that automatically closes a position if the price moves against you by a predetermined amount. This limits potential losses and protects your trading capital from significant drawdowns.
To follow this tutorial, ensure you have the following Python libraries installed:
pip install backtrader yfinance pandas matplotlib numpy
We’ll structure our backtrader
strategy into distinct
components for clarity and modularity.
First, we set up our environment and download the historical data. We’ll use Ethereum (ETH-USD) data for this example.
import backtrader as bt
import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
# Set matplotlib style for better visualization
%matplotlib inline
'figure.figsize'] = (10, 6)
plt.rcParams[
# Download historical data for Ethereum (ETH-USD)
# Remember the instruction: yfinance download with auto_adjust=False and droplevel(axis=1, level=1).
print("Downloading ETH-USD data from 2021-01-01 to 2024-01-01...")
= yf.download('ETH-USD', '2021-01-01', '2024-01-01', auto_adjust=False)
data = data.columns.droplevel(1) # Drop the second level of multi-index columns
data.columns print("Data downloaded successfully.")
print(data.head()) # Display first few rows of the data
# Create a Backtrader data feed from the pandas DataFrame
= bt.feeds.PandasData(dataname=data) data_feed
Explanation:
yfinance.download
: Fetches historical cryptocurrency
price data. auto_adjust=False
is used as per our persistent
instruction to ensure raw prices.data.columns = data.columns.droplevel(1)
:
yfinance
can return a multi-level column index (e.g.,
('Close', 'ETH-USD')
). backtrader
expects a
single-level index (Close
). This line flattens the
index.bt.feeds.PandasData
: Converts our cleaned pandas
DataFrame into a format backtrader
can consume.TEMAStrategy
)This is the core of our trading system. We’ll define a
bt.Strategy
class that incorporates TEMAs, volume
filtering, and stop-loss management.
class TEMAStrategy(bt.Strategy):
# Define strategy parameters
= (
params 'fast_period', 7), # Period for the fast TEMA
('slow_period', 30), # Period for the slow TEMA
('volume_period', 7), # Period for the volume SMA to confirm signals
('stop_loss_pct', 0.01), # Percentage for the stop-loss (e.g., 0.01 = 1%)
(
)
def __init__(self):
# Initialize TEMA indicators
# bt.indicators.TEMA automatically handles the triple exponential smoothing
self.fast_tema = bt.indicators.TEMA(self.data.close, period=self.params.fast_period)
self.slow_tema = bt.indicators.TEMA(self.data.close, period=self.params.slow_period)
# Create a CrossOver indicator to detect when fast_tema crosses slow_tema
# crossover > 0 for fast_tema crossing above slow_tema (bullish)
# crossover < 0 for fast_tema crossing below slow_tema (bearish)
self.crossover = bt.indicators.CrossOver(self.fast_tema, self.slow_tema)
# Initialize Volume confirmation
# Calculate Simple Moving Average of Volume
self.volume_sma = bt.indicators.SMA(self.data.volume, period=self.params.volume_period)
# Create a boolean signal: True if current volume is greater than its SMA
self.volume_signal = self.data.volume > self.volume_sma
# Variables to keep track of active orders to prevent multiple orders
self.order = None # Holds a reference to any active buy/sell order
self.stop_order = None # Holds a reference to any active stop-loss order
def notify_order(self, order):
# This method is called by Cerebro whenever an order's status changes.
# If the order has been completed (filled)
if order.status in [order.Completed]:
# If it was a buy order and we now have a long position
if order.isbuy() and self.position.size > 0:
# Calculate the stop-loss price (e.g., 1% below entry price)
= order.executed.price * (1 - self.params.stop_loss_pct)
stop_price # Place a sell stop order
self.stop_order = self.sell(exectype=bt.Order.Stop, price=stop_price)
self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, Size: {order.executed.size}, Stop Loss set at: {stop_price:.2f}')
# If it was a sell order (for shorting) and we now have a short position
elif order.issell() and self.position.size < 0:
# Calculate the stop-loss price (e.g., 1% above entry price)
= order.executed.price * (1 + self.params.stop_loss_pct)
stop_price # Place a buy stop order to cover the short
self.stop_order = self.buy(exectype=bt.Order.Stop, price=stop_price)
self.log(f'SELL EXECUTED (Short), Price: {order.executed.price:.2f}, Size: {order.executed.size}, Stop Loss set at: {stop_price:.2f}')
# If the order is completed, canceled, or rejected, clear the order references
if order.status in [order.Completed, order.Canceled, order.Rejected]:
self.order = None # Clear main order reference
if order == self.stop_order: # If the completed order was the stop-loss
self.stop_order = None # Clear stop-loss order reference
def log(self, txt, dt=None):
''' Logging function for the strategy '''
= dt or self.datas[0].datetime.date(0) # Get current date
dt print(f'{dt.isoformat()}, {txt}')
def next(self):
# Prevent new orders if there's already an active order pending execution
if self.order is not None:
return
# Trading logic: TEMA crossover with volume confirmation
# Bullish signal: Fast TEMA crosses above Slow TEMA AND current volume is above its SMA
if self.crossover > 0 and self.volume_signal[0]: # [0] refers to current bar's value
if self.position.size < 0: # If currently in a short position
# Close the short position first
self.log(f'CLOSING SHORT POSITION (Crossover Up), Price: {self.data.close[0]:.2f}')
if self.stop_order is not None:
self.cancel(self.stop_order) # Cancel any active stop-loss for the short
self.order = self.close() # Close the short position
elif not self.position: # If not in any position
# Open a long position
self.log(f'OPENING LONG POSITION (Crossover Up with Volume), Price: {self.data.close[0]:.2f}')
self.order = self.buy() # Execute a buy order
# Bearish signal: Fast TEMA crosses below Slow TEMA AND current volume is above its SMA
elif self.crossover < 0 and self.volume_signal[0]: # [0] refers to current bar's value
if self.position.size > 0: # If currently in a long position
# Close the long position first
self.log(f'CLOSING LONG POSITION (Crossover Down), Price: {self.data.close[0]:.2f}')
if self.stop_order is not None:
self.cancel(self.stop_order) # Cancel any active stop-loss for the long
self.order = self.close() # Close the long position
elif not self.position: # If not in any position
# Open a short position
self.log(f'OPENING SHORT POSITION (Crossover Down with Volume), Price: {self.data.close[0]:.2f}')
self.order = self.sell() # Execute a sell order
Explanation of TEMAStrategy
:
params
: Defines the configurable
parameters for our strategy, such as TEMA periods, volume period, and
stop-loss percentage.__init__(self)
:
self.fast_tema
and self.slow_tema
:
Instances of bt.indicators.TEMA
are created for the fast
and slow TEMA lines. backtrader
automatically handles the
complex TEMA calculation.self.crossover
: bt.indicators.CrossOver
is
used to detect when the fast_tema
crosses
slow_tema
. This indicator returns a positive value (+1) on
an upward crossover and a negative value (-1) on a downward
crossover.self.volume_sma
: Calculates a Simple Moving Average of
the self.data.volume
(the volume line of our data
feed).self.volume_signal
: This is a boolean line. It’s
True
when the current volume is greater than its SMA,
indicating above-average volume.self.order
and self.stop_order
: These
variables are crucial for managing order flow. We set them to
None
when no orders are pending or active.notify_order(self, order)
: This is a
callback method that backtrader
invokes whenever an order’s
status changes.
Completed
(meaning it has been filled
by the broker simulation), we proceed to place a corresponding stop-loss
order.buy
order (long position), a sell
stop order is placed below the entry price.sell
order (short position), a buy
stop order is placed above the entry price.self.order
and
self.stop_order
references once orders are no longer
active, allowing the strategy to place new orders.log(self, txt, dt=None)
: A simple
utility function to print informative messages to the console with the
current date.next(self)
: This method contains the
core trading logic and is executed for each new bar of data.
if self.order is not None: return
: This is a safeguard
to prevent multiple orders from being sent if a previous one is still
pending.self.crossover > 0
(fast TEMA crosses above slow TEMA,
indicating an uptrend) AND self.volume_signal[0]
(current
volume is above its average, confirming the signal), the strategy checks
if it’s currently short. If so, it closes the short position. Otherwise,
if not in any position, it opens a new long position.self.crossover < 0
(fast TEMA crosses below slow TEMA,
indicating a downtrend) AND self.volume_signal[0]
(current
volume is above its average), the strategy checks if it’s currently
long. If so, it closes the long position. Otherwise, if not in any
position, it opens a new short position.self.cancel(self.stop_order)
: When closing a position
due to a TEMA crossover signal, any existing stop-loss order for that
position must be canceled to avoid unintended trades.Finally, we set up the backtrader
Cerebro engine, add
our strategy, data, and configure broker settings. We’ll also add
several backtrader.analyzers
to get detailed performance
statistics.
# Create a Cerebro entity
= bt.Cerebro()
cerebro
# Add the strategy
cerebro.addstrategy(TEMAStrategy)
# Add the data feed
cerebro.adddata(data_feed)
# Set the sizer: invest 95% of available cash on each trade
=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Set starting cash
100000.0) # Start with $100,000
cerebro.broker.setcash(
# Set commission (e.g., 0.1% per transaction)
=0.001)
cerebro.broker.setcommission(commission
# --- Add Analyzers for comprehensive performance evaluation ---
='sharpe')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='tradeanalyzer')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='sqn') # System Quality Number
cerebro.addanalyzer(bt.analyzers.SQN, _name='transactions') # To see individual transactions
cerebro.addanalyzer(bt.analyzers.Transactions, _name
# Print starting portfolio value
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')
# Run the backtest
print("Running backtest...")
= cerebro.run()
results print("Backtest finished.")
# Print final portfolio value
= cerebro.broker.getvalue()
final_value print(f'Final Portfolio Value: ${final_value:,.2f}')
# --- Get and print analysis results ---
= results[0] # Access the strategy instance from the results
strat
print("\n--- Strategy Performance Metrics ---")
# 1. Returns Analysis
= strat.analyzers.returns.get_analysis()
returns_analysis = returns_analysis.get('rtot', 'N/A') * 100
total_return = returns_analysis.get('rnorm100', 'N/A')
annual_return print(f"Total Return: {total_return:.2f}%")
print(f"Annualized Return: {annual_return:.2f}%")
# 2. Sharpe Ratio (Risk-adjusted return)
= strat.analyzers.sharpe.get_analysis()
sharpe_ratio print(f"Sharpe Ratio: {sharpe_ratio.get('sharperatio', 'N/A'):.2f}")
# 3. Drawdown Analysis (Measure of risk)
= strat.analyzers.drawdown.get_analysis()
drawdown_analysis = drawdown_analysis.get('maxdrawdown', 'N/A')
max_drawdown print(f"Max Drawdown: {max_drawdown:.2f}%")
print(f"Longest Drawdown Duration: {drawdown_analysis.get('maxdrawdownperiod', 'N/A')} bars")
# 4. Trade Analysis (Details about trades)
= strat.analyzers.tradeanalyzer.get_analysis()
trade_analysis = trade_analysis.get('total', {}).get('total', 0)
total_trades = trade_analysis.get('won', {}).get('total', 0)
won_trades = trade_analysis.get('lost', {}).get('total', 0)
lost_trades = (won_trades / total_trades) * 100 if total_trades > 0 else 0
win_rate print(f"Total Trades: {total_trades}")
print(f"Winning Trades: {won_trades} ({win_rate:.2f}%)")
print(f"Losing Trades: {lost_trades} ({100-win_rate:.2f}%)")
print(f"Average Win (PnL): {trade_analysis.get('won',{}).get('pnl',{}).get('average', 'N/A'):.2f}")
print(f"Average Loss (PnL): {trade_analysis.get('lost',{}).get('pnl',{}).get('average', 'N/A'):.2f}")
print(f"Ratio Avg Win/Avg Loss: {abs(trade_analysis.get('won',{}).get('pnl',{}).get('average', 0) / trade_analysis.get('lost',{}).get('pnl',{}).get('average', 1)):.2f}")
# 5. System Quality Number (SQN) - Dr. Van Tharp's measure of system quality
= strat.analyzers.sqn.get_analysis()
sqn_analysis print(f"System Quality Number (SQN): {sqn_analysis.get('sqn', 'N/A'):.2f}")
# --- Plot the results ---
print("\nPlotting results...")
# iplot=False for static plot, style='candlestick' for candlestick chart
# plotreturn=True to show the equity curve in a separate subplot
=False, style='candlestick',
cerebro.plot(iplot=dict(fill=False, lw=1.0, ls='-', color='green'), # Customize bullish candles
barup=dict(fill=False, lw=1.0, ls='-', color='red'), # Customize bearish candles
bardown=True, # Show equity curve
plotreturn=1 # Ensure only one figure is generated
numfigs
)print("Plot generated.")
Explanation of Backtesting Setup:
bt.Cerebro()
: The central engine that orchestrates the
backtest.cerebro.addstrategy(TEMAStrategy)
: Registers our custom
strategy with Cerebro.cerebro.adddata(data_feed)
: Feeds the historical data
into the backtesting engine.cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
:
This position sizer ensures that 95% of the available cash is used for
each trade, preventing over-allocation or leaving too much cash
idle.cerebro.broker.setcash(100000.0)
: Sets the initial
trading capital.cerebro.broker.setcommission(commission=0.001)
: Applies
a commission of 0.1% on each trade, making the backtest more
realistic.cerebro.addanalyzer(...)
: These lines
are crucial for evaluating the strategy’s performance beyond just total
return. They add various backtrader
analysis modules:
SharpeRatio
: Measures risk-adjusted return.DrawDown
: Calculates maximum drawdown and
duration.Returns
: Provides total and annualized returns.TradeAnalyzer
: Offers detailed statistics on individual
trades (wins, losses, average profit/loss).SQN
: System Quality Number, a measure of strategy
robustness.Transactions
: Logs all trade transactions for
review.cerebro.run()
: Executes the backtest.results
object (which holds the
executed strategies) and prints them in a structured way, offering deep
insights into the strategy’s profitability, risk, and trade
characteristics.cerebro.plot(iplot=False)
: Generates a visual plot of
the backtest. This plot is invaluable for visually inspecting trade
entries, exits, and how the indicators behaved relative to price action.
iplot=False
ensures a static plot suitable for
non-interactive environments. ## Interpreting the Results
After running the code, you will get:
fast_tema
and slow_tema
lines.This comprehensive output helps you assess if the strategy is indeed “good” and identify areas for further optimization.
While this strategy is robust, here are some ideas for future improvements:
current_volume > SMA(volume)
). You
could explore:
current_volume
to be a certain
multiple of volume_sma
.fast_period
and
slow_period
of the TEMAs adaptive to market volatility.
This would require creating a custom AdaptiveTEMA
indicator
similar to the AdaptiveSMA
you developed.backtrader
to
account for the difference between expected and executed trade
prices.backtrader
’s optstrategy
feature to
systematically test different combinations of fast_period
,
slow_period
, volume_period
, and
stop_loss_pct
to find the most robust and profitable
settings for ETH-USD
or other assets.You have successfully built and backtested a TEMA Crossover strategy
with Volume Confirmation and stop-loss using backtrader
.
This strategy represents a significant step beyond basic moving average
systems by incorporating a more responsive indicator and a crucial
market sentiment filter. By leveraging the power of
backtrader
and its robust analysis tools, you can
thoroughly evaluate and refine your trading ideas, bringing you closer
to developing profitable algorithmic strategies. Remember that
backtesting is a continuous process of experimentation, learning, and
adaptation.