← Back to Home
Volatility Momentum and WMA Profit Target Strategy

Volatility Momentum and WMA Profit Target Strategy

The VolMomWMAProfitTargetStrategy is an advanced quantitative trading system that seeks to capitalize on market momentum by combining volatility trends with price direction, utilizing a Weighted Moving Average (WMA) for trend identification and incorporating a fixed profit target alongside a trailing stop-loss for risk management.

Strategy Overview

This strategy aims to identify periods where there’s a significant increase in price volatility that is also aligned with the underlying price trend. It seeks to enter trades during these “strong volatility momentum” periods and manage them with predefined exit conditions.

Entry Logic

An entry is initiated when two primary conditions are met:

  1. Strong Volatility Momentum: The strategy calculates the volatility momentum by comparing the current standard deviation of returns to its value vol_momentum_window periods ago. A positive and sufficiently large vol_momentum (exceeding vol_momentum_threshold) indicates that market volatility is accelerating, suggesting a potentially strong directional move is underway or impending.
  2. Price Trend Alignment (WMA): The current price must be aligned with its Weighted Moving Average (WMA), which gives more weight to recent prices, making it more responsive to current trends.
    • For a long entry, the current closing price must be above the price_wma.
    • For a short entry, the current closing price must be below the price_wma.

Both of these conditions must be satisfied simultaneously for a trade order to be placed.

Exit Logic

The strategy employs a dual-pronged approach to position management:

  1. Fixed Profit Target: Upon entry, a fixed profit target price is immediately calculated. For long positions, this is the entry price plus a profit_target_percent. For short positions, it’s the entry price minus the profit_target_percent. If the price reaches this target, the position is closed, securing profits.
  2. Volatility Momentum Reversal: If the initial profit target isn’t met, the strategy also closes positions if the vol_momentum turns negative or falls below a certain threshold. This acts as an adaptive exit, indicating that the driving force behind the trade might be dissipating.
  3. ATR-Based Trailing Stop: A dynamic trailing stop-loss is maintained to protect capital and lock in gains. This stop is calculated as the current price minus/plus a multiple of the Average True Range (ATR). It adjusts in the favorable direction as the trade progresses, but it never moves against the trade. A manual check for this stop is also included as a backup.

Technical Indicators Utilized

Backtrader Implementation

The strategy is implemented in backtrader as follows:

import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams['figure.figsize'] = (12, 8)

