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.
The PSARRSIConfirmStrategy
aims to capture confirmed
trends by ensuring alignment across multiple indicators and adapting to
changing market conditions.
An entry order is placed when the following conditions are met:
rsi_slope_lookback
bars.rsi_slope_lookback
bars.All these conditions must align for an entry order to be placed.
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.
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.
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
= max(
min_required_bars 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
= self.p.psar_af_min + (self.p.psar_af_max - self.p.psar_af_min) * \
current_af 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
= all(self.rsi[-i] > self.rsi[-i-1] for i in range(self.p.rsi_slope_lookback))
rsi_rising = all(self.rsi[-i] < self.rsi[-i-1] for i in range(self.p.rsi_slope_lookback))
rsi_falling
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()
params
)The strategy’s behavior is configured through its parameters:
ma_period
: Period for the Simple Moving Average (SMA),
used as the long-term trend filter.psar_af_min
, psar_af_max
: Minimum and
maximum acceleration factor for the Parabolic SAR. The adaptive AF will
operate within this range.atr_period
: Period for the Average True Range (ATR),
used to measure market volatility.atr_smoothing_period
: Period for the SMA of ATR,
providing a baseline for typical volatility to normalize current
ATR.rsi_period
: Period for the Relative Strength Index
(RSI). Increased to 21 for smoother signals.rsi_slope_lookback
: Number of bars to check for
consistent RSI slope (rising or falling).trail_percent
: The percentage at which the trailing
stop-loss trails the market price. This is always used for exits.__init__
)In the __init__
method, all necessary indicators and
internal state variables are set up:
self.order
: This variable keeps track of any pending
orders to prevent multiple orders from being placed on the same
bar.self.dataclose
: A convenient reference to the close
price line of the data feed.self.sma
: The Simple Moving Average for trend
filtering.self.atr
: The Average True Range indicator for
measuring current volatility.self.avg_atr
: The Simple Moving Average of the ATR,
used as a reference point for adaptive PSAR AF.self.psar
: The Parabolic SAR indicator, initially set
with psar_af_min
and psar_af_max
. Its
af
parameter will be dynamically adjusted in
next()
.self.psar_cross
: A CrossOver
indicator to
detect when the price crosses the PSAR (signaling trend reversals).self.rsi
: The Relative Strength Index indicator.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:
buy
order completes (meaning a
long position has just been opened), a corresponding sell
trailing stop order is immediately placed using
self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
.sell
order completes (meaning a
short position has just been opened), a corresponding buy
trailing stop order is placed using
self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
.self.order = None
ensures that the internal order
reference is cleared once the order is no longer in a submitted or
accepted state, allowing for new orders on subsequent bars if conditions
are met.next
)The next
method contains the core trading logic and is
executed on each new bar of data:
if self.order: return
ensures that no new actions are taken
if a previous order is still pending.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.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.rsi_slope_lookback
bars. This adds a momentum confirmation
layer, ensuring that the PSAR signal is backed by underlying strength or
weakness.if not self.position
): The strategy only looks to
enter a trade if no position is currently open.
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).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).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,=None
strategy_params
):= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
= end_dt
current_end if current_start >= current_end:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
= yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
data
if data.empty or len(data) < 90:
print("Not enough data for this period. Skipping.")
+= rd.relativedelta(months=window_months)
current_start continue
# Droplevel the columns (considering user preference)
if isinstance(data.columns, pd.MultiIndex):
= data.droplevel(axis=1, level=1)
data
= data['Close'].iloc[0]
start_price = data['Close'].iloc[-1]
end_price = (end_price - start_price) / start_price * 100
benchmark_ret
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro
**strategy_params)
cerebro.addstrategy(strategy_class,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val try:
cerebro.run()except Exception as e:
print(f"Error running backtest for {current_start.date()} to {current_end.date()}: {e}")
+= rd.relativedelta(months=window_months)
current_start continue
= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
strategy_ret
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}%")
+= rd.relativedelta(months=window_months)
current_start
if current_start > end_dt:
break
return pd.DataFrame(all_results)
ticker
, start
, end
: The asset
symbol and the overall historical period for the backtest.window_months
: The duration of each individual
backtesting window in months.strategy_class
: The backtrader.Strategy
class to be tested (e.g., PSARRSIConfirmStrategy
).strategy_params
: A dictionary to pass specific
parameters to the chosen strategy for each run.yfinance
with
auto_adjust=False
and droplevel
applied.backtrader.Cerebro
instance.strategy_class
and its
parameters.Pandas DataFrame
containing the
comprehensive results for each rolling window. 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.