← Back to Home
Trend Reversals and Momentum Confirmation Rolling Backtest of a Parabolic SAR Strategy with RSI Filter

Trend Reversals and Momentum Confirmation Rolling Backtest of a Parabolic SAR Strategy with RSI Filter

This article introduces a trend-following strategy, ParabolicSARStrategy, that utilizes the Parabolic Stop and Reverse (SAR) indicator for identifying trend direction and potential reversals. To enhance the robustness of its signals and avoid whipsaws, the strategy incorporates the Relative Strength Index (RSI) as a momentum filter and implements a fixed percentage stop-loss for disciplined risk management. The strategy’s effectiveness is then evaluated using a comprehensive rolling backtesting framework.

1. The Parabolic SAR Strategy Concept

The ParabolicSARStrategy is designed to capture trends by following price action with the Parabolic SAR, which provides a trailing stop-loss-like mechanism. It aims to enter positions when a new trend is indicated by SAR and confirm this with momentum.

Key Components:

Entry Logic:

Exit Logic:

2. The ParabolicSARStrategy Implementation

Here’s the core backtrader strategy code:

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

class ParabolicSARStrategy(bt.Strategy):
    params = (
        ('af', 0.02),            # Acceleration factor for PSAR
        ('afmax', 0.1),          # Maximum acceleration factor for PSAR
        ('rsi_period', 14),      # RSI period for momentum
        ('rsi_overbought', 70),  # RSI overbought level for filtering long entries
        ('rsi_oversold', 30),    # RSI oversold level for filtering short entries
        ('stop_loss_pct', 0.02), # Fixed percentage stop loss (e.g., 0.02 for 2%)
    )
    
    def __init__(self):
        # Parabolic SAR indicator
        self.psar = bt.indicators.ParabolicSAR(
            af=self.params.af, 
            afmax=self.params.afmax
        )
        
        # Momentum confirmation with RSI
        self.rsi = bt.indicators.RSI(period=self.params.rsi_period)
        
        # Define signals based on Price vs. SAR (for readability)
        # Price above SAR (self.data.close > self.psar) means SAR is below price, bullish setup
        # Price below SAR (self.data.close < self.psar) means SAR is above price, bearish setup
        self.sar_long_position = self.data.close > self.psar 
        self.sar_short_position = self.data.close < self.psar
        
        # Detect actual SAR directional changes (crossovers)
        # self.sar_signal > 0 when SAR turns bullish (price crosses above SAR)
        # self.sar_signal < 0 when SAR turns bearish (price crosses below SAR)
        self.sar_signal = bt.indicators.CrossOver(self.sar_long_position, 0.5) 
        
        # Track orders to prevent multiple simultaneous orders
        self.order = None
        self.stop_order = None # For the fixed stop-loss order

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

    def notify_order(self, order):
        # Ignore submitted/accepted orders, wait for completion or failure
        if order.status in [order.Submitted, order.Accepted]:
            return
        
        # Handle completed orders
        if order.status in [order.Completed]:
            if order.isbuy(): # If a buy order (entry or cover short) completed
                self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, Size: {order.executed.size:.2f}')
                # If we are now in a long position, set a fixed stop-loss
                if self.position.size > 0:
                    stop_price = order.executed.price * (1 - self.params.stop_loss_pct)
                    # Place a Stop Sell order at the calculated stop_price
                    self.stop_order = self.sell(exectype=bt.Order.Stop, price=stop_price, size=self.position.size)
                    self.log(f'Long Stop Loss set at {stop_price:.2f}')
            
            elif order.issell(): # If a sell order (entry or exit long) completed
                self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, Size: {order.executed.size:.2f}')
                # If we are now in a short position, set a fixed stop-loss
                if self.position.size < 0:
                    stop_price = order.executed.price * (1 + self.params.stop_loss_pct)
                    # Place a Stop Buy order at the calculated stop_price
                    self.stop_order = self.buy(exectype=bt.Order.Stop, price=stop_price, size=abs(self.position.size))
                    self.log(f'Short Stop Loss set at {stop_price:.2f}')
            
            # Clear the main order reference after any order completion
            self.order = None 
            # If the completed order was our stop-loss order, clear its reference
            if self.stop_order and order.ref == self.stop_order.ref:
                self.stop_order = None
        
        # Handle canceled/rejected orders
        elif order.status in [order.Canceled, order.Rejected]:
            self.log(f'Order Canceled/Rejected: Status {order.getstatusname()}')
            if self.order and order.ref == self.order.ref: # If entry order failed
                self.order = None
            if self.stop_order and order.ref == self.stop_order.ref: # If stop order failed
                self.stop_order = None # IMPORTANT: If stop fails, the position is unprotected!

    def notify_trade(self, trade):
        ''' Reports profit/loss when a trade is closed '''
        if not trade.isclosed:
            return
        self.log(f'TRADE P/L: GROSS {trade.pnl:.2f}, NET {trade.pnlcomm:.2f}')

    def next(self):
        # Prevent new entry/exit orders if one is already pending
        if self.order is not None:
            return
        
        # Ensure indicators have warmed up enough. PSAR needs a few bars, RSI needs its period.
        min_warmup = max(self.params.afmax / self.params.af * 10, self.params.rsi_period) # Rough estimate for PSAR warmup
        if len(self.data) < min_warmup:
             return # Not enough data for reliable indicator calculations

        # --- Trading Logic based on SAR signals and RSI confirmation ---
        
        # If SAR turns bullish (price crosses above SAR)
        if self.sar_signal[0] > 0: # This indicates a bullish crossover
            # Confirm with RSI not overbought (i.e., has room to move up)
            if self.rsi[0] < self.params.rsi_overbought:
                if self.position.size < 0: # If currently short, close short position
                    self.log(f'SAR BULLISH signal. Close SHORT. Price: {self.data.close[0]:.2f}')
                    if self.stop_order is not None: self.cancel(self.stop_order) # Cancel old stop order
                    self.order = self.close()
                elif not self.position: # If flat, go long
                    self.log(f'SAR BULLISH signal. BUY. Price: {self.data.close[0]:.2f}')
                    self.order = self.buy() # Stop loss will be set in notify_order
                    
        # If SAR turns bearish (price crosses below SAR)
        elif self.sar_signal[0] < 0: # This indicates a bearish crossover
            # Confirm with RSI not oversold (i.e., has room to move down)
            if self.rsi[0] > self.params.rsi_oversold:
                if self.position.size > 0: # If currently long, close long position
                    self.log(f'SAR BEARISH signal. Close LONG. Price: {self.data.close[0]:.2f}')
                    if self.stop_order is not None: self.cancel(self.stop_order) # Cancel old stop order
                    self.order = self.close()
                elif not self.position: # If flat, go short
                    self.log(f'SAR BEARISH signal. SELL (Short). Price: {self.data.close[0]:.2f}')
                    self.order = self.sell() # Stop loss will be set in notify_order

    def stop(self):
        self.log(f'Final Portfolio Value: {self.broker.getvalue():.2f}')

