├── historical-premium.png ├── README.md ├── spread-production-tastytrade.py ├── spread-production.py └── spread-backtest-settlement.py /historical-premium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quantgalore/selling-volatility/HEAD/historical-premium.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Working System for Selling 0-DTE SPX Options 2 | This script contains the functionality needed to backtest and deploy a systematic 0-DTE option selling strategy on SPX. 3 | 4 | Original Source + Full Methodology - "Selling Volatility The RIGHT Way." - The Quant's Playbook @ Quant Galore 5 | 6 | 7 | -------------------------------------------------------------------------------- /spread-production-tastytrade.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created in 2024 4 | 5 | @author: Quant Galore 6 | """ 7 | 8 | import requests 9 | import pandas as pd 10 | import numpy as np 11 | import time 12 | 13 | from datetime import datetime, timedelta 14 | from pandas_market_calendars import get_calendar 15 | # from self_email import send_message 16 | 17 | # ============================================================================= 18 | # Tastytrade Integration 19 | # ============================================================================= 20 | 21 | base_url = 'https://api.tastyworks.com' 22 | 23 | # Authenticate session 24 | 25 | auth_url = 'https://api.tastyworks.com/sessions' 26 | headers = {'Content-Type': 'application/json'} 27 | 28 | session_data = { 29 | # Tastytrade email + pw 30 | "login": "your-tastytrade-email@email.com", 31 | "password": "your-tastytrade-password", 32 | "remember-me": True 33 | } 34 | 35 | authentication_response = requests.post(auth_url, headers=headers, json=session_data) 36 | authentication_json = authentication_response.json() 37 | 38 | session_token = authentication_json["data"]["session-token"] 39 | authorized_header = {'Authorization': session_token} 40 | 41 | # End of authentication 42 | 43 | # Pull account information and verify balance 44 | 45 | accounts = requests.get(f"{base_url}/customers/me/accounts", headers = {'Authorization': session_token}).json() 46 | account_number = accounts["data"]["items"][0]["account"]["account-number"] 47 | 48 | balances = requests.get(f"{base_url}/accounts/{account_number}/balances", headers = {'Authorization': session_token}).json()["data"] 49 | 50 | option_buying_power = np.float64(balances["derivative-buying-power"]) 51 | print(f"Buying Power: ${option_buying_power}") 52 | 53 | # ============================================================================= 54 | # Polygon Data 55 | # ============================================================================= 56 | 57 | polygon_api_key = "KkfCQ7fsZnx0yK4bhX9fD81QplTh0Pf3" 58 | calendar = get_calendar("NYSE") 59 | 60 | trading_dates = calendar.schedule(start_date = "2023-01-01", end_date = (datetime.today()+timedelta(days=1))).index.strftime("%Y-%m-%d").values 61 | 62 | date = trading_dates[-1] 63 | 64 | vix_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/I:VIX1D/range/1/day/2023-05-01/{date}?sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 65 | vix_data.index = pd.to_datetime(vix_data.index, unit="ms", utc=True).tz_convert("America/New_York") 66 | vix_data["1_mo_avg"] = vix_data["c"].rolling(window=30).mean() 67 | vix_data["3_mo_avg"] = vix_data["c"].rolling(window=63).mean() 68 | vix_data["6_mo_avg"] = vix_data["c"].rolling(window=126).mean() 69 | vix_data['vol_regime'] = vix_data.apply(lambda row: 1 if (row['1_mo_avg'] > row['3_mo_avg']) else 0, axis=1) 70 | vix_data["str_date"] = vix_data.index.strftime("%Y-%m-%d") 71 | 72 | # Define the volatility regime 73 | vol_regime = vix_data["vol_regime"].iloc[-1] 74 | 75 | big_underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/SPY/range/1/day/2020-01-01/{date}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 76 | big_underlying_data.index = pd.to_datetime(big_underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 77 | big_underlying_data["1_mo_avg"] = big_underlying_data["c"].rolling(window=20).mean() 78 | big_underlying_data["3_mo_avg"] = big_underlying_data["c"].rolling(window=60).mean() 79 | big_underlying_data['regime'] = big_underlying_data.apply(lambda row: 1 if (row['c'] > row['1_mo_avg']) else 0, axis=1) 80 | 81 | # Define the regime of the underlying asset 82 | trend_regime = big_underlying_data['regime'].iloc[-1] 83 | 84 | ticker = "I:SPX" 85 | index_ticker = "I:VIX1D" 86 | options_ticker = "SPX" 87 | 88 | trading_date = datetime.now().strftime("%Y-%m-%d") 89 | 90 | underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{ticker}/range/1/minute/{trading_date}/{trading_date}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 91 | underlying_data.index = pd.to_datetime(underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 92 | 93 | index_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{index_ticker}/range/1/minute/{trading_date}/{trading_date}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 94 | index_data.index = pd.to_datetime(index_data.index, unit="ms", utc=True).tz_convert("America/New_York") 95 | 96 | index_price = index_data[index_data.index.time >= pd.Timestamp("09:35").time()]["c"].iloc[0] 97 | price = underlying_data[underlying_data.index.time >= pd.Timestamp("09:35").time()]["c"].iloc[0] 98 | 99 | expected_move = (round((index_price / np.sqrt(252)), 2)/100)*.50 100 | 101 | exp_date = trading_date 102 | 103 | if trend_regime == 0: 104 | 105 | valid_calls = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/reference/options/contracts?underlying_ticker={options_ticker}&contract_type=call&as_of={trading_date}&expiration_date={exp_date}&limit=1000&apiKey={polygon_api_key}").json()["results"]) 106 | valid_calls = valid_calls[valid_calls["ticker"].str.contains("SPXW")].copy() 107 | valid_calls["days_to_exp"] = (pd.to_datetime(valid_calls["expiration_date"]) - pd.to_datetime(trading_date)).dt.days 108 | valid_calls["distance_from_price"] = abs(valid_calls["strike_price"] - price) 109 | 110 | upper_price = round(price + (price * expected_move)) 111 | otm_calls = valid_calls[valid_calls["strike_price"] >= upper_price] 112 | 113 | short_call = otm_calls.iloc[[0]] 114 | long_call = otm_calls.iloc[[1]] 115 | 116 | short_strike = short_call["strike_price"].iloc[0] 117 | long_strike = long_call["strike_price"].iloc[0] 118 | 119 | short_ticker_polygon = short_call["ticker"].iloc[0] 120 | long_ticker_polygon = long_call["ticker"].iloc[0] 121 | 122 | elif trend_regime == 1: 123 | 124 | valid_puts = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/reference/options/contracts?underlying_ticker={options_ticker}&contract_type=put&as_of={trading_date}&expiration_date={exp_date}&limit=1000&apiKey={polygon_api_key}").json()["results"]) 125 | valid_puts = valid_puts[valid_puts["ticker"].str.contains("SPXW")].copy() 126 | valid_puts["days_to_exp"] = (pd.to_datetime(valid_puts["expiration_date"]) - pd.to_datetime(trading_date)).dt.days 127 | valid_puts["distance_from_price"] = abs(price - valid_puts["strike_price"]) 128 | 129 | lower_price = round(price - (price * expected_move)) 130 | otm_puts = valid_puts[valid_puts["strike_price"] <= lower_price].sort_values("distance_from_price", ascending = True) 131 | 132 | short_put = otm_puts.iloc[[0]] 133 | long_put = otm_puts.iloc[[1]] 134 | 135 | short_strike = short_put["strike_price"].iloc[0] 136 | long_strike = long_put["strike_price"].iloc[0] 137 | 138 | short_ticker_polygon = short_put["ticker"].iloc[0] 139 | long_ticker_polygon = long_put["ticker"].iloc[0] 140 | 141 | 142 | # ============================================================================= 143 | # Pulling the option via Tastytrade 144 | # ============================================================================= 145 | 146 | option_url = f"https://api.tastyworks.com/option-chains/SPXW/nested" 147 | 148 | option_chain = pd.json_normalize(requests.get(option_url, headers = {'Authorization': session_token}).json()["data"]["items"][0]["expirations"][0]["strikes"]) 149 | option_chain["strike_price"] = option_chain["strike-price"].astype(float) 150 | 151 | short_option = option_chain[option_chain["strike_price"] == short_strike].copy() 152 | long_option = option_chain[option_chain["strike_price"] == long_strike].copy() 153 | 154 | if trend_regime == 0: 155 | 156 | short_ticker = short_option["call"].iloc[0] 157 | long_ticker = long_option["call"].iloc[0] 158 | 159 | elif trend_regime == 1: 160 | 161 | short_ticker = short_option["put"].iloc[0] 162 | long_ticker = long_option["put"].iloc[0] 163 | 164 | 165 | # ============================================================================= 166 | # Get most recent bid/ask 167 | # ============================================================================= 168 | 169 | short_option_quote = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{short_ticker_polygon}?&sort=timestamp&order=desc&limit=10&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp").sort_index().tail(1) 170 | short_option_quote.index = pd.to_datetime(short_option_quote.index, unit = "ns", utc = True).tz_convert("America/New_York") 171 | 172 | long_option_quote = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{long_ticker_polygon}?&sort=timestamp&order=desc&limit=10&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp").sort_index().tail(1) 173 | long_option_quote.index = pd.to_datetime(long_option_quote.index, unit = "ns", utc = True).tz_convert("America/New_York") 174 | 175 | natural_price = round(short_option_quote["bid_price"].iloc[0] - long_option_quote["ask_price"].iloc[0], 2) 176 | mid_price = round(((short_option_quote["bid_price"].iloc[0] + short_option_quote["ask_price"].iloc[0]) / 2) - ((long_option_quote["bid_price"].iloc[0] + long_option_quote["ask_price"].iloc[0]) / 2), 2) 177 | 178 | optimal_price = round(np.int64(round((mid_price - .05) / .05, 2)) * .05, 2) 179 | 180 | order_details = { 181 | "time-in-force": "Day", 182 | "order-type": "Limit", 183 | "price": optimal_price, 184 | "price-effect": "Credit", 185 | "legs": [{"action": "Buy to Open", 186 | "instrument-type": "Equity Option", 187 | "symbol": f"{long_ticker}", 188 | "quantity": 1}, 189 | 190 | {"action": "Sell to Open", 191 | "instrument-type": "Equity Option", 192 | "symbol": f"{short_ticker}", 193 | "quantity": 1}] 194 | 195 | } 196 | 197 | # Do an order dry-run to make sure the trade will go through (i.e., verifies balance, valid symbol, etc. ) 198 | 199 | validate_order = requests.post(f"https://api.tastyworks.com/accounts/{account_number}/orders/dry-run", json = order_details, headers = {'Authorization': session_token}) 200 | validation_text = validate_order.text 201 | 202 | submit_order = requests.post(f"{base_url}/accounts/{account_number}/orders", json = order_details, headers = {'Authorization': session_token}) 203 | order_submission_text = submit_order.text 204 | -------------------------------------------------------------------------------- /spread-production.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created in 2024 4 | 5 | @author: Quant Galore 6 | """ 7 | 8 | import requests 9 | import pandas as pd 10 | import numpy as np 11 | import matplotlib.pyplot as plt 12 | import time 13 | 14 | from datetime import datetime, timedelta 15 | from pandas_market_calendars import get_calendar 16 | # from self_email import send_message 17 | 18 | polygon_api_key = "KkfCQ7fsZnx0yK4bhX9fD81QplTh0Pf3" 19 | calendar = get_calendar("NYSE") 20 | 21 | trading_dates = calendar.schedule(start_date = "2023-01-01", end_date = (datetime.today()+timedelta(days=1))).index.strftime("%Y-%m-%d").values 22 | 23 | date = trading_dates[-1] 24 | 25 | vix_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/I:VIX1D/range/1/day/2023-05-01/{date}?sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 26 | vix_data.index = pd.to_datetime(vix_data.index, unit="ms", utc=True).tz_convert("America/New_York") 27 | vix_data["1_mo_avg"] = vix_data["c"].rolling(window=20).mean() 28 | vix_data["3_mo_avg"] = vix_data["c"].rolling(window=60).mean() 29 | vix_data['vol_regime'] = vix_data.apply(lambda row: 1 if (row['1_mo_avg'] > row['3_mo_avg']) else 0, axis=1) 30 | vix_data["str_date"] = vix_data.index.strftime("%Y-%m-%d") 31 | # vix_data = vix_data.set_index("str_date") 32 | 33 | vol_regime = vix_data["vol_regime"].iloc[-1] 34 | 35 | big_underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/SPY/range/1/day/2020-01-01/{date}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 36 | big_underlying_data.index = pd.to_datetime(big_underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 37 | big_underlying_data["1_mo_avg"] = big_underlying_data["c"].rolling(window=20).mean() 38 | big_underlying_data["3_mo_avg"] = big_underlying_data["c"].rolling(window=60).mean() 39 | big_underlying_data['regime'] = big_underlying_data.apply(lambda row: 1 if (row['c'] > row['1_mo_avg']) else 0, axis=1) 40 | 41 | trend_regime = big_underlying_data['regime'].iloc[-1] 42 | 43 | ticker = "I:SPX" 44 | index_ticker = "I:VIX1D" 45 | options_ticker = "SPX" 46 | 47 | trade_list = [] 48 | 49 | real_trading_dates = calendar.schedule(start_date = (datetime.today()-timedelta(days=10)), end_date = (datetime.today())).index.strftime("%Y-%m-%d").values 50 | 51 | today = real_trading_dates[-1] 52 | 53 | while 1: 54 | 55 | try: 56 | 57 | underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{ticker}/range/1/minute/{today}/{today}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 58 | underlying_data.index = pd.to_datetime(underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 59 | 60 | index_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{index_ticker}/range/1/minute/{today}/{today}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 61 | index_data.index = pd.to_datetime(index_data.index, unit="ms", utc=True).tz_convert("America/New_York") 62 | 63 | index_price = index_data[index_data.index.time >= pd.Timestamp("09:35").time()]["c"].iloc[0] 64 | price = underlying_data[underlying_data.index.time >= pd.Timestamp("09:35").time()]["c"].iloc[0] 65 | 66 | expected_move = (round((index_price / np.sqrt(252)), 2)/100)*.50 67 | 68 | lower_price = round(price - (price * expected_move)) 69 | upper_price = round(price + (price * expected_move)) 70 | 71 | exp_date = date 72 | 73 | minute_timestamp = (pd.to_datetime(today).tz_localize("America/New_York") + timedelta(hours = pd.Timestamp("09:35").time().hour, minutes = pd.Timestamp("09:35").time().minute)) 74 | quote_timestamp = minute_timestamp.value 75 | minute_after_timestamp = (pd.to_datetime(today).tz_localize("America/New_York") + timedelta(hours = pd.Timestamp("09:36").time().hour, minutes = pd.Timestamp("09:36").time().minute)) 76 | quote_minute_after_timestamp = minute_after_timestamp.value 77 | 78 | if trend_regime == 0: 79 | 80 | side = "Call" 81 | 82 | valid_calls = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/reference/options/contracts?underlying_ticker={options_ticker}&contract_type=call&as_of={today}&expiration_date={exp_date}&limit=1000&apiKey={polygon_api_key}").json()["results"]) 83 | valid_calls = valid_calls[valid_calls["ticker"].str.contains("SPXW")].copy() 84 | valid_calls["days_to_exp"] = (pd.to_datetime(valid_calls["expiration_date"]) - pd.to_datetime(date)).dt.days 85 | valid_calls["distance_from_price"] = abs(valid_calls["strike_price"] - price) 86 | 87 | otm_calls = valid_calls[valid_calls["strike_price"] >= upper_price] 88 | 89 | short_call = otm_calls.iloc[[0]] 90 | long_call = otm_calls.iloc[[1]] 91 | 92 | short_strike = short_call["strike_price"].iloc[0] 93 | long_strike = long_call["strike_price"].iloc[0] 94 | 95 | short_call_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{short_call['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={quote_minute_after_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 96 | short_call_quotes.index = pd.to_datetime(short_call_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 97 | short_call_quote = short_call_quotes.median(numeric_only=True).to_frame().copy().T 98 | short_call_quote["mid_price"] = (short_call_quote["bid_price"] + short_call_quote["ask_price"]) / 2 99 | 100 | long_call_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{long_call['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={quote_minute_after_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 101 | long_call_quotes.index = pd.to_datetime(long_call_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 102 | long_call_quote = long_call_quotes.median(numeric_only=True).to_frame().copy().T 103 | long_call_quote["mid_price"] = (long_call_quote["bid_price"] + long_call_quote["ask_price"]) / 2 104 | 105 | spread_value = short_call_quote["mid_price"].iloc[0] - long_call_quote["mid_price"].iloc[0] 106 | 107 | underlying_data["distance_from_short_strike"] = round(((short_strike - underlying_data["c"]) / underlying_data["c"].iloc[0])*100, 2) 108 | 109 | cost = spread_value 110 | 111 | updated_short_call_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{short_call['ticker'].iloc[0]}?order=desc&limit=100&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 112 | updated_short_call_quotes.index = pd.to_datetime(updated_short_call_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 113 | updated_short_call_quotes["mid_price"] = (updated_short_call_quotes["bid_price"] + updated_short_call_quotes["ask_price"]) / 2 114 | 115 | updated_long_call_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{long_call['ticker'].iloc[0]}?order=desc&limit=100&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 116 | updated_long_call_quotes.index = pd.to_datetime(updated_long_call_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 117 | updated_long_call_quotes["mid_price"] = (updated_long_call_quotes["bid_price"] + updated_long_call_quotes["ask_price"]) / 2 118 | 119 | updated_spread_value = updated_short_call_quotes["mid_price"].iloc[0] - updated_long_call_quotes["mid_price"].iloc[0] 120 | 121 | gross_pnl = cost - updated_spread_value 122 | gross_pnl_percent = round((gross_pnl / cost)*100,2) 123 | 124 | print(f"Live PnL: ${round(gross_pnl*100,2)} | {gross_pnl_percent}% | {updated_short_call_quotes.index[0].strftime('%H:%M')}") 125 | print(f"Side: {side} | Short Strike: {short_strike} | Long Strike: {long_strike} | % Away from strike: {underlying_data['distance_from_short_strike'].iloc[-1]}%") 126 | time.sleep(10) 127 | 128 | elif trend_regime == 1: 129 | 130 | side = "Put" 131 | 132 | valid_puts = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/reference/options/contracts?underlying_ticker={options_ticker}&contract_type=put&as_of={today}&expiration_date={exp_date}&limit=1000&apiKey={polygon_api_key}").json()["results"]) 133 | valid_puts = valid_puts[valid_puts["ticker"].str.contains("SPXW")].copy() 134 | valid_puts["days_to_exp"] = (pd.to_datetime(valid_puts["expiration_date"]) - pd.to_datetime(date)).dt.days 135 | valid_puts["distance_from_price"] = abs(price - valid_puts["strike_price"]) 136 | 137 | otm_puts = valid_puts[valid_puts["strike_price"] <= lower_price].sort_values("distance_from_price", ascending = True) 138 | 139 | short_put = otm_puts.iloc[[0]] 140 | long_put = otm_puts.iloc[[1]] 141 | 142 | short_strike = short_put["strike_price"].iloc[0] 143 | long_strike = long_put["strike_price"].iloc[0] 144 | 145 | short_put_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{short_put['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={quote_minute_after_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 146 | short_put_quotes.index = pd.to_datetime(short_put_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 147 | short_put_quote = short_put_quotes.median(numeric_only=True).to_frame().copy().T 148 | short_put_quote["mid_price"] = (short_put_quote["bid_price"] + short_put_quote["ask_price"]) / 2 149 | 150 | long_put_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{long_put['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={quote_minute_after_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 151 | long_put_quotes.index = pd.to_datetime(long_put_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 152 | long_put_quote = long_put_quotes.median(numeric_only=True).to_frame().copy().T 153 | long_put_quote["mid_price"] = (long_put_quote["bid_price"] + long_put_quote["ask_price"]) / 2 154 | 155 | spread_value = short_put_quote["mid_price"].iloc[0] - long_put_quote["mid_price"].iloc[0] 156 | 157 | underlying_data["distance_from_short_strike"] = round(((underlying_data["c"] - short_strike) / short_strike)*100, 2) 158 | 159 | cost = spread_value 160 | 161 | updated_short_put_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{short_put['ticker'].iloc[0]}?order=desc&limit=100&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 162 | updated_short_put_quotes.index = pd.to_datetime(updated_short_put_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 163 | updated_short_put_quotes["mid_price"] = (updated_short_put_quotes["bid_price"] + updated_short_put_quotes["ask_price"]) / 2 164 | 165 | updated_long_put_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{long_put['ticker'].iloc[0]}?order=desc&limit=100&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 166 | updated_long_put_quotes.index = pd.to_datetime(updated_long_put_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 167 | updated_long_put_quotes["mid_price"] = (updated_long_put_quotes["bid_price"] + updated_long_put_quotes["ask_price"]) / 2 168 | 169 | updated_spread_value = updated_short_put_quotes["mid_price"].iloc[0] - updated_long_put_quotes["mid_price"].iloc[0] 170 | 171 | gross_pnl = cost - updated_spread_value 172 | gross_pnl_percent = round((gross_pnl / cost)*100,2) 173 | 174 | print(f"\nLive PnL: ${round(gross_pnl*100,2)} | {gross_pnl_percent}% | {updated_short_put_quotes.index[0].strftime('%H:%M')}") 175 | print(f"Side: {side} | Short Strike: {short_strike} | Long Strike: {long_strike} | % Away from strike: {underlying_data['distance_from_short_strike'].iloc[-1]}%") 176 | 177 | time.sleep(10) 178 | 179 | except Exception as data_error: 180 | print(data_error) 181 | continue -------------------------------------------------------------------------------- /spread-backtest-settlement.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Created in 2024 4 | 5 | @author: Quant Galore 6 | """ 7 | 8 | import requests 9 | import pandas as pd 10 | import numpy as np 11 | import matplotlib.pyplot as plt 12 | 13 | from datetime import datetime, timedelta 14 | from pandas_market_calendars import get_calendar 15 | 16 | polygon_api_key = "KkfCQ7fsZnx0yK4bhX9fD81QplTh0Pf3" 17 | calendar = get_calendar("NYSE") 18 | 19 | trading_dates = calendar.schedule(start_date = "2023-05-01", end_date = (datetime.today()-timedelta(days = 1))).index.strftime("%Y-%m-%d").values 20 | 21 | ticker = "I:SPX" 22 | index_ticker = "I:VIX1D" 23 | options_ticker = "SPX" 24 | etf_ticker = "SPY" 25 | 26 | trade_time = "09:35" 27 | 28 | move_adjustment = .5 29 | spread_width = 1 30 | 31 | trade_list = [] 32 | times = [] 33 | 34 | # date = trading_dates[1:][-1] 35 | for date in trading_dates[1:]: 36 | 37 | try: 38 | 39 | start_time = datetime.now() 40 | 41 | prior_day = trading_dates[np.where(trading_dates==date)[0][0]-1] 42 | 43 | prior_day_underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{ticker}/range/1/day/{prior_day}/{prior_day}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 44 | prior_day_underlying_data.index = pd.to_datetime(prior_day_underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 45 | 46 | big_underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{etf_ticker}/range/1/day/2020-01-01/{prior_day}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 47 | big_underlying_data.index = pd.to_datetime(big_underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 48 | 49 | etf_underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{etf_ticker}/range/1/minute/{date}/{date}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 50 | etf_underlying_data.index = pd.to_datetime(etf_underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 51 | 52 | underlying_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{ticker}/range/1/minute/{date}/{date}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 53 | underlying_data.index = pd.to_datetime(underlying_data.index, unit="ms", utc=True).tz_convert("America/New_York") 54 | 55 | index_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/{index_ticker}/range/1/minute/{date}/{date}?adjusted=true&sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 56 | index_data.index = pd.to_datetime(index_data.index, unit="ms", utc=True).tz_convert("America/New_York") 57 | 58 | etf_underlying_data = etf_underlying_data[etf_underlying_data.index.time >= pd.Timestamp(trade_time).time()].copy() 59 | underlying_data = underlying_data[(underlying_data.index.time >= pd.Timestamp(trade_time).time()) & (underlying_data.index.time <= pd.Timestamp("16:00").time())].copy() 60 | index_data = index_data[index_data.index.time >= pd.Timestamp(trade_time).time()].copy() 61 | 62 | prior_day_price = prior_day_underlying_data["c"].iloc[0] 63 | index_price = index_data["c"].iloc[0] 64 | price = underlying_data["c"].iloc[0] 65 | closing_value = underlying_data["c"].iloc[-1] 66 | 67 | overnight_move = round(((price - prior_day_price) / prior_day_price)*100, 2) 68 | 69 | expected_move = (round((index_price / np.sqrt(252)), 2)/100)*move_adjustment 70 | 71 | lower_price = round(price - (price * expected_move)) 72 | upper_price = round(price + (price * expected_move)) 73 | 74 | exp_date = date 75 | 76 | # Pull the data at 9:35 to represent the most up-to-date regime that would be available 77 | concatenated_regime_dataset = pd.concat([big_underlying_data, etf_underlying_data.head(1)], axis = 0) 78 | concatenated_regime_dataset["1_mo_avg"] = concatenated_regime_dataset["c"].rolling(window=20).mean() 79 | concatenated_regime_dataset["3_mo_avg"] = concatenated_regime_dataset["c"].rolling(window=60).mean() 80 | concatenated_regime_dataset['regime'] = concatenated_regime_dataset.apply(lambda row: 1 if (row['c'] > row['1_mo_avg']) else 0, axis=1) 81 | 82 | direction = concatenated_regime_dataset["regime"].iloc[-1] 83 | minute_timestamp = (pd.to_datetime(date).tz_localize("America/New_York") + timedelta(hours = pd.Timestamp(trade_time).time().hour, minutes = pd.Timestamp(trade_time).time().minute)) 84 | quote_timestamp = minute_timestamp.value 85 | close_timestamp = (pd.to_datetime(date).tz_localize("America/New_York") + timedelta(hours = 16, minutes = 0)).value 86 | 87 | if direction == 0: 88 | 89 | valid_calls = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/reference/options/contracts?underlying_ticker={options_ticker}&contract_type=call&as_of={date}&expiration_date={exp_date}&limit=1000&apiKey={polygon_api_key}").json()["results"]) 90 | valid_calls = valid_calls[valid_calls["ticker"].str.contains("SPXW")].copy() 91 | valid_calls["days_to_exp"] = (pd.to_datetime(valid_calls["expiration_date"]) - pd.to_datetime(date)).dt.days 92 | valid_calls["distance_from_price"] = abs(valid_calls["strike_price"] - price) 93 | 94 | otm_calls = valid_calls[valid_calls["strike_price"] >= upper_price] 95 | 96 | short_call = otm_calls.iloc[[0]] 97 | long_call = otm_calls.iloc[[spread_width]] 98 | 99 | short_strike = short_call["strike_price"].iloc[0] 100 | 101 | short_call_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{short_call['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={close_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 102 | short_call_quotes.index = pd.to_datetime(short_call_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 103 | short_call_quotes["mid_price"] = round((short_call_quotes["bid_price"] + short_call_quotes["ask_price"]) / 2, 2) 104 | short_call_quotes = short_call_quotes[short_call_quotes.index.strftime("%Y-%m-%d %H:%M") <= minute_timestamp.strftime("%Y-%m-%d %H:%M")].copy() 105 | 106 | short_call_quote = short_call_quotes.median(numeric_only=True).to_frame().copy().T 107 | short_call_quote["t"] = minute_timestamp.strftime("%Y-%m-%d %H:%M") 108 | 109 | long_call_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{long_call['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={close_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 110 | long_call_quotes.index = pd.to_datetime(long_call_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 111 | long_call_quotes["mid_price"] = round((long_call_quotes["bid_price"] + long_call_quotes["ask_price"]) / 2, 2) 112 | long_call_quotes = long_call_quotes[long_call_quotes.index.strftime("%Y-%m-%d %H:%M") <= minute_timestamp.strftime("%Y-%m-%d %H:%M")].copy() 113 | 114 | long_call_quote = long_call_quotes.median(numeric_only=True).to_frame().copy().T 115 | long_call_quote["t"] = minute_timestamp.strftime("%Y-%m-%d %H:%M") 116 | 117 | spread = pd.concat([short_call_quote.add_prefix("short_call_"), long_call_quote.add_prefix("long_call_")], axis = 1).dropna() 118 | 119 | spread["spread_value"] = spread["short_call_mid_price"] - spread["long_call_mid_price"] 120 | cost = spread["spread_value"].iloc[0] 121 | max_loss = abs(short_call["strike_price"].iloc[0] - long_call["strike_price"].iloc[0]) - cost 122 | 123 | elif direction == 1: 124 | 125 | valid_puts = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/reference/options/contracts?underlying_ticker={options_ticker}&contract_type=put&as_of={date}&expiration_date={exp_date}&limit=1000&apiKey={polygon_api_key}").json()["results"]) 126 | valid_puts = valid_puts[valid_puts["ticker"].str.contains("SPXW")].copy() 127 | valid_puts["days_to_exp"] = (pd.to_datetime(valid_puts["expiration_date"]) - pd.to_datetime(date)).dt.days 128 | valid_puts["distance_from_price"] = abs(price - valid_puts["strike_price"]) 129 | 130 | otm_puts = valid_puts[valid_puts["strike_price"] <= lower_price].sort_values("distance_from_price", ascending = True) 131 | 132 | short_put = otm_puts.iloc[[0]] 133 | long_put = otm_puts.iloc[[spread_width]] 134 | 135 | short_strike = short_put["strike_price"].iloc[0] 136 | 137 | short_put_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{short_put['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={close_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 138 | short_put_quotes.index = pd.to_datetime(short_put_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 139 | short_put_quotes["mid_price"] = round((short_put_quotes["bid_price"] + short_put_quotes["ask_price"]) / 2, 2) 140 | short_put_quotes = short_put_quotes[short_put_quotes.index.strftime("%Y-%m-%d %H:%M") <= minute_timestamp.strftime("%Y-%m-%d %H:%M")].copy() 141 | 142 | short_put_quote = short_put_quotes.median(numeric_only=True).to_frame().copy().T 143 | short_put_quote["t"] = minute_timestamp.strftime("%Y-%m-%d %H:%M") 144 | 145 | long_put_quotes = pd.json_normalize(requests.get(f"https://api.polygon.io/v3/quotes/{long_put['ticker'].iloc[0]}?timestamp.gte={quote_timestamp}×tamp.lt={close_timestamp}&order=asc&limit=5000&sort=timestamp&apiKey={polygon_api_key}").json()["results"]).set_index("sip_timestamp") 146 | long_put_quotes.index = pd.to_datetime(long_put_quotes.index, unit = "ns", utc = True).tz_convert("America/New_York") 147 | long_put_quotes["mid_price"] = round((long_put_quotes["bid_price"] + long_put_quotes["ask_price"]) / 2, 2) 148 | long_put_quotes = long_put_quotes[long_put_quotes.index.strftime("%Y-%m-%d %H:%M") <= minute_timestamp.strftime("%Y-%m-%d %H:%M")].copy() 149 | 150 | long_put_quote = long_put_quotes.median(numeric_only=True).to_frame().copy().T 151 | long_put_quote["t"] = minute_timestamp.strftime("%Y-%m-%d %H:%M") 152 | 153 | spread = pd.concat([short_put_quote.add_prefix("short_put_"), long_put_quote.add_prefix("long_put_")], axis = 1).dropna() 154 | 155 | spread["spread_value"] = spread["short_put_mid_price"] - spread["long_put_mid_price"] 156 | cost = spread["spread_value"].iloc[0] 157 | max_loss = abs(short_put["strike_price"].iloc[0] - long_put["strike_price"].iloc[0]) - cost 158 | 159 | if direction == 1: 160 | settlement = closing_value - short_strike 161 | if settlement > 0: 162 | settlement = 0 163 | final_pnl = cost 164 | else: 165 | final_pnl = settlement + cost 166 | 167 | elif direction == 0: 168 | settlement = short_strike - closing_value 169 | if settlement > 0: 170 | settlement = 0 171 | final_pnl = cost 172 | else: 173 | final_pnl = settlement + cost 174 | 175 | gross_pnl = np.maximum(final_pnl, max_loss*-1) 176 | gross_pnl_percent = round((gross_pnl / cost)*100,2) 177 | 178 | trade_data = pd.DataFrame([{"date": date, "cost": cost, "gross_pnl": gross_pnl, 179 | "gross_pnl_percent": gross_pnl_percent, "ticker": ticker, "direction": direction, 180 | "short_strike": short_strike, "closing_value": closing_value, 181 | "overnight_move": overnight_move}]) 182 | 183 | trade_list.append(trade_data) 184 | 185 | end_time = datetime.now() 186 | seconds_to_complete = (end_time - start_time).total_seconds() 187 | times.append(seconds_to_complete) 188 | iteration = round((np.where(trading_dates==date)[0][0]/len(trading_dates))*100,2) 189 | iterations_remaining = len(trading_dates) - np.where(trading_dates==date)[0][0] 190 | average_time_to_complete = np.mean(times) 191 | estimated_completion_time = (datetime.now() + timedelta(seconds = int(average_time_to_complete*iterations_remaining))) 192 | time_remaining = estimated_completion_time - datetime.now() 193 | print(f"{iteration}% complete, {time_remaining} left, ETA: {estimated_completion_time}") 194 | 195 | except Exception as data_error: 196 | print(data_error) 197 | continue 198 | 199 | ############################################# 200 | 201 | vix_data = pd.json_normalize(requests.get(f"https://api.polygon.io/v2/aggs/ticker/I:VIX/range/1/day/2018-03-01/{trading_dates[-1]}?sort=asc&limit=50000&apiKey={polygon_api_key}").json()["results"]).set_index("t") 202 | vix_data.index = pd.to_datetime(vix_data.index, unit="ms", utc=True).tz_convert("America/New_York") 203 | 204 | vix_data["1_mo_avg"] = vix_data["c"].rolling(window=20).mean() 205 | vix_data["3_mo_avg"] = vix_data["c"].rolling(window=60).mean() 206 | vix_data['vol_regime'] = vix_data.apply(lambda row: 1 if (row['1_mo_avg'] > row['3_mo_avg']) else 0, axis=1).shift(1) 207 | vix_data["str_date"] = vix_data.index.strftime("%Y-%m-%d") 208 | vix_data = vix_data.set_index("str_date") 209 | 210 | ############################################# 211 | 212 | all_trades = pd.concat(trade_list).drop_duplicates("date").set_index("date") 213 | # all_trades = pd.concat([all_trades, vix_data[["3_mo_avg","vol_regime"]]], axis = 1).dropna() 214 | # all_trades = all_trades[all_trades["vol_regime"] == 0].copy() 215 | all_trades.index = pd.to_datetime(all_trades.index).tz_localize("America/New_York") 216 | 217 | all_trades["contracts"] = 1 218 | all_trades["fees"] = all_trades["contracts"] * 0.04 219 | all_trades["net_pnl"] = (all_trades["gross_pnl"] * all_trades["contracts"]) - all_trades["fees"] 220 | 221 | capital = 3000 222 | 223 | all_trades["net_capital"] = capital + (all_trades["net_pnl"]*100).cumsum() 224 | 225 | plt.figure(dpi=200) 226 | plt.xticks(rotation=45) 227 | plt.suptitle(f"Selling 0-DTE Credit Spreads - Trend Following") 228 | plt.plot(all_trades.index, all_trades["net_capital"]) 229 | # plt.plot(np.arange(0, len(all_trades)), all_trades["net_capital"]) 230 | plt.legend(["Net PnL (Incl. Fees)"]) 231 | plt.show() 232 | 233 | monthly = all_trades.resample("M").sum(numeric_only=True) 234 | 235 | total_return = round(((all_trades["net_capital"].iloc[-1] - capital) / capital)*100, 2) 236 | sd = round(all_trades["gross_pnl_percent"].std(), 2) 237 | 238 | wins = all_trades[all_trades["net_pnl"] > 0] 239 | losses = all_trades[all_trades["net_pnl"] < 0] 240 | 241 | avg_win = wins["net_pnl"].mean() 242 | avg_loss = losses["net_pnl"].mean() 243 | 244 | win_rate = round(len(wins) / len(all_trades), 2) 245 | 246 | expected_value = round((win_rate * avg_win) + ((1-win_rate) * avg_loss), 2) 247 | 248 | print(f"EV per trade: ${expected_value*100}") 249 | print(f"Win Rate: {win_rate*100}%") 250 | print(f"Avg Profit: ${round(avg_win*100,2)}") 251 | print(f"Avg Loss: ${round(avg_loss*100,2)}") 252 | print(f"Total Profit: ${all_trades['net_pnl'].sum()*100}") 253 | --------------------------------------------------------------------------------