Ctrl K

Selective Long VIXY Hedge Strategy

A hedged equity strategy applied to ten large-cap NASDAQ stocks. The strategy holds an equal-weight long position across the stock universe at all times, and selectively adds a long VIXY position during volatility spikes, when the 5-day moving average of VIXY closes at or above its 60-day upper Bollinger Band (mean + 1.5 standard deviations). The hedge is sized at 50% of the portfolio and is intended to offset equity drawdowns during high-volatility regimes.

Results are compared against two baselines: an equal-weight buy-and-hold portfolio across the same ten stocks, and QQQ as a passive NASDAQ market proxy.

Data source and setup

This notebook uses Tiingo daily adjusted price data for all stocks, QQQ, and VIXY.

To run the notebook, you must provide a Tiingo API key through a local .env file. The code expects the variable below to be available in the environment:

In [1]:
import os
import time
import requests
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.ticker as mticker

from pathlib import Path
from dotenv import load_dotenv
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from IPython.display import display, HTML

FL_BLUE   = '#2563eb'
FL_SLATE  = '#64748b'
FL_AMBER  = '#f59e0b'
FL_RED    = '#ef4444'
FL_BG     = '#ffffff'
FL_GRID   = '#e2e8f0'
FL_TEXT   = '#0f172a'
FL_TEXT2  = '#334155'
FL_BORDER = '#e2e8f0'

STRATEGY_NAME = 'Selective Long VIXY Hedge'
COLORS = {
    STRATEGY_NAME:                 FL_BLUE,
    'Equal Weighted Buy and Hold': FL_SLATE,
    'QQQ Buy and Hold':            FL_AMBER,
}
LWIDTHS = {
    STRATEGY_NAME:                 2.0,
    'Equal Weighted Buy and Hold': 1.5,
    'QQQ Buy and Hold':            1.5,
}

matplotlib.rcParams.update({
    'figure.facecolor':  FL_BG,
    'axes.facecolor':    FL_BG,
    'axes.edgecolor':    FL_BORDER,
    'axes.labelcolor':   FL_TEXT2,
    'axes.spines.top':   False,
    'axes.spines.right': False,
    'axes.grid':         True,
    'grid.color':        FL_GRID,
    'grid.linewidth':    0.7,
    'xtick.color':       FL_TEXT2,
    'ytick.color':       FL_TEXT2,
    'xtick.labelsize':   10,
    'ytick.labelsize':   10,
    'axes.labelsize':    11,
    'axes.titlesize':    12,
    'axes.titlecolor':   FL_TEXT,
    'axes.titlepad':     12,
    'legend.frameon':    False,
    'legend.fontsize':   10,
    'figure.dpi':        300,
    'savefig.bbox':      'tight',
    'font.family':       'sans-serif',
    'font.sans-serif':   ['Inter', 'Helvetica Neue', 'Arial', 'DejaVu Sans'],
})

PROJECT_ROOT = Path.cwd()
load_dotenv(PROJECT_ROOT / '.env')
TIINGO_API_KEY = os.getenv('TIINGO_API_KEY')
if not TIINGO_API_KEY:
    raise RuntimeError('TIINGO_API_KEY is missing from the project-level .env')

FIXED_TICKERS        = ['AAPL','MSFT','NVDA','GOOGL','META','AMZN','AVGO','TSLA','NFLX','COST']
STARTING_CAPITAL     = 100_000.0
START_DATE           = '2018-01-02'
REQUEST_SLEEP        = 0.15
INCLUDE_QQQ_BASELINE = True

# VIXY hedge parameters
VIXY_SIGNAL_MA       = 5     # short MA on VIXY close
VIXY_BAND_WINDOW     = 60    # rolling window for mean + std
VIXY_BAND_STD        = 1.5   # upper band multiplier
HEDGE_RATIO          = 0.5   # VIXY allocation relative to stock portfolio
In [2]:
def make_session():
    session = requests.Session()
    session.mount('https://', HTTPAdapter(max_retries=Retry(
        total=5, backoff_factor=1,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=['GET']
    )))
    return session