Explanation of ParabolicSARStrategy:

3. Backtesting and Analysis

The provided script includes two primary ways to test this strategy:

  1. Single Backtest (for initial testing and plotting):

    if __name__=='__main__':
        # Download data and run backtest (Example for BTC-USD)
        data = yf.download('BTC-USD', '2021-01-01', '2024-01-01', auto_adjust=False) # Use auto_adjust=False as per preference
        data.columns = data.columns.droplevel(1) if isinstance(data.columns, pd.MultiIndex) else data.columns # Droplevel if MultiIndex
        data_feed = bt.feeds.PandasData(dataname=data)
    
        cerebro = bt.Cerebro()
        cerebro.addstrategy(ParabolicSARStrategy) # Add your strategy
        cerebro.adddata(data_feed)
        cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
        cerebro.broker.setcash(100000)
        cerebro.broker.setcommission(commission=0.001)
    
        print(f'Start: ${cerebro.broker.getvalue():,.2f}')
        results = cerebro.run()
        print(f'End: ${cerebro.broker.getvalue():,.2f}')
        print(f'Return: {((cerebro.broker.getvalue() / 100000) - 1) * 100:.2f}%')
    
        # Fix matplotlib plotting issues (already in your code)
        plt.rcParams['figure.max_open_warning'] = 0
        plt.rcParams['agg.path.chunksize'] = 10000
    
        try:
            cerebro.plot(iplot=False, style='candlestick', volume=True) # Added candlestick and volume
            plt.show()
        except Exception as e:
            print(f"Plotting error: {e}")
            print("Strategy completed successfully - plotting skipped")

    This block allows you to quickly run the strategy over a single period and visualize its trades using cerebro.plot().

  2. Rolling Backtest (for robustness evaluation): This method is crucial for understanding a strategy’s consistency across various market conditions, preventing curve-fitting to a single historical period.

    import dateutil.relativedelta as rd # Added import
    import seaborn as sns # Added import
    from datetime import datetime # Added import for current date
    
    # Define the strategy for the rolling backtest
    strategy = ParabolicSARStrategy
    
    def run_rolling_backtest(
        ticker="ETH-USD",
        start="2018-01-01",
        # Updated end date to the current date for a more live test
        end=datetime.now().date(), # Get current date for the end of the backtest
        window_months=3,
        strategy_params=None
    ):
        strategy_params = strategy_params or {}
        all_results = []
        start_dt = pd.to_datetime(start)
        end_dt = pd.to_datetime(end)
        current_start = start_dt
    
        while True:
            current_end = current_start + rd.relativedelta(months=window_months)
            if current_end > end_dt:
                current_end = end_dt 
                if current_start >= current_end:
                    break
    
            print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
    
            # Data download using yfinance, respecting user's preference
            # Using the saved preference: yfinance download with auto_adjust=False and droplevel(axis=1, level=1)
            data = yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
    
            # Apply droplevel if data is a MultiIndex, as per user's preference
            if isinstance(data.columns, pd.MultiIndex):
                data = data.droplevel(1, axis=1)
    
            # Check for sufficient data after droplevel for strategy warm-up
            # PSAR needs a few bars, RSI needs its period.
            # Longest period is RSI.
            rsi_period = strategy_params.get('rsi_period', ParabolicSARStrategy.params.rsi_period)
            min_bars_needed = max(rsi_period, 50) # Rough estimate for PSAR warmup to be safe, or just RSI period
    
            if data.empty or len(data) < min_bars_needed:
                print(f"Not enough data for period {current_start.date()} to {current_end.date()} (requires at least {min_bars_needed} bars). Skipping.")
                if current_end == end_dt:
                    break
                current_start = current_end
                continue
    
            feed = bt.feeds.PandasData(dataname=data)
            cerebro = bt.Cerebro()
            cerebro.addstrategy(strategy, **strategy_params)
            cerebro.adddata(feed)
            cerebro.broker.setcash(100000)
            cerebro.broker.setcommission(commission=0.001)
            cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
    
            start_val = cerebro.broker.getvalue()
            cerebro.run()
            final_val = cerebro.broker.getvalue()
            ret = (final_val - start_val) / start_val * 100
    
            all_results.append({
                'start': current_start.date(),
                'end': current_end.date(),
                'return_pct': ret,
                'final_value': final_val,
            })
    
            print(f"Return: {ret:.2f}% | Final Value: {final_val:.2f}")
    
            if current_end == end_dt:
                break
            current_start = current_end
    
        return pd.DataFrame(all_results)
    
    
    def report_stats(df):
        returns = df['return_pct']
        stats = {
            'Mean Return %': np.mean(returns),
            'Median Return %': np.median(returns),
            'Std Dev %': np.std(returns),
            'Min Return %': np.min(returns),
            'Max Return %': np.max(returns),
            'Sharpe Ratio': np.mean(returns) / np.std(returns) if np.std(returns) > 0 else np.nan
        }
        print("\n=== ROLLING BACKTEST STATISTICS ===")
        for k, v in stats.items():
            print(f"{k}: {v:.2f}")
        return stats
    
    def plot_four_charts(df, rolling_sharpe_window=4):
        """
        Generates four analytical plots for rolling backtest results.
        """
        fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(12, 8)) # Adjusted figsize for clarity
    
        periods = list(range(len(df)))
        returns = df['return_pct']
    
        # 1. Period Returns (Top Left)
        colors = ['green' if r >= 0 else 'red' for r in returns]
        ax1.bar(periods, returns, color=colors, alpha=0.7)
        ax1.set_title('Period Returns', fontsize=14, fontweight='bold')
        ax1.set_xlabel('Period')
        ax1.set_ylabel('Return %')
        ax1.axhline(y=0, color='black', linestyle='-', alpha=0.3)
        ax1.grid(True, alpha=0.3)
    
        # 2. Cumulative Returns (Top Right)
        cumulative_returns = (1 + returns / 100).cumprod() * 100 - 100
        ax2.plot(periods, cumulative_returns, marker='o', linewidth=2, markersize=4, color='blue')
        ax2.set_title('Cumulative Returns', fontsize=14, fontweight='bold')
        ax2.set_xlabel('Period')
        ax2.set_ylabel('Cumulative Return %')
        ax2.grid(True, alpha=0.3)
    
        # 3. Rolling Sharpe Ratio (Bottom Left)
        rolling_sharpe = returns.rolling(window=rolling_sharpe_window).apply(
            lambda x: x.mean() / x.std() if x.std() > 0 else np.nan, raw=False
        )
        valid_mask = ~rolling_sharpe.isna()
        valid_periods = [i for i, valid in enumerate(valid_mask) if valid]
        valid_sharpe = rolling_sharpe[valid_mask]
    
        ax3.plot(valid_periods, valid_sharpe, marker='o', linewidth=2, markersize=4, color='orange')
        ax3.axhline(y=0, color='red', linestyle='--', alpha=0.5)
        ax3.set_title(f'Rolling Sharpe Ratio ({rolling_sharpe_window}-period)', fontsize=14, fontweight='bold')
        ax3.set_xlabel('Period')
        ax3.set_ylabel('Sharpe Ratio')
        ax3.grid(True, alpha=0.3)
    
        # 4. Return Distribution (Bottom Right)
        bins = min(15, max(5, len(returns)//2))
        ax4.hist(returns, bins=bins, alpha=0.7, color='steelblue', edgecolor='black')
        mean_return = returns.mean()
        ax4.axvline(mean_return, color='red', linestyle='--', linewidth=2, 
                    label=f'Mean: {mean_return:.2f}%')
        ax4.set_title('Return Distribution', fontsize=14, fontweight='bold')
        ax4.set_xlabel('Return %')
        ax4.set_ylabel('Frequency')
        ax4.legend()
        ax4.grid(True, alpha=0.3)
    
        plt.tight_layout()
        plt.show()
    
    if __name__ == '__main__':
        # The end date is set to the current date for a more "live" simulation
        current_date = datetime.now().date() 
    
        df = run_rolling_backtest(
            ticker="ETH-USD", # Default ticker for article's example
            start="2018-01-01",
            end=current_date, # Use the current date
            window_months=3,
    
        )
    
        print("\n=== ROLLING BACKTEST RESULTS ===")
        print(df)
    
        stats = report_stats(df)
        plot_four_charts(df)
Pasted image 20250622012154.png

4. Conclusion

The ParabolicSARStrategy provides a straightforward yet effective approach to trend following by utilizing the dynamic nature of the Parabolic SAR indicator. The integration of RSI acts as a crucial momentum filter, helping to avoid trades in overextended conditions and potentially reducing whipsaws. The fixed percentage stop-loss is an essential component for disciplined risk management, ensuring that potential losses are controlled. The rigorous rolling backtesting framework is invaluable for assessing the strategy’s consistency and resilience across diverse market environments, offering a more reliable evaluation of its long-term viability. Further fine-tuning of the SAR acceleration factors, RSI thresholds, and stop-loss percentage could lead to optimized performance for specific assets or market conditions.