Pull the Lever, Kronk!¶

Introduction¶

One of my close friends who I also consider to be an extremely bright individual surprised me during lunch one day. He was a portfolio manager working within a big bank and yet when it came to his personal finances, he had told me that all of his capital was invested in geared equity index products. To me this notion sounded absurd - leveraged ETFs, just as cryptocurrencies and the latest hot stock tips were the realm of investment junkies looking for a quick lottery win. Certainly not the school of thought to be pursued by the business scool educated elite who were taught 'sound' investment practices and to be selectively sceptical of too-good-to-be-true investment proposals. Returning to my peculiar friend's confession, I badgered the point a bit further. Surely a simple performance chart would articulate my feelings - leverage is a double-edged sword and so the good times will come to nought once you overshoot the optimal Kelly during a bear market and are unable to recoup your losses.

In [1]:
import yfinance as yf
from datetime import datetime, timedelta
import seaborn as sns
import numpy as np
import warnings
import matplotlib.pyplot as plt
import pandas as pd
import random

warnings.simplefilter(action='ignore', category=FutureWarning)

priceData = yf.download('TQQQ', start='2000-01-01', end=datetime.today(), progress=False)['Close'].squeeze()
dailyReturns = priceData.pct_change()
annReturn = (((priceData[-1] / priceData[0]) ** (365 / (priceData.index[-1] - priceData.index[0]).days)) - 1) * 100
annVolatility = dailyReturns.std() * np.sqrt(252) * 100
sharpeRatio = (annReturn / annVolatility)

plt.figure(figsize=(10, 6))
plt.plot(np.log(priceData))
plt.title('Nasdaq 100 x3 ETF Log Performance', fontsize=16)
plt.xlabel('Date', fontsize=14)
text_box = f'Annualized Return: {annReturn:.2f}%\nAnnualized Volatility: {annVolatility:.2f}%\nSharpe Ratio: {sharpeRatio:.2f}'
plt.text(0.15, 0.85, text_box, transform=plt.gcf().transFigure,
         verticalalignment='top', horizontalalignment='left', fontsize=12, bbox=dict(facecolor='white', alpha=0.8))
plt.show()
YF.download() has changed argument auto_adjust default to True

Impact of Time-Dependency¶

It would be fair to challenge plotting the best performing index during a bull market. As an alternative exercise, I have plotted a histogram of annualised returns for 10,000 random entry/exit points into a 2x leveraged S&P 500 strategy using over one hundred years of price history with a minimum holding period of 3 years. Doing so results in 73% of positive annualised returns with a mean of 6% and a rather nasty negative tail (the worst performing simulation returning -66% per annum). These results are not particularly inspiring given the context that an unlevered exposure would provide the same mean return with less negative skew.

To investigate whether this may be improved upon, I have plotted the resultant mean annualised returns when simulations are run with different leverage ratios. There appears to be a gradual improvement in mean returns with a peak ratio of 1.5x before agressively tumbling down below the risk-free rate of return. This seems to corroborate the concept behind the Kelly Criterion, with a half-Kelly leading to a leverage factor just above one.

In [3]:
import pandas_datareader.data as web

spx_data = yf.download("^GSPC", start="1900-01-01", end="2025-01-01", progress=False, auto_adjust=False)
spx_data = spx_data[['Adj Close']].rename(columns={'Adj Close': 'Close'})
risk_free_data = web.DataReader('TB3MS', 'fred', start='1900-01-01', end='2025-01-01')
risk_free_data['RiskFreeDaily'] = (risk_free_data['TB3MS'] / 100) / 252
data = spx_data.merge(risk_free_data[['RiskFreeDaily']], left_index=True, right_index=True, how='left')
data['RiskFreeDaily'] = data['RiskFreeDaily'].fillna(method='ffill')
data = data.fillna(0)
data.columns = [col[0] if isinstance(col, tuple) else col for col in data.columns]