def fetch_tiingo_prices(session, ticker, start_date):
    resp = session.get(
        f'https://api.tiingo.com/tiingo/daily/{ticker}/prices',
        headers={'Authorization': f'Token {TIINGO_API_KEY}', 'Content-Type': 'application/json'},
        params={'startDate': start_date, 'resampleFreq': 'daily', 'format': 'json'},
        timeout=60
    )
    resp.raise_for_status()
    raw = pd.DataFrame(resp.json())
    if raw.empty:
        raise ValueError(f'No price data returned for {ticker}')
    df = pd.DataFrame({
        'date':   pd.to_datetime(raw['date'], utc=True).dt.tz_localize(None),
        'open':   pd.to_numeric(raw['adjOpen'],   errors='coerce'),
        'high':   pd.to_numeric(raw['adjHigh'],   errors='coerce'),
        'low':    pd.to_numeric(raw['adjLow'],    errors='coerce'),
        'close':  pd.to_numeric(raw['adjClose'],  errors='coerce'),
        'volume': pd.to_numeric(raw['adjVolume'], errors='coerce'),
    })
    df = df.dropna(subset=['date','open','high','low','close','volume']).sort_values('date').reset_index(drop=True)
    if df.empty:
        raise ValueError(f'Processed dataframe is empty for {ticker}')
    return df


def build_daily_equity_curve_from_returns(daily_returns_df, starting_capital):
    if daily_returns_df.empty:
        return pd.DataFrame(columns=['date','daily_return','equity','cum_pnl','daily_pnl'])
    eq = daily_returns_df.copy().sort_values('date').reset_index(drop=True)
    eq['equity']    = starting_capital * (1 + eq['daily_return']).cumprod()
    eq['cum_pnl']   = eq['equity'] - starting_capital
    eq['daily_pnl'] = eq['equity'].diff().fillna(eq['equity'] - starting_capital)
    return eq


def calc_cagr_and_sharpe(daily_equity_df, starting_capital):
    if daily_equity_df.empty:
        return 0.0, 0.0
    years  = (daily_equity_df['date'].max() - daily_equity_df['date'].min()).days / 365.25
    ending = daily_equity_df['equity'].iloc[-1]
    cagr   = (ending / starting_capital) ** (1 / years) - 1 if years > 0 and ending > 0 else 0.0
    dr     = daily_equity_df['daily_return'].dropna()
    sharpe = (dr.mean() / dr.std(ddof=1)) * (252 ** 0.5) if len(dr) > 1 and dr.std(ddof=1) > 0 else 0.0
    return round(cagr, 6), round(sharpe, 6)


def build_summary_row(strategy_name, equity_df):
    if not equity_df.empty:
        total_pnl    = equity_df['cum_pnl'].iloc[-1]
        ending       = equity_df['equity'].iloc[-1]
        max_dd       = (equity_df['equity'] - equity_df['equity'].cummax()).min()
        cagr, sharpe = calc_cagr_and_sharpe(equity_df[['date','daily_return','equity']].copy(), STARTING_CAPITAL)
        start_dt, end_dt = equity_df['date'].min(), equity_df['date'].max()
    else:
        total_pnl = ending = max_dd = cagr = sharpe = 0.0
        start_dt = end_dt = pd.NaT
    return {
        'Strategy':      strategy_name,
        'Start':         start_dt.strftime('%Y-%m-%d') if pd.notna(start_dt) else '',
        'End':           end_dt.strftime('%Y-%m-%d')   if pd.notna(end_dt)   else '',
        'Ending Equity': round(float(ending), 2),
        'Total PnL':     round(float(total_pnl), 2),
        'Max Drawdown':  round(float(max_dd), 2),
        'CAGR':          cagr,
        'Sharpe':        sharpe,
    }


