Stock returns and Risk metrics

Stock returns and risk estimation

One of the common methods to estimate risk is drawdown. Drawdown1 is the difference between the high and a subsequent low before it goes above the high. Underwater period is the period between the high and the time taken to reach the next high.

Though it is one of the common methods to measure risk, it takes the exact historical path taken by the stock returns. Even optimistically assuming that the stock returns would reflect historical returns2, it may not take the same path as it took previously.

Let us look at some data and do some simulations.

In [1]:
# Necessary imports

import pandas_datareader.data as web
import pandas as pd
import numpy as np
import random
import yfinance as yf
import quantstats as qs
import empyrical as ep # For faster processing of portfolio stats and metrics
yf.pdr_override() # Fix to make pandas_datareader work properly in latest version
import seaborn as sns
sns.set()
In [2]:
# parameters - change the parameters here and run the notebook again

symbol:str = 'AAPL'
start_date:str = '2016-01-04'
num_simulations:int = 1000 # number of simulations to run
In [3]:
# Download data and print some stats

df = web.get_data_yahoo([symbol], start=start_date)
df['ret'] = df['Adj Close'].pct_change()
df['Adj Close'].plot()
[*********************100%***********************]  1 of 1 completed
Out[3]:
<AxesSubplot:xlabel='Date'>
In [4]:
qs.reports.basic(df['Adj Close'])
Performance Metrics
                    Strategy
------------------  ----------
Start Period        2016-01-04
End Period          2023-01-26
Risk-Free Rate      0.0%
Time in Market      100.0%

Cumulative Return   491.09%
CAGR﹪              28.59%

Sharpe              0.98
Prob. Sharpe Ratio  99.53%
Sortino             1.44
Sortino/√2          1.02
Omega               1.2

Max Drawdown        -38.52%
Longest DD Days     387

Gain/Pain Ratio     0.2
Gain/Pain (1M)      1.02

Payoff Ratio        1.04
Profit Factor       1.2
Common Sense Ratio  1.18
CPC Index           0.67
Tail Ratio          0.98
Outlier Win Ratio   3.85
Outlier Loss Ratio  4.01

MTD                 9.69%
3M                  -6.29%
6M                  -6.54%
YTD                 9.69%
1Y                  -10.28%
3Y (ann.)           27.14%
5Y (ann.)           28.27%
10Y (ann.)          28.59%
All-time (ann.)     28.59%

Avg. Drawdown       -4.17%
Avg. Drawdown Days  26
Recovery Factor     12.75
Ulcer Index         0.12
Serenity Index      3.77
Strategy Visualization

Shuffled drawdown

When we look at stock returns, we look at the specific path taken by the stock to reach the current level. But the stock would have taken any path to reach the current level. We can think of this as a route to reach some destination. There could be multiple routes to reach the destination and each route can be different.

We would create a shuffled_prices function that shuffles the daily returns and gives a new price series. Note that the starting and ending value would be the same since the source and destination are the same, only the routes differ.

In [5]:
def shuffled_prices(start_price, returns, index):
    np.random.shuffle(returns)
    s = start_price*(1+returns).cumprod()
    prices = np.hstack([start_price, s]) # To always match the first value
    return pd.Series(prices, index=index)
In [6]:
daily_returns = df.dropna()['ret'].values
start_value = df.iloc[0]['Adj Close']
index = df.index
df['Adj Close'].plot(figsize=(10,6))
for i in range(5):
    s = shuffled_prices(start_value, daily_returns, index)
    s.plot()

Let us simulate 1000 different paths and look at the drawdown distribution.

In [7]:
%%time
sharpe = []
returns = []
drawdowns = []

for i in range(num_simulations):
    s = shuffled_prices(start_value, daily_returns, index)
    rets = s.pct_change()
    sharpe.append(ep.sharpe_ratio(rets))
    returns.append(ep.cum_returns_final(rets))
    drawdowns.append(ep.max_drawdown(rets))
CPU times: user 4.33 s, sys: 4.77 ms, total: 4.34 s
Wall time: 4.34 s

The returns and sharpe ratio must be the same since we are only shuffling the returns

Let us check and confirm its fine

In [8]:
pd.DataFrame({
    'sharpe': sharpe,
    'returns': returns
}).plot(subplots=True)
Out[8]:
array([<AxesSubplot:>, <AxesSubplot:>], dtype=object)

Now we could plot the drawdowns distribution

