Meme Stocks#

The Question#

What happens when retail traders discover options on highly shorted stocks?

Stocks analyzed: GameStop (GME), AMC, BlackBerry (BB), Bed Bath & Beyond (BBBY), and others from the 2021 meme stock frenzy.

Key metrics we’re tracking:

  • Options activity relative to shares outstanding

  • Volume patterns in calls vs puts

  • How options trading compares to stock trading

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

%matplotlib inline
plt.rcParams['figure.figsize'] = (9,5)
plt.rcParams['font.size'] = 11
plt.rcParams['legend.fontsize'] = 11
FILENAME = '../data/meme_stocks.xlsx'
SHEET = 'meme stock data'
ts = pd.read_excel(FILENAME, sheet_name=SHEET, header=[0, 1], index_col=0)
ts.tail().style.format('{:.1f}').format_index('{:%Y-%m-%d}')
  GME US Equity AMC US Equity AAPL US Equity TSLA US Equity NVDA US Equity SPY US Equity
  PX_LAST OPEN_INT_TOTAL_CALL OPEN_INT_TOTAL_PUT VOLUME VOLUME_TOTAL_CALL VOLUME_TOTAL_PUT EQY_SH_OUT PX_LAST OPEN_INT_TOTAL_CALL OPEN_INT_TOTAL_PUT VOLUME VOLUME_TOTAL_CALL VOLUME_TOTAL_PUT EQY_SH_OUT PX_LAST OPEN_INT_TOTAL_CALL OPEN_INT_TOTAL_PUT VOLUME VOLUME_TOTAL_CALL VOLUME_TOTAL_PUT EQY_SH_OUT PX_LAST OPEN_INT_TOTAL_CALL OPEN_INT_TOTAL_PUT VOLUME VOLUME_TOTAL_CALL VOLUME_TOTAL_PUT EQY_SH_OUT PX_LAST OPEN_INT_TOTAL_CALL OPEN_INT_TOTAL_PUT VOLUME VOLUME_TOTAL_CALL VOLUME_TOTAL_PUT EQY_SH_OUT PX_LAST OPEN_INT_TOTAL_CALL OPEN_INT_TOTAL_PUT VOLUME VOLUME_TOTAL_CALL VOLUME_TOTAL_PUT EQY_SH_OUT
date                                                                                    
2025-06-24 23.3 996503.0 530788.0 11660172.0 135776.0 28848.0 447.3 3.0 629523.0 146802.0 4379887.0 36549.0 4166.0 433.1 200.3 2903818.0 2087842.0 54064033.0 554893.0 276554.0 14935.8 340.5 4231080.0 4009088.0 114736245.0 1096515.0 825473.0 3221.0 147.9 9641076.0 8768880.0 187566121.0 1573615.0 943560.0 24400.0 606.8 5292474.0 11305608.0 67735293.0 4021862.0 5257330.0 1023.2
2025-06-25 23.6 1013588.0 535743.0 9840749.0 98805.0 27869.0 447.3 3.0 638125.0 147400.0 4968114.0 26970.0 5303.0 433.1 201.6 2970777.0 2123396.0 39525730.0 438941.0 215690.0 14935.8 327.6 4395663.0 4118931.0 119845050.0 1703670.0 1186768.0 3221.0 154.3 10137395.0 9131950.0 269146471.0 3863998.0 1539360.0 24400.0 607.1 5426166.0 11495609.0 62114767.0 3811191.0 4158585.0 1029.9
2025-06-26 23.9 1036220.0 538544.0 8777965.0 130822.0 39860.0 447.3 3.0 649393.0 148795.0 4237091.0 38928.0 4006.0 433.1 201.0 3065468.0 2163559.0 50799121.0 601823.0 392877.0 14935.8 325.8 4466995.0 4202770.0 80440907.0 1158862.0 886635.0 3221.0 155.0 10316490.0 9414999.0 198145746.0 2074735.0 1351663.0 24400.0 611.9 5528473.0 12030509.0 78548361.0 3609760.0 4683545.0 1028.5
2025-06-27 23.6 1088638.0 551234.0 11638237.0 192027.0 46109.0 447.3 3.1 674242.0 153354.0 9739939.0 53032.0 8412.0 433.1 201.1 3162125.0 2190106.0 73188571.0 697983.0 322696.0 14935.8 323.6 4576464.0 4481144.0 89067049.0 1668487.0 1625142.0 3221.0 157.8 10509692.0 9756422.0 263234539.0 2628172.0 1618135.0 24400.0 614.9 5603665.0 12312087.0 86258398.0 4693802.0 5119717.0 1030.1
2025-06-30 24.4 988156.0 526013.0 10439313.0 170159.0 29771.0 447.3 3.1 645698.0 150747.0 6322139.0 59150.0 13676.0 433.1 205.2 3072744.0 2171894.0 91912816.0 1272592.0 365953.0 14935.8 317.7 4130528.0 3916484.0 76695081.0 681068.0 611189.0 3221.0 158.0 9984979.0 9123520.0 194580316.0 1292108.0 724481.0 24400.0 617.9 5524009.0 12421425.0 92502541.0 3853371.0 4346607.0 1024.9