def fmt_dollar(v): return f'${v:>12,.0f}'
def fmt_pct(v):    return f'{v:.2%}'
def fmt_f2(v):     return f'{v:.2f}'
In [3]:
selected_tickers = FIXED_TICKERS.copy()
session = make_session()

price_cache = {}
fetch_rows  = []

for i, ticker in enumerate(selected_tickers, start=1):
    try:
        time.sleep(REQUEST_SLEEP)
        df_prices = fetch_tiingo_prices(session, ticker, START_DATE)
        price_cache[ticker] = df_prices.set_index('date').copy()
        fetch_rows.append({'Ticker': ticker, 'Status': 'OK',
                           'First': df_prices['date'].min().strftime('%Y-%m-%d'),
                           'Last':  df_prices['date'].max().strftime('%Y-%m-%d'),
                           'Rows':  len(df_prices), 'Error': ''})
        print(f'{i:2}/{len(selected_tickers)}  {ticker:<6} OK  {len(df_prices)} rows')
    except Exception as e:
        fetch_rows.append({'Ticker': ticker, 'Status': 'ERROR',
                           'First': '', 'Last': '', 'Rows': None, 'Error': str(e)})
        print(f'{i:2}/{len(selected_tickers)}  {ticker:<6} ERROR  {e}')

# Fetch VIXY
time.sleep(REQUEST_SLEEP)
vixy_price_df = fetch_tiingo_prices(session, 'VIXY', START_DATE)
print(f'     VIXY   OK  {len(vixy_price_df)} rows')

if INCLUDE_QQQ_BASELINE:
    time.sleep(REQUEST_SLEEP)
    qqq_price_df = fetch_tiingo_prices(session, 'QQQ', START_DATE)
    print(f'     QQQ    OK  {len(qqq_price_df)} rows')
else:
    qqq_price_df = pd.DataFrame(columns=['date','open','high','low','close','volume'])

fetch_summary_df = pd.DataFrame(fetch_rows).sort_values(['Status','Ticker']).reset_index(drop=True)
 1/10  AAPL   OK  2109 rows
 2/10  MSFT   OK  2109 rows
 3/10  NVDA   OK  2109 rows
 4/10  GOOGL  OK  2109 rows
 5/10  META   OK  2109 rows
 6/10  AMZN   OK  2109 rows
 7/10  AVGO   OK  2109 rows
 8/10  TSLA   OK  2109 rows
 9/10  NFLX   OK  2109 rows
10/10  COST   OK  2109 rows
     VIXY   OK  2109 rows
     QQQ    OK  2109 rows
