custom alerts demo
import os
import requests
import polars as pl
from datetime import datetime
from rich import print as rprint
The newly-added "alerts" endpoints give us access to all the Custom Alerts associated with our account:
https://api.unusualwhales.com/docs#/operations/PublicApi.AlertsController.alerts
But we can also reach the "configs" endpoint, which will give us some details about the existing Custom Alerts:
https://api.unusualwhales.com/docs#/operations/PublicApi.AlertsController.configs
Let's try the "configs" endpoint first to get a better idea about which Custom Alerts we want to selectively bring back:
uw_token = os.environ['UW_TOKEN']
headers = {'Accept': 'application/json, text/plain', 'Authorization': uw_token}
configs_url = r'https://api.unusualwhales.com/api/alerts/configuration'
configs_rsp = requests.get(configs_url, headers=headers)
configs_rsp.status_code
200
Cool, configs_rsp.json()
gives us a collection of
dictionaries describing the Custom Alerts like this:
{'data': [{'config': {'issue_types': ['Common Stock', 'ADR', 'ETF'],
'max_multileg_volume_ratio': 0.2,
'min_bid_perc': 0.8,
'min_dte': 6,
'min_premium': 250000,
'minute': [10],
'symbols': 'all',
'type': 'put',
'vol_greater_oi': True},
'created_at': '2024-12-16T16:07:23Z',
'id': '9657ed04-81c4-4bfe-8a46-47013b3c4356',
'mobile_only': False,
'name': '250K Put Seller Stock or ETF Custom Alert',
'noti_type': 'option_contract_interval',
'status': 'active'},
...
{'config': {'interval_opening_flow': True,
'is_otm': True,
'issue_types': ['Common Stock', 'ADR'],
'max_ask_perc': 1,
'max_dte': '183',
'max_iv': 1,
'max_multileg_volume_ratio': 0.1,
'min_ask_perc': 0.8,
'min_dte': '6',
'min_premium': '500000',
'minute': [5],
'symbols': 'all',
'type': 'call'},
'created_at': '2024-12-12T01:36:33Z',
'id': 'a98f6c2b-25b6-4b60-a42f-e92aa9d77690',
'mobile_only': False,
'name': '500K OTM Call Buyer Custom Alert',
'noti_type': 'option_contract_interval',
'status': 'active'}]}
Fortunately polars can accomodate this kind of "nested" format,
so I'm just going to pull the entire response into a DataFrame
then drop the config
sub-dictionary:
pl.Config.set_fmt_str_lengths(100)
raw_configs_df = pl.DataFrame(configs_rsp.json()['data'])
desired_columns = ['name', 'id', 'status', 'noti_type', 'created_at']
configs_df = (
raw_configs_df.select(desired_columns)
)
configs_df
name | id | status | noti_type | created_at |
---|---|---|---|---|
str | str | str | str | str |
"500K OTM Call Buyer Custom Alert" | "a98f6c2b-25b6-4b60-a42f-e92aa9d77690" | "active" | "option_contract_interval" | "2024-12-20T05:36:07Z" |
"250K Put Seller Stock or ETF Custom Alert" | "9657ed04-81c4-4bfe-8a46-47013b3c4356" | "active" | "option_contract_interval" | "2024-12-16T16:07:23Z" |
"3X over 30d avg option volume Call emphasis $100K" | "ec5bb6b0-6f21-4311-bcca-54b1314eeb93" | "active" | "stock" | "2024-10-22T21:25:01Z" |
"Low Volume 3x Avg 10K Bets" | "2bb448e5-ed87-4015-9ec8-def1d7fff30a" | "active" | "stock" | "2024-10-08T04:27:18Z" |
"ITM Put Sellers in Industrial Metals and Sand WL" | "357df1a6-347a-489f-bd26-69b0df42ad39" | "active" | "option_contract" | "2023-11-13T05:26:56Z" |
"For ON when there is an option contract" | "a1dd5f42-b9a6-4b74-beb2-4e1fadd0de7d" | "active" | "option_contract" | "2023-11-13T05:03:58Z" |
The first Custom Alert in the group, "250K Put Seller Stock or ETF Custom Alert", is potentially quite interesting given the context: today is Thursday Dec 19th, 2024. Yesterday was an FOMC day and the SPY was down a horrendous -2.98% with the VIX trading up from its previous close of 15.88 to 27.61... suffice it to say yesterday was about as textbook a correlation 1.0 sell-off as you could ask for.
Let's examine the trades that were flagged yesterday to see
where market participants were "rushing into a burning building"
to sell Puts in size by obtaining the id
associated
with this Custom Alert then hitting the Alerts endpoint:
https://api.unusualwhales.com/docs#/operations/PublicApi.AlertsController.alerts
put_seller_id = (
configs_df
.filter(pl.col('name') == '250K Put Seller Stock or ETF Custom Alert')
.select('id')
.item()
)
alerts_url = f'https://api.unusualwhales.com/api/alerts'
alerts_params = {
'config_ids[]': put_seller_id,
'limit': 200
}
alerts_rsp = requests.get(alerts_url, headers=headers, params=alerts_params)
alerts_rsp.status_code
200
Nice, now alerts_rsp.json()
gives us a collection
of alert dictionaries like this:
{'data': [{'created_at': '2024-12-19T16:16:36Z',
'id': 'c44fafce-b07f-4f15-980f-c102a62f637e',
'meta': {'ask_volume': 0,
'avg_fill': '6.1000',
'bid_volume': 614,
'close': '6.10',
'diff': '-0.3646',
'minute': 10,
'multi_leg_vol_ratio': '0',
'open_interest': 2,
'rounded_tape_time': 1734624600000,
'total_premium': '374543',
'underlying_symbol': 'IREN',
'vol_oi_ratio': '307',
'volume': 614},
'name': '250K Put Seller Stock or ETF Custom Alert',
'noti_type': 'option_contract_interval',
'symbol': 'IREN250718P00016000',
'symbol_type': 'option_chain',
'tape_time': '2024-12-19T16:16:34Z',
'user_noti_config_id': '9657ed04-81c4-4bfe-8a46-47013b3c4356'},
...
{'created_at': '2024-12-16T16:16:42Z',
'id': 'b518e9f3-dfe1-406b-89b8-261c606bc6fc',
'meta': {'ask_volume': 49,
'avg_fill': '8.2976',
'bid_volume': 274,
'close': '8.30',
'diff': '0.3020',
'minute': 10,
'multi_leg_vol_ratio': '0.1300',
'open_interest': 140,
'rounded_tape_time': 1734365400000,
'total_premium': '268013',
'underlying_symbol': 'IONQ',
'vol_oi_ratio': '2.3071',
'volume': 323},
'name': '250K Put Seller Stock or ETF Custom Alert',
'noti_type': 'option_contract_interval',
'symbol': 'IONQ260116P00025000',
'symbol_type': 'option_chain',
'tape_time': '2024-12-16T16:16:41Z',
'user_noti_config_id': '9657ed04-81c4-4bfe-8a46-47013b3c4356'}]}
OK this time we actually need to do a bit of nested dictionary unfolding to end up with the best polars DataFrame possible, so let's get to it:
flattened_json = [
{
**{k: v for k, v in d.items() if k != 'meta'},
**{f'alert_{k}': v for k, v in d['meta'].items()}
} for d in alerts_rsp.json()['data']
]
flattened_json[0]
{'created_at': '2024-12-19T21:10:47Z', 'id': 'ebd031b9-6139-4ae4-9c99-70dafff592a4', 'name': '250K Put Seller Stock or ETF Custom Alert', 'noti_type': 'option_contract_interval', 'symbol': 'EWZ250117P00031000', 'symbol_type': 'option_chain', 'tape_time': '2024-12-19T21:10:34Z', 'user_noti_config_id': '9657ed04-81c4-4bfe-8a46-47013b3c4356', 'alert_ask_volume': 0, 'alert_avg_fill': '7.10', 'alert_bid_volume': 7557, 'alert_close': '7.10', 'alert_diff': '-0.3561', 'alert_minute': 10, 'alert_multi_leg_vol_ratio': '0', 'alert_open_interest': 3504, 'alert_rounded_tape_time': 1734642600000, 'alert_total_premium': '5365470', 'alert_underlying_symbol': 'EWZ', 'alert_vol_oi_ratio': '2.1567', 'alert_volume': 7557}
Much better... now let's compile all these custom alerts into a single DataFrame for additional transformation and analysis:
pl.Config.set_fmt_str_lengths(50)
raw_alerts_df = pl.DataFrame(flattened_json)
raw_alerts_df.head(5)
created_at | id | name | noti_type | symbol | symbol_type | tape_time | user_noti_config_id | alert_ask_volume | alert_avg_fill | alert_bid_volume | alert_close | alert_diff | alert_minute | alert_multi_leg_vol_ratio | alert_open_interest | alert_rounded_tape_time | alert_total_premium | alert_underlying_symbol | alert_vol_oi_ratio | alert_volume |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
str | str | str | str | str | str | str | str | i64 | str | i64 | str | str | i64 | str | i64 | i64 | str | str | str | i64 |
"2024-12-19T21:10:47Z" | "ebd031b9-6139-4ae4-9c99-70dafff592a4" | "250K Put Seller Stock or ETF Custom Alert" | "option_contract_interval" | "EWZ250117P00031000" | "option_chain" | "2024-12-19T21:10:34Z" | "9657ed04-81c4-4bfe-8a46-47013b3c4356" | 0 | "7.10" | 7557 | "7.10" | "-0.3561" | 10 | "0" | 3504 | 1734642600000 | "5365470" | "EWZ" | "2.1567" | 7557 |
"2024-12-19T21:10:47Z" | "fa54357d-0d51-4064-b295-245dfc16bfbc" | "250K Put Seller Stock or ETF Custom Alert" | "option_contract_interval" | "EWZ250117P00030000" | "option_chain" | "2024-12-19T21:10:34Z" | "9657ed04-81c4-4bfe-8a46-47013b3c4356" | 0 | "5.85" | 1563 | "5.85" | "-0.3123" | 10 | "0" | 770 | 1734642600000 | "914355" | "EWZ" | "2.0299" | 1563 |
"2024-12-19T21:10:47Z" | "5b3a0939-60eb-43ca-9cfe-ce20495574d4" | "250K Put Seller Stock or ETF Custom Alert" | "option_contract_interval" | "EWZ250321P00032000" | "option_chain" | "2024-12-19T21:10:34Z" | "9657ed04-81c4-4bfe-8a46-47013b3c4356" | 0 | "8.10" | 20722 | "8.10" | "-0.3998" | 10 | "0" | 10001 | 1734642600000 | "16784820" | "EWZ" | "2.0720" | 20722 |
"2024-12-19T20:56:33Z" | "9c6a3d1e-e79e-4047-9351-619e28e8e7b6" | "250K Put Seller Stock or ETF Custom Alert" | "option_contract_interval" | "TOL250117P00155000" | "option_chain" | "2024-12-19T20:56:32Z" | "9657ed04-81c4-4bfe-8a46-47013b3c4356" | 0 | "30.90" | 306 | "30.90" | "-0.2537" | 10 | "0" | 225 | 1734641400000 | "945540" | "TOL" | "1.36" | 306 |
"2024-12-19T20:52:40Z" | "929718d6-aaf9-48f2-8476-698b3ca6d106" | "250K Put Seller Stock or ETF Custom Alert" | "option_contract_interval" | "DOW250117P00060000" | "option_chain" | "2024-12-19T20:52:39Z" | "9657ed04-81c4-4bfe-8a46-47013b3c4356" | 0 | "20.30" | 590 | "20.30" | "-0.5302" | 10 | "0" | 289 | 1734641400000 | "1197700" | "DOW" | "2.0415" | 590 |
OK, let's parse these results into a concise message for the trading floor, maybe something like this:
Opening Put Seller: 3095x SPY 566P 2/28/2025 (64dte 3.74% OTM) @ -9.4828 avg for $2.94M credit at 11:09AM
Let's get to it:
def format_total_premium(x: int) -> str:
if x >= 1_000_000:
return f'${x/1_000_000:.1f}M'
elif x >= 1_000:
return f'${x/1_000:.1f}K'
else:
return f'${x:.0f}'
alerts_df = (
raw_alerts_df
.with_columns(
pl.col('symbol').str.slice(-8).cast(pl.Float64).alias('raw_strike_price')
)
.with_columns(
(pl.col('raw_strike_price') / 1000).cast(pl.String).str.replace(r'\.0', '').alias('strike_price_str')
)
.with_columns(
pl.col('symbol').str.slice(-15, 6).str.strptime(pl.Date, '%y%m%d').alias('expiration_dt')
)
.with_columns(
(pl.col('expiration_dt') - pl.lit(datetime.today().date())).alias('dte')
)
.with_columns(
(pl.col('dte').dt.total_milliseconds() / (24 * 60 * 60 * 1000)).cast(pl.Int64).alias('dte_days')
)
.with_columns(
(pl.col('alert_diff').cast(pl.Float64) * 100).round(1).alias('alert_diff_rounded')
)
.with_columns(
pl.when(pl.col('alert_diff_rounded') > 0)
.then(pl.concat_str([pl.col('alert_diff_rounded'), pl.lit('% OTM')]))
.otherwise(pl.concat_str([pl.col('alert_diff_rounded').abs(), pl.lit('% ITM')]))
.alias('pct_moneyness_str')
)
.with_columns(
pl.col('alert_avg_fill').cast(pl.Float64).round(2).alias('alert_avg_fill_rounded')
)
.with_columns(
pl.col('alert_total_premium').cast(pl.Int64).map_elements(format_total_premium).alias('formatted_total_premium')
)
.with_columns(
pl.col('tape_time').str.strptime(pl.Datetime, '%Y-%m-%dT%H:%M:%S%.fZ').alias('tape_time_dt')
)
.with_columns(
pl.col('tape_time_dt').dt.convert_time_zone('America/New_York').alias('tape_time_tz_dt')
)
.with_columns(
pl.col('tape_time_tz_dt').dt.strftime('%I:%M %p').alias('tape_time_tz_str')
)
.with_columns(
pl.concat_str(
[
pl.lit('Opening Put Seller: '),
pl.col('alert_volume').cast(pl.String),
pl.lit('x '),
pl.col('alert_underlying_symbol'),
pl.lit(' '),
pl.col('strike_price_str'),
pl.lit('P '),
pl.col('expiration_dt').dt.strftime('%m/%d/%Y'),
pl.lit(' ('),
pl.col('dte_days').cast(pl.String),
pl.lit('dte, '),
pl.col('pct_moneyness_str'),
pl.lit(') @ avg '),
pl.col('alert_avg_fill_rounded').cast(pl.String),
pl.lit(' for '),
pl.col('formatted_total_premium'),
pl.lit(' credit at '),
pl.col('tape_time_tz_str')
],
separator=''
).alias('message')
)
)
messages = alerts_df['message'].to_list()
rprint(messages[0:10]) # print the 10 most recent alerts as a "spot check"
[ 'Opening Put Seller: 7557x EWZ 31P 01/17/2025 (28dte, 35.6% ITM) @ avg 7.1 for $5.4M credit at 04:10 PM', 'Opening Put Seller: 1563x EWZ 30P 01/17/2025 (28dte, 31.2% ITM) @ avg 5.85 for $914.4K credit at 04:10 PM', 'Opening Put Seller: 20722x EWZ 32P 03/21/2025 (91dte, 40.0% ITM) @ avg 8.1 for $16.8M credit at 04:10 PM', 'Opening Put Seller: 306x TOL 155P 01/17/2025 (28dte, 25.4% ITM) @ avg 30.9 for $945.5K credit at 03:56 PM', 'Opening Put Seller: 590x DOW 60P 01/17/2025 (28dte, 53.0% ITM) @ avg 20.3 for $1.2M credit at 03:52 PM', 'Opening Put Seller: 530x DOW 65P 01/17/2025 (28dte, 65.8% ITM) @ avg 25.3 for $1.3M credit at 03:52 PM', 'Opening Put Seller: 2460x DOW 50P 01/17/2025 (28dte, 27.5% ITM) @ avg 10.3 for $2.5M credit at 03:52 PM', 'Opening Put Seller: 730x DOW 52.5P 01/17/2025 (28dte, 33.9% ITM) @ avg 12.6 for $919.8K credit at 03:52 PM', 'Opening Put Seller: 157x COIN 220P 01/15/2027 (756dte, 20.1% OTM) @ avg 64.05 for $1.0M credit at 03:39 PM', 'Opening Put Seller: 400x DELL 125P 01/24/2025 (35dte, 11.2% ITM) @ avg 14.14 for $565.6K credit at 03:39 PM' ]
Hmm... Lots of very deep ITM Put selling here, which is potentially more of an arb strategy and less of a directional bet. Let's filter out the extremely deep ITM (say more than 10% ITM) Put Sellers and send the messages again:
filtered_df = (
alerts_df
.filter(pl.col('alert_diff_rounded') > -10)
)
filtered_messages = filtered_df['message'].to_list()
rprint(filtered_messages[0:40]) # print the last 40 alerts to demonstrate the interesting trades that show up
[ 'Opening Put Seller: 157x COIN 220P 01/15/2027 (756dte, 20.1% OTM) @ avg 64.05 for $1.0M credit at 03:39 PM', 'Opening Put Seller: 6020x IWM 209P 01/17/2025 (28dte, 5.1% OTM) @ avg 2.71 for $1.6M credit at 03:35 PM', 'Opening Put Seller: 30x MELI 1540P 01/16/2026 (392dte, 7.8% OTM) @ avg 175.3 for $525.9K credit at 03:35 PM', 'Opening Put Seller: 1408x XLE 80P 02/21/2025 (63dte, 4.4% OTM) @ avg 1.79 for $252.0K credit at 03:26 PM', 'Opening Put Seller: 256x ACN 350P 06/20/2025 (182dte, 6.2% OTM) @ avg 14.99 for $383.7K credit at 03:23 PM', 'Opening Put Seller: 372x WFC 70P 01/16/2026 (392dte, 1.2% ITM) @ avg 8.15 for $303.2K credit at 03:22 PM', 'Opening Put Seller: 254x COR 220P 06/20/2025 (182dte, 2.9% OTM) @ avg 10.1 for $256.5K credit at 03:15 PM', 'Opening Put Seller: 170x PANW 180P 08/15/2025 (238dte, 5.1% OTM) @ avg 15.5 for $263.5K credit at 03:13 PM', 'Opening Put Seller: 2500x WMT 75P 12/18/2026 (728dte, 20.1% OTM) @ avg 3.73 for $932.5K credit at 02:45 PM', 'Opening Put Seller: 475x PANW 185P 02/21/2025 (63dte, 2.8% OTM) @ avg 9.4 for $446.5K credit at 02:35 PM', 'Opening Put Seller: 700x C 65P 01/15/2027 (756dte, 5.3% OTM) @ avg 7.65 for $535.5K credit at 02:16 PM', 'Opening Put Seller: 385x TSLA 430P 07/18/2025 (210dte, 0.8% OTM) @ avg 77.5 for $3.0M credit at 02:13 PM', 'Opening Put Seller: 152x APP 320P 01/03/2025 (14dte, 0.3% OTM) @ avg 17.9 for $272.1K credit at 02:02 PM', 'Opening Put Seller: 1895x SLV 26P 07/18/2025 (210dte, 1.8% OTM) @ avg 1.66 for $314.6K credit at 01:57 PM', 'Opening Put Seller: 830x KRE 59P 03/21/2025 (91dte, 1.9% OTM) @ avg 3.05 for $253.2K credit at 01:40 PM', 'Opening Put Seller: 186x MSTR 220P 04/17/2025 (118dte, 32.6% OTM) @ avg 26.12 for $485.8K credit at 01:13 PM', 'Opening Put Seller: 400x UNP 215P 05/16/2025 (147dte, 4.1% OTM) @ avg 8.45 for $338.0K credit at 01:07 PM', 'Opening Put Seller: 1432x VST 130P 12/27/2024 (7dte, 5.5% OTM) @ avg 2.2 for $315.0K credit at 12:57 PM', 'Opening Put Seller: 100x MSTR 342.5P 12/27/2024 (7dte, 3.0% ITM) @ avg 25.27 for $252.7K credit at 12:57 PM', 'Opening Put Seller: 349x XLY 230P 03/21/2025 (91dte, 1.0% ITM) @ avg 10.81 for $377.2K credit at 12:46 PM', 'Opening Put Seller: 923x SPY 580P 12/26/2024 (6dte, 1.4% OTM) @ avg 3.11 for $286.7K credit at 12:45 PM', 'Opening Put Seller: 275x META 600P 12/18/2026 (728dte, 0.6% OTM) @ avg 99.0 for $2.7M credit at 12:20 PM', 'Opening Put Seller: 329x SPY 587P 02/21/2025 (63dte, 0.4% OTM) @ avg 13.46 for $442.9K credit at 12:15 PM', 'Opening Put Seller: 255x FSLR 125P 12/19/2025 (364dte, 30.4% OTM) @ avg 9.9 for $252.4K credit at 12:12 PM', 'Opening Put Seller: 3000x TLT 91P 01/31/2025 (42dte, 3.5% ITM) @ avg 3.39 for $1.0M credit at 12:07 PM', 'Opening Put Seller: 290x AVGO 200P 01/15/2027 (756dte, 8.8% OTM) @ avg 31.5 for $913.5K credit at 12:01 PM', 'Opening Put Seller: 557x SBUX 91P 01/31/2025 (42dte, 1.2% ITM) @ avg 4.5 for $250.4K credit at 11:37 AM', 'Opening Put Seller: 553x SOUN 20P 02/21/2025 (63dte, 9.4% ITM) @ avg 5.95 for $329.3K credit at 11:26 AM', 'Opening Put Seller: 3095x SPY 566P 02/28/2025 (70dte, 3.7% OTM) @ avg 9.48 for $2.9M credit at 11:09 AM', 'Opening Put Seller: 2422x BE 21P 01/17/2025 (28dte, 5.0% OTM) @ avg 1.42 for $343.9K credit at 11:04 AM', 'Opening Put Seller: 1191x ALLY 32P 06/20/2025 (182dte, 7.9% OTM) @ avg 2.11 for $251.2K credit at 10:59 AM', 'Opening Put Seller: 392x PYPL 90P 01/16/2026 (392dte, 3.6% ITM) @ avg 13.0 for $509.6K credit at 10:34 AM', 'Opening Put Seller: 1003x TSLA 380P 01/31/2025 (42dte, 15.6% OTM) @ avg 16.0 for $1.6M credit at 10:14 AM', 'Opening Put Seller: 770x TLT 91P 01/24/2025 (35dte, 3.5% ITM) @ avg 3.32 for $255.7K credit at 10:11 AM', 'Opening Put Seller: 759x MSFT 415P 01/31/2025 (42dte, 5.7% OTM) @ avg 5.41 for $410.5K credit at 10:09 AM', 'Opening Put Seller: 9260x BSX 82.5P 01/17/2025 (28dte, 6.4% OTM) @ avg 0.6 for $555.7K credit at 10:08 AM', 'Opening Put Seller: 311x MSFT 440P 08/15/2025 (238dte, 0.0% ITM) @ avg 30.35 for $943.9K credit at 10:04 AM', 'Opening Put Seller: 1000x KWEB 30P 09/19/2025 (273dte, 0.2% OTM) @ avg 3.0 for $300.0K credit at 09:58 AM', 'Opening Put Seller: 371x SPY 562P 02/28/2025 (70dte, 4.9% OTM) @ avg 7.4 for $274.5K credit at 09:52 AM', 'Opening Put Seller: 3368x SLV 27P 01/03/2025 (14dte, 2.2% ITM) @ avg 0.81 for $272.8K credit at 09:52 AM' ]