Options Activity: The Scale of the Phenomenon#

How Big Is the Options Market?#

We’re measuring open interest (total outstanding option contracts) as a percentage of shares outstanding.

SHARES_PER_CONTRACT = 100
SCALE_BB_SHARES_OUT = 1e6
open_call = ts.xs('OPEN_INT_TOTAL_CALL', axis=1, level=1)
open_put = ts.xs('OPEN_INT_TOTAL_PUT', axis=1, level=1)
sh_out   = ts.xs('EQY_SH_OUT',         axis=1, level=1)
open_call_pct = open_call.mul(SHARES_PER_CONTRACT).div(sh_out.mul(SCALE_BB_SHARES_OUT))
open_put_pct = open_put.mul(SHARES_PER_CONTRACT).div(sh_out.mul(SCALE_BB_SHARES_OUT))

Call option open interest as ratio of shares outstanding.#

Do any of these stocks have more call option open interest than shares outstanding?

WINDOW = 21

fig, axes = plt.subplots(1, 2, figsize=(15, 5))

# Call options plot
open_call_pct.dropna().rolling(WINDOW).mean().plot(ax=axes[0], title='Call Open Interest as Ratio of Shares Outstanding')
axes[0].axhline(y=1, color='black', linestyle='--', linewidth=1)

# Put options plot  
open_put_pct.dropna().rolling(WINDOW).mean().plot(ax=axes[1], title='Put Open Interest as Ratio of Shares Outstanding')
axes[1].axhline(y=1, color='black', linestyle='--', linewidth=1)

plt.tight_layout()
plt.show()
../_images/fef9218c48af4791e1fcd95d73630f062d43c703e30d875a7bd85c7a5b2ca7e1.png

Total Options Activity#

Combined calls + puts: Shows the full scale of options open interest relative to the shares outstanding.

open_opt_pct = open_call_pct + open_put_pct

ax = open_opt_pct.dropna().rolling(WINDOW).mean().plot(title='Total Option Interest as Ratio of Shares Outstanding')
ax.axhline(y=1, color='black', linestyle='--', linewidth=1)
plt.show()
../_images/54a367b5bc7a3476c61d2752e17ba5563e8799a4d586b3506d77c29f0e5f00b5.png

Volumes#

Call Volume Ratio: Call option volume as % of stock volume.

When this exceeds the 1% line, it means call option trading is unusually heavy relative to stock trading.

WINDOW = 21
FLDS = [
    'VOLUME',
    'VOLUME_TOTAL_CALL',
    'VOLUME_TOTAL_PUT'
]

volumes = ts.xs(FLDS[0], axis=1, level=1)
volumes_call = ts.xs(FLDS[1], axis=1, level=1)
volumes_put = ts.xs(FLDS[2], axis=1, level=1)

volume_c_pct = (volumes_call * SHARES_PER_CONTRACT / volumes).rolling(WINDOW).mean()
volume_p_pct = volumes_put * SHARES_PER_CONTRACT / volumes
ax = volume_c_pct.drop(columns=['SPY US Equity']).plot(title='Call Volume as Ratio of Stock Volume')
ax.axhline(y=1, color='black', linestyle='--', linewidth=1)
plt.show()
../_images/cbe29994adf0b45fb12ae6da31134b305fbc4219f89641dddbd5806da3f3beab.png

Stock Prices#

import math
import matplotlib.pyplot as plt