In [4]:
def run_vixy_hedge_strategy(tickers, price_cache, vixy_price_df, hedge_ratio):
    # Stock portfolio: equal-weight daily returns
    stock_frames = []
    for ticker in tickers:
        if ticker not in price_cache:
            continue
        df = price_cache[ticker][['close']].copy()
        df['return'] = df['close'].pct_change()
        stock_frames.append(df[['return']].rename(columns={'return': ticker}))

    if not stock_frames:
        return {
            'equity_df':    pd.DataFrame(columns=['date','daily_return','equity','cum_pnl','daily_pnl']),
            'signal_series': pd.Series(dtype=float),
            'vixy_df':       pd.DataFrame(),
            'tickers_used':  0,
        }

    stock_returns = pd.concat(stock_frames, axis=1).sort_index().mean(axis=1, skipna=True)

    # VIXY signal
    v = vixy_price_df[['date','close']].copy().sort_values('date').reset_index(drop=True)
    v['vixy_ma']         = v['close'].rolling(VIXY_SIGNAL_MA).mean()
    v['vixy_band_mean']  = v['close'].rolling(VIXY_BAND_WINDOW).mean()
    v['vixy_band_std']   = v['close'].rolling(VIXY_BAND_WINDOW).std()
    v['vixy_upper']      = v['vixy_band_mean'] + VIXY_BAND_STD * v['vixy_band_std']
    v['signal']          = np.where(v['vixy_ma'] >= v['vixy_upper'], 1, 0)
    v['vixy_return']     = v['close'].pct_change()
    # Hedge return: signal today --> VIXY return next day (forward-shifted)
    v['hedge_return']    = v['signal'] * v['vixy_return'].shift(-1) * hedge_ratio
    v = v.set_index('date')

    signal_series = v['signal']

    combined = pd.concat(
        [stock_returns.rename('stock'), v['hedge_return'].rename('hedge')],
        axis=1
    ).sort_index()
    combined['stock'] = combined['stock'].fillna(0.0)
    combined['hedge'] = combined['hedge'].fillna(0.0)
    combined['daily_return'] = (combined['stock'] + combined['hedge']) / (1 + hedge_ratio)

    dr_df = (
        combined[['daily_return']]
        .dropna()
        .reset_index()
        .rename(columns={'index': 'date', 'date': 'date'})
    )
    # handle both index name cases
    if 'date' not in dr_df.columns:
        dr_df = dr_df.reset_index().rename(columns={'index': 'date'})

    equity_df = build_daily_equity_curve_from_returns(dr_df, STARTING_CAPITAL)

    return {
        'equity_df':     equity_df,
        'signal_series': signal_series,
        'vixy_df':       v,
        'tickers_used':  len(stock_frames),
    }


def run_equal_weight_buy_and_hold(tickers, price_cache):
    frames = []
    for ticker in tickers:
        if ticker not in price_cache:
            continue
        df = price_cache[ticker].copy()
        if df.empty:
            continue
        df['asset_return'] = df['close'].pct_change()
        frames.append(df[['asset_return']].rename(columns={'asset_return': ticker}))
    if not frames:
        return {'equity_df': pd.DataFrame(columns=['date','daily_return','equity','cum_pnl','daily_pnl']), 'tickers_used': 0}
    ret_df = pd.concat(frames, axis=1).sort_index()
    dr_df  = ret_df.mean(axis=1, skipna=True).rename('daily_return').dropna().reset_index().rename(columns={'index': 'date'})
    return {'equity_df': build_daily_equity_curve_from_returns(dr_df, STARTING_CAPITAL), 'tickers_used': len(ret_df.columns)}


def run_qqq_buy_and_hold(qqq_price_df):
    if qqq_price_df.empty:
        return {'equity_df': pd.DataFrame(columns=['date','daily_return','equity','cum_pnl','daily_pnl']), 'tickers_used': 0}
    w = qqq_price_df[['date','close']].copy()
    w['daily_return'] = w['close'].pct_change()
    dr_df = w[['date','daily_return']].dropna().reset_index(drop=True)
    return {'equity_df': build_daily_equity_curve_from_returns(dr_df, STARTING_CAPITAL), 'tickers_used': 1}


vixy_result   = run_vixy_hedge_strategy(selected_tickers, price_cache, vixy_price_df, HEDGE_RATIO)
ew_bh_result  = run_equal_weight_buy_and_hold(selected_tickers, price_cache)
qqq_bh_result = run_qqq_buy_and_hold(qqq_price_df)

vixy_equity_df    = vixy_result['equity_df']
ew_bh_equity_df   = ew_bh_result['equity_df']
qqq_bh_equity_df  = qqq_bh_result['equity_df']
vixy_signal       = vixy_result['signal_series']
vixy_df           = vixy_result['vixy_df']
In [5]:
comparison_frames, drawdown_frames = [], []

strategy_specs = [
    (STRATEGY_NAME,                vixy_equity_df,   'result'),
    ('Equal Weighted Buy and Hold', ew_bh_equity_df,  'baseline'),
    ('QQQ Buy and Hold',            qqq_bh_equity_df, 'baseline'),
]