def backtest_random_window(data, leverage=2, min_days=252*3, max_days=252*20):
    start_idx = random.randint(0, len(data) - max_days)
    end_idx = random.randint(start_idx + min_days, min(start_idx + max_days, len(data) - 1))
    
    price_data = data['Close'].iloc[start_idx:end_idx]
    risk_free = data['RiskFreeDaily'].iloc[start_idx:end_idx]
    
    daily_returns = price_data.pct_change()
    strategy_returns = (daily_returns - risk_free) * leverage + risk_free
    strategy_returns = strategy_returns.dropna()
    
    cum_returns = (1 + strategy_returns).cumprod()
    days_held = (cum_returns.index[-1] - cum_returns.index[0]).days
    ann_return = (cum_returns.iloc[-1] / cum_returns.iloc[0]) ** (365 / days_held) - 1
    
    return ann_return

def get_annualised_return_stats(returns):
    mean_return = np.mean(returns)
    median_return = np.median(returns)
    positive_ratio = np.sum(returns > 0) / len(returns)
    
    sns.set(style="whitegrid")
    plt.figure(figsize=(10, 6))
    sns.histplot(returns, bins=50, kde=False, color='blue')
    plt.axvline(mean_return, color='red', linestyle='--', label=f'Mean: {mean_return:.2%}')
    plt.axvline(median_return, color='green', linestyle='--', label=f'Median: {median_return:.2%}')
    
    text_box = f'Positive Return Ratio: {positive_ratio:.0%}'
    plt.text(0.02, 0.95, text_box, transform=plt.gca().transAxes,
             verticalalignment='top', horizontalalignment='left', fontsize=12,
             bbox=dict(facecolor='white', alpha=0.8))
    
    plt.gca().xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f'{x:.0%}'))
    plt.xlabel("Annualized Return (%)")
    plt.ylabel("Frequency")
    plt.title("Histogram for 2x Leveraged S&P 500 Strategy Annualised Returns")
    plt.legend()
    plt.show()

# Run simulations
num_simulations = 10000
returns = []
for _ in range(num_simulations):
    result = backtest_random_window(data)
    returns.append(result)

returns = np.array(returns)
get_annualised_return_stats(returns)
In [56]:
# Define leverage levels you want to test
leverage_levels = np.linspace(0.0, 3.0, 13)

# Store mean returns for each leverage
mean_returns = []

# Run the simulations at each leverage
for lev in leverage_levels:
    returns = []
    for _ in range(3000):  # Fewer simulations per leverage to keep it fast
        result = backtest_random_window(data, leverage=lev)
        returns.append(result)
    returns = np.array(returns)
    mean_returns.append(np.mean(returns))

# Plot mean annualized return vs leverage
plt.figure(figsize=(10, 6))
plt.plot(leverage_levels, np.array(mean_returns) * 100, marker='o')
plt.axhline(0, color='black', linestyle='--')
plt.xlabel("Leverage Ratio (x)")
plt.ylabel("Mean Annualized Return (%)")
plt.title("Mean Annualized Return vs Leverage")
plt.grid(True)
plt.show()

On Common Sense¶

Truthfully, my original write-up argued in favour of leverage - I had appended a slight modification to the simple buy-and-hold simulation strategy above with one that applies a trailing stop loss to minimize periods of extended drawdowns. The results looked quite positive and seemed to trump its unlevered counterpart. When I revisited this many months later, I had found what seemed like a relatively innocuous blunder in my calculations. I had used the S&P500 index returns to scale with leverage, which would inadvertently gear up the risk-free rate of return as well. In a zero interest rate environment, this is less meaningul; however, in periods throughout the 1900s during which interest rates were relatively high, this makes all the difference. After accounting for this, the results pointed in the opposite direction and interestingly so, in the direction that common instincts would suggest. This was meant to be a write-up exonerating the use of leverage, which has transmogrified into a teaching moment around challenging one's baseline assumptions. Whichever way they do lean, there's no harm in doubling down on caution.

In [ ]: