Tail Risk and Short-Term Capital Management#

Note:#

To run this notebook, skip the first cells describing the puzzle.

A Puzzle#

Suppose we are examining a fund, Short-Term Capital Management, (STCM). We see the following:

stcm_returns.loc[['mean','std','sharpe_ratio'],['Conditional']].style.format('{:,.1%}')
  Conditional
mean 25.1%
std 16.8%
sharpe_ratio 149.6%

Seems like a great strategy right? Well, let’s look at the higher moments.

stcm_returns[['Conditional']].style.format('{:,.1%}')
  Conditional
mean 25.1%
std 16.8%
sharpe_ratio 149.6%
skewness -827.1%
excess_kurtosis 7,590.9%

What is going on here? Is this still a good strategy?

  • Notwithstanding the substantial negative skewness, the Sharpe (mean-versus-vol) is very good.

  • Does VaR or CVaR reveal anything?

rstcm_cond = STCM_Performance[STCM_Performance['Fund Bust'] == 0]['Gross Returns'].astype('float')

tab = pd.DataFrame(index = ['VaR (.01)','CVaR (.01)','VaR-to-vol'], columns=['Conditional'],dtype=float)
tab.loc['VaR (.01)'] = rstcm_cond.quantile(.01)
tab.loc['CVaR (.01)'] = rstcm_cond[rstcm_cond<rstcm_cond.quantile(.01)].mean()
tab.loc['VaR-to-vol'] = rstcm_cond.quantile(.01) / rstcm_cond.std()
tab.style.format('{:.1%}')
  Conditional
VaR (.01) -20.3%
CVaR (.01) -37.2%
VaR-to-vol -419.2%

The Strategy#

Their strategy is simple: each month they sell out-of-the-money put options.

They invest the fund’s assets along with the proceeds from selling the puts into riskless treasuries. One month later, the assets have grown by the riskless rate, and they buy back the put options to cover last month’s position. They then repeat the process by selling fresh put options and investing everything back in the riskless rate.

# import libraries
import math
import pandas as pd
import numpy as np
import seaborn as sns
import scipy.stats as stats
import matplotlib.pyplot as plt
import statsmodels.api as sm
from scipy.stats import norm
import warnings
warnings.filterwarnings("ignore")
pd.set_option("display.precision", 2)
sns.set(rc={'figure.figsize':(12, 5)})
USE_SPY = True

# Constant monthly risk free rate
rf = 0.002

# Constant implied vol
vol = 0.05

# Fund Starting Value
W = 50000000
if USE_SPY:
    SHEET = 'total returns'
    KEY = 'SPY'
    spy = pd.read_excel('../data/spy_data.xlsx', sheet_name=SHEET).set_index('date')[[KEY]]#.rename(columns={KEY:'rets'})
else:
    SHEET = 'returns'
    KEY = 'sp500'
    spy = pd.read_excel('../data/crsp_market_data.xlsx', sheet_name=SHEET).set_index('date')[[KEY]]#.rename(columns={KEY:'rets'})


### resample to monthly
def cumulative_returns(r):
    return (1 + r).prod() - 1

spy = spy.resample('M').apply(cumulative_returns)
def BSMPricer(S, K, vol, T, rf):
    d1 = (np.log(S/K) + (rf + (vol**2)/2)*T)/(np.sqrt(T)*vol)
    d2 = d1 - (np.sqrt(T)*vol)
    price = norm.cdf(-d2)*K*np.exp(-rf*T) - norm.cdf(-d1)*S
    return price


def tradingStrategy(Wt, Pt, Pt1, sigma, rate):
    put_price = BSMPricer(Pt, 0.8*Pt, sigma, 2, rate)
    nt = 0.03*Wt/put_price
    Lt1 = nt*BSMPricer(Pt1, 0.8*Pt, sigma, 1, rate)
    Wt1_bar = 1.03*Wt*(1 + rate) - Lt1
    gross_return = (Wt1_bar/Wt - 1) - rate
    fee = max(0.02*Wt/12, 0) + max(0.2*Wt*gross_return, 0)
    Wt1 = Wt1_bar - fee
    if Wt1 < 0:
        bust = 1
    else:
        bust = 0
    return [Wt1, Wt1_bar, gross_return, fee, bust]
spy['Price'] = (1 + spy[KEY]).cumprod()
STCM_Performance = pd.DataFrame(index = spy.index, columns = ['Net Assets', 'Gross Assets', 'Gross Returns', 'Management Fee', 'Fund Bust'])

dates = list(STCM_Performance.index)
STCM_Performance.iloc[0] = [W,W,0,0,0]

for i in range(1, len(dates)):
    today, yesterday = dates[i], dates[i-1]
    STCM_Performance.loc[today] = tradingStrategy(STCM_Performance['Net Assets'].loc[yesterday], spy['Price'].loc[yesterday], spy['Price'].loc[today], vol, rf)
    if STCM_Performance.loc[today]['Fund Bust'] == 1:
        STCM_Performance.loc[today][['Gross Assets', 'Net Assets']] = [W, W]

fund_bust_dates = STCM_Performance['Fund Bust'][STCM_Performance['Fund Bust'] == 1].to_frame('Fund Bust')

Assets: Boom and Bust#

spy
SPY Price
date
1993-12-31 0.00 1.00
1994-01-31 0.03 1.03
1994-02-28 -0.03 1.00
1994-03-31 -0.05 0.96
1994-04-30 0.01 0.97
... ... ...
2025-06-30 0.05 13.26
2025-07-31 0.02 13.57
2025-08-31 0.02 13.84
2025-09-30 0.03 14.30
2025-10-31 0.02 14.59

383 rows × 2 columns

display(STCM_Performance.head(5))
display(STCM_Performance.tail(5))
Net Assets Gross Assets Gross Returns Management Fee Fund Bust
date
1993-12-31 50000000 50000000 0 0 0
1994-01-31 51218973.22 51602883.19 0.03 383909.97 0
1994-02-28 52416561.49 52797055.27 0.03 380493.78 0
1994-03-31 53441477.01 53780698.78 0.02 339221.77 0
1994-04-30 54743293.5 55153363.29 0.03 410069.79 0
Net Assets Gross Assets Gross Returns Management Fee Fund Bust
date
2025-06-30 127720654.41 128678023.26 0.03 957368.84 0
2025-07-31 130833825.6 131814342.77 0.03 980517.17 0
2025-08-31 134022628.53 135026982.82 0.03 1004354.29 0
2025-09-30 137289965.15 138319001.8 0.03 1029036.65 0
2025-10-31 140636119.56 141690033.94 0.03 1053914.38 0
ax = STCM_Performance['Net Assets'].plot()
if KEY != 'SPY':
    ax.set_yscale('log')  # Set y-axis to log scale
for i in fund_bust_dates.index:
    plt.axvline(i, linestyle='dotted', color='r', label='Fund Death - ' + str(i.date()))

plt.title('Fund Net Assets')
plt.ylabel('Net Assets ($100 mil)')
plt.legend()
plt.show()
../_images/da872f8bf381a1498b7c0d062d4562f07f6fcf8210688898c9371b1d1d1c0aff.png
display(fund_bust_dates)
Fund Bust
date
1998-08-31 1
2008-10-31 1
2020-03-31 1
# Dynamically create fund DataFrames according to number of fund busts
funds = []
bust_dates = list(fund_bust_dates.index)

for i in range(len(bust_dates) + 1):
    if i == 0:
        fund_period = STCM_Performance.loc[:str(bust_dates[0])]
    elif i < len(bust_dates):
        fund_period = STCM_Performance.loc[str(bust_dates[i-1]):str(bust_dates[i])].iloc[1:]
    else:
        fund_period = STCM_Performance.loc[str(bust_dates[-1]):].iloc[1:]
    funds.append(fund_period)

# Now funds[0], funds[1], ..., funds[n] are the DataFrames for each era between busts
# (corresponds to fund_1, fund_2, etc.)
# Build funds DataFrame for each era between busts (plus one for final epoch)
funds_list = []
start_idx = 0
bust_dates = list(fund_bust_dates.index)  # List of bust dates (sorted as index)

# Iterate through bust periods
for i in range(len(bust_dates) + 1):
    if i == 0:
        fund_period = STCM_Performance.loc[:str(bust_dates[0])]
    elif i < len(bust_dates):
        fund_period = STCM_Performance.loc[str(bust_dates[i-1]):str(bust_dates[i])].iloc[1:]  # skip repeated date
    else:
        fund_period = STCM_Performance.loc[str(bust_dates[-1]):].iloc[1:]
    if len(fund_period) > 0:
        lifespan = f"{fund_period.index.date[0]} - {fund_period.index.date[-1]}"
        max_net_assets = fund_period['Net Assets'].max() / 1_000_000
        funds_list.append({'Lifespan': lifespan, 'Max Net Assets ($ mil)': max_net_assets})

funds = pd.DataFrame(funds_list, index=[f"Fund {i+1}" for i in range(len(funds_list))])

display(funds)
Lifespan Max Net Assets ($ mil)
Fund 1 1993-12-31 - 1998-08-31 182.77
Fund 2 1998-09-30 - 2008-10-31 206.82
Fund 3 2008-11-30 - 2020-03-31 375.71
Fund 4 2020-04-30 - 2025-10-31 140.64
print('Maximum value of net assets is ${:,.2f} million. This high water mark is observed on {}'.
      format(max(STCM_Performance['Net Assets'])/1000000, 
              STCM_Performance[STCM_Performance['Net Assets'] == max(STCM_Performance['Net Assets'])].index.date[0]))
Maximum value of net assets is $375.71 million. This high water mark is observed on 2020-01-31

Performance Metrics#

STCM_Performance['Gross Returns'].plot()
plt.title('Gross Monthly Returns')
plt.ylabel('Gross Returns')
plt.ylim(-.15, 0.05)
plt.show()
../_images/b2da77ee90a295e8ad5ec48c39a9f206a7995a240c5ab4c84198d1790c0957db.png

Good-Times Stats#

For the conditional stats, the mean is quite high and the Sharpe ratio is fantastic. However, the returns are very negatively skewed and massively kurtotic. Thus, even conditional on surviving it is clear the fund has substantial nonlinear risk.

Full-Sample Stats#

As seen in Table above, the return stats change enormously. The mean return is now negative and the volatility is massive. Furthermore, the skewness and kurtosis were already extreme but now even more so.

def getPerformanceMetrics(data):

    data_desc = data.describe().loc[['mean','std']]

    data_desc.loc['mean'] = data_desc.loc['mean']*12 # annualize
    data_desc.loc['std'] = data_desc.loc['std']*np.sqrt(12) # annualize
    data_desc.loc['sharpe_ratio'] = data_desc.loc['mean'] / data_desc.loc['std']
    
    data_desc.loc['skewness'] = data.skew()
    data_desc.loc['excess_kurtosis'] = data.kurt()-3
    
    return data_desc
stcm_returns = getPerformanceMetrics(STCM_Performance[STCM_Performance['Fund Bust'] == 0]['Gross Returns'].astype('float')).to_frame('Conditional')
stcm_returns['Unconditional'] = getPerformanceMetrics(STCM_Performance['Gross Returns'].astype('float'))
display(stcm_returns.style.format('{:,.1%}'))
  Conditional Unconditional
mean 25.1% -27.2%
std 16.8% 210.8%
sharpe_ratio 149.6% -12.9%
skewness -827.1% -1,667.9%
excess_kurtosis 7,590.9% 29,439.2%
ax = STCM_Performance[STCM_Performance['Fund Bust'] == 0]['Gross Returns'].astype('float').hist(bins=50)
ax.set_ylim([0,15]);
ax.set_xlim([-.5,.05]);
../_images/0c98547857b5fb5013ae6ae4137c61a9c8cbe43af95f732f6a22f7432dbfb44d.png
ax = STCM_Performance['Gross Returns'].astype('float').hist(bins=200)
ax.set_ylim([0,15]);
ax.set_xlim([-12,0.05]);
../_images/96e07fdef30eab8701aa55de87cc3c43c0865d84e0cf2abfa3d14411feb5b909.png

Investors in nonlinear strategies need to be very careful that they evaluate the fund performance on a large enough data sample so as to see the true distribution of the returns.


Appendix#

Use the following data to test the strategy:

  • Use the market index data in the “S&P500” tab.

  • Assume a constant monthly riskless rate of \(r_f\) = 0.0020.

  • Assume a constant implied volatility of \(\sigma\) = 0.05.

  • Test the strategy assuming the fund begins with assets of \(W_0\) = $50, 000, 000.

The details of the strategy are as follows:

  • At the end of the first month, we sell puts with maturity \(\tau = 2\) and strike price of \(K = 0.80S\), where \(S\) is the current market price, \(P^m_t\)

  • Calculate the price you receive on the puts using the Black-Scholes formulas:

\[ put = f_p(S,K,\sigma, \tau, r_f) = \mathcal{N}(-d_2)*K*e^{-r_f \tau} - S*\mathcal{N}(-d_1) \]
\[ d_1 = \frac{log\frac{S}{K} + (r_f + \frac{\sigma^2}{2})\tau}{\sigma\sqrt{\tau}} \]
\[ d_2 = d_1 - \sigma\sqrt{\tau} \]

where, \(r_f\) is the constant riskless rate given above, \(\sigma\) is the constant implied volatility given above, and \(\mathcal{N}(·)\) is the standard normal cumulative distribution.

  • We sell \(n_t\) of these put options, where \(n_t\) is calculated such that the premium we receive equals 3% of the fund’s total assets, \(W_t\),

\[ n_t = \frac{0.03}{f_p(P^m_t,0.8P^m_t,\sigma, \tau, r_f)} * W_t \]
  • Thus the dollars received, \(G_t\), from selling this number of puts will immediately increase the fund’s assets by 3%.

\[ G_t = n_t * \frac{0.03}{f_p(P^m_t,0.8P^m_t,\sigma, \tau, r_f)} = 0.03W_t \]
  • At the end of the following month, t + 1, we must use Lt+1 in assets to cover the puts from last month buy purchasing puts with \(\tau = 1\) and \(K = .80P^m_t\). We use the Black-Scholes formula above to calculate this amount:

\[ L_{t+1} = n_t * f_p(P^m_{t+1},0.8P^m_t,\sigma, 1, r_f) \]
  • Calculate the fund’s gross (before-fees) assets, \(\tilde{W}\), at time \(t + 1\) after covering the puts:

\[ \tilde{W}_{t+1} = (W_t + G_t)(1+r_f) - L_{t+1} = (W_t + 0.03W_t)(1+r_f) - L_{t+1} \]

where the first term is the past month’s assets plus the 3% jump from issuing puts at the last month, all invested at the riskless rate until the end of this month, at which point we cover the position

  • Calculate the fund’s gross (before fees) excess return from \(t\) to \(t + 1\):

\[ R^{\#stcm}_{t+1} = \frac{\tilde{W}_{t+1} - W_t}{W_t} - r_f \]
  • Calculate the management compensation at time \(t\) as follows:

\[ \pi_{t+1} = max(\frac{0.02}{12}*W_t, 0) + max(0.2W_t * R^{\#stcm}_{t+1}, 0) \]
  • Get the fund’s net capital by subtracting the managerial fee:

\[ W_{t+1} = \tilde{W}_{t+1} - \pi_{t+1} \]
  • Let \(B_t\) be a true/false variable indicating whether the fund has gone bust. If \(W_t < 0\), then assume the fund immediately re-opens with the initial asset level of \(W_0\) = $50 million

  • Finally, still at the end of month \(t + 1\), we repeat the whole process by selling \(n_{t+1}\) put options, so as to make the fund’s net assets jump 3% immediately

  • Thus, equations above give a recursion for building a timeseries of the funds net assets, \(W_{t}\), the managerial compensation, \(\pi_{t}\), and the fund’s gross excess returns, \(R^{\#stcm}_{t}\)