def plot_field(ts, field, window=10,
               figsize=None, sharex=False, sharey=False):
    """
    Extract one field from a MultiIndex-ts DataFrame and plot each stock.

    Parameters
    ----------
    ts : pd.DataFrame
        Time-indexed DataFrame whose columns are a MultiIndex
        (level0=stock tickers, level1=fields).
    field : str
        The second-level field name to extract (e.g. 'volume', 'price', etc.).
    window : int, default=10
        Rolling window size for smoothing.
    figsize : tuple, optional
        Figure size; if None it defaults to (4*n, 3*n).
    sharex, sharey : bool, default=False
        Whether subplots share x-/y-axes.

    Returns
    -------
    fig, axes : matplotlib Figure and flattened Axes array
    """
    # 1) extract the field across all tickers
    try:
        df = ts.xs(field, axis=1, level=1)
    except KeyError:
        raise KeyError(f"Field {field!r} not found in ts.columns")

    # 2) set up grid
    tickers = df.columns.tolist()
    m = len(tickers)
    n = math.ceil(math.sqrt(m))
    fig, axes = plt.subplots(n, n,
                             figsize=figsize or (4*n, 3*n),
                             sharex=sharex,
                             sharey=sharey)
    axes = axes.flatten()

    # 3) pull the default color cycle
    colors = plt.rcParams['axes.prop_cycle'].by_key()['color']

    # 4) loop & plot
    for i, (ax, ticker) in enumerate(zip(axes, tickers)):
        sm = df[ticker].rolling(window=window, min_periods=1).mean()
        ax.plot(sm.index, sm.values,
                color=colors[i % len(colors)])
        ax.axhline(1, linestyle='--', linewidth=1, color='black')
        ax.set_title(ticker)

    # 5) hide any unused axes
    for ax in axes[m:]:
        ax.set_visible(False)

    plt.tight_layout()
    return fig, axes
FLD = 'PX_LAST'
fig, axes = plot_field(ts, FLD, window=WINDOW)
plt.show()

FLD = 'OPEN_INT_TOTAL_CALL'
fig, axes = plot_field(ts, FLD, window=WINDOW)
#plt.show()
../_images/1fe377f34e4bdbd6806b96c7c8c2a4ee52fec893e5a949932147482414022b0d.png ../_images/5a941cb370799f03c8a297055ea166c3b18175fc4ff3d0bf24093fae91949481.png

Stock Volume vs. Shares Outstanding#

Interpretation: Higher ratios indicate unusually heavy trading relative to the company’s share count.

import math
import matplotlib.pyplot as plt

def plot_field_ratio(ts, num_field, den_field,
                     window=10, scale=1.0,
                     figsize=None, sharex=False, sharey=False, do_hline=False):
    """
    For each ticker in `ts`, plot the rolling‐mean of
    scale * (ts[ticker, num_field] / ts[ticker, den_field]) on an n×n grid.

    Parameters
    ----------
    ts : pd.DataFrame
        Time‐indexed DataFrame with a 2‐level columns index
        (level0=ticker, level1=field).
    num_field : str
        The numerator field name (level1) to extract.
    den_field : str
        The denominator field name (level1) to extract.
    window : int, default=10
        Rolling window size for smoothing.
    scale : float, default=1.0
        Constant to multiply the raw ratio by.
    figsize : tuple, optional
        Figure size; default = (4⋅n, 3⋅n).
    sharex, sharey : bool, default=False
        Whether to share x or y axes across subplots.

    Returns
    -------
    fig, axes : matplotlib Figure and flattened Axes array
    """
    # extract numerator & denominator
    try:
        num_df = ts.xs(num_field, axis=1, level=1)
    except KeyError:
        raise KeyError(f"Numerator field {num_field!r} not in ts.columns")
    try:
        den_df = ts.xs(den_field, axis=1, level=1)
    except KeyError:
        raise KeyError(f"Denominator field {den_field!r} not in ts.columns")

    # form scaled ratio
    ratio = (num_df / den_df) * scale

    # grid setup
    tickers = ratio.columns.tolist()
    m = len(tickers)
    n = math.ceil(math.sqrt(m))
    fig, axes = plt.subplots(n, n,
                             figsize=figsize or (4*n, 3*n),
                             sharex=sharex, sharey=sharey)
    axes = axes.flatten()

    # color cycle
    colors = plt.rcParams['axes.prop_cycle'].by_key()['color']

    # plot
    for i, (ax, tkr) in enumerate(zip(axes, tickers)):
        sm = ratio[tkr].rolling(window=window, min_periods=1).mean()
        ax.plot(sm.index, sm.values,
                color=colors[i % len(colors)])
        
        if do_hline:
            ax.axhline(1, linestyle='--', linewidth=1, color='black')

        ax.set_title(tkr)

    # hide extras
    for ax in axes[m:]:
        ax.set_visible(False)

    plt.tight_layout()
    return fig, axes

Stock Volume vs. Call Open Interest#

What this shows: How many shares trade relative to call option positions.

Lower ratios suggest options activity is very large compared to stock trading.

fig, axes = plot_field_ratio(ts, 'VOLUME', 'EQY_SH_OUT', window=WINDOW,scale=1/1e6)
plt.show()
../_images/4170879abdee9cf5f942e3e887039f778a7e2cd0c469cada913adab3115d90ec.png