for name, eq_df, role in strategy_specs:
    if eq_df.empty:
        continue
    row = eq_df[['date','daily_return','daily_pnl','cum_pnl','equity']].copy()
    row['Strategy'] = name
    comparison_frames.append(row)
    dd = eq_df[['date','equity']].copy()
    dd['drawdown'] = dd['equity'] - dd['equity'].cummax()
    dd['Strategy'] = name
    drawdown_frames.append(dd)

comparison_equity_df   = pd.concat(comparison_frames,  ignore_index=True) if comparison_frames  else pd.DataFrame()
drawdown_comparison_df = pd.concat(drawdown_frames,    ignore_index=True) if drawdown_frames    else pd.DataFrame()

summary_rows = [
    build_summary_row(STRATEGY_NAME,                vixy_equity_df),
    build_summary_row('Equal Weighted Buy and Hold', ew_bh_equity_df),
    build_summary_row('QQQ Buy and Hold',            qqq_bh_equity_df),
]
summary_df = pd.DataFrame(summary_rows)

Universe

The stock portfolio uses a fixed list of ten large-cap NASDAQ stocks. The hedge instrument is VIXY (the ProShares VIX Short-Term Futures ETF) which tracks front-month VIX futures and rises sharply during equity market stress.

In [6]:
selected_tickers
Out[6]:
['AAPL',
 'MSFT',
 'NVDA',
 'GOOGL',
 'META',
 'AMZN',
 'AVGO',
 'TSLA',
 'NFLX',
 'COST']

Data coverage

Daily adjusted OHLCV prices are fetched from Tiingo starting January 2018 for all ten stock tickers. VIXY prices are fetched separately over the same period.

In [7]:
summary_view = fetch_summary_df[['Ticker', 'Status', 'First', 'Last', 'Rows']].reset_index(drop=True)
display(summary_view)
Ticker Status First Last Rows
0 AAPL OK 2018-01-02 2026-05-22 2109
1 AMZN OK 2018-01-02 2026-05-22 2109
2 AVGO OK 2018-01-02 2026-05-22 2109
3 COST OK 2018-01-02 2026-05-22 2109
4 GOOGL OK 2018-01-02 2026-05-22 2109
5 META OK 2018-01-02 2026-05-22 2109
6 MSFT OK 2018-01-02 2026-05-22 2109
7 NFLX OK 2018-01-02 2026-05-22 2109
8 NVDA OK 2018-01-02 2026-05-22 2109
9 TSLA OK 2018-01-02 2026-05-22 2109

Strategy

Stock leg. An equal-weight long position is held across all ten stocks every day. Unlike the other strategies in this series, the stock portfolio is always invested - there is no entry/exit signal on the stock side.

Hedge trigger. Each day, the 5-day moving average of VIXY's closing price is compared against its 60-day upper Bollinger Band (60-day mean + 1.5 × 60-day std). When the 5-day MA is at or above the upper band, the signal fires: VIXY is in an elevated volatility regime and is expected to continue rising.

Hedge leg. On signal days, a long VIXY position is entered at today's close and exited at the next day's close (forward-shifted return). The hedge is sized at 50% of the portfolio.

Combined return. (stock_return + hedge_return) / (1 + hedge_ratio) - dividing by 1.5 normalises for the fact that total exposure is 150% on hedge days.

The chart below shows the VIXY price alongside the upper band trigger and signal activations.

In [8]:
plt.figure(figsize=(8, 4.5))

if not vixy_df.empty:
    plt.plot(
        vixy_df.index,
        vixy_df['close'],
        color=FL_AMBER,
        linewidth=1.4,
        label='VIXY close'
    )
    plt.plot(
        vixy_df.index,
        vixy_df['vixy_upper'],
        color=FL_RED,
        linewidth=1.0,
        linestyle='--',
        label='Upper band (60d mean + 1.5σ)'
    )
    plt.plot(
        vixy_df.index,
        vixy_df['vixy_ma'],
        color=FL_SLATE,
        linewidth=1.0,
        linestyle=':',
        label=f'{VIXY_SIGNAL_MA}-day MA'
    )

    signal_on = vixy_df['signal'] == 1
    ymin, ymax = plt.ylim()
    plt.fill_between(
        vixy_df.index,
        ymin,
        vixy_df['close'].max() * 1.05,
        where=signal_on,
        alpha=0.08,
        color=FL_RED,
        label='Hedge active'
    )

