spot iv correlation
In [1]:
import os
import requests
import polars as pl
import numpy as np
from lets_plot import *
LetsPlot.setup_html()
uw_token = os.environ['UW_TOKEN']
headers = {'Accept': 'application/json, text/plain', 'Authorization': uw_token}
In [2]:
def theme_dark():
darkest_black = '#0d1117'
darker_black = '#161b22'
dark_black = '#21262d'
darkest_gray = '#89929b'
darker_gray = '#c6cdd5'
dark_gray = '#ecf2f8'
white = '#ffffff'
red = '#fa7970'
orange = '#faa356'
green = '#7ce38b'
sky_blue = 'a2d2fb'
blue = '#77bdfb'
purple = '#cea5fb'
return theme_none() + theme(
line=element_line(color=dark_gray, size=1),
rect=element_rect(color=darkest_gray, fill=darkest_black, size=2),
text=element_text(color=darker_gray),
axis_ontop=True,
axis_ticks=element_line(color=darker_gray),
legend_background=element_rect(size=2, fill=darker_black),
legend_position='bottom',
panel_grid_major=element_line(color=darker_gray, size=1, linetype='dashed'),
panel_grid_minor=element_blank(),
plot_background=element_rect(fill=darkest_black),
plot_title=element_text(hjust=0.5, color=dark_gray),
tooltip=element_rect(fill=darkest_black, color=dark_gray),
axis_tooltip=element_rect(color=darker_gray)
)
def theme_light():
darkest_black = '#0d1117'
darker_black = '#161b22'
dark_black = '#21262d'
darkest_gray = '#89929b'
darker_gray = '#c6cdd5'
dark_gray = '#ecf2f8'
white = '#ffffff'
red = '#fa7970'
orange = '#faa356'
green = '#7ce38b'
sky_blue = 'a2d2fb'
blue = '#77bdfb'
purple = '#cea5fb'
return theme_none() + theme(
line=element_line(color=darkest_black, size=1),
rect=element_rect(color=darkest_black, fill=white, size=2),
text=element_text(color=darkest_black),
axis_ontop=True,
axis_ticks=element_line(color=darker_black),
legend_background=element_rect(size=2),
legend_position='bottom',
panel_grid_major=element_line(color=darker_black, size=1, linetype='dashed'),
panel_grid_minor=element_blank(),
# plot_background=element_rect(fill=dark_gray),
plot_title=element_text(hjust=0.5, color=darkest_black),
tooltip=element_rect(color=dark_black),
axis_tooltip=element_rect(color=darker_black)
)
dark_theme = {
'darkest_black': '#0d1117',
'darker_black': '#161b22',
'dark_black': '#21262d',
'darkest_gray': '#89929b',
'darker_gray': '#c6cdd5',
'dark_gray': '#ecf2f8',
'white': '#ffffff',
'red': '#fa7970',
'orange': '#faa356',
'green': '#7ce38b',
'sky_blue': '#a2d2fb',
'blue': '#77bdfb',
'purple': '#cea5fb'
}
def create_uw_iv_rv_df(ticker: str) -> pl.DataFrame:
"""
Return a Polars DataFrame containing queried data from
the Unusual Whales API endpoint for realized volatilty.
"""
# Collect implied volatility and closing price data
# # from the Unusual Whales API
base_url = ('https://api.unusualwhales.com/api/'
'stock/{}/volatility/realized?timeframe={}')
full_url = base_url.format(ticker, '2Y')
r = requests.get(full_url, headers=headers)
df = pl.DataFrame(r.json()['data'])
df = (
df
.with_columns(
[
pl.col('date').cast(pl.Date),
pl.col('implied_volatility').cast(pl.Float64) * 100,
pl.col('price').cast(pl.Float64),
pl.col('realized_volatility').cast(pl.Float64) * 100
]
)
)
return (
df
.with_columns(
[
(
(pl.col('price') / pl.col('price').shift(1) - 1) * 100
).alias('price_pct_change_from_yesterday'),
(
pl.col('implied_volatility') - pl.col('implied_volatility').shift(1)
).alias('iv_change_from_yesterday')
]
)
)
def create_pct_price_vs_iv_plot(ticker: str,
df: pl.DataFrame,
dark_color: bool = True) -> ggplot:
"""
Create a scatter plot of the percentage change in price vs the
percentage change in implied volatility then add a line of best
fit to the plot.
"""
price_pct_raw = df['price_pct_change_from_yesterday'].to_numpy()
iv_pct_raw = df['iv_change_from_yesterday'].to_numpy()
price_pct = price_pct_raw[1:] # remove the known first value, it's a NaN
iv_pct = iv_pct_raw[1:] # remove the known first value, it's a NaN
# Linear regression
coefficients = np.polyfit(price_pct, iv_pct, 1)
slope, intercept = coefficients
linear_regression = slope * price_pct + intercept
if dark_color:
return ggplot(df, aes(x='price_pct_change_from_yesterday',
y='iv_change_from_yesterday')) + \
geom_point(color=dark_theme['sky_blue']) + \
geom_line(aes(x=price_pct,
y=linear_regression),
color=dark_theme['orange'],
size=1) + \
ggtitle(f'Spot-to-Implied Volatility Correlation: {ticker}') + \
xlab('Price % Change from Yesterday') + \
ylab('Implied Volatility % Change from Yesterday') + \
ggsize(600, 600) + \
theme_dark()
else:
return ggplot(df, aes(x='price_pct_change_from_yesterday',
y='iv_change_from_yesterday')) + \
geom_point(color=dark_theme['blue']) + \
geom_line(aes(x=price_pct,
y=linear_regression),
color=dark_theme['red'],
size=1) + \
ggtitle(f'Spot-to-Implied Volatility Correlation: {ticker}') + \
xlab('Price % Change from Yesterday') + \
ylab('Implied Volatility % Change from Yesterday') + \
ggsize(600, 600) + \
theme_light()
def create_linear_regression_str(df: pl.DataFrame) -> str:
"""
Calculate the line of best fit (linear regression) for the
price pct change since yesterday and iv pct change since
yesterday data provided in the DataFrame and return a string
for easy printing.
"""
price_pct_raw = df['price_pct_change_from_yesterday'].to_numpy()
iv_pct_raw = df['iv_change_from_yesterday'].to_numpy()
price_pct = price_pct_raw[1:] # remove the known first value, it's a NaN
iv_pct = iv_pct_raw[1:] # remove the known first value, it's a NaN
# Linear regression
coefficients = np.polyfit(price_pct, iv_pct, 1)
slope, intercept = coefficients
linear_regression = slope * price_pct + intercept
# Calculate R-squared
ss_tot = np.sum((iv_pct - np.mean(iv_pct)) ** 2)
ss_res = np.sum((iv_pct - linear_regression) ** 2)
r_squared = 1 - (ss_res / ss_tot)
return (f'Linear regression (y=mx+b): y={slope:.6f}x + {intercept:.6f} || R^2 = {r_squared:.6f}')
In [3]:
ticker = 'TSLA'
df = create_uw_iv_rv_df(ticker)
df
Out[3]:
shape: (502, 6)
date | implied_volatility | price | realized_volatility | price_pct_change_from_yesterday | iv_change_from_yesterday |
---|---|---|---|---|---|
date | f64 | f64 | f64 | f64 | f64 |
2022-11-16 | 63.293183 | 186.92 | 61.917333 | null | null |
2022-11-17 | 62.165243 | 183.17 | 62.677051 | -2.006206 | -1.127939 |
2022-11-18 | 63.608796 | 180.19 | 62.288186 | -1.626904 | 1.443553 |
2022-11-21 | 65.735118 | 167.87 | 68.509445 | -6.837227 | 2.126322 |
2022-11-22 | 64.523037 | 169.91 | 63.968654 | 1.215226 | -1.212081 |
… | … | … | … | … | … |
2024-11-11 | 67.699666 | 350.0 | null | 8.959592 | 7.900809 |
2024-11-12 | 60.843296 | 328.49 | null | -6.145714 | -6.85637 |
2024-11-13 | 60.695861 | 330.24 | null | 0.532741 | -0.147435 |
2024-11-14 | 56.223077 | 311.18 | null | -5.77156 | -4.472783 |
2024-11-15 | 58.584288 | 318.0503 | null | 2.207822 | 2.361211 |
In [3]:
ticker = 'SPY'
df = create_uw_iv_rv_df(ticker)
linreg_str = create_linear_regression_str(df)
print(linreg_str)
p = create_pct_price_vs_iv_plot(ticker, df, dark_color=False)
p.show()
Linear regression (y=mx+b): y=-0.719900x + 0.037563 || R^2 = 0.425913
SPY Spot-to-Implied Volatility Correlation Chart (above)
- Pretty tight R2 of 0.4259
- Slope of the linear regression is -0.719900 which tells us that, based on the last two years, it is expected that for every +1% move in the SPY its 30-calendar day implied volatility will drop by 0.7199 points
- 30-calendar day implied volatility in SPY is very similar to VIX (though of course VIX is derived from SPX options)
- This past Friday (2024-09-27) was very unusual since SPY declined close-to-close by -0.15% but the VIX was up +1.59 points... using our linear regression we can say that a +1.59 point increase in VIX would be much more expected if SPY was down by more than -2%
In [4]:
ticker = 'QQQ'
df = create_uw_iv_rv_df(ticker)
linreg_str = create_linear_regression_str(df)
print(linreg_str)
p = create_pct_price_vs_iv_plot(ticker, df, dark_color=False)
p.show()
Linear regression (y=mx+b): y=-0.541614x + 0.034197 || R^2 = 0.334099
QQQ Spot-to-Implied Volatility Correlation Chart (above)
- Less tight than SPY but still respectable R2 of 0.3341
- Slope of the linear regression is -0.5416 which tells us that, based on the last two years, it is expected that for every +1% move in the QQQ its 30-calendar day implied volatility will drop by 0.5416 points
- 30-calendar day implied volatility in QQQ is very similar to VXN (though of course VXN is calculated from NDX options, which are much thinner trading-wise than SPX options)
- Not nearly as extreme a reaction in the QQQ/implied volatility relationship this past Friday as QQQ was down by -0.56% and VXN was up by +0.75 points
- Whatever was driving the outsize reaction in S&P500 implied volatility this past Friday was not as pronounced in the Nasdaq 100
In [5]:
ticker = 'MSTR'
df = create_uw_iv_rv_df(ticker)
linreg_str = create_linear_regression_str(df)
print(linreg_str)
p = create_pct_price_vs_iv_plot(ticker, df, dark_color=False)
p.show()
Linear regression (y=mx+b): y=-0.001678x + -0.040973 || R^2 = 0.000003
MSTR Spot-to-Implied Volatility Correlation Chart (above)
- Hmm... R2 at zero and slope of the regression roughly the same
- Does this mean that implied volatility does not move with spot price?
- ... of course not lol. But this DOES tell us that moves in implied volatility are just as likely to come with price increases as they are with price decreases, which is very interesting for equity where the risk is much more likely to be down than up.
- ... very commodity-like.
In [6]:
ticker = 'GME'
df = create_uw_iv_rv_df(ticker)
linreg_str = create_linear_regression_str(df)
print(linreg_str)
p = create_pct_price_vs_iv_plot(ticker, df, dark_color=False)
p.show()
Linear regression (y=mx+b): y=1.288279x + -0.330326 || R^2 = 0.477297
GME Spot-to-Implied Volatility Correlation Chart (above)
- C'mon, you knew I was going to pick a fun one for the very end, and what's more fun than GME!?
- Remarkably high R2 at 0.4773
- And you're reading that right, 30-calendar day implied volatility is expected to INCREASE by 1.2883 pts for every +1% move in GME
- Fundamentally this makes sense considering GME's history... the risk is to the upside here