Call vs. Put Volume Ratio#

Sentiment indicator: Values above 1.0 show more call than put volume (bullish bias).

Meme stocks typically show very high call/put ratios during rallies.

fig, axes = plot_field_ratio(ts, 'VOLUME', 'OPEN_INT_TOTAL_CALL', window=WINDOW,scale=1/100)
../_images/060d96efbfe79f1b200585368b05eecd4221edb7293c1cd7aebd7c73b51697bf.png

Call Volume as % of Stock Volume#

Scale of speculation: When call volume exceeds 10-20% of stock volume, it indicates massive options speculation.

fig, axes = plot_field_ratio(ts, 'VOLUME_TOTAL_CALL', 'VOLUME_TOTAL_PUT', window=WINDOW)
../_images/28ed4fa7b2a20ab43864da291fac8b8bbc24cf4630316772f8344eb4fc38bc26.png

Put Volume as % of Stock Volume#

Bearish activity: Generally much lower than call volume for meme stocks - retail traders were primarily betting on upward moves.

Extra Analysis#

Peak Options Activity Summary#

# Summary of peak options activity for each stock
summary_stats = pd.DataFrame()

# Peak call open interest as % of shares outstanding  
summary_stats['Peak Call OI %'] = open_call_pct.max()

# Peak put open interest as % of shares outstanding
summary_stats['Peak Put OI %'] = open_put_pct.max()

# Peak total options activity
summary_stats['Peak Total OI %'] = open_opt_pct.max()

# Peak call volume as % of stock volume
peak_call_vol = (volumes_call * 100 / volumes).max()
summary_stats['Peak Call Vol %'] = peak_call_vol

# Clean up and sort
summary_stats = summary_stats.round(2)
summary_stats = summary_stats.sort_values('Peak Total OI %', ascending=False)

print("Peak Options Activity by Stock:")
print("=" * 40)
summary_stats
Peak Options Activity by Stock:
========================================
Peak Call OI % Peak Put OI % Peak Total OI % Peak Call Vol %
AMC US Equity 4.36 5.11 9.48 1.74
TSLA US Equity 1.40 2.68 4.08 6.15
GME US Equity 0.77 3.02 3.73 5.16
SPY US Equity 1.37 1.97 3.11 13.07
NVDA US Equity 0.11 0.09 0.20 3.85
AAPL US Equity 0.07 0.07 0.14 5.79

Options Activity vs. Price Volatility#

# Calculate price volatility and compare with options activity
prices = ts.xs('PX_LAST', axis=1, level=1)
returns = prices.pct_change()
volatility = returns.rolling(WINDOW).std() * np.sqrt(252) * 100  # Annualized vol

# Create scatter plot of options activity vs volatility
fig, ax = plt.subplots(1, 1, figsize=(8, 4))

for stock in open_opt_pct.columns:
    if stock in volatility.columns:
        # Get data where both exist
        opt_data = open_opt_pct[stock].dropna()
        vol_data = volatility[stock].dropna()
        
        # Align the data
        common_dates = opt_data.index.intersection(vol_data.index)
        if len(common_dates) > 10:  # Only plot if we have enough data
            ax.scatter(opt_data[common_dates], vol_data[common_dates], 
                      alpha=0.6, label=stock, s=20)

ax.set_xlabel('Total Options Open Interest (% of Shares Outstanding)')
ax.set_ylabel('Annualized Volatility (%)')
ax.set_title('Options Activity vs. Price Volatility')
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
plt.tight_layout()
plt.show()
../_images/a0ff94869ccdd9c60e76cb6179b9c632a577c3a507d3d3bde6d64afbea958923.png

Key Takeaways#

What Makes a Meme Stock?
  • Massive options activity: Open interest often exceeds 5-10% of shares outstanding
  • Bullish bias: Call volume dominates put volume by huge margins
  • Retail-driven: Options volume can exceed 20%+ of stock volume during peaks
  • High volatility: Options activity correlates with extreme price swings

The 2021 meme stock phenomenon showed how options markets can amplify retail sentiment and create feedback loops between options activity and stock prices.

fig, axes = plot_field_ratio(ts, 'VOLUME_TOTAL_CALL', 'VOLUME', window=WINDOW,scale=100)
plt.show()
../_images/ad0d17070f5c5fabf7fe6908b3210939c8834b79069cf0fba69eccb940c81626.png
fig, axes = plot_field_ratio(ts, 'VOLUME_TOTAL_PUT', 'VOLUME', window=WINDOW,scale=100)
plt.show()
../_images/189df5f7ce592d5b832836d9824d9330406ed4c240b679acb0b915fdf48c2588.png