In [9]:
dds = pd.Series(drawdowns)
print(f"Maximum expected drawdown is {dds.min()*100 :.2f}% and Minimum expected drawdown={dds.max()*100 :.2f}%")
sns.histplot(dds).set_title('Drawdowns distribution')
Maximum expected drawdown is -66.83% and Minimum expected drawdown=-23.59%
Out[9]:
Text(0.5, 1.0, 'Drawdowns distribution')
In [10]:
dds.describe()
Out[10]:
count    1000.000000
mean       -0.401853
std         0.075064
min        -0.668313
25%        -0.451122
50%        -0.392851
75%        -0.346064
max        -0.235889
dtype: float64

Despite the same returns and the same sharpe ratio, we could see a wide variation in the drawdown percentages. I have purposefully not set random.seed so that it throws different results each time. I ran this experiment with a seed of 1000

You could see drawdowns in excess of 50% a lot of time. Half of the times, the drawdown exceeded the historical drawdown. This is expected since the historical returns is just one route in which we arrived at the present returns. The worst possible case is around 60% which is way off the 38% but it still produced the same returns with the same volatility.

This could be tried with different stocks and different periods and also different number of simulations. The more the volatility and the more the number of simulations, you could expect the drawdown distribution to be more volatile.

Always look at drawdown distributions when estimating risk with drawdown

Sampled drawdown

The second way to estimate drawdowns is to assume the distribution of returns would persist in the future and then try to simulate how the returns would behave if randomly sampled from this distribution.

To do this, we could randomly sample returns with replacement and then try to estimate the stock returns. This could give us an estimate of how given the same distribution of returns, how the stock price would have changed. We create a function sampled_prices that draws randomly with replacement from the given stock returns.

We would be repeating the same process for what we did with shuffled drawdown

Note since we are sampling with replacement, all metrics would change.

In [11]:
def sampled_prices(start_price, returns, index):
    rets = np.random.choice(returns, size=len(returns))
    s = start_price*(1+rets).cumprod()
    prices = np.hstack([start_price, s]) # To always match the first value
    return pd.Series(prices, index=index)
In [12]:
daily_returns = df.dropna()['ret'].values
start_value = df.iloc[0]['Adj Close']
index = df.index
df['Adj Close'].plot(figsize=(10,6))
for i in range(5):
    s = sampled_prices(start_value, daily_returns, index)
    s.plot()
In [13]:
%%time
sharpe = []
returns = []
drawdowns = []

for i in range(num_simulations):
    s = sampled_prices(start_value, daily_returns, index)
    rets = s.pct_change()
    sharpe.append(ep.sharpe_ratio(rets))
    returns.append(ep.cum_returns_final(rets))
    drawdowns.append(ep.max_drawdown(rets))
CPU times: user 3.77 s, sys: 11.3 ms, total: 3.78 s
Wall time: 3.81 s
In [14]:
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(10,6))
sns.histplot(returns, ax=axes[0]).set(title='Returns Distribution')
sns.histplot(drawdowns, ax=axes[1]).set(title='Drawdown Distribution')
Out[14]:
[Text(0.5, 1.0, 'Drawdown Distribution')]
In [15]:
sns.histplot(sharpe).set(title='Sharpe Ratio distribution')
print(f"Maximum expected drawdown is {min(drawdowns)*100 :.2f}% and Minimum expected drawdown= {max(drawdowns)*100 :.2f}%")
print(f"Maximum expected returns is {max(returns)*100 :.2f}% and Minimum expected returns = {min(returns)*100 :.2f}%")
Maximum expected drawdown is -81.87% and Minimum expected drawdown= -19.37%
Maximum expected returns is 7118.02% and Minimum expected returns = -70.14%
In [16]:
pd.DataFrame({
    'returns': returns,
    'drawdown': drawdowns,
    'sharpe': sharpe
}).describe()
Out[16]:
returns drawdown sharpe
count 1000.000000 1000.000000 1000.000000
mean 7.065627 -0.412985 0.972503
std 7.426793 0.104811 0.388797
min -0.701385 -0.818685 -0.393827
25% 2.245840 -0.479273 0.707935
50% 4.879541 -0.403131 0.979291
75% 9.276520 -0.334050 1.246753
max 71.180200 -0.193679 2.147071

We can see the returns all over the place though the drawdown behaves more or less the same compared to the previous simulation based on shuffling returns. Even with a stock like Apple you could see how the returns could be very minimal even though everything is based on the same underlying distribution of returns. If you are lucky enough, you could end up on the other side where you could have easily got more than 1000% returns and looked like a rockstar.

We could appreciate this quote from Keynes

Markets can stay irrational longer than you can stay solvent

Bottom line

Using drawdown as a sole criteria for estimating risk is not so advisable
Volatility does play an important role in estimating future returns

Footnotes

  1. A better and detailed explanation on [drawdown](https://www.investopedia.com/terms/d/drawdown.asp) could be found at investopedia
  2. Future returns may or may not reflect historical returns. Most of the times for individual stocks, it may not and might depend upon a lot of factors