plt.ylabel('VIXY price ($)')
plt.title('VIXY price, trigger band, and hedge activation periods')
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.gca().xaxis.set_major_locator(mdates.YearLocator())
plt.legend(loc='upper right', fontsize=9)
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()

plt.figure(figsize=(8, 4.5))

if not vixy_signal.empty:
    plt.fill_between(vixy_signal.index, vixy_signal.values, alpha=0.4, color=FL_RED)
    plt.plot(vixy_signal.index, vixy_signal.values, color=FL_RED, linewidth=1.0)

plt.ylabel('Signal')
plt.ylim(-0.05, 1.3)
plt.yticks([0, 1], ['Off', 'On'])
plt.title('Hedge signal state over time')
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.gca().xaxis.set_major_locator(mdates.YearLocator())
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()
No description has been provided for this image
No description has been provided for this image

Hedge activation frequency

How often the VIXY hedge was active over the backtest period. A high hedge frequency may erode returns on calm days when VIXY drifts lower.

In [9]:
if not vixy_signal.empty:
    total_days = vixy_signal.notna().sum()
    active_days = int(vixy_signal.sum())
    inactive_days = total_days - active_days
    active_pct = active_days / total_days if total_days > 0 else 0

    freq_df = pd.DataFrame({
        'State': ['Hedge active (VIXY long)', 'Hedge inactive (stocks only)'],
        'Days': [active_days, inactive_days],
        'Share': [active_pct, 1 - active_pct],
    })

    plt.figure(figsize=(8, 4.5))
    bar_colors = ['#ef4444', FL_SLATE]
    bars = plt.barh(
        freq_df['State'],
        freq_df['Days'],
        color=bar_colors,
        height=0.45
    )

    for bar, (_, row) in zip(bars, freq_df.iterrows()):
        plt.text(
            bar.get_width() + 5,
            bar.get_y() + bar.get_height() / 2,
            f"{row['Share']:.1%}",
            va='center',
            fontsize=10,
            color=FL_TEXT2
        )

    plt.xlabel('Number of days')
    plt.title('Hedge activation frequency')
    plt.tick_params(axis='both', which='both', length=0)
    plt.gca().invert_yaxis()
    plt.tight_layout()
    plt.show()

    freq_display_df = freq_df.copy()
    freq_display_df['Days'] = freq_display_df['Days'].map(lambda v: f'{int(v):,}')
    freq_display_df['Share'] = freq_display_df['Share'].map(fmt_pct)

    display(freq_display_df)
No description has been provided for this image
State Days Share
0 Hedge active (VIXY long) 117 5.55%
1 Hedge inactive (stocks only) 1,992 94.45%

Performance summary

All three strategies start from $100,000 over the same period.

  • CAGR: compound annual growth rate from start to end equity
  • Sharpe: annualised mean daily return / standard deviation × sqrt(252), no risk-free rate
  • Max drawdown: largest peak to current loss in dollar terms

The hedge is expected to reduce max drawdown during stress periods at the cost of some upside on calm days when the VIXY position decays.

In [10]:
fmt = {
    'Ending Equity': fmt_dollar,
    'Total PnL': fmt_dollar,
    'Max Drawdown': fmt_dollar,
    'CAGR': fmt_pct,
    'Sharpe': fmt_f2,
}

cols = ['Strategy', 'Start', 'End', 'Ending Equity', 'Total PnL', 'Max Drawdown', 'CAGR', 'Sharpe']