class VolMomWMAProfitTargetStrategy(bt.Strategy):
    """
    Advanced Volatility Momentum: Uses Weighted Moving Average for trend,
    and incorporates a fixed profit target.
    """
    params = (
        ('vol_window', 30),
        ('vol_momentum_window', 7),
        ('price_wma_window', 90),
        ('atr_window', 7),
        ('atr_multiplier', 5.),  #  risk management
        ('profit_target_percent', 0.05),  #  profit target (was 5%, often too ambitious)
        ('vol_momentum_threshold', 0.001),  # Minimum threshold to filter noise
    )

    def __init__(self):
        self.returns = bt.indicators.PctChange(self.data.close, period=1)
        self.volatility = bt.indicators.StandardDeviation(self.returns, period=self.params.vol_window)
        self.vol_momentum = self.volatility - self.volatility(-self.params.vol_momentum_window)
        
        # Price trend = Weighted Moving Average
        self.price_wma = bt.indicators.WeightedMovingAverage(self.data.close, period=self.params.price_wma_window)
        
        self.atr = bt.indicators.ATR(self.data, period=self.params.atr_window)
        
        # Trade management variables
        self.stop_price = 0
        self.profit_target_price = 0
        self.trade_count = 0
        self.entry_price = 0
        
        # Order tracking
        self.order_ref = None  # Entry order
        self.profit_order_ref = None  # Profit target order
        self.stop_order_ref = None  # Stop loss order

    def next(self):
        # Ensure enough data for all indicators
        min_periods = max(self.params.vol_window, self.params.price_wma_window, self.params.atr_window)
        if len(self) < min_periods:
            return

        vol_momentum = self.vol_momentum[0]
        current_price = self.data.close[0]
        wma_price = self.price_wma[0]

        # Skip if we have pending entry orders
        if self.order_ref:
            return

        # Position management
        if self.position:
            # Manual stop loss check (backup in case stop order fails)
            position_size = self.position.size
            
            if position_size > 0 and current_price <= self.stop_price:
                self._close_position(f'MANUAL STOP LOSS - Long closed at {current_price:.2f}')
                return
            elif position_size < 0 and current_price >= self.stop_price:
                self._close_position(f'MANUAL STOP LOSS - Short closed at {current_price:.2f}')
                return
            
            # Exit if volatility momentum turns negative or becomes too weak
            if vol_momentum <= -self.params.vol_momentum_threshold:
                self._close_position(f'VOL MOMENTUM REVERSAL - Vol momentum: {vol_momentum:.6f}')
                return
            
            # Update trailing stop (only move in favorable direction)
            if position_size > 0: # Long position
                new_stop = current_price - (self.atr[0] * self.params.atr_multiplier)
                if new_stop > self.stop_price: # Only move up
                    self.stop_price = new_stop
                    self.log(f'TRAILING STOP UPDATED - New stop: {self.stop_price:.2f}')
            elif position_size < 0: # Short position
                new_stop = current_price + (self.atr[0] * self.params.atr_multiplier)
                if new_stop < self.stop_price: # Only move down
                    self.stop_price = new_stop
                    self.log(f'TRAILING STOP UPDATED - New stop: {self.stop_price:.2f}')

        # Entry signals: Strong vol momentum + price direction confirmation
        if not self.position and vol_momentum > self.params.vol_momentum_threshold:
            # Long entry: price above WMA and strong vol momentum
            if current_price > wma_price:
                self.order_ref = self.buy()
                self.stop_price = current_price - (self.atr[0] * self.params.atr_multiplier)
                self.profit_target_price = current_price * (1 + self.params.profit_target_percent)
                self.trade_count += 1
                self.log(f'LONG SIGNAL - Price: {current_price:.2f}, WMA: {wma_price:.2f}, Vol Mom: {vol_momentum:.6f}, Stop: {self.stop_price:.2f}, Target: {self.profit_target_price:.2f}')
            
            # Short entry: price below WMA and strong vol momentum
            elif current_price < wma_price:
                self.order_ref = self.sell()
                self.stop_price = current_price + (self.atr[0] * self.params.atr_multiplier)
                self.profit_target_price = current_price * (1 - self.params.profit_target_percent)
                self.trade_count += 1
                self.log(f'SHORT SIGNAL - Price: {current_price:.2f}, WMA: {wma_price:.2f}, Vol Mom: {vol_momentum:.6f}, Stop: {self.stop_price:.2f}, Target: {self.profit_target_price:.2f}')

    def _close_position(self, message):
        """Helper method to close position and cancel pending orders"""
        if self.position:
            self.close()
            self.log(message)
        
        # Cancel any pending profit target orders
        if self.profit_order_ref:
            self.cancel(self.profit_order_ref)
            self.profit_order_ref = None
        
        # Cancel any pending stop orders
        if self.stop_order_ref:
            self.cancel(self.stop_order_ref)
            self.stop_order_ref = None

    def log(self, txt, dt=None):
        dt = dt or self.datas[0].datetime.date(0)
        print(f'{dt.isoformat()}: {txt}')

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]:
            return
        
        if order.status == order.Completed:
            # Entry order completed
            if self.order_ref and order.ref == self.order_ref.ref:
                self.entry_price = order.executed.price
                
                if order.isbuy():
                    # Long position - set profit target as sell limit order
                    self.profit_order_ref = self.sell(
                        price=self.profit_target_price, 
                        exectype=bt.Order.Limit
                    )
                    self.log(f'LONG EXECUTED - Entry: {self.entry_price:.2f}, Target: {self.profit_target_price:.2f}')
                
                elif order.issell():
                    # Short position - set profit target as buy limit order
                    self.profit_order_ref = self.buy(
                        price=self.profit_target_price, 
                        exectype=bt.Order.Limit
                    )
                    self.log(f'SHORT EXECUTED - Entry: {self.entry_price:.2f}, Target: {self.profit_target_price:.2f}')
                
                self.order_ref = None # Clear entry order reference

            # Profit target order completed
            elif self.profit_order_ref and order.ref == self.profit_order_ref.ref:
                self.log(f'PROFIT TARGET HIT - Exit: {order.executed.price:.2f}')
                self.profit_order_ref = None
                # Position is now closed, reset tracking variables
                self.stop_price = 0
                self.profit_target_price = 0
                self.entry_price = 0
            
        elif order.status in [order.Canceled, order.Rejected]:
            # Handle canceled/rejected orders
            if self.order_ref and order.ref == self.order_ref.ref:
                self.log(f'ENTRY ORDER CANCELED/REJECTED - Status: {order.getstatusname()}')
                self.order_ref = None
            
            elif self.profit_order_ref and order.ref == self.profit_order_ref.ref:
                self.log(f'PROFIT TARGET ORDER CANCELED/REJECTED - Status: {order.getstatusname()}')
                self.profit_order_ref = None
            
            elif self.stop_order_ref and order.ref == self.stop_order_ref.ref:
                self.log(f'STOP ORDER CANCELED/REJECTED - Status: {order.getstatusname()}')
                self.stop_order_ref = None

    def notify_trade(self, trade):
        if trade.isclosed:
            profit_pct = (trade.pnl / abs(trade.value)) * 100 if trade.value != 0 else 0
            self.log(f'TRADE CLOSED - PnL: ${trade.pnl:.2f} ({profit_pct:.2f}%)')
            
    def stop(self):
        print(f'\n=== WMA VOLATILITY MOMENTUM WITH PROFIT TARGET RESULTS ===')
        print(f'Total Trades: {self.trade_count}')
        print(f'Strategy: Vol momentum with WMA trend and Profit Target')
        print(f'Parameters: Vol={self.params.vol_window}d, Vol_Mom={self.params.vol_momentum_window}d, WMA={self.params.price_wma_window}d')
        print(f'Risk Management: ATR {self.params.atr_window}d × {self.params.atr_multiplier}, Profit Target: {self.params.profit_target_percent:.2%}')
        print(f'Vol Momentum Threshold: {self.params.vol_momentum_threshold}')

