market tide v3
import os
import httpx
import polars as pl
from lets_plot import *
LetsPlot.setup_html()
uw_token = os.environ['UW_TOKEN'] # Replace the os.environ['UW_TOKEN'] with 'abc123etc' your token
headers = {'Accept': 'application/json, text/plain', 'Authorization': uw_token}
Our first task is to collect data from the Market Tide endpoint:
https://api.unusualwhales.com/docs#/operations/PublicApi.MarketController.market_tide
target_date = '2025-03-17'
market_tide_url = f'https://api.unusualwhales.com/api/market/market-tide'
market_tide_params = {
'interval_5m': 'false', # Let's look at 1-min data instead of 5-min data
'date': target_date # Set this to whatever date you want to consider
}
market_tide_rsp = httpx.get(market_tide_url, headers=headers, params=market_tide_params)
market_tide_rsp.status_code
200
Status code 200 means it worked, and
market_tide_rsp.json()
will return data formatted
like this:
{'data': [{'date': '2025-03-17',
'net_call_premium': '696601.0000',
'net_put_premium': '3526354.0000',
'net_volume': -5236,
'timestamp': '2025-03-17T09:30:00-04:00'},
{'date': '2025-03-17',
'net_call_premium': '-462855.0000',
'net_put_premium': '-755811.0000',
'net_volume': 12381,
'timestamp': '2025-03-17T09:31:00-04:00'},
...
Let's get this information into a dataframe, which operates a lot like a spreadsheet and will let us do some cool stuff like execute bulk operations and plot the Market Tide values (eventually).
raw_df = pl.DataFrame(market_tide_rsp.json()['data'])
clean_df = (
raw_df
.with_columns(
pl.col('net_call_premium').cast(pl.Float64),
pl.col('net_put_premium').cast(pl.Float64),
pl.col('timestamp').cast(pl.Datetime)
)
.with_columns(
pl.col('timestamp').dt.strftime('%m/%d %H:%M').alias('timestamp_ny_str')
)
)
clean_df
timestamp | date | net_call_premium | net_put_premium | net_volume | timestamp_ny_str |
---|---|---|---|---|---|
datetime[μs] | str | f64 | f64 | i64 | str |
2025-03-17 09:30:00 | "2025-03-17" | 696601.0 | 3.526354e6 | -5236 | "03/17 09:30" |
2025-03-17 09:31:00 | "2025-03-17" | -462855.0 | -755811.0 | 12381 | "03/17 09:31" |
2025-03-17 09:32:00 | "2025-03-17" | 5.184775e6 | -1.070213e6 | 24667 | "03/17 09:32" |
2025-03-17 09:33:00 | "2025-03-17" | 7.283853e6 | -2.334024e6 | 41271 | "03/17 09:33" |
2025-03-17 09:34:00 | "2025-03-17" | 8.143267e6 | 246743.0 | 45693 | "03/17 09:34" |
… | … | … | … | … | … |
2025-03-17 16:10:00 | "2025-03-17" | 4.4513091e7 | -6.4359351e7 | 1144845 | "03/17 16:10" |
2025-03-17 16:11:00 | "2025-03-17" | 4.4375282e7 | -6.4505875e7 | 1142534 | "03/17 16:11" |
2025-03-17 16:12:00 | "2025-03-17" | 4.4566166e7 | -6.4617023e7 | 1146292 | "03/17 16:12" |
2025-03-17 16:13:00 | "2025-03-17" | 4.4787701e7 | -6.3962126e7 | 1149102 | "03/17 16:13" |
2025-03-17 16:14:00 | "2025-03-17" | 4.4544932e7 | -6.3884381e7 | 1144938 | "03/17 16:14" |
Our plotting package prefers "tall" data sets over "wide" data
sets, so let's use the convenient melt
method to
convert this "wide" dataframe into a "tall" one before plotting
our Market Tide results.
melted_df = (
clean_df
.melt(
id_vars=['timestamp_ny_str'],
value_vars=['net_call_premium', 'net_put_premium'],
variable_name='flow_type',
value_name='premium'
)
)
melted_df
timestamp_ny_str | flow_type | premium |
---|---|---|
str | str | f64 |
"03/17 09:30" | "net_call_premi… | 696601.0 |
"03/17 09:31" | "net_call_premi… | -462855.0 |
"03/17 09:32" | "net_call_premi… | 5.184775e6 |
"03/17 09:33" | "net_call_premi… | 7.283853e6 |
"03/17 09:34" | "net_call_premi… | 8.143267e6 |
… | … | … |
"03/17 16:10" | "net_put_premiu… | -6.4359351e7 |
"03/17 16:11" | "net_put_premiu… | -6.4505875e7 |
"03/17 16:12" | "net_put_premiu… | -6.4617023e7 |
"03/17 16:13" | "net_put_premiu… | -6.3962126e7 |
"03/17 16:14" | "net_put_premiu… | -6.3884381e7 |
And with that we are ready to plot! In the next cell we do a little styling work to make sure the plot looks good in our environment (totally optional if you are following along), then in the final cell we will actually create the plots.
UW_DARK_THEME = {
'red': '#dc3545',
'yellow': '#ffc107',
'teal': '#20c997',
'black': '#161c2d',
'gray_medium': '#748196',
'gray_light': '#f9fbfd',
}
def uw_dark_theme(colors: dict, show_legend: bool=True) -> theme:
"""Create a dark theme for lets-plot using UW colors."""
t = theme_none() + theme(
plot_background=element_rect(fill=colors['black']),
panel_background=element_rect(fill=colors['black']),
panel_grid_major=element_blank(),
panel_grid_minor=element_blank(),
axis_ontop=True,
axis_ticks=element_blank(),
axis_tooltip=element_rect(color=colors['gray_light']),
tooltip=element_rect(color=colors['gray_light'], fill=colors['black']),
line=element_line(color=colors['gray_medium'], size=1),
rect=element_rect(color=colors['black'], fill=colors['black'], size=2),
text=element_text(color=colors['gray_light'], size=10),
legend_background=element_rect(color=colors['gray_light'], fill=colors['black'], size=2),
plot_title=element_text(hjust=0.5, size=16, color=colors['gray_light']),
)
if show_legend:
return t + theme(legend_position='bottom')
else:
return t + theme(legend_position='none')
market_tide_color_mapping = {
'net_call_premium': UW_DARK_THEME['teal'],
'net_put_premium': UW_DARK_THEME['red']
}
market_tide_plot = (
ggplot(melted_df)
+ aes(x='timestamp_ny_str', y='premium', color='flow_type')
+ geom_line(size=1)
+ scale_color_manual(values=market_tide_color_mapping)
+ ggtitle(f'Market Tide for {target_date}')
+ xlab('Timestamp')
+ ylab('Net Premium')
+ ggsize(800, 500)
+ uw_dark_theme(UW_DARK_THEME, show_legend=True)
)
market_tide_plot.show()
Interesting to see the steady ramp up in Net Call Premium (green line) and ramp down in Net Put Premium (red line) from 1:20PM to 3:10PM eastern before the trend pulled back and finally dropped quickly at 3:53PM. Let's collect the SPY price from the OHLC endpoint and see how it reacted at these inflection points.
https://api.unusualwhales.com/docs#/operations/PublicApi.TickerController.ohlc
ticker = 'SPY'
candle_size = '1m' # 1 minute data slices to match our delta flows above
ohlc_params = {
'date': target_date,
}
ohlc_url = f'https://api.unusualwhales.com/api/stock/{ticker}/ohlc/{candle_size}'
ohlc_rsp = httpx.get(ohlc_url, headers=headers, params=ohlc_params)
ohlc_rsp.status_code
200
ohlc_rsp.json()
will give us minute-by-minute SPY
price data like this:
{'data': [{'close': '603.26',
'end_time': '2024-12-10T23:41:00Z',
'high': '603.26',
'low': '603.26',
'market_time': 'po',
'open': '603.26',
'start_time': '2024-12-10T23:40:00Z',
'total_volume': 37197946,
'volume': 5769},
{'close': '603.12',
'end_time': '2024-12-10T23:26:00Z',
'high': '603.12',
'low': '603.12',
'market_time': 'po',
'open': '603.12',
'start_time': '2024-12-10T23:25:00Z',
'total_volume': 37192177,
'volume': 1092},
...
Let's drop this into a dataframe for bulk editing.
raw_ohlc_df = pl.DataFrame(ohlc_rsp.json()['data'])
clean_ohlc_df = (
raw_ohlc_df
.with_columns(
pl.col('open').cast(pl.Float64),
pl.col('high').cast(pl.Float64),
pl.col('low').cast(pl.Float64),
pl.col('close').cast(pl.Float64),
pl.col('volume').cast(pl.Int64),
)
.with_columns(
pl.col('start_time').cast(pl.Datetime),
)
.with_columns(
pl.col('start_time').dt.convert_time_zone('America/New_York').alias('timestamp_ny')
)
.with_columns(
pl.col('timestamp_ny').dt.strftime('%m/%d %H:%M').alias('timestamp_ny_str')
)
.filter(
(pl.col('timestamp_ny_str') >= '03/17 09:30') & (pl.col('timestamp_ny_str') <= '03/17 16:15')
)
.sort('timestamp_ny', descending=False)
)
clean_ohlc_df
close | high | low | open | start_time | volume | end_time | market_time | total_volume | timestamp_ny | timestamp_ny_str |
---|---|---|---|---|---|---|---|---|---|---|
f64 | f64 | f64 | f64 | datetime[μs] | i64 | str | str | i64 | datetime[μs, America/New_York] | str |
562.41 | 562.88 | 562.35 | 562.79 | 2025-03-17 13:30:00 | 545206 | "2025-03-17T13:… | "r" | 1819613 | 2025-03-17 09:30:00 EDT | "03/17 09:30" |
563.195 | 563.26 | 562.42 | 562.44 | 2025-03-17 13:31:00 | 220767 | "2025-03-17T13:… | "r" | 2040380 | 2025-03-17 09:31:00 EDT | "03/17 09:31" |
563.93 | 563.93 | 563.14 | 563.22 | 2025-03-17 13:32:00 | 235376 | "2025-03-17T13:… | "r" | 2275756 | 2025-03-17 09:32:00 EDT | "03/17 09:32" |
564.2575 | 564.37 | 563.92 | 563.92 | 2025-03-17 13:33:00 | 327531 | "2025-03-17T13:… | "r" | 2603287 | 2025-03-17 09:33:00 EDT | "03/17 09:33" |
564.36 | 564.42 | 564.1 | 564.24 | 2025-03-17 13:34:00 | 230955 | "2025-03-17T13:… | "r" | 2834242 | 2025-03-17 09:34:00 EDT | "03/17 09:34" |
… | … | … | … | … | … | … | … | … | … | … |
566.7 | 566.84 | 566.69 | 566.84 | 2025-03-17 20:11:00 | 1650255 | "2025-03-17T20:… | "po" | 47912113 | 2025-03-17 16:11:00 EDT | "03/17 16:11" |
566.67 | 567.15 | 566.67 | 567.15 | 2025-03-17 20:12:00 | 187870 | "2025-03-17T20:… | "po" | 48099983 | 2025-03-17 16:12:00 EDT | "03/17 16:12" |
566.85 | 566.9489 | 566.73 | 566.83 | 2025-03-17 20:13:00 | 36134 | "2025-03-17T20:… | "po" | 48136117 | 2025-03-17 16:13:00 EDT | "03/17 16:13" |
566.73 | 566.85 | 566.72 | 566.85 | 2025-03-17 20:14:00 | 6679 | "2025-03-17T20:… | "po" | 48142796 | 2025-03-17 16:14:00 EDT | "03/17 16:14" |
566.7601 | 566.7601 | 566.72 | 566.73 | 2025-03-17 20:15:00 | 9706 | "2025-03-17T20:… | "po" | 48152502 | 2025-03-17 16:15:00 EDT | "03/17 16:15" |
We can now apply the same "melt" technique to the clean OHLC dataframe then stack Market Tide on top of SPY price to see if these inflection points were meaningful:
melted_ohlc_df = (
clean_ohlc_df
.melt(
id_vars=['timestamp_ny_str'],
value_vars=['close'],
variable_name='flow_type',
value_name='price'
)
)
ohlc_color_mapping = {
'close': UW_DARK_THEME['yellow']
}
price_plot = (
ggplot(melted_ohlc_df)
+ aes(x='timestamp_ny_str', y='price', color='flow_type')
+ geom_line(size=1)
+ scale_color_manual(values=ohlc_color_mapping)
+ ggsize(800, 500)
+ ggtitle(f'{ticker} Price')
+ xlab('Timestamp')
+ ylab('Price')
+ uw_dark_theme(UW_DARK_THEME, show_legend=False)
)
mt_and_p_full_plots = [market_tide_plot, price_plot]
mt_and_p_full_grid = (
gggrid(
mt_and_p_full_plots,
ncol=1,
widths=[800, 800],
heights=[400, 375],
align=True,
)
+ ggsize(800, 800)
+ theme(plot_background=element_rect(fill=UW_DARK_THEME['black']))
)
mt_and_p_full_grid.show()
No fancy math needed, we can clearly see how this played out!
The SPY price rallied almost 1% during the extended run of increasing Net Call Premium (green line) / decreasing Net Put Premium (red line).
Once this trend was exhausted and the premium metrics leveled off, the SPY price flattened out too.
It should come as no surprise then that the sharp decline in Net Call Premium at 3:53PM (a whole 10% decline in that single minute) corresponded to a sharp decline in SPY price.
Market Tide seems to be, at very least, a coincident indicator for price in the SPY.