Markets can be volatile, ripping higher over a few weeks before accumulating a series of down days. Timing these momentum trades is never going to be perfect, but indicators like the Parabolic Stop-and-Reverse (Parabolic SAR or PSAR) are designed to help you find the trend and ride it to the top (or bottom) and get out with a tidy profit.
The PSAR is predicated off of the sequence of highs and lows that a stock makes. If the highs keep getting higher, it's accelerating and the indicator will be flashing BUY! It has a short lookback period, so it will signal an exit when the trend stops, and give you a short signal when it reverses (the Stop-and-Reverse in the name makes a bit more sense now, doesn't it?).
In a previous post, we walked through a detailed explanation of how the PSAR is calculated with plenty of example code and diagrams. If you missed that, go check it out, otherwise we're going to press on and provide three example backtests that you can trade.
TL;DR
We provide backtests and code for three distinct trading strategies using the PSAR alone as well as with other indicators. These tests are designed to give you a feel for the indicator and running backtests. If you want something faster and more complete, check out our free, no-code platform here to get started today!
Trend Following with the PSAR
The PSAR is a trend following indicator - it's designed to identify periods of up trends, down trends, and when they stop. The most straightforward way to apply this is simply to buy when the PSAR indicates a stock is going up and short it when it says the trend is going down.
Here's the code for the strategy:
def PSARTrendStrategy(data: pd.DataFrame, init_af: float=0.02,
max_af: float=0.2, af_step: float=0.02,
shorts: bool=True):
indic = PSAR(init_af, max_af, af_step)
data['PSAR'] = data.apply(
lambda x: indic.calcPSAR(x['High'], x['Low']), axis=1)
data['Trend'] = indic.trend_list
# Long when trend is up, short when trend is down
data['position'] = 0
data['position'] = np.where(data['Trend']==1, 1,
data['position'])
if shorts:
data['position'] = np.where(data['Trend']==0, -1,
data['position'])
return calcReturns(data)
We initialize our PSAR class with an initial acceleration factor and set the associated parameters, then apply that to our data to calculate the PSAR. The thing we're going to be looking at is the Trend value for making our decisions. We use this to determine our position (1 = long, 0 = neutral, -1 = short) and calculate our returns using the helper functions here.
Now, we just need some data to test our simple strategy!
We'll sample from the S&P 500 and get our data using the yfinance package.
import pandas as pd
import yfinance as yf
import numpy as np
import matplotlib.pyplot as plt
table = pd.read_html(
'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')
df = table[0]
syms = df['Symbol']
# Sample symbols
ticker = np.random.choice(syms.values)
# ticker = 'ABMD' # Uncomment this line to re-create my example
print(f"Ticker Symbol: {ticker}")
start = '2000-01-01'
end = '2021-12-31'
# PSAR Trend Strategy
yfObj = yf.Ticker(ticker)
data = yfObj.history(start=start, end=end)
data.drop(['Open', 'Volume', 'Dividends', 'Stock Splits'],
axis=1, inplace=True)
Ticker Symbol: ABMD
My sample chose ABMD, some biomedical stock that appears to have taken off in recent years. Go ahead and pass this data to our strategy and plot it to see what happens.
df_psar = PSARTrendStrategy(data.copy())
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
fig, ax = plt.subplots(2, figsize=(12, 8), sharex=True)
ax[0].plot(df_psar['strat_cum_returns']*100, label='PSAR Strategy')
ax[0].plot(df_psar['cum_returns']*100, label='Buy and Hold')
ax[0].legend()
ax[0].set_ylabel('Returns (%)')
ax[0].set_title('PSAR Strategy Cumulative Returns')
ax[1].plot(df_psar['Close'], label='Close')
ax[1].set_xlabel('Date')
ax[1].set_ylabel('Price ($)')
ax[1].set_title(f'{ticker} Price')
plt.tight_layout()
plt.show()
Our strategy had some major volatility! But, it outperformed the buy and hold strategy by a bit.
We can use our getStratStats helper function to look at some of the key performance metrics.
bh_stats = pd.DataFrame(getStratStats(df_psar['log_returns']),
index=['Buy and Hold'])
psar_stats = pd.DataFrame(getStratStats(df_psar['strat_log_returns']),
index=['PSAR'])
stats = pd.concat([bh_stats, psar_stats])
stats
So this strategy did outperform our baseline with some healthy, annual returns, but the equity curve does have some odd lumps in it.
For example, wtf happened in 2012? It was doing great - then dropped off a cliff!
At this time scale, we can't really see the moves of the underlying very well, so let's zoom in on the year.
# What happened in 2012?
df_psar['year'] = df_psar.index.map(lambda x: x.year)
df_sub = df_psar.loc[df_psar['year']==2012]
# Reset cumulative returns
df_sub['strat_cum_returns'] = np.exp(df_sub['strat_log_returns'].cumsum()) - 1
df_sub['strat_peak'] = df_sub['strat_cum_returns'].cummax()
psar_bull = df_sub.loc[df_sub['Trend']==1]['PSAR']
psar_bear = df_sub.loc[df_sub['Trend']==0]['PSAR']
fig, ax = plt.subplots(2, figsize=(12, 8), sharex=True)
ax[0].plot(df_sub['Close'], label='Close')
ax[0].scatter(psar_bull.index, psar_bull,
color=colors[1], label='PSAR Up')
ax[0].scatter(psar_bear.index, psar_bear,
color=colors[3], label='PSAR Down')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and PSAR for {ticker}')
ax[1].plot(df_sub['strat_cum_returns'] * 100)
ax[1].set_xlabel('Date')
ax[1].set_ylabel('Returns (%)')
ax[1].set_title('2012 Cumulative Returns')
plt.tight_layout()
plt.show()
The strategy peaked in 2012 on June 1 at 69% YTD - then it was all downhill from there (which, I suppose is the definition of a peak). The strategy was caught short with a big move up, reversing the trend - but it was too late and it wound up closing out the month of June "only" up 32% YTD. If that was it, nobody would complain, but it got worse from there.
You can see in the chart above that the strategy was consistently a day or two behind with big, choppy moves pushing it to the wrong side as it slowly gave up equity. The worst of it came on November 1.
ABMD closed out October about 20% off of its highs for the year after a 4-month down trend. The price finished up on October 31st, closing nearly 5% up vs the day before. This caused the PSAR to flip from short to long - the lone blue dot from early October to late November in the chart above - expecting a trend reversal. It couldn't have been more wrong. November 1 closed with ABMD losing 37% in a single day causing the strategy to go a YTD winner to down 15% so far in 2012.
It reversed to go short again a day too late and got whipped around for the last two months of the year to finish down 41% for all of 2012.
The same story played out in 2015 when the underlying hit a 33% single day loss on October 29, 2015, reversing a brief uptrend during a longer, down-trend.
These equity curves look so choppy because there is no risk management at play in these simplified strategies. The algo puts 100% of its capital at risk on each and every position, so a 30% move in the ABMD is going to cause the cumulative returns to drop 30% as well. So if the strategy had racked up a whopping 7,000% cumulative return over 15 years, that one bad move would see that track record eviscerated by 30% as well.
Most traders combine the PSAR with another indicator to look for confirmation to avoid being whipped in and out of positions. We'll look at some of these strategies next.
Trading the PSAR with the RSI
A classic combination is to use the PSAR in conjunction with the Relative Strength Index (RSI).
Most frequently, the RSI is used in mean reversion strategies to identify overbought or oversold positions in preparation for the price to move the other direction. It can also be used as a momentum or trend following indicator, which is how we'll employ it here.
The RSI measures the strength of a price move on a scale of 0-100. If it is over 50, it is interpreted as being in a rising trend, under 50 a downward or falling trend. The more extreme it gets, the stronger the trend. An easy momentum strategy with the RSI is to go long if it moves above 50 and short if it's under 50. In this case, we're going to combine it with the PSAR, so we'll go long/short only if the PSAR and RSI are in agreement, otherwise, we'll be neutral.
This is going to cut down on the number of trades and may lead to longer times out of the market, but the hope is that we'll have a more reliable indicator to work with.
Let's get to the code!
First, here's the function to calculate RSI - which is explained in detail here.
def calcRSI(data, P=7):
data['diff_close'] = data['Close'] - data['Close'].shift(1)
data['gain'] = np.where(data['diff_close']>0,
data['diff_close'], 0)
data['loss'] = np.where(data['diff_close']<0,
np.abs(data['diff_close']), 0)
data[['init_avg_gain', 'init_avg_loss']] = data[
['gain', 'loss']].rolling(P).mean()
avg_gain = np.zeros(len(data))
avg_loss = np.zeros(len(data))
for i, _row in enumerate(data.iterrows()):
row = _row[1]
if i < P - 1:
last_row = row.copy()
continue
elif i == P-1:
avg_gain[i] += row['init_avg_gain']
avg_loss[i] += row['init_avg_loss']
else:
avg_gain[i] += ((P - 1) * avg_gain[i-1] + row['gain']) / P
avg_loss[i] += ((P - 1) * avg_loss[i-1] + row['loss']) / P
last_row = row.copy()
data['avg_gain'] = avg_gain
data['avg_loss'] = avg_loss
data['RS'] = data['avg_gain'] / data['avg_loss']
data['RSI'] = 100 - 100 / (1 + data['RS'])
return data
Now we will write a function that will take our data and parameters, calculate our indicators, then determine the position our algorithm will take based on the rules we outlined above.
def RSITrendStrategy(data: pd.DataFrame, init_af: float=0.02, max_af: float=0.2,
af_step: float=0.02, rsi_p: int=7, centerline: float=50,
shorts: bool=True):
'''
Enter when RSI moves above/below centerline and PSAR is in agreement with the
move. Exit when either reverses.
'''
# Calculate indicators
data = calcRSI(data, rsi_p)
indic = PSAR(init_af, max_af, af_step)
data['PSAR'] = data.apply(
lambda x: indic.calcPSAR(x['High'], x['Low']), axis=1)
data['Trend'] = indic.trend_list
data['position'] = np.nan
data['position'] = np.where(
(data['RSI']>centerline) & (data['Trend']==1), 1, data['position'])
if shorts:
data['position'] = np.where(
(data['RSI']<centerline) & (data['Trend']==0), -1, data['position'])
data['position'] = data['position'].fillna(0)
return calcReturns(data)
Finally, let's test our strategy and plot the results.
df_rsi = RSITrendStrategy(data.copy()) #, rsi_p=20)
fig, ax = plt.subplots(2, figsize=(12, 8), sharex=True)
ax[0].plot(df_rsi['strat_cum_returns']*100, label='RSI Trend Strategy')
ax[0].plot(df_rsi['cum_returns']*100, label='Buy and Hold')
ax[0].legend()
ax[0].set_ylabel('Returns (%)')
ax[0].set_title('Cumulative Returns')
ax[1].plot(df_rsi['Close'], label='Close')
ax[1].set_ylabel('Price ($)')
ax[1].set_title(f'{ticker} Price')
ax[1].set_xlabel('Date')
plt.tight_layout()
plt.show()
rsi_stats = pd.DataFrame(getStratStats(df_rsi['strat_log_returns']),
index=['PSAR and RSI'])
stats = pd.concat([bh_stats, psar_stats, rsi_stats])
stats
Adding the RSI does increase our time out of market from 2 days (for initialization) to 1,123 days. Unfortunately, adding the RSI doesn't prevent us from getting caught off guard on those key days that hurt our original PSAR strategy.
Let's zoom in on that gnarly 2015 again, this time we'll overlay the PSAR indicator with the RSI and mark trade entry/exit points on the price to get a better feel for what's going on.
You can see that again, the combined indicators went long (just barely) at exactly the wrong time in October.
Even though we have two indicators, this new system still was caught on the wrong side of the major moves. This could possibly be ameliorated by adjusting the RSI lookback period or changing some other parameters such as the long/short thresholds to be stronger before reversing. Of course, always be careful that you don't run too many backtests and overfit your data (I shudder to think how many traders are doing this).
Let's turn to one more combo where we use the PSAR with the Trend Intensity Indicator.
Trading the PSAR and Trend Intensity Indicator
The Trend Intensity Indicator (TII or sometimes called the Trend Intensity Index) is another measure of the strength of a given trend.
Like the RSI, this indicator is scaled from 0-100 with 50 being a neutral point, values greater than 50 indicating up-trends, and values below 50 indicating down trends. The farther you get away from 50, the stronger the trend. If you read the previous section, this should all be familiar.
Here's the code to calculate it, and, of course, all of the details for the TII calculation are given here.
def calcTrendIntensityIndex(data, P):
data[f'SMA_{P}'] = data['Close'].rolling(P).mean()
diff = data['Close'] - data[f'SMA_{P}']
pos_count = diff.map(
lambda x: 1 if x > 0 else 0).rolling(int(P/2)).sum()
data['TII'] = 200 * pos_count / P
return data
There are multiple ways to trade the Trend Intensity Indicator, either as a trend following/momentum indicator or in a mean reversion strategy. We're going to stick to our momentum theme here by combining the PSAR with the TII to see if we can get some additional alpha out of it.
The TII is commonly a slower indicator than something like the RSI, so the hope is that we can combine this with the PSAR in order to avoid those few catastrophic days that demolished our returns before.
We'll set this up with the same basic rules as the RSI model, buy if PSAR is moving up and the TII is above 50, short if the values are flipped.
Here's the code for the TII strategy:
def TrendIntensityPSARStrategy(data: pd.DataFrame, init_af: float=0.02,
max_af: float=0.2, af_step: float=0.02,
tii_p: int=30, centerline: float=50,
shorts: bool=True):
'''
Long if PSAR is in up trend and TII > centerline.
Short/sells if PSAR is in down trend and TII < centerline.
'''
# Calculate indicators
data = calcTrendIntensityIndex(data, tii_p)
indic = PSAR(init_af, max_af, af_step)
data['PSAR'] = data.apply(
lambda x: indic.calcPSAR(x['High'], x['Low']), axis=1)
data['Trend'] = indic.trend_list
data['position'] = np.nan
data['position'] = np.where(
(data['TII']>centerline) & (data['Trend']==1),
1, data['position'])
if shorts:
data['position'] = np.where(
(data['TII']<centerline) & (data['Trend']==0),
-1, data['position'])
data['position'] = data['position'].fillna(0)
return calcReturns(data)
And to test it...
df_tii = TrendIntensityPSARStrategy(data.copy())
fig, ax = plt.subplots(2, figsize=(12, 8), sharex=True)
ax[0].plot(df_tii['strat_cum_returns']*100, label='TII Strategy')
ax[0].plot(df_tii['cum_returns']*100, label='Buy and Hold')
ax[0].legend()
ax[0].set_ylabel('Returns (%)')
ax[0].set_title('Cumulative Returns')
ax[1].plot(df_tii['Close'], label='Close')
ax[1].set_ylabel('Price ($)')
ax[1].set_title(f'{ticker} Price')
ax[1].set_xlabel('Date')
plt.tight_layout()
plt.show()
tii_stats = pd.DataFrame(getStratStats(df_tii['strat_log_returns']),
index=['PSAR and TII'])
stats = pd.concat([bh_stats, psar_stats,
rsi_stats, tii_stats])
stats
This model did seem to cut down on some of the volatility significantly and kept us from getting wrecked by some of the sudden downward moves, yielding the lowest volatility and max drawdown of all the models. That difficult 2015 was even tamed with a 90% return for the year!
Maybe 90% isn't that impressive in a year where the underlying returned over 240%...but still, this was a highly volatile year and was very difficult for the other models.
Unfortunately, this model was more conservative than all the others yielding the lowest total and risk adjusted returns.
How to Trade with the PSAR
We looked at a few ways to work with the PSAR, but we never looked at changing its parameters. Slowing it down a bit may help it stay in some of those trends a bit longer without getting knocked out on the reversal, which could be key into juicing some alpha out of your strategy. There are a million ways to combine this with other indicators and rules - we didn't even try to work in a mean reversion model - and its up to you to figure out an approach you're comfortable with.
Of course, one of the biggest limitations of any of these simplified tests is the lack of any risk management techniques: diversification, position sizing, stops, or position management. Perhaps the raw PSAR would do great on its own if only it was diversified across a variety of instruments, or positions were sized according to some optimality condition, or we adjusted our position size as a function of volatility.
The trouble is, a lot of these things are difficult and time consuming to test. At Raposa, we want to handle that for you so you can spend more time designing strategies and no time coding them. With just a few clicks, you can get a custom trading bot built and deploy it to work in the markets for you.
Join us in the retail trading revolution and try our free demo today!