← Back to Home
A TEMA Crossover Strategy with Volume Confirmation in Python with Backtrader

A TEMA Crossover Strategy with Volume Confirmation in Python with Backtrader

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.

Why TEMA and Volume Confirmation?

Triple Exponential Moving Average (TEMA)

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:

TEMA = (3 \times EMA_1) - (3 \times EMA_2) + EMA_3

Where:

Volume Confirmation

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:

Stop-Loss

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.

Prerequisites

To follow this tutorial, ensure you have the following Python libraries installed:

pip install backtrader yfinance pandas matplotlib numpy

Step-by-Step Implementation

We’ll structure our backtrader strategy into distinct components for clarity and modularity.

1. Initial Setup and Data Acquisition

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
plt.rcParams['figure.figsize'] = (10, 6)

# 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...")
data = yf.download('ETH-USD', '2021-01-01', '2024-01-01', auto_adjust=False)
data.columns = data.columns.droplevel(1) # Drop the second level of multi-index columns
print("Data downloaded successfully.")
print(data.head()) # Display first few rows of the data

# Create a Backtrader data feed from the pandas DataFrame
data_feed = bt.feeds.PandasData(dataname=data)

Explanation:

2. The TEMA Crossover Strategy with Volume Confirmation (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)
                stop_price = order.executed.price * (1 - self.params.stop_loss_pct)
                # 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)
                stop_price = order.executed.price * (1 + self.params.stop_loss_pct)
                # 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 = dt or self.datas[0].datetime.date(0) # Get current date
        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:

3. Running the Backtest and Analyzing Results

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
cerebro = bt.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
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

# Set starting cash
cerebro.broker.setcash(100000.0) # Start with $100,000

# Set commission (e.g., 0.1% per transaction)
cerebro.broker.setcommission(commission=0.001)

# --- Add Analyzers for comprehensive performance evaluation ---
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='tradeanalyzer')
cerebro.addanalyzer(bt.analyzers.SQN, _name='sqn') # System Quality Number
cerebro.addanalyzer(bt.analyzers.Transactions, _name='transactions') # To see individual transactions

# Print starting portfolio value
print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():,.2f}')

# Run the backtest
print("Running backtest...")
results = cerebro.run()
print("Backtest finished.")

# Print final portfolio value
final_value = cerebro.broker.getvalue()
print(f'Final Portfolio Value: ${final_value:,.2f}')

# --- Get and print analysis results ---
strat = results[0] # Access the strategy instance from the results

print("\n--- Strategy Performance Metrics ---")

# 1. Returns Analysis
returns_analysis = strat.analyzers.returns.get_analysis()
total_return = returns_analysis.get('rtot', 'N/A') * 100
annual_return = returns_analysis.get('rnorm100', 'N/A')
print(f"Total Return: {total_return:.2f}%")
print(f"Annualized Return: {annual_return:.2f}%")

# 2. Sharpe Ratio (Risk-adjusted return)
sharpe_ratio = strat.analyzers.sharpe.get_analysis()
print(f"Sharpe Ratio: {sharpe_ratio.get('sharperatio', 'N/A'):.2f}")

# 3. Drawdown Analysis (Measure of risk)
drawdown_analysis = strat.analyzers.drawdown.get_analysis()
max_drawdown = drawdown_analysis.get('maxdrawdown', 'N/A')
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)
trade_analysis = strat.analyzers.tradeanalyzer.get_analysis()
total_trades = trade_analysis.get('total', {}).get('total', 0)
won_trades = trade_analysis.get('won', {}).get('total', 0)
lost_trades = trade_analysis.get('lost', {}).get('total', 0)
win_rate = (won_trades / total_trades) * 100 if total_trades > 0 else 0
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
sqn_analysis = strat.analyzers.sqn.get_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
cerebro.plot(iplot=False, style='candlestick',
             barup=dict(fill=False, lw=1.0, ls='-', color='green'), # Customize bullish candles
             bardown=dict(fill=False, lw=1.0, ls='-', color='red'),  # Customize bearish candles
             plotreturn=True, # Show equity curve
             numfigs=1 # Ensure only one figure is generated
            )
print("Plot generated.")

Explanation of Backtesting Setup:

Pasted image 20250608030639.png ## Interpreting the Results

After running the code, you will get:

  1. Console Output: A detailed summary of key performance metrics, including:
    • Total Return / Annualized Return: The percentage change in your portfolio value over the backtest period and annually.
    • Sharpe Ratio: Indicates how much return you get per unit of risk. A higher Sharpe Ratio (generally > 1.0) is desirable.
    • Max Drawdown: The largest percentage drop from a peak in your portfolio value to a subsequent trough. Lower is better.
    • Trade Statistics: Information on the number of trades, win rate, average profit/loss per trade, and the ratio of average win to average loss.
    • System Quality Number (SQN): Provides a single metric for the “quality” of a trading system. Higher SQN values (e.g., > 1.6) suggest a more robust system.
  2. Backtest Plot: A visual representation that includes:
    • Candlestick chart of the asset’s price.
    • The fast_tema and slow_tema lines.
    • Buy and sell arrows indicating trade executions.
    • A separate panel showing the volume and its SMA.
    • The equity curve of your portfolio, showing its growth or decline over time.

This comprehensive output helps you assess if the strategy is indeed “good” and identify areas for further optimization.

Further Enhancements and Considerations

While this strategy is robust, here are some ideas for future improvements:

  1. Refined Volume Confirmation: The current volume check is simple (current_volume > SMA(volume)). You could explore:
    • Requiring current_volume to be a certain multiple of volume_sma.
    • Differentiating volume by price direction (e.g., high volume on up-candles for buys, high volume on down-candles for sells).
    • Using other volume indicators like On-Balance Volume (OBV) or Volume Price Trend (VPT).
  2. Adaptive Periods for TEMAs: Just as in the previous example, you could make the 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.
  3. Dynamic Stop-Loss: Instead of a fixed percentage, implement a dynamic stop-loss based on volatility (e.g., a multiple of ATR) or trailing stops to lock in profits.
  4. Take-Profit Targets: Add specific profit targets to exit positions once a certain gain is achieved, preventing profit erosion if the market reverses.
  5. Market Regime Filtering: TEMA crossover strategies often perform best in trending markets. Consider adding an additional filter (e.g., ADX indicator, or a higher timeframe trend filter) to avoid trading in choppy, non-trending markets.
  6. Slippage Simulation: For higher realism, especially with crypto assets, enable slippage in backtrader to account for the difference between expected and executed trade prices.
  7. Parameter Optimization: Use 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.
  8. Timeframe Analysis: Test the strategy on different timeframes (e.g., hourly, weekly data) to see how its performance varies.

Conclusion

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.