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.
# 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()
# 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
# 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()
qs.reports.basic(df['Adj Close'])
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.
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)
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.
%%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))
The returns and sharpe ratio must be the same since we are only shuffling the returns
Let us check and confirm its fine
pd.DataFrame({
'sharpe': sharpe,
'returns': returns
}).plot(subplots=True)
Now we could plot the drawdowns distribution
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')
dds.describe()
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.
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.
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)
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()
%%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))
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')
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}%")
pd.DataFrame({
'returns': returns,
'drawdown': drawdowns,
'sharpe': sharpe
}).describe()
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¶
Footnotes¶
- A better and detailed explanation on [drawdown](https://www.investopedia.com/terms/d/drawdown.asp) could be found at investopedia
- 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