Parameters

The strategy’s behavior is customized through its parameters:

Initialization (__init__)

In the __init__ method, all necessary indicators and internal state variables are set up:

Main Logic (next)

The next method contains the core trading logic and is executed on each new bar of data:

  1. Data Warm-up Check: It first ensures that enough historical data is available for all indicators to calculate valid values.
  2. Pending Order Check: If there’s an order_ref (meaning an entry order is still pending submission or acceptance), the method returns to avoid placing multiple orders.
  3. Position Management (if self.position): If a position is currently open:
    • Manual Stop Loss Check: It includes a direct price check against self.stop_price. This acts as a backup in case the Stop or StopTrail order placed via backtrader’s broker mechanism doesn’t trigger immediately due to specific order execution models or market gaps.
    • Volatility Momentum Reversal Exit: If vol_momentum falls below or equals a negative vol_momentum_threshold, the position is closed using the _close_position helper method. This acts as an adaptive exit, indicating that the driving force behind the trade might be dissipating.
    • Trailing Stop Update: The stop_price is updated. For long positions, it only moves up (higher), always trailing current_price by atr_multiplier * atr[0]. For short positions, it only moves down (lower). This dynamically locks in profits.
  4. Entry Signals (if not self.position): If no position is currently open and vol_momentum is above vol_momentum_threshold:
    • Long Entry: If current_price is above wma_price, a buy order is placed. The stop_price and profit_target_price are immediately set.
    • Short Entry: If current_price is below wma_price, a sell order is placed. The stop_price and profit_target_price are immediately set.
    • trade_count is incremented, and detailed logs are printed for the signal and initial exit levels.

Helper Method (_close_position)

Logging (log)

Order Notification (notify_order)

This method is called by backtrader whenever an order’s status changes. It plays a critical role in managing the strategy’s active orders:

Trade Notification (notify_trade)

Strategy Stop (stop)

Example Usage and Backtesting Function

The provided run_vol_mom_wma_strategy function demonstrates how to set up and execute a backtest for the VolMomWMAProfitTargetStrategy using historical data from yfinance.

# Example usage and backtesting function
def run_vol_mom_wma_strategy(symbol='SPY', start_date='2020-01-01', end_date='2024-01-01', initial_cash=10000):
    """
    Function to run the Vol Momentum WMA strategy with example parameters
    """
    # Download data
    print(f"Downloading data for {symbol} from {start_date} to {end_date}...")
    data = yf.download(symbol, start=start_date, end=end_date)
    
    if data.empty:
        print("No data downloaded. Please check the symbol and date range.")
        return None, None
    
    # Create backtrader data feed
    data_feed = bt.feeds.PandasData(dataname=data)
    
    # Initialize cerebro
    cerebro = bt.Cerebro()
    cerebro.adddata(data_feed)
    cerebro.addstrategy(VolMomWMAProfitTargetStrategy)
    
    # Set initial capital and commission
    cerebro.broker.setcash(initial_cash)
    cerebro.broker.setcommission(commission=0.001)  # 0.1% commission
    
    # Add analyzers
    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='trades')
    
    print(f'Starting Portfolio Value: ${cerebro.broker.getvalue():.2f}')
    
    # Run strategy
    results = cerebro.run()
    
    final_value = cerebro.broker.getvalue()
    print(f'Final Portfolio Value: ${final_value:.2f}')
    print(f'Total Return: {((final_value - initial_cash) / initial_cash) * 100:.2f}%')
    
    # Print analyzer results
    strat = results[0]
    
    # Sharpe Ratio
    sharpe_analysis = strat.analyzers.sharpe.get_analysis()
    sharpe = sharpe_analysis.get('sharperatio', 'N/A')
    print(f'Sharpe Ratio: {sharpe:.3f}' if sharpe != 'N/A' else f'Sharpe Ratio: {sharpe}')
    
    # Drawdown
    drawdown_analysis = strat.analyzers.drawdown.get_analysis()
    max_drawdown = drawdown_analysis.get('max', {}).get('drawdown', 'N/A')
    print(f'Max Drawdown: {max_drawdown:.2f}%' if max_drawdown != 'N/A' else f'Max Drawdown: {max_drawdown}')
    
    # Trade Analysis
    trade_analysis = strat.analyzers.trades.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}')
    print(f'Losing Trades: {lost_trades}')
    print(f'Win Rate: {win_rate:.1f}%')
    
    if won_trades > 0:
        avg_win = trade_analysis.get('won', {}).get('pnl', {}).get('average', 0)
        print(f'Average Win: ${avg_win:.2f}')
    
    if lost_trades > 0:
        avg_loss = trade_analysis.get('lost', {}).get('pnl', {}).get('average', 0)
        print(f'Average Loss: ${avg_loss:.2f}')
    
    # Plot results
    try:
        cerebro.plot(style='candlestick', volume=False)
        plt.show()
    except Exception as e:
        print(f"Plotting failed: {e}")
    
    return cerebro, results

# Uncomment to run the strategy:
# if __name__ == '__main__':
#     cerebro, results = run_vol_mom_wma_strategy(symbol='SPY', start_date='2022-01-01', end_date='2024-01-01')

How the Backtesting Function Works:

Pasted image 20250722104050.png Pasted image 20250722104056.png Pasted image 20250722104101.png

Conclusion

The VolMomWMAProfitTargetStrategy provides a dynamic and comprehensive approach to momentum-based trading. By integrating volatility momentum as a key driver for entry, confirming trends with a Weighted Moving Average, and employing a robust exit framework that includes both a fixed profit target and an adaptive trailing stop, the strategy aims to capture significant moves while effectively managing risk. The detailed logging and analytical outputs from the backtesting function allow for thorough evaluation of its performance characteristics across various market conditions.