← Back to Home
Adaptive PSAR and RSI Confirmation Strategy

Adaptive PSAR and RSI Confirmation Strategy

This article presents the PSARRSIConfirmStrategy, a robust trading system that combines the Parabolic SAR (PSAR) for trend following, RSI slope for momentum confirmation, and a long-term Simple Moving Average (SMA) for trend filtering. A key feature is its adaptive PSAR Acceleration Factor (AF), which adjusts based on market volatility, and mandatory trailing stops for risk management.

Strategy Overview

The PSARRSIConfirmStrategy aims to capture confirmed trends by ensuring alignment across multiple indicators and adapting to changing market conditions.

Entry Logic

An entry order is placed when the following conditions are met:

  1. Trend Filter (SMA): The current price must be in alignment with the long-term trend, as indicated by a Simple Moving Average (SMA).
    • For long entries, the closing price must be above the SMA.
    • For short entries, the closing price must be below the SMA.
  2. PSAR Signal: The Parabolic SAR must generate a reversal signal in the direction of the trade.
    • For long entries, PSAR must cross below the price, indicating an upward trend.
    • For short entries, PSAR must cross above the price, indicating a downward trend.
  3. RSI Slope Confirmation: The Relative Strength Index (RSI) must show consistent momentum in the direction of the trade over a specified lookback period.
    • For long entries, the RSI must be consistently rising over the last rsi_slope_lookback bars.
    • For short entries, the RSI must be consistently falling over the last rsi_slope_lookback bars.

All these conditions must align for an entry order to be placed.

Adaptive PSAR Acceleration Factor (AF)

A crucial aspect of this strategy is the adaptive nature of the PSAR’s Acceleration Factor (AF). The AF determines the sensitivity of the PSAR to price changes. In this strategy, the AF dynamically adjusts based on the current market volatility, measured by the Average True Range (ATR) relative to its smoothed average.

Exit Logic

As per your explicit instructions, all positions are managed with trailing stop-loss orders. This means that immediately upon a successful entry, a stop-loss order is automatically placed to trail the market price by a fixed trail_percent. As the market moves favorably, this stop-loss adjusts to lock in profits, but it never moves against the trade if the market reverses, thereby protecting gains and limiting potential losses.

Backtrader Implementation

The strategy is implemented in backtrader as follows:

import backtrader as bt

class PSARRSIConfirmStrategy(bt.Strategy):
    """
    PSAR signals confirmed by RSI slope,
    long-term SMA filter, adaptive PSAR AF, and trailing stop.
    """
    params = (
        ('ma_period', 30),
        ('psar_af_min', 0.01),
        ('psar_af_max', 0.1),
        ('atr_period', 7),
        ('atr_smoothing_period', 7),
        ('rsi_period', 21), # Increased for smoother RSI
        ('rsi_slope_lookback', 3), # Check RSI direction over last N bars
        ('trail_percent', 0.02), # Adjusted trailing stop
    )

    def __init__(self):
        self.order = None
        self.dataclose = self.datas[0].close

        self.sma = bt.indicators.SimpleMovingAverage(self.datas[0], period=self.p.ma_period)
        self.atr = bt.indicators.ATR(self.datas[0], period=self.p.atr_period)
        self.avg_atr = bt.indicators.SMA(self.atr, period=self.p.atr_smoothing_period)

        self.psar = bt.indicators.ParabolicSAR(self.datas[0], af=self.p.psar_af_min, afmax=self.p.psar_af_max)
        self.psar_cross = bt.indicators.CrossOver(self.dataclose, self.psar)
        
        self.rsi = bt.indicators.RSI(self.datas[0], period=self.p.rsi_period)

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]:
            return
        if order.status in [order.Completed]:
            if order.isbuy():
                # Always use trailing stops as per your saved information
                self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
            elif order.issell():
                # Always use trailing stops as per your saved information
                self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
        self.order = None

    def next(self):
        if self.order:
            return

        # Calculate the absolute minimum number of bars required for all indicators
        min_required_bars = max(
            self.p.ma_period,
            self.p.atr_smoothing_period,
            # RSI needs its period + slope lookback + 1 additional bar for comparison
            self.p.rsi_period + self.p.rsi_slope_lookback + 1 
        )

        if len(self) < min_required_bars:
            return

        # Adaptive AF calculation
        # Normalized ATR helps scale the AF between min and max
        current_af = self.p.psar_af_min + (self.p.psar_af_max - self.p.psar_af_min) * \
                     min(1.0, max(0.0, (self.atr[0] / self.avg_atr[0] if self.avg_atr[0] else 1.0) - 0.5))
        self.psar.af = current_af
        self.psar.afmax = current_af * 10 # Cap the max AF based on current adaptive AF

        # RSI Slope Confirmation: Check if RSI is consistently moving in the trade direction
        # Ensure enough RSI data buffered for the slope calculation
        if self.rsi.buflen() < (self.p.rsi_slope_lookback + 1):
            return 

        # Check if RSI has been consistently rising or falling over the lookback period
        # Using a generator expression with 'all' for efficiency
        rsi_rising = all(self.rsi[-i] > self.rsi[-i-1] for i in range(self.p.rsi_slope_lookback))
        rsi_falling = all(self.rsi[-i] < self.rsi[-i-1] for i in range(self.p.rsi_slope_lookback))

        if not self.position: # Only enter if no position is open
            if self.dataclose[0] > self.sma[0]: # Long-Only Regime (Price above SMA)
                # Buy if PSAR crosses up AND RSI is consistently rising
                if self.psar_cross[0] > 0.0 and rsi_rising:
                    self.order = self.buy()
            
            elif self.dataclose[0] < self.sma[0]: # Short-Only Regime (Price below SMA)
                # Sell if PSAR crosses down AND RSI is consistently falling
                if self.psar_cross[0] < 0.0 and rsi_falling:
                    self.order = self.sell()