summary_view = summary_df[cols].copy()

for col, fn in fmt.items():
    summary_view[col] = summary_view[col].map(fn)

display(summary_view.T)
0 1 2
Strategy Selective Long VIXY Hedge Equal Weighted Buy and Hold QQQ Buy and Hold
Start 2018-01-02 2018-01-03 2018-01-03
End 2026-05-22 2026-05-22 2026-05-22
Ending Equity $     613,746 $   1,199,878 $     478,324
Total PnL $     513,746 $   1,099,878 $     378,324
Max Drawdown $    -121,687 $    -237,926 $     -92,011
CAGR 24.16% 34.51% 20.53%
Sharpe 1.04 1.19 0.91

Equity curves

Portfolio value over time from $100,000 starting capital. Selective Long VIXY Hedge (blue) vs equal-weight buy-and-hold (slate) and QQQ (amber).

In [11]:
plt.figure(figsize=(8, 4.5))

for name, group in comparison_equity_df.groupby('Strategy'):
    plt.plot(
        group['date'],
        group['equity'],
        label=name,
        color=COLORS.get(name, '#333'),
        linewidth=LWIDTHS.get(name, 1.5),
        linestyle='-' if name == STRATEGY_NAME else '--',
    )

plt.ylabel('Portfolio equity ($)')
plt.title('Portfolio equity from $100,000 starting capital')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda y, _: f'${y:,.0f}')
)
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.gca().xaxis.set_major_locator(mdates.YearLocator())
plt.legend(loc='upper left')
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()
No description has been provided for this image
In [12]:
equity_end_df = (
    comparison_equity_df
    .sort_values('date')
    .groupby('Strategy')
    .tail(1)[['Strategy', 'date', 'equity']]
    .copy()
)

equity_end_df['date'] = equity_end_df['date'].dt.strftime('%Y-%m-%d')
equity_end_df['equity'] = equity_end_df['equity'].map(lambda v: f'${v:,.0f}')
equity_end_df.columns = ['Strategy', 'End date', 'Ending equity']

display(equity_end_df)
Strategy End date Ending equity
4216 Equal Weighted Buy and Hold 2026-05-22 $1,199,878
2108 Selective Long VIXY Hedge 2026-05-22 $613,746
6324 QQQ Buy and Hold 2026-05-22 $478,324

Drawdown

Dollar distance from each strategy's running equity peak.

In [13]:
plt.figure(figsize=(8, 4.5))

for name, group in drawdown_comparison_df.groupby('Strategy'):
    plt.plot(
        group['date'],
        group['drawdown'],
        label=name,
        color=COLORS.get(name, '#333'),
        linewidth=LWIDTHS.get(name, 1.5),
        linestyle='-' if name == STRATEGY_NAME else '--',
    )

plt.axhline(0, color=FL_GRID, linewidth=0.8)
plt.ylabel('Drawdown ($)')
plt.title('Drawdown from equity peak')
plt.gca().yaxis.set_major_formatter(
    mticker.FuncFormatter(lambda y, _: f'${y:,.0f}')
)
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.gca().xaxis.set_major_locator(mdates.YearLocator())
plt.legend(loc='lower left')
plt.tick_params(axis='both', which='both', length=0)
plt.tight_layout()
plt.show()
No description has been provided for this image
In [14]:
drawdown_summary_df = (
    drawdown_comparison_df
    .groupby('Strategy', as_index=False)['drawdown']
    .min()
    .copy()
)

drawdown_summary_df['drawdown'] = drawdown_summary_df['drawdown'].map(lambda v: f'${v:,.0f}')
drawdown_summary_df.columns = ['Strategy', 'Worst drawdown']

display(drawdown_summary_df)
Strategy Worst drawdown
0 Equal Weighted Buy and Hold $-237,926
1 QQQ Buy and Hold $-92,011
2 Selective Long VIXY Hedge $-121,687