Parameters (params)

The strategy’s behavior is configured through its parameters:

Initialization (__init__)

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

Order Notification (notify_order)

This method is automatically called by backtrader whenever an order’s status changes. It is crucial for managing the trailing stop-loss, as per the explicit instruction to always use trailing stops:

Main Logic (next)

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

  1. Pending Order Check: if self.order: return ensures that no new actions are taken if a previous order is still pending.
  2. Data Warm-up Check: if len(self) < min_required_bars: return ensures that all indicators have enough historical data to provide valid readings before the strategy attempts any trading decisions.
  3. Adaptive PSAR Acceleration Factor (AF) Calculation: The current_af for the PSAR is calculated based on the ratio of current ATR to its smoothed average. This current_af then dynamically updates self.psar.af and sets self.psar.afmax to current_af * 10 (though afmax would typically be set as a hard cap in PSAR parameters, here it’s shown adapting with af). This allows the PSAR to respond more aggressively in high-volatility environments and more smoothly in low-volatility ones.
  4. RSI Slope Confirmation: The strategy checks if the RSI has been consistently rising or falling over the rsi_slope_lookback bars. This adds a momentum confirmation layer, ensuring that the PSAR signal is backed by underlying strength or weakness.
  5. Entry Conditions (if not self.position): The strategy only looks to enter a trade if no position is currently open.
    • Long Signal: A buy order is placed if dataclose[0] (current close price) is above the sma[0] (long-term uptrend) AND the psar_cross[0] is positive (price crossed above PSAR, signaling bullish reversal) AND rsi_rising is True (RSI consistently rising).
    • Short Signal: A sell order is placed if dataclose[0] (current close price) is below the sma[0] (long-term downtrend) AND the psar_cross[0] is negative (price crossed below PSAR, signaling bearish reversal) AND rsi_falling is True (RSI consistently falling).

Rolling Backtesting Setup

To comprehensively evaluate the strategy’s performance, a rolling backtest is used. This method assesses the strategy over multiple, successive time windows, providing a more robust view of its consistency compared to a single, fixed backtest.

from collections import deque
import backtrader as bt
import numpy as np
import pandas as pd
import yfinance as yf
import dateutil.relativedelta as rd

# Assuming PSARRSIConfirmStrategy class is defined above this section.

def run_rolling_backtest(
    ticker,
    start,
    end,
    window_months,
    strategy_class,
    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 = yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)

        if data.empty or len(data) < 90:
            print("Not enough data for this period. Skipping.")
            current_start += rd.relativedelta(months=window_months)
            continue

        # Droplevel the columns (considering user preference)
        if isinstance(data.columns, pd.MultiIndex):
            data = data.droplevel(axis=1, level=1)

        start_price = data['Close'].iloc[0]
        end_price = data['Close'].iloc[-1]
        benchmark_ret = (end_price - start_price) / start_price * 100

        feed = bt.feeds.PandasData(dataname=data)
        cerebro = bt.Cerebro()
        
        cerebro.addstrategy(strategy_class, **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()
        try:
            cerebro.run()
        except Exception as e:
            print(f"Error running backtest for {current_start.date()} to {current_end.date()}: {e}")
            current_start += rd.relativedelta(months=window_months)
            continue

        final_val = cerebro.broker.getvalue()
        strategy_ret = (final_val - start_val) / start_val * 100

        all_results.append({
            'start': current_start.date(),
            'end': current_end.date(),
            'strategy_return_pct': strategy_ret,
            'benchmark_return_pct': benchmark_ret,
            'final_value': final_val,
        })

        print(f"Strategy Return: {strategy_ret:.2f}% | Buy & Hold Return: {benchmark_ret:.2f}%")

        current_start += rd.relativedelta(months=window_months)

        if current_start > end_dt:
            break

    return pd.DataFrame(all_results)

How the Rolling Backtest Works:

Conclusion

The PSARRSIConfirmStrategy offers a sophisticated approach to trend-following by combining the adaptive nature of PSAR with the confirmatory power of RSI slope and a long-term trend filter. This multi-indicator approach aims to reduce false signals and capture robust trends. The dynamic adjustment of the PSAR’s Acceleration Factor based on volatility allows the strategy to remain responsive in varying market conditions. With its built-in trailing stop-loss, the strategy prioritizes effective risk management. The use of a rolling backtest is crucial for thoroughly evaluating the strategy’s consistency and adaptability across diverse market conditions, providing a more reliable assessment of its performance potential.