Finding Expired Options and Backtesting a Short Iron Condor Strategy

Authors:

Dr. Haykaz Aramyan
Developer Advocate Developer Advocate
Jonathan Legrand
Developer Advocate Developer Advocate

Overview

This article explores how short iron condor strategies can be backtested. The important part of this article is the reconstruction of expired options, which used to be a challenge reported many times in the Developer community Q&A forum. The challenge is that one cannot directly access expired options through a single API call. To get historical data on options, one will need to reconstruct options Refinitiv Identification Codes (RIC) following the logic of RIC construction rules and the rules specified by the exchange where the option is traded. Further, in this article, RIC reconstruction and validation functions are presented; they can be used for options on OPRA exchange-traded indices and stocks. Functions reconstruct and validate AM settlement options expiring on the 3rd Friday of each month.

In this article, we test and visualize the profit/loss of the short Iron Condor strategy with 10% and 20% Out of Money (OTM) sizes for NDX; however one can use the functions and codes of this article to backtest other OPRA exchange-traded indices (such as SPX) and equities (such as IBM, TWTR) at the same time specifying OTM sizes other than 10/20%. It should be noted that options with different underlying assets have different liquidity, and one may not get data for specific OTM ranges for some underlying assets. The current backtesting model is not at the production level and is not tested on a wide range of underlying assets. Except for NDX, we tested the functions on SPX, IBM, TWTR and AAPL, which produced the required results to be used for backtesting of options on these assets. Additionally, we considered also the impact of stock split event on the underlying price and strike price, and adjusted the prices accordingly. The impact of other corporate events, such as stock dividends, right offering, M&A is not considerd and tested in the scope of this article.

The article follows the following structure. In Section 1, we build and present a function for expired option RIC reconstruction and validation. Section 2 introduces functions to conduct option transactions to build iron condor legs and report the strategy outcome. Also, we use a function to offset the transactions before expiration based on VIX movements. Finally, Section 3 implements a short iron condor strategy on NDX and visualizes the backtesting results.

Install and import packages

To start, we first install and import the necessary packages. We use the Refinitiv Data platform API to retrieve the data. To do that, we need to authorize ourselves with an app key. The code are built using Python 3.9. Other prerequisite packages are installed below:

    	
            !pip install refinitiv.dataplatform
        
        
    
    	
            !pip install plotly
        
        
    
    	
            !pip install pandas_market_calendars
        
        
    
    	
            

import refinitiv.dataplatform as rdp

import calendar

import numpy as np

import pandas as pd

import datetime as dt

import pandas_market_calendars as mcal

from datetime import timedelta

from datetime import datetime

import plotly.express as px

import plotly.graph_objects as go

from plotly.subplots import make_subplots

from plotly.graph_objs.scatter.marker import Line

import warnings

warnings.filterwarnings("ignore")

 

# read AppKey from a text file and authorize yourself

app_key = open("app_key.txt","r").read()

rdp.open_desktop_session(app_key)

Section 1: Reconstruct expired option RICs

Below, we illustrate the Refinitiv Identification Code (RIC) structure of an expired option on the NASDAQ 100 index (NDX) as an underlying. Options on other indices and equities follow the same structure. The first three letters indicate the name of the asset, which can be a maximum of 5 for other assets according to 'RULES7'. The following letter represents the Expiration month code, which is different for put and call options and can be found in 'RULES2'. Additionally, the letter indicating expiration month is lower case for any strikes > 999.999. The following two digits represent the Expiration day, which is the 3rd Friday of every month for AM settled monthly options. This is followed by another two digits indicating the Expiration year. Subsequently, we have five characters for the Strike price with different integral and decimal part combinations depending on the value of the strike price (more can be found in 'RULES7'). "." followed by up to 2 characters is the Exchange code, OPRA in the examined case. "^" symbol indicates that the option is expired, which is followed by the Expiration month code and the Expiration year. Based on the following considerations, the first RIC from the below image is the RIC for NDX call option with a strike price of 12100, which was traded at OPRA Exchange and expired on November 20, 2020. The other RIC is a put option with a strike price of 10425.

Below can be found the comprehensive list of Rules used to reconstruct expired option RICs on indices and equities.

RULES7 | RIC rules to construct options on equities and indices (search in Workspace).

RULES2 | RIC Rules for Delivery Month Codes for Futures & Options (search in Workspace).

RULES3 and RULES4 | RIC Rules for Exchange Identifiers (search in Workspace).

Guideline to pull expired options in Refinitiv Eikon | can be accessed at My Refinitiv.

Guideline on RIC structure Enhancement for Strike Price 10000 and above | can be accessed at My Refinitiv.

Equity Options Product Specifications | CBOE exchange rules to be accessed here.

SPX Options Product Specification | CBOE exchange rules to be accessed here.

NDX options Product Specification | NASDAQ Factsheet to be accessed here.

In order to reconstruct RICs, we need to get all the required components of the RIC structure presented above. Most of the components are inputted directly to the function get_potential_rics introduced further in this article, whereas we have constructed separate functions to get Expiration days and Transaction days (we need this for the strike price component) as we will need to call those functions several times throughout the process.

Additionally, we check for stock split corporate events for equities. Those are the one of the most common corporate event types, and will greatly impact on the underlying price and therefore on the strike price component of the RIC. In case there is a stock split event impacting on the prices we adjust the historical prices by an adjustment factor to make sure we arrive at a valid strike and reconstruct a valid RIC.

1.1 Function for Expiration days

First, we define a function to get expiration dates for each month. Since AM settled monthly options expire on the 3rd Friday of each month, we define a function that gets that after having a year as an input. It should be noted that the function also considers exchange holidays, and if a day is a holiday, the previous day is considered as suggested by the exchange rules.

For the current and for all the following functions, code explanations are made in code cell by using code comments

    	
            

def get_exp_dates(year):

    '''

    This function gets expiration dates for a year for NDX options, which are the 3rd Fridays of each month.

    Dependencies

    ----------------------------------------------

    Python library 'pandas_market_calendars' version 3.2

    

    Parameters

    -----------------------------------------------

    Input:

        year(int): year for which expiration days are requested

        

    Output:

        dates(dict): dictionary of expiration days for each month of a specified year

    '''

    

    # get CBOE market holidays

    CBOE = mcal.get_calendar('CBOE_Index_Options')

    holidays = CBOE.holidays().holidays

    

    # set calendar starting from Saturday

    c = calendar.Calendar(firstweekday=calendar.SATURDAY)

    

    # get the 3rd Friday of each month 

    exp_dates = {}

    for i in range(1, 13):        

        monthcal = c.monthdatescalendar(year, i)

        date = monthcal[2][-1]

        # check if found date is an holiday and get the previous date if it is

        if date in holidays:

            date = date + timedelta(-1)

        # append the date to the dictionary

        if year in exp_dates:

            exp_dates[year].append(date.day)

        else:

            exp_dates[year] = [date.day]        

    return exp_dates

Below we run the function to showcase the output for 2020

    	
            

dates = get_exp_dates(2020)

dates

{2020: [17, 21, 20, 17, 15, 19, 17, 21, 18, 16, 20, 18]}

1.2 Function for Transaction days

Next, we define a function returning a transaction day for each of 12 months which is used when a. Requesting underlying asset prices, based on which we calculate strike prices while also considering the specified Out of Money (OTM) size, and b. Conducting monthly option transaction.

We provide two possible days for conducting option transactions which are controlled by the function parameter: 1. First business day of each month, 2. Third Friday (which matches with general expiration cycles) of each month. We always open options contract positions to be expiring on the following month of the purchase. For example, for January 2021, we open an option contract expiring on February 2021 either on the first business day, which is January 4, or on the third Friday of the month, which is January 15.

    	
            

def get_trans_days(year, trans_day = 'first'):

    '''

    This function gets transaction days for each month of a specified year.

    Dependencies

    ----------------------------------------------

    Python library 'pandas_market_calendars' version 3.2

    

    Parameters

    -----------------------------------------------

    Input:

        year (int): year for which transaction days are requested

        trans_day (str, default = 'first'): takes either 'first' or 'third' indicating to the first business day or the 3rd Friday of a month respectively

                                            

    Output:

        trans_days (list): list of days for 12 month

    '''

    # get the first business day of each month

    if trans_day == 'first':

        CBOE = mcal.get_calendar('CBOE_Index_Options')

        holidays = CBOE.holidays().holidays

        

        # set start and end day ranges

        start_date = str(year) + '-01' +'-01'

        end_date = str(year) + '-12' + '-31'

        trans_days = []

        

        for date in pd.date_range(start_date, end_date, freq='BMS'):

            # get the first day after the weekend after checking for holiday

            while date.isoweekday() > 5 or date in holidays:

                date += dt.timedelta(1)

            # add found day to the list

            trans_days.append(date.date().day)

            

    # get the 3rd Friday for each month by calling function "get_exp_dates"

    elif trans_day == 'third':

        trans_days = get_exp_dates(year)[year]

    else:

        print('Please input "first" or "third" for transaction day')

        return

    return trans_days

Below we run the function to showcase the output for 2020 with a parameter value of 'first'.

    	
            

trans_days = get_trans_days(2020, 'first')

trans_days

[2, 3, 2, 1, 1, 1, 1, 3, 1, 1, 2, 1]

1.3 Function to get adjustment factor of stock split

Here, we check for stock split corporate events for equities and retrieve the adjustment factor. Further, the adjustment factor is used to adjust underlying prices and strike price of option contracts. The function takes asset RIC, either year or an exact date of the request as input and returns the adjustment factor(s). If no stock split event happened after the expiration date of an option, the function returns adjustment factor(s) of 1, which, in fact, doesn't change the price values. It should be noted that if more than one stock split happened after the requested date, we need to take into consideration the all adjustments combined.

In case of year is used as an input, trans_day argument needs to be passed as well. Based on those arguments the function will first calculate request dates for each month and then return the list of adjustment factors for each month. In contrast, if an exact date is inputted, there is no need for trans_day argument and the function will return the adjustment factor for that date only. This is done to ensure the flexibility of the usage of the function.

    	
            

def adjustment_factor(corp_event, year = None, date = None, trans_day = 'first'):

    '''

    This function gets adjustment factor(s) of stock split for a given asset. If no split event is happened during the requested period

    function returns 1(if date argument is used) or list of twelve 1s (if year argument is used), which assumes no adjustment in prices.

    Dependencies

    ----------------------------------------------

    Python library 'pandas_market_calendars' version 3.2

    

    Parameters

    -----------------------------------------------

    Input:

        asset (str): RIC code of the asset

        year (int): year for which stock split events are requested

        date (str with date (YYYY-MM-DD) format): Date as of which stock split events are requested

        trans_day (str, default = 'first'): indicates the date of the transaction for get_trans_days function

    Output:

        adj_factor (float): This is returned in case of date argument is used. The output is the Adjustment factor after split

        adj_factors(list): This is returned in case of year argument is used. The output is the list of Adjustment factors after split for each month

    ''' 

    # if there is no stock split corporate event

    if corp_event is None or corp_event['TR.CAEffectiveDate'][0] is None:

        if year != None and date == None:

            # return list of 1s if year argument is used

            adj_factors = 12 * [1]

            return adj_factors

        elif date != None and year == None:

            # return 1 if exact date argument is used

            adj_factor = 1

            return adj_factor

        else:

            print('Either Year or exact date needs to be passed to the function')

    # if there is an event adjustment factor(s) is(are) calculated

    else: 

        if year != None and date == None: # in case of year argument is used

            # request transaction dates

            trans_days = get_trans_days(year, trans_day)

            adj_factors = []

            for i in range(1,13):

                # get exp_dates and use it as a request date for stock split corporate events

                exp_date = str(year) + '-' + str(i) + '-' + str(trans_days[i - 1])

                # initiate adj_factor with 1

                adj_factor = 1

                # we first check if the expiration date of option is after or before the adjustment date

                for j in reversed(range(len(corp_event))):

                    # if expiration date is smaller than adjustment date then we need adjustment

                    if pd.to_datetime(exp_date).strftime('%Y-%m-%d') < corp_event['TR.CAEffectiveDate'][j]:

                        adj_factor = float(corp_event['TR.CAAdjustmentFactor'][j]) * adj_factor # we should consider all adjustment factors which are after the expiration day

                # append adjustment factor of the month to the list

                adj_factors.append(adj_factor)              

            return adj_factors

    

        elif date != None and year == None: #in case exact date  argument is ued

            adj_factor = 1

            for j in reversed(range(len(corp_event))):

                # if expiration date is smaller than adjustment date then we need adjustment

                if pd.to_datetime(date).strftime('%Y-%m-%d') < corp_event['TR.CAEffectiveDate'][j]:

                    adj_factor = float(corp_event['TR.CAAdjustmentFactor'][j]) * adj_factor

            return adj_factor

        else:

            print('Either Year or exact date needs to be passed to the function')

1.4 Function to get potential RICs

As mentioned earlier, most of the RIC components are constructed via a separate function introduced below. This function calls the functions mentioned above and uses the input to construct other components of RIC. The function takes the year, transaction day, asset name, OTM size, tolerated difference in OTM size, and option type as an input and returns a dictionary of potential RICs per month. For each month, it produces several RICs, which depend on the parameter of tolerated difference in OTM size. We use tolerated difference in OTM because it is mostly impossible to arrive at a valid strike price with any specified OTM.

The general workflow on how the function works is illustrated in the image below:

The function works for both indices and equities. The exact RIC codes (e.g., ".NDX" or 'MSFT.O') for each asset needs to be given to the function. Further, the function trims the necessary part of the asset RIC for option RIC construction. The rest of the details on constructing each component of an option RIC is explained inside the code as comments.

After the code is fully executed, we report the logs in a text file.

    	
            

def get_potential_rics(year, trans_day, asset, OTM_size, diff, opt_type):

    '''

    This function returns the list of potential option RICs for a specified year reconstructed based on Refinitiv RIC and option trading rules.

    Dependencies

    ----------------------------------------------

    Python library 'Refinitiv Dataplatform' version 1.0.0a8.post1

    

    Parameters

    -----------------------------------------------

    Input:

        year (int): year for which transaction days are requested

        trans_day (str, default = 'first'): takes either 'first' or 'third' indicating to the first business day or the 3rd Friday of a month respectively

        asset (str): RIC code of the asset

        OTM_size (int): percentage number indicating how far away is the strike price from the price of the underlying asset

        diff (int): Tolarated difference in OTM to construct upper and lower bounds of strike prices

        opt_type (str): takes either "call" or "put"

        

    Output:

        potential_RICs (dict): dictionary containing potential RICs for each month with strike prices from the lower to upper bounds of strikes

        strikes (list): list of the strike prices calculated based on OTM size for each month

    '''

        

    # open file to report log of the function output

    report = open("Log report.txt", "a")

    

    # call functions to get expiration and transaction days

    trans_days = get_trans_days(year, trans_day)

    trans_days_prev = get_trans_days(year - 1, trans_day)

    dates = get_exp_dates(year) 

    

    # trim underlying asset's RIC to get the required part for option RIC

    if asset[0] == '.': # check if the asset is an index or an equity

        asset_name = asset[1:] # get the asset name - we remove "." symbol for index options

        adj_factors = 12 * [1] # set adjustment factors to be equal to 1 for each month (no stock split corporate event is applicable to indices)

    else:

        asset_name = asset.split('.')[0] # we need only the first part of the RICs for equities

        # get list of corporate events for equities

        corp_event = rdp.get_data(universe = asset,

                                  fields = ["TR.CAEffectiveDate", "TR.CAAdjustmentFactor", "TR.CAAdjustmentType"],

                                  parameters = {"CAEventType": "SSP", "SDate": datetime.today().strftime("%Y-%m-%d"), "EDate": "-50Y"})

        # run adjustment_factor function to get the factors

        adj_factors = adjustment_factor(corp_event, year = year, trans_day = trans_day)

        

    # define expiration month codes to be used after "^" sign

    exp = ['A', 'B', 'C', 'D','E','F','G', 'H', 'I', 'J', 'K', 'L']

    potential_RICs = {}

    strikes = []

    

    # construct potential RICs for each month of a specified year

    for j in range(1,13):

        # get day of expiration for a month

        day = dates[year][j - 1]

        # get date of price request, which is in the previous month of expiration 

        if j != 1:

            date = str(year) + '-' + str(j - 1) + '-' + str(trans_days[j - 2])

        if j == 1: # for January, we need to subtract a year along with the month

            date = str(year - 1) + '-' + str(j + 11) + '-' + str(trans_days_prev[j + 10])    

        # get price of underlying asset as of the transaction date

        

        # get the corresponding adjustment factor for the month

        adj_factor = adj_factors[j-1]

        

        price = rdp.get_data(asset, fields = ['TR.PriceClose'], parameters = {'SDate': date})

        price = float(price.iloc[0,1]) / adj_factor # adjust prices by the adjustment factor. if no sptick split events adj_factor = 1

                

        # calculate the strike price for call options

        if opt_type.lower() == 'call':

            strike = price + price * OTM_size / 100

            # define expiration month codes for call options while also considering the strike price

            if strike > 999.999:

                exp_codes_call = ['a', 'b', 'c', 'd','e','f','g', 'h', 'i', 'j', 'k', 'l']

            else:

                exp_codes_call = ['A', 'B', 'C', 'D','E','F','G', 'H', 'I', 'J', 'K', 'L']

            # get expiration month code for a month

            exp_month = exp_codes_call[j-1]

            

        # calculate the strike price and get expiration month code for a month for put options

        elif opt_type.lower() == 'put':

            strike = price - price * OTM_size/100

            if strike > 999.999:

                exp_codes_put = ['m', 'n', 'o', 'p','q','r','s', 't', 'u', 'v', 'w', 'x'] 

            else:

                exp_codes_put = ['M', 'N', 'O', 'P','Q','R','S', 'T', 'U', 'V', 'W', 'X']

            exp_month = exp_codes_put[j-1]

            

        strikes.append(int(round(strike,0))) # append the calculated strike price to the list of strikes

        

        # calculate lower and upper bounds for strikes considering the value of the strike

        if strike > 999.999:

            step = 5 # we loop over strikes with a step 5 for larger strikes

            strike_ub = int(round((strike + strike * diff / 100),-1))

            strike_lb = int(round((strike - strike * diff / 100),-1))

        else:

            step = 1 # we loop over strikes with a step 1 for smaller strikes

            strike_ub = int(strike + strike * diff / 100)

            strike_lb = int(strike - strike * diff / 100)

        

        # construct RICs for each strike from the lower to upper bound ranges of strikes

        for n in range(strike_lb, strike_ub + step, step):

            k = None # for strikes < 1000 along with 1 step increment change in strikes we do 0.5 point increment change which 

                     #allows us to consider strikes with decimal points. This is important to get closer OTMs for smaller valued assets.

            # here we construct option RICs by adding together all the RIC components

            # Please note some of the components are different depending on the strike value

            plc_holdr1 = asset_name + exp_month + str(day) + str(year)[-2:]

            plc_holdr2 = exp[j - 1] + str(year)[-2:]

            if n < 10:

                z = plc_holdr1 + '00' + str(n) + '00.U^' + plc_holdr2# for integer steps

                k = plc_holdr1 + '00' + str(n) + '50.U^' + plc_holdr2# for decimal steps

            elif n >= 10 and n < 100:

                z = plc_holdr1 + '0' + str(n) + '00.U^' + plc_holdr2

                k = plc_holdr1 + '0' + str(n) + '50.U^' + plc_holdr2

            if n >= 100 and n < 1000:

                z = plc_holdr1 + str(n) + '00.U^' + plc_holdr2  

                k = plc_holdr1 + str(n) + '50.U^' + plc_holdr2

            elif n >= 1000 and n < 10000:

                z = plc_holdr1 + str(n) + '0.U^' + plc_holdr2

            elif n >= 10000 and n < 20000:

                z = plc_holdr1 + 'A' + str(n)[-4:] + '.U^' + plc_holdr2

            elif n >= 20000 and n < 30000:

                z = plc_holdr1 + 'B' + str(n)[-4:] + '.U^' + plc_holdr2

            elif n >= 30000 and n < 40000:

                z = plc_holdr1 + 'C' + str(n)[-4:] + '.U^' + plc_holdr2

            elif n >= 40000 and n < 50000:

                z = plc_holdr1 + 'D' + str(n)[-4:] + '.U^' + plc_holdr2

            

            # append RICs with integer strikes to the dictionary

            if j in potential_RICs:

                potential_RICs[j].append(z)

                # append RICs with decimal point strikes to the dictionary

                if k is not None:

                    potential_RICs[j].append(k)

            else:

                potential_RICs[j] = [z]

                if k is not None:

                    potential_RICs[j].append(k)  

                    

    # report funtion results and close the log file

    now = {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

    report.write(f'{now}: Potential RICs for {opt_type} options with {OTM_size}% OTM for {year} are constructed\n')

    report.close()

    return potential_RICs, strikes

Below we run the function to showcase the output for call options for 2020 with 10% OTM and 0.5% tolerance.

    	
            

call_RICs, call_strikes = get_potential_rics(2013, 'first','AAPL.O', 5, 3, 'call')

print(call_RICs)

{1: ['NDXa172090900.U^A20',..., 'NDXa172091900.U^A20'], ..., 12: ['NDXl1820A2130.U^L20', ... 'NDXl1820A2250.U^L20']}

The function get_potential_rics lists the RICs for each month with strike prices ranging from lower to upper strike bounds. In order to make sure we are trading the option with a strike price closest to the specified OTM price, we need to sort the RICs of each month in order of closeness of options contract strike price with the OTM price. The function below takes potential RICs and strike prices (calculated based on OTM) as an input and returns the sorted dictionary of RICs.

    	
            

def sort_option(pot_RICs, strikes):

    '''

    This function sorts the potential RICs in order of closeness of option contract strike price with the underlying asset price with given OTM.

    --------------------------------------

    Input:

        pot_RICs (dict): dictionary containing potential RICs for each month with strike prices from the lower to upper bounds of strikes

        strikes (list): list of strike prices calculated based on the OTM size for each month 

        

    Output:

        pot_RICs (dict): dictionary containing potential RICs for each month sorted in order of closeness of option contract strike price with the underlying asset price with given OTM

    '''

    i = -1

    # loop over each month

    for month in pot_RICs:

        i += 1

        # loop over each RIC per month

        for RIC in pot_RICs[month]:

            # calculate the absolute difference of option strike price and the strike price calculated based on the specified OTM size

            sort = [abs(int(RIC[-11:-7]) - int((str(strikes[i])[-4:] + '0')[:4])) if RIC[-11] != 'A' else 

                    abs(int(RIC[-10:-6]) - int(str(strikes[i])[-4:])) for RIC in pot_RICs[month]]

            # sort original dictionary with the sort order

            pot_RICs[month] = [month_sorted for _, month_sorted in sorted(zip(sort, pot_RICs[month]))]

    return pot_RICs

Below we run the function to showcase the sorted output.

    	
            

call_sorted = sort_option(call_RICs,  call_strikes)

print(call_sorted)

{1: ['NDXa172091400.U^A20',..., 'NDXa172091900.U^A20'], ..., 12: ['NDXl1820A2195.U^L20', ..., 'NDXl1820A2130.U^L20']}

1.5 Function to validate potential RICs

As the name of the get_potential_rics function indicates, it produces a list of potential RICs, part of which (sometimes all, if you request for higher OTMs for illiquid option contract) is not an actual RIC. In order to validate those, we need to make API calls. Here we use the get_historical_price_summaries function from the RDP API, which results in "None" if the RIC is invalid; otherwise, it returns the values from the specified fields. One may use get_date function from Eikon API; however, it returns an error if the RIC is invalid, and one may need to write try/except loop and hide the errors/warnings.

It should be noted that the function mentioned below should be used only if one wants to validate all RICs from the list of potential RICs. As a rule, in options strategy backtesting, when one finds a valid RIC for a month, one does not have to test others from that month as the option contract with the closest OTM size is already found for trading. In our example, to test the short iron condor strategy, we do not call this function; instead, we check the validity of a RIC right inside the transaction function (see function trans_option) to avoid extensive API requests and hitting the daily limit of 10000 requests.

    	
            

def get_valid_rics(year, trans_day, pot_RICs_sorted):

    '''

    This function checks the validity of a RIC by making API calls and returns dictionary of valid RICs per month for a specified year. 

    Dependencies

    ----------------------------------------------

    Python library 'Refinitiv Dataplatform' version 1.0.0a8.post1

    

    Parameters

    -----------------------------------------------

    Input:

        year (int): year for which transaction days are requested

        trans_day (str, default = 'first'): takes either 'first' or 'third' indicating to the first business day or the 3rd Friday of a month respectively

        pot_RICs_sorted (dict): dictionary containing potential RICs which are already sorted

        

    Output:

        valid_rics (dict): dictionary containing validated RICs for each month

    '''

    report = open("Log report.txt", "a")

    

    trans_days = get_trans_days(year, trans_day)

    dates = get_exp_dates(year)

    trans_days_prev = get_trans_days(year - 1, trans_day)

    valid_rics = {}

    

    # get start and end date of the api requests

    for month in pot_RICs_sorted:

        edate = str(year) + '-' + str(month) + '-' + str(dates[year][month - 1])

        if month > 1:

            sdate = str(year) + '-' + str(month - 1) + '-' + str(trans_days[month - 2])

        if month == 1:

            sdate = str(year-1) + '-' + str(month + 11) + '-' + str(trans_days_prev[month + 10])

        

        # make an API call for each RIC for each month

        for RIC in pot_RICs_sorted[month]:

            vals = rdp.get_historical_price_summaries(RIC,  start = sdate, end = edate, interval = rdp.Intervals.DAILY,

                                                        fields = ['BID','ASK','TRDPRC_1'])  

            # add valid RICs to the dictionary

            if vals is not None:

                vals.reset_index(inplace = True)

                if month in valid_rics:

                    valid_rics[month].append(RIC)

                else:

                    valid_rics[month] = [RIC]

                    

    now = {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

    report.write(f'{now}: Valid RICs with specified criteria for {len(valid_rics)} month during {year} are found\n')

    report.close()

    return valid_rics

Below we run the function to showcase the valid RICs.

    	
            

valid_rics_call = get_valid_rics(2020,'first', call_sorted)

print(valid_rics_call)

{1: ['NDXa172091400.U^A20',..., 'NDXa172091900.U^A20'],..., 12: ['NDXl1820A2190.U^L20', ...,'NDXl1820A2130.U^L20']}

Section 2: Option transactions and strategy outcome

After we have the list of potential RICs for each month, we trade them by taking long or short positions for a contract every month of a given year. The trans_option function takes the year, transaction day ("first" or "third"), asset name, potential sorted RICs, option type and position ("short" or "long") and returns a dataframe consisting of transaction details.

Here should be additionally stated that the function works by the potential RICs, and one does not have to run the function get_valid_rics. This function takes the first RIC from the month (already sorted having the ones we want at the beginning) and checks the validity by requesting Trade, BID, and ASK prices through RDP API function get_historical_price_summaries. If valid, the prices are considered for the trade; if not, the next RIC is tried. This continues until a valid RIC with a valid option contract price is found for a month. If no valid RIC is found for a month, no option contract is traded for that month, which is reported in the Log file accordingly. As it comes to the valid option contract price, it is considered to be one of the following (in order as provided below):

a. Trade price

b. BID/ASK mid price

c. ASK price minus average bid/ask spread for the period

d. BID price plus average bid/ask spread for the period

e. ASK price

f. BID price

In order to get more values for a more robust estimate of the option contract price, we request price data from 2 months before the transaction date. The transaction date is either the first business day or the 3rd Friday of every month, which needs to be specified as a function parameter. Options expire in the expiration cycle of the following month of the purchase.

The rest of the details on building a dataframe of transaction details is explained inside the code as comments.

After the code is fully executed, we report the logs in a text file.

2.1 Function to create option positions

    	
            

def trans_option(year, trans_day, asset, RICs, opt_type, position):

    '''

    This function registers the option contract buy/sell transactions for each month in a given year along with other transaction details,

    such as strike price, underlying price, actual OTM size etc.  

    Dependencies

    ----------------------------------------------

    Python library 'Refinitiv Dataplatform' version 1.0.0a8.post1

    Python library 'Pandas' version 1.3.3

    

    Parameters

    -----------------------------------------------

    Input:

        year (int): year for which transaction days are requested

        trans_day (str, default = 'first'): takes either 'first' or 'third' indicating to the first business day or the 3rd Friday of a month respectively

        asset (str): RIC code of the asset

        RICs (dict): dictionary containing potential RICs which are already sorted

        opt_type (str): takes either "call" or "put"

        position (str): takes either "short" or "long"

        

    Output:

        trans_details (DataFrame): Pandas DataFrame containing transaction details

    '''

    report = open("Log report.txt", "a")

    trans_days = get_trans_days(year, trans_day)

    dates = get_exp_dates(year)

    trans_days_prev = get_trans_days(year-1, trans_day)

    

    if asset[0] == '.': # check if the asset is an index or an equity

        adj_factors = 12 * [1] # set adjustment factors to be equal to 1 for each month (no stock split corporate event is applicable to indices)

    else:

        # get the list of stock split corporate events for equities

        corp_event = rdp.get_data(universe = asset,

                                  fields = ["TR.CAEffectiveDate", "TR.CAAdjustmentFactor", "TR.CAAdjustmentType"],

                                  parameters = {"CAEventType": "SSP", "SDate": datetime.today().strftime("%Y-%m-%d"), "EDate": "-50Y"})

        # run adjustment_factor function to get the factors

        adj_factors = adjustment_factor(corp_event, year= year, trans_day = trans_day)   

        

    # create empty disctionary with the transaction details as keys

    trans_details = {'RIC':[],'Trans_Date':[],'Exp_date': [],'Option type':[], 'Position':[], 'Strike':[], 'Price_Trans_Date': [], 'Option Price':[], 'OTM size': []}

    

    for month in RICs:

        # get start and end date of the api requests

        edate = str(year) + '-' + str(month) + '-' + str(dates[year][month-1])

        if month > 2:

            sdate = str(year) + '-' + str(month - 1) + '-' + str(trans_days[month - 2])

            sdate_req = str(year) + '-' + str(month - 2) + '-' + str(trans_days[month - 3]) # we request 2 month before the trans days for option contract prices 

        if month == 1:

            sdate = str(year - 1) + '-' + str(month + 11) + '-' + str(trans_days_prev[month + 10])

            sdate_req = str(year - 1) + '-' + str(month + 10) + '-' + str(trans_days_prev[month-4])

        if month == 2:

            sdate = str(year) + '-' + str(month - 1) + '-' + str(trans_days[month-2])

            sdate_req = str(year - 1) + '-' + str(month + 10) + '-' + str(trans_days_prev[month-4])

            

        # get the adjustment factor for the corresponding month

        adj_factor = adj_factors[month-1]

        

        for RIC in RICs[month]:

            # request option contract prices

            vals = rdp.get_historical_price_summaries(RIC,  start = sdate_req, end = edate, interval = rdp.Intervals.DAILY,

                                                        fields = ['BID','ASK','TRDPRC_1'])     

            

            # check validity of a RIC and if valid register the transaction details

            if vals is not None:

                vals.reset_index(inplace = True)

                # get the best price for option contract trade with the order as sepcified in the cell above

                if vals.loc[vals['index'] == sdate].shape[0] == 1:

                    if len(vals['TRDPRC_1'].loc[vals['index'] == sdate]) == 1 and vals['TRDPRC_1'].loc[vals['index'] == sdate].notna().any():

                        price = vals['TRDPRC_1'].loc[vals['index'] == sdate].values[0]

                    elif (vals['ASK']).sum() == 0 and (vals['BID']).sum() != 0:

                        price = vals['BID'].loc[vals['index'] == sdate].values[0]  

                    elif (vals['ASK']).sum() != 0 and (vals['BID']).sum() == 0:

                        price = vals['ASK'].loc[vals['index'] == sdate].values[0]

                    elif vals['ASK'].loc[vals['index'] == sdate].any() == True and vals['BID'].loc[vals['index'] == sdate].any() == True:

                        price = (vals['ASK'].loc[vals['index'] == sdate].values[0] + vals['BID'].loc[vals['index'] == sdate].values[0]) / 2

                    elif vals['ASK'].loc[vals['index'] == sdate].any() == True and vals['BID'].loc[vals['index'] == sdate].any() == False:

                        av_spread = ((vals['ASK'] - vals['BID'])/vals['ASK']).mean()

                        price = vals['ASK'].loc[vals['index'] == sdate].values[0] - vals['ASK'].loc[vals['index'] == sdate].values[0] * av_spread

                    elif vals['ASK'].loc[vals['index'] == sdate].any() == False and vals['BID'].loc[vals['index'] == sdate].any() == True:

                        av_spread = ((vals['ASK'] - vals['BID'])/vals['BID']).mean()

                        price = vals['BID'].loc[vals['index'] == sdate].values[0] + vals['BID'].loc[vals['index'] == sdate].values[0] * av_spread  

                    

                    # if valid price is found register some of the transaction details

                    trans_details['Option Price'].append(round(float(price),2))

                    trans_details['Trans_Date'].append(vals['index'].loc[vals['index'] == sdate].values[0])

                    trans_details['Option type'].append(opt_type.lower())

                    trans_details['Exp_date'].append(datetime.strptime(edate, '%Y-%m-%d'))

                    trans_details['RIC'].append(RIC)              

                    trans_details['Position'].append(position.lower())

                    

                    # to construct strike price, calculate OTM size and add those (along with the price) to the transaction details 

                    # we need to request the underlying price as of the transaction date

                    prices = []

                    

                    val = rdp.get_data(asset, fields = ['TR.PriceClose'], parameters = {'SDate': sdate})

                    price = float(val.iloc[0,1]) / adj_factor # adjust prices. adj_factor = 1, if no stock split event for the requested date

                    trans_details['Price_Trans_Date'].append(price)

                    

                    # get the integer and decimal parts of the strike price depending on the underlying asset price value

                    # for prices < 1000 we get both integer and decimal parts

                    # strikes are constructed according to RULES7

                    if price < 10:

                        int_part = int(RIC[-11:-8])

                        dec_part = RIC[-8:-6]

                        strike = float(int_part + '.' + dec_part)

                    elif price >= 10 and price < 100:

                        int_part = RIC[-11:-8]

                        dec_part = RIC[-8:-6]

                        strike = float(int_part + '.' + dec_part)

                    elif price >= 100 and price < 1000:

                        int_part = RIC[-11:-8]

                        dec_part = RIC[-8:-6]

                        strike = float(int_part + '.' + dec_part)

                    elif price >= 1000 and price < 10000:

                        if RIC[-11] == 'A': # sometimes when underlying price is closer to 10000, the strike with OTM size falls in the range of >10000

                            strike = float(RIC[-11:-6].replace('A', "1"))

                        else:

                            strike = float(RIC[-11:-7])

                    elif price >= 10000 and price < 20000:

                        if RIC[-11] == 'A':

                            strike = float(RIC[-11:-6].replace('A', "1"))

                        else: # sometimes when underlying price is closer to 10000, the strike with OTM size falls in the range of <10000

                            strike = float(RIC[-11:-7]) 

                    elif price >= 20000 and price < 30000:

                        strike = float(RIC[-11:-6].replace('B', "2"))

                    elif price >= 30000 and price < 40000:

                        strike = float(RIC[-11:-6].replace('C', "3"))

                    elif price >= 40000 and price < 50000:

                        strike = float(RIC[-11:-6].replace('D', "4"))

                    trans_details['Strike'].append(strike)

                    

                    # calculate and register actual OTM size

                    if opt_type.lower() == 'call':

                        OTM = round((strike - price) / price * 100, 2)

                    elif opt_type.lower() == 'put':

                        OTM = round((price - strike) / price * 100, 2)  

                    trans_details['OTM size'].append(OTM)

                    break # stop the loop if an option contract is traded and all transaction details are added

                    

        # check if an option contract is purchased for a month and report if not

        if datetime.strptime(edate, '%Y-%m-%d') not in trans_details['Exp_date']:

            now = {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

            report.write(f'{now}: No {opt_type} option is purchased for month {month}\n')

    

    # report total number of transactions during a year

    now = {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

    report.write(f'{now}: {len(pd.DataFrame(trans_details))} {position} {opt_type} transactions for {year} are registered\n')

    report.close()

    return pd.DataFrame(trans_details)

Below we run the function to showcase the resulting dataframe of the transaction output. Explanations follow the dataframe table.

    	
            

call_trans = trans_option(2020,'first', '.NDX', call_sorted, 'call', 'short')

call_trans

  RIC Trans_Date Exp_date Option type Position Strike Price_Trans_Date Option Price OTM size
0 NDXa172091500.U^A20 2019-12-02 2020-01-17 call short 9150.0 8309.260 1.18 10.12
1 NDXb212098000.U^B20 2020-01-02 2020-02-21 call short 9800.0 8872.219 1.83 10.46
2 NDXc2020A0000.U^C20 2020-02-03 2020-03-20 call short 10000.0 9126.232 2.77 9.57
3 NDXd172097750.U^D20 2020-03-02 2020-04-17 call short 9775.0 8877.978 15.45 10.10
4 NDXe152082500.U^E20 2020-04-01 2020-05-15 call short 8250.0 7486.287 135.10 10.20
5 NDXf192096000.U^F20 2020-05-01 2020-06-19 call short 9600.0 8718.179 66.20 10.11
6 NDXg1720A0550.U^G20 2020-06-01 2020-07-17 call short 10550.0 9598.887 22.85 9.91
7 NDXh2120A1300.U^H20 2020-07-01 2020-08-21 call short 11300.0 10279.248 40.10 9.93
8 NDXi1820A2150.U^I20 2020-08-03 2020-09-18 call short 12150.0 11055.076 38.15 9.90
9 NDXj1620A3500.U^J20 2020-09-01 2020-10-16 call short 13500.0 12292.863 111.50 9.82
10 NDXk2020A2750.U^K20 2020-10-01 2020-11-20 call short 12750.0 11583.203 96.80 10.07
11 NDXl1820A2200.U^L20 2020-11-02 2020-12-18 call short 12200.0 11084.757 105.67 10.06

As we can see above, the resulting dataframe consists of the following transaction details:

  • RIC - Option RIC with a strike price closest to the strike calculated based on OTM size (e.g. for call option: strike = price + price * OTM / 100)
  • Trans_Date - Transaction date, which is either the first business day or the expiration day of a month based on a parameter trans_day
  • Exp_Date - Expiration day of an option contract, which is the 3rd Friday of each month for OPRA exchange-traded monthly options with AM settlement
  • Option Type - Call or Put as specified in the respective parameter of the function
  • Position - Long or Short as specified in the respective parameter of the function
  • Strike - Strike price of the traded option contract. This is retrieved based on the underlying asset price value and following RULES7
  • Price_trans_Date - Price of the underlying asset at the transaction date
  • Option Price - Price of the option contract retrieved/calculated based on the considerations from the cell above
  • OTM size - The actual size of OTM, calculated based on the underlying asset price and the Strike of the traded option contract

As option strategies are mostly done by pairs we created a separate pair_trans function which conducts paired transactions by calling get_potential_ricssort_option, and trans_option functions for call and put options with the specified parameter values. This function takes the year, trans_day, asset name, OTM sizes, and positions for call and put options and returns transaction details for both call and put option trades.

    	
            

def pair_trans(year, trans_day, asset, call_OTM, put_OTM, diff, call_pos, put_pos):

    '''

    This function implements option pair transaction by calling some of the abovementioned functions for a call/put pair.

    Dependencies

    ----------------------------------------------

    Python library 'Pandas' version 1.3.3

    

    Parameters

    -----------------------------------------------

    Input:

        year (int): year for which transaction days are requested

        trans_day (str, default = 'first'): takes either 'first' or 'third' indicating to the first business day or the 3rd Friday of a month respectively

        asset (str): RIC code of the asset

        RICs (dict): dictionary containing potential RICs which are already sorted

        call_OTM (int): OTM size for Call options

        put_OTM (int): OTM size for Put options

        diff (int): tolarated difference in actual OTM size

        call_pos (str): trade position for call options. Takes either "short" or "long"

        put_pos (str): trade position for put options. Takes either "short" or "long"

 

    Output:

        call_trans (DataFrame): Pandas DataFrame containing transaction details for call options

        put_trans (DataFrame): Pandas DataFrame containing transaction details for put options

    '''  

    # request functions for call options

    call_RICs, call_strikes = get_potential_rics(year, trans_day, asset, call_OTM, diff, 'call')

    call_sorted = sort_option(call_RICs, call_strikes)

    call_trans = trans_option(year, trans_day, asset, call_sorted, 'call', call_pos.lower())

 

    # request functions for put options

    put_RICs, put_strikes = get_potential_rics(year, trans_day, asset, put_OTM, diff, 'put')

    put_sorted = sort_option(put_RICs, put_strikes)

    put_trans = trans_option(year, trans_day, asset, put_sorted, 'put', put_pos.lower())

    

    # join put and call transactions

    trans = pd.concat([call_trans, put_trans]).reset_index()

    trans.drop(columns = ['index'], inplace = True)

    trans = trans.sort_values(by = ['Exp_date'])

 

    return trans

Below we run the function to showcase the resulting dataframe of the transaction outputs.

    	
            

trans = pair_trans(2020, 'first', '.NDX', 10, 20, 2, 'short', 'long')

trans

  RIC Trans_Date Exp_date Option type Position Strike Price_Trans_Date Option Price OTM size
0 NDXa172091500.U^A20 02/12/2019 17/01/2020 call short 9150 8309.26 1.18 10.1
12 NDXm172066500.U^A20 02/12/2019 17/01/2020 put long 6650 8309.26 6.45 20
1 NDXb212098000.U^B20 02/01/2020 21/02/2020 call short 9800 8872.219 1.83 10.5
13 NDXn212071000.U^B20 02/01/2020 21/02/2020 put long 7100 8872.219 8.1 20
2 NDXc2020A0000.U^C20 03/02/2020 20/03/2020 call short 10000 9126.232 2.77 9.57
14 NDXo202073000.U^C20 03/02/2020 20/03/2020 put long 7300 9126.232 12.7 20
3 NDXd172097750.U^D20 02/03/2020 17/04/2020 call short 9775 8877.978 15.45 10.1
15 NDXp172071000.U^D20 02/03/2020 17/04/2020 put long 7100 8877.978 62.5 20
4 NDXe152082500.U^E20 01/04/2020 15/05/2020 call short 8250 7486.287 135.1 10.2
16 NDXq152060000.U^E20 01/04/2020 15/05/2020 put long 6000 7486.287 102.5 19.9
17 NDXr192069750.U^F20 01/05/2020 19/06/2020 put long 6975 8718.179 70.15 20
5 NDXf192096000.U^F20 01/05/2020 19/06/2020 call short 9600 8718.179 66.2 10.1
18 NDXs172076750.U^G20 01/06/2020 17/07/2020 put long 7675 9598.887 34.05 20
6 NDXg1720A0550.U^G20 01/06/2020 17/07/2020 call short 10550 9598.887 22.85 9.91
19 NDXt212082250.U^H20 01/07/2020 21/08/2020 put long 8225 10279.25 52.15 20
7 NDXh2120A1300.U^H20 01/07/2020 21/08/2020 call short 11300 10279.25 40.1 9.93
20 NDXu182088500.U^I20 03/08/2020 18/09/2020 put long 8850 11055.08 42.55 20
8 NDXi1820A2150.U^I20 03/08/2020 18/09/2020 call short 12150 11055.08 38.15 9.9
21 NDXv162098250.U^J20 01/09/2020 16/10/2020 put long 9825 12292.86 63.7 20.1
9 NDXj1620A3500.U^J20 01/09/2020 16/10/2020 call short 13500 12292.86 111.5 9.82
22 NDXw202092750.U^K20 01/10/2020 20/11/2020 put long 9275 11583.2 69.95 19.9
10 NDXk2020A2750.U^K20 01/10/2020 20/11/2020 call short 12750 11583.2 96.8 10.1
11 NDXl1820A2200.U^L20 02/11/2020 18/12/2020 call short 12200 11084.76 105.67 10.1
23 NDXx182088750.U^L20 02/11/2020 18/12/2020 put long 8875 11084.76 74.95 19.9

2.2 Function to offset positions

One will not open positions in option trading strategies and wait until expiration to calculate the strategy outcome. Most of the time, traders use offset models, which create triggers for closing the open positions and calculate the outcome as of the position close day. Traders use sophisticated models for determining offset triggers; however, this article aims not to suggest the best model for that but rather showcase how Refinitiv APIs can be used for option strategy backtesting, which would support both opening and closing of the positions. Thus, in this article, we base our offset strategy on CBOE Volatility Index (VIX).

Particularly, we first create a threshold equal to 2 standard deviations of the previous 30 days VIX change. Then we calculate the 3-day moving average (MA) of VIX and track MA change after the transaction date. If the MA exceeds the threshold at any point after the position is open, we create an offset transaction to short RICs with long positions and long ones with short positions. Usage of MA, instead of actual VIX change, makes price movements smoother, allowing to avoid False Alarms, and threshold based on previous period's standard deviations allows to adapt to the changing market conditions.

The exit_trans function takes the call and put option transactions as an input, and returns a dataframe containing the offset transactions. To conduct offset transactions, the function first requests VIX data from RDP API function get_historical_price_summaries for each transaction date of inputted option transactions. The request period is from 30 days before the transaction to the expiration date of the respective option contract. Then based on the VIX trade price, the function calculates VIX change and the MA (including the change). Finally, the threshold for that month is calculated. Then, the function loops over all days following the position's open date. If the MA change for a day exceeds the threshold, offset transaction date is fixed, and historical price summaries are requested for respective option contracts, both put and call. The considerations described for option contract price retrieval/calculations are applied for the offset transactions as well. After all transaction details are known, those are appended to the transaction dictionary. The rest of the details on building a dataframe of transaction details is explained inside the code as comments.

After the code is fully executed, we report the logs in a text file.

    	
            

def exit_trans(option_trans):

    '''

    This function creates offset transactions after a trigger happens. 

    ----------------------------------------------

    Python library 'Refinitiv Dataplatform' version 1.0.0a8.post1

    Python library 'Pandas' version 1.3.3

    Python library 'Numpy' version 1.21.2

    

    Parameters

    -----------------------------------------------

    Input:

        option_trans (DataFrame): Pandas DataFrame containing all option transactions, including call and put

        

    Output:

        exit_trans (DataFrame): Pandas DataFrame containing all offset transactions, including call and put

    '''    

    report = open("Log report.txt", "a")

    exit_dict = {'RIC':[],'Trans_Date':[],'Exp_date': [],'Option type':[], 'Position':[], 'Strike':[], 'Price_Trans_Date': [], 'Option Price':[], 'OTM size': []}

    

    for date in option_trans['Trans_Date'].unique():

        # get start and end dates for VIX api request

        sdate = pd.to_datetime(str(date - np.timedelta64(1,'D'))).strftime('%Y-%m-%d')

        sdate_req = pd.to_datetime(str(date - np.timedelta64(30,'D'))).strftime('%Y-%m-%d')

        date = pd.to_datetime(str(date)).strftime('%Y-%m-%d')

        index = option_trans.loc[option_trans['Trans_Date'] == date].index

        edate = pd.to_datetime(str(option_trans['Exp_date'][index[0]])).strftime('%Y-%m-%d')

        edate_1 = pd.to_datetime(str(option_trans['Exp_date'][index[0]]- np.timedelta64(1,'D'))).strftime('%Y-%m-%d') # we don't consider the expiration day

        

        # request VIX trade price details for a specified period

        vix = rdp.get_historical_price_summaries('.VIX',  start = sdate_req, end = edate_1,

                                                 fields = ['TRDPRC_1'], interval = rdp.Intervals.DAILY).astype(float)

        # calculate VIX price changes

        vix["VIX_change"] = vix['TRDPRC_1'].pct_change()*100

        vix.reset_index(inplace = True)

        

        # calculate the 3-day MA of VIX price

        vix_pr = vix['TRDPRC_1'].to_numpy()

        vix['ma'] = moving_average(vix_pr, 3) # we call external function for MA calculation

        vix["ma_change"] = vix['ma'].pct_change() * 100  # calculate MA change

        

        # calculate the threshold for a transaction date

        thresh = vix['VIX_change'].loc[vix['index'] < date].std() * 2

        

        for day in vix.loc[vix['index'] > date]['index']:

            # check if MA exceeds the threshold for a day following the position opening date

            if vix['ma_change'].loc[vix['index'] == day].values > thresh:

                # if yes, fix the date when trigger happened and get start and end date of the option contract price request

                vix_date = vix['index'].loc[vix['index'] == day].values

                sdate = pd.to_datetime(str(vix_date[0] - np.timedelta64(30,'D'))).strftime('%Y-%m-%d')

                edate = pd.to_datetime(str(vix_date[0] + np.timedelta64(1,'D'))).strftime('%Y-%m-%d')

                

                # request option contract price for both call and put option positions opened on a transaction date

                for idx in index:

                    RIC = option_trans['RIC'][idx]

                    prices = rdp.get_historical_price_summaries(RIC,  start = str(sdate), end = str(edate), interval = rdp.Intervals.DAILY,

                                                                    fields = ['BID','ASK','TRDPRC_1']).reset_index()

                    # get the best price for option contract trade with the order as sepcified in the above section

                    if len(prices['TRDPRC_1'].loc[prices['index'] == vix_date[0]]) == 1 and prices['TRDPRC_1'].loc[prices['index'] == vix_date[0]].notna().any():

                        price = prices['TRDPRC_1'].loc[prices['index'] == vix_date[0]].values[0]

                    elif (prices['ASK']).sum() == 0 and (prices['BID']).sum() != 0:

                        price = prices['BID'].loc[prices['index'] == vix_date[0]].values[0]

                    elif (prices['ASK']).sum() != 0 and (prices['BID']).sum() == 0:

                        price = prices['ASK'].loc[prices['index'] == vix_date[0]].values[0]

                    elif prices['ASK'].loc[prices['index'] == vix_date[0]].any() == True and prices['BID'].loc[prices['index'] == vix_date[0]].any() == True:

                        price = (prices['ASK'].loc[prices['index'] == vix_date[0]].values[0] + prices['BID'].loc[prices['index'] == vix_date[0]].values[0])/2

                    elif prices['ASK'].loc[prices['index'] == vix_date[0]].any() == True and prices['BID'].loc[prices['index'] == vix_date[0]].any() == False:

                        av_spread = ((prices['ASK'] - prices['BID'])/prices['ASK']).mean()

                        price = prices['ASK'].loc[prices['index'] == vix_date[0]].values[0] - prices['ASK'].loc[prices['index'] == vix_date[0]].values[0]*av_spread

                    elif prices['ASK'].loc[prices['index'] == vix_date[0]].any() == False and prices['BID'].loc[prices['index'] == vix_date[0]].any() == True:

                        av_spread = ((prices['ASK'] - prices['BID'])/prices['BID']).mean()

                        price = prices['BID'].loc[prices['index'] == vix_date[0]].values[0] + prices['BID'].loc[prices['index'] == vix_date[0]].values[0]*av_spread  

                    else:

                        break # stop the loop if there is no appropriate price 

                    

                    # if valid price is found register the offset transaction details

                    exit_dict['RIC'].append(RIC)

                    exit_dict['Trans_Date'].append(vix_date[0])

                    exit_dict['Exp_date'].append(option_trans['Exp_date'][idx])

                    exit_dict['Option type'].append(option_trans['Option type'][idx])

                    if option_trans['Position'][idx] == 'short':

                        exit_dict['Position'].append('long')

                    else:

                        exit_dict['Position'].append('short')

                    exit_dict['Strike'].append(option_trans['Strike'][idx])

                    exit_dict['Price_Trans_Date'].append(np.nan)

                    exit_dict['Option Price'].append((round(float(price),2))) 

                    exit_dict['OTM size'].append(np.nan)

                

                # report the transaction log

                now = {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

                report.write(f'{now}: Positions for option pair opened on {date} has been exited through offset transaction\n')

                break # stop the loop if offset transaction is registered

    report.close()

    exit_trans = pd.DataFrame(exit_dict)

    return exit_trans

Below we define a function to calculate simple moving average, which is used inside the exit_trans function.

    	
            

def moving_average(stock_price, n = 3):

    '''

    Calculates the n-day moving average for a given stock over time.

    ----------------------------------------------

    Python library 'Numpy' version 1.21.2

    

    Parameters

    -----------------------------------------------

    Input:

        stock_price (ndarray): single column with the share prices over time for one stock up to the current day.

        n (int, default 7): period of the moving average (in days).

        

    Output:

        ma (ndarray): the n-day (possibly weighted) moving average of the share price over time.

    '''

    i = len(stock_price)

    ma_l = []

    # reverse loop over stock prices untill point n-1

    for i in range(len(stock_price), n - 1, -1):

        point = stock_price[(i - n) : i] #set ma calculation point range

        point_average = np.sum(point) / n #calculate point average

        ma_l.append(point_average) #append ma list with the average value

        

    #reverse loop over stock prices from n-1 to 0

    for i in range(n - 1, 0, - 1):

        point = stock_price[0 : i]

        point_average = np.sum(point) / i

        ma_l.append(point_average)

        

    #reverse the list to ensure consistency    

    ma_l.reverse()

    #convert ma list into an array

    ma = np.array(ma_l)

 

    return ma

    	
            

offset_trans = exit_trans(trans)

offset_trans

  RIC Trans_Date Exp_date Option type Position Strike Price_Trans_Date Option Price OTM size
0 NDXa172091500.U^A20 2019-12-03 2020-01-17 call long 9150.0 NaN 0.93 NaN
1 NDXm172066500.U^A20 2019-12-03 2020-01-17 put short 6650.0 NaN 7.25 NaN
2 NDXb212098000.U^B20 2020-01-27 2020-02-21 call long 9800.0 NaN 0.76 NaN
3 NDXn212071000.U^B20 2020-01-27 2020-02-21 put short 7100.0 NaN 2.80 NaN
4 NDXc2020A0000.U^C20 2020-02-24 2020-03-20 call long 10000.0 NaN 4.00 NaN
5 NDXo202073000.U^C20 2020-02-24 2020-03-20 put short 7300.0 NaN 10.37 NaN
6 NDXr192069750.U^F20 2020-06-11 2020-06-19 put short 6975.0 NaN 2.15 NaN
7 NDXf192096000.U^F20 2020-06-11 2020-06-19 call long 9600.0 NaN 210.85 NaN
8 NDXs172076750.U^G20 2020-06-11 2020-07-17 put short 7675.0 NaN 34.75 NaN
9 NDXg1720A0550.U^G20 2020-06-11 2020-07-17 call long 10550.0 NaN 50.40 NaN

2.3 Function to calculate and report the outcome of the strategy

After we have all transactions, we calculate the transaction's outcome by calculating and summing up profit/loss over all traded options contracts. For that, we define function trans_outcome, which takes option transactions, asset name, and VIX consideration as an input and returns the same option transactions dataframe by adding the outcome details, including contract price, exercise outcome, and total profit/loss per options transaction. We do not need to run exit_trans before running this function; instead, we decide through the parameter cons_vix consider either offsetting or not.

Before running the calculation part, the function first checks if the respective pair exists in the transaction list. If it doesn't, we remove the unpaired option transaction and report it in the log file. The rest of the details on calculating the transaction outcome are explained inside the code as comments and in the cells following the running of the code.

After the code is fully executed, we report the logs in a text file.

    	
            

def trans_outcome(option_trans, asset, cons_vix = 'yes'):

    '''

    This function summarizes the strategy outcome by calculating profit/loss of each trnsaction and the bundle.

    ----------------------------------------------

    Python library 'Refinitiv Dataplatform' version 1.0.0a8.post1

    Python library 'Pandas' version 1.3.3

    Python library 'Numpy' version 1.21.2

    

    Parameters

    -----------------------------------------------

    Input:

        option_trans (DataFrame): Pandas DataFrame containing all option transactions, including call and put, open and offset

        asset (str): RIC code of the asset

        cons_vix (str, default = 'yes'): parameter to activate offseting

        

    Output:

        option_trans (DataFrame): Pandas DataFrame containing all transactions along with caculated outcomes

    ''' 

    report = open("Log report.txt", "a")

    

    for date in option_trans['Exp_date']:

        # check if option transaction pair exists and drop the unpaired transaction if not

        if option_trans[option_trans['Exp_date'] == date].shape[0] < 2:

            option_trans.drop(option_trans.loc[option_trans['Exp_date'] == date].index, inplace=True)

            now = {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

            report.write(f'{now}: Transations with {date} expiration date has been removed because of unavailble option pair\n')

    

    # call offset function, if not specified otherwise

    if cons_vix.lower() != 'no':

        offset_trans = exit_trans(option_trans)

        option_trans = pd.concat([option_trans, offset_trans]).sort_values(by=['Exp_date'])

    

 

    # create lists to store transaction close dates and prices as of the close date 

    close_date = []

    prices_cdate = []

    # check if there is offset transacion for an option contract

    for RIC in option_trans['RIC']:

        # append expiration date for option contracts which are not offsetted

        if option_trans.loc[option_trans['RIC'] == RIC].shape[0] == 1:

            close_date.append(option_trans['Exp_date'].loc[option_trans['RIC'] == RIC].values[0])

        # append offset transaction date (bigger from the two transaction dates) for option contracts which are offsetted

        elif option_trans.loc[option_trans['RIC'] == RIC].shape[0] == 2:

            close_date.append(np.max(option_trans['Trans_Date'].loc[option_trans['RIC'] == RIC].values))

            

    # append the list of close dates to the transaction dataframe        

    option_trans.insert(loc = len(option_trans.columns), column = 'Close_date', value = close_date)

 

    if asset[0] == '.': # check id the asset is an index or an equity

        adj_factor = 1 # set adjustment factors to be equal to 1 for indices

    else:

        # get the list of stock split corporate events for equities

        corp_event = rdp.get_data(universe = asset,

                                  fields = ["TR.CAEffectiveDate", "TR.CAAdjustmentFactor", "TR.CAAdjustmentType"],

                                  parameters = {"CAEventType": "SSP", "SDate": datetime.today().strftime("%Y-%m-%d"), "EDate": "-50Y"})    

    

    # for each close date get the price and append the list of close date prices to the transaction dataframe

    # group items by Close date in order to reduce the number of API requests

    c_dates = dict(tuple(option_trans.groupby('Close_date')))

    for c_date in c_dates:

        # get asset price for each distinct close date

        price = rdp.get_data(asset, fields = ['TR.PriceClose'] , parameters={'SDate': c_date.strftime('%Y-%m-%d')})

        

        # get adjustment factor for equities

        if asset[0] != '.': # check if equity

            adj_factor = adjustment_factor(corp_event, date = c_date.strftime('%Y-%m-%d'))

            

        # append the values into the list

        prices_cdate.append([float(price.iloc[0,1]) / adj_factor] * len(c_dates[c_date]))

    # convert list of list into one single list

    prices_cdate_flat = [item for sublist in prices_cdate for item in sublist]

    # append close price values to the dataframe

    option_trans.insert(loc = len(option_trans.columns), column = 'Close_date_prices', value = prices_cdate_flat)

    

    # calculate and append option contract price by multplying option price by 100 and adding "+-" sign depending on the position

    option_trans.insert(loc = len(option_trans.columns), column = "Contract price", value = np.where(option_trans['Position'] == 'short', option_trans['Option Price']*100, -option_trans['Option Price']*100))

   

    # calculate and append exercise outcome which is 0, if the option is expired worthless

    option_trans.insert(loc = len(option_trans.columns), column = "Exercise outcome", value = 

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'long') & (option_trans['Strike'] > option_trans['Close_date_prices']), 0,

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'short') & (option_trans['Strike'] > option_trans['Close_date_prices']), 0,

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'long') & (option_trans['Strike'] < option_trans['Close_date_prices']), (option_trans['Close_date_prices'] - option_trans['Strike'])*100,

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'short') & (option_trans['Strike'] < option_trans['Close_date_prices']), -(option_trans['Close_date_prices'] -  option_trans['Strike'])*100,

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'long') & (option_trans['Strike'] > option_trans['Close_date_prices']), (option_trans['Strike'] - option_trans['Close_date_prices'])*100,

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'short') & (option_trans['Strike'] > option_trans['Close_date_prices']), -(option_trans['Strike'] - option_trans['Close_date_prices'])*100,

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'long') & (option_trans['Strike'] < option_trans['Close_date_prices']), 0,

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'short') & (option_trans['Strike'] < option_trans['Close_date_prices']), 0, 0)))))))).astype(float))

    

    # calculate and append total profit/loss by summing up contract price and exercise outcome

    option_trans.insert(loc = len(option_trans.columns), column = "Total Profit/Loss", value =  option_trans["Contract price"] + option_trans["Exercise outcome"].astype(float))

    

    # show textual transaction outcome for better readability

    option_trans.insert(loc = len(option_trans.columns), column = "Outcome", value = 

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'long') & (option_trans['Strike'] > option_trans['Close_date_prices']), "Expired worthless",

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'short') & (option_trans['Strike'] > option_trans['Close_date_prices']), "Expired worthless",

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'long') & (option_trans['Strike'] < option_trans['Close_date_prices']), "Exercised option",

        np.where((option_trans['Option type'] == 'call') & (option_trans['Position'] == 'short') & (option_trans['Strike'] < option_trans['Close_date_prices']), "Exercised option",

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'long') & (option_trans['Strike'] > option_trans['Close_date_prices']), "Exercised option",

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'short') & (option_trans['Strike'] > option_trans['Close_date_prices']), "Exercised option",

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'long') & (option_trans['Strike'] < option_trans['Close_date_prices']), "Expired worthless",

        np.where((option_trans['Option type'] == 'put') & (option_trans['Position'] == 'short') & (option_trans['Strike'] < option_trans['Close_date_prices']), "Expired worthless", 'NA')))))))))

    

    now = {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

    report.write(f'{now}: {len(option_trans)} option transactions are conducted\n')

    report.close()

    return option_trans

Below we run the function to showcase the resulting dataframe of transaction outcomes. Explanation follows the dataframe table.

    	
            

results = trans_outcome(trans, '.NDX', 'yes')

results.head()

  RIC Trans_Date Exp_date Option type Position Strike Price_Trans_Date Option Price OTM size Close_date Close_date_prices Contract price Exercise outcome Total Profit/Loss Outcome
0 NDXa172091500.U^A20 2019-12-02 2020-01-17 call short 9150.0 8309.260 1.18 10.12 2019-12-03 8254.737 118.0 0.0 118.0 Expired worthless
1 NDXm172066500.U^A20 2019-12-03 2020-01-17 put short 6650.0 NaN 7.25 NaN 2019-12-03 8254.737 725.0 0.0 725.0 Expired worthless
0 NDXa172091500.U^A20 2019-12-03 2020-01-17 call long 9150.0 NaN 0.93 NaN 2019-12-03 8254.737 -93.0 0.0 -93.0 Expired worthless
12 NDXm172066500.U^A20 2019-12-02 2020-01-17 put long 6650.0 8309.260 6.45 19.97 2019-12-03 8254.737 -645.0 0.0 -645.0 Expired worthless
13 NDXn212071000.U^B20 2020-01-02 2020-02-21 put long 7100.0 8872.219 8.10 19.97 2020-01-27 8952.178 -810.0 0.0 -810.0 Expired worthless

In addition to the columns from the original transaction dataframe the following outcome details are added:

  • Close_date - transaction close date, which is expiration day for transactions that are not offsite, and offset day for transactions that are closed by offset transactions
  • Close_date_prices - underlying asset prices as of the close day. We use this to calculate exercise outcome
  • Contract Price - Contract price, which equals to option price multiplied by 100, which is the option contract multiplier
  • Exercise outcome - shows the profit/loss from exercised options and equals to 0 if the option contract is expired worthless
  • Total Profit/Loss - shows aggregated profit/loss from an option transaction and equals to sum of the option contract and exercise outcome
  • Outcome - textual representation of whether the option is exercised or expired worthless

Below we show the aggregated profit during each month of 2020 grouped by the expiration date.

    	
            

agg_prof = results.groupby(by = ['Exp_date'])['Total Profit/Loss'].sum().reset_index()

agg_prof

  Exp_date Total Profit/Loss
0 2020-01-17 105.0
1 2020-02-21 -423.0
2 2020-03-20 -356.0
3 2020-04-17 -4705.0
4 2020-05-15 -87003.9
5 2020-06-19 -21265.0
6 2020-07-17 -2685.0
7 2020-08-21 -26721.4
8 2020-09-18 -440.0
9 2020-10-16 4780.0
10 2020-11-20 2685.0
11 2020-12-18 -50746.1

Here, it should be noted that this is the outcome for a paired transaction only and not for an option strategy. In the next section, we implement a short iron condor strategy and visualize the backtesting outcome during 2015-2021.

Section 3: Implement and visualize short iron condor strategy outcome

This section implements the short iron condor strategy with 10%/20% legs for options on the NDX index. The short iron condor strategy aims to profit from low volatility. The maximum profit is equal to the net credit received from option contract prices, and this profit is realized if the stock price is equal to or between the strike prices of the short options at expiration. In this outcome, all options expire worthless, and the net credit is kept as income. The maximum loss is equal to the difference between the strike prices of the long put spread (or short call spread) less the net credit received. More about the short iron condor strategy can be found here. It should be mentioned that in the scope of the current article, transactions fees are not taken into consideration and are equal to zero. The actual profit and loss are calculated based on contract price differences and exercise outcomes.

3.1 Implement short iron condor strategy

    	
            

# create an empty dataframe

short_IC10_20 = pd.DataFrame()

 

for d in range(2015,2022):

    # call functions to get call short/put long pair leg

    option_trans10_20 = pair_trans(d, 'first',  '.NDX', 10, 20, 5, 'short', 'long')

    results10_20 = trans_outcome(option_trans10_20, '.NDX', 'yes')

    short_IC10_20 = short_IC10_20.append(results10_20)

 

    # call functions to get second long call/short put pair leg

    option_trans20_10 = pair_trans(d, 'first', '.NDX', 20, 10, 5, 'long', 'short')

    results20_10 = trans_outcome(option_trans20_10, '.NDX', 'yes')

    short_IC10_20 = short_IC10_20.append(results20_10)

We store the data in excel, which allows us to look at the results and visualizations anytime without running the entire code.

    	
            short_IC10_20.to_excel('short_IC10_20ndx1521.xlsx')
        
        
    

Here we visualize results from the iron condor strategy with 10/20% OTM size for NDX. There are other datasets in the GitHub folder which are retrieved by the codes above. The below-mentioned codes could be easily adjusted to visualize that as well.

    	
            

# short_IC10_20 = pd.read_excel('short_IC10_20spx1521.xlsx')

# short_IC10_20 = pd.read_excel('short_IC10_20thirdspx1521.xlsx')

# short_IC10_20 = pd.read_excel('short_IC_10_20ndx1521.xlsx')

# short_IC5_10 = pd.read_excel('short_IC_5_10ibm1521.xlsx')

# short_IC5_10 = pd.read_excel('short_IC5_10ndx1521.xlsx')

# short_IC10_20 = pd.read_excel('short_IC10_20twtr1521.xlsx')

# short_IC10_20 = pd.read_excel('short_IC10_20aapl1521.xlsx')

Before moving to the visualizations, first, we define and call a function that takes option transactions, checks if four legs of the short iron condor strategy exist, removes unpaired transactions, and returns complete pairs of transactions.

After the code is fully executed, we report the logs in a text file.

    	
            

def iron_condor_output(transactions):

    '''

    This function checks and removes unpaired transactions leaving only transactions with complete short iron condor strategy legs

    ----------------------------------------------

    Python library 'Pandas' version 1.3.3

    

    Parameters

    -----------------------------------------------

    Input:

        transactions (DataFrame): Pandas DataFrame containing all option transactions

 

    Output:

        transactions (DataFrame): Pandas DataFrame containing all transactions after removing transactions with missing legs 

    '''     

    for trans_date, exp_date in zip(transactions['Trans_Date'], transactions['Exp_date']):

        if transactions[transactions['Trans_Date'] == trans_date].shape[0] < 4:

            transactions.drop(transactions.loc[transactions['Exp_date'] == exp_date].index, inplace=True)

            print(f'Data with a transaction date of {trans_date} has been removed because of unavailble option pair')

 

    transactions.reset_index(inplace=True)

    transactions.insert(loc = len(transactions.columns), column = 'Year', value = [transactions['Exp_date'][j].year for j in range(len(transactions))])

    return transactions

Below we run the function to showcase the resulting dataframe of complete transactions.

    	
            

transaction = iron_condor_output(short_IC10_20)

transaction.head()

  RIC Trans_Date Exp_date Option type Position Strike Price_Trans_Date Option Price OTM size Close_date Close_date_prices Contract price Exercise outcome Total Profit/Loss Outcome Year
0 NDXb201546500.U^B15 2015-01-02 2015-02-20 call short 4650 4230.237 1.90 9.92 2015-02-20 4443.053 190.0 0.0 190.0 Expired worthless 2015
1 NDXn201533750.U^B15 2015-01-02 2015-02-20 put long 3375 4230.237 5.85 20.22 2015-02-20 4443.053 -585.0 0.0 -585.0 Expired worthless 2015
2 NDXb201550000.U^B15 2015-01-02 2015-02-20 call long 5000 4230.237 1.17 18.20 2015-02-20 4443.053 -117.0 0.0 -117.0 Expired worthless 2015
3 NDXn201538000.U^B15 2015-01-02 2015-02-20 put short 3800 4230.237 21.07 10.17 2015-02-20 4443.053 2107.0 0.0 2107.0 Expired worthless 2015
4 NDXc201546000.U^C15 2015-02-02 2015-03-20 call short 4600 4188.586 2.00 9.82 2015-03-20 4458.538 200.0 0.0 200.0 Expired worthless 2015

3.2 Visualize the outcome of the strategy

After we have the complete short iron condor transaction legs, we first look at the number of open and offsite positions in each year of the observation period.

    	
            

# get number of all complete iron condor transaction per year

all_trans = transaction.loc[transaction['OTM size'].isnull() != True].groupby(by = ['Year'])['OTM size'].count()/4

# get number of all offsetted iron condor transaction per year

closed_trans = transaction.loc[transaction['OTM size'].isnull() == True].groupby(by = ['Year'])['RIC'].count()/4

 

# create a dataframe of all and offsetted transaction counts

all_trans_df = pd.concat([all_trans, closed_trans], axis =1, keys = ['All positions',  'Closed Positions']).reset_index().fillna(0)

# add a column of open positions by subtracting closed positions from all positions

all_trans_df['Open Positions'] = all_trans_df['All positions'] - all_trans_df['Closed Positions']

 

# plot the results

fig = px.bar(all_trans_df, x = "Year", y = ["Open Positions", "Closed Positions"], title ='Graph 1. Number of open and closed positions')

fig.show()

As can be observed from the graph above, there have been missing transactions for several months throughout the years. This is because of the lack of liquidity in higher OTMs. For example, if we look at the short iron condor example with 5%/10% legs, we will observe many more complete transactions (you can try this by reading the excel file "short_IC5_10ndx1521.xlsx". Additionally, we can notice from the graph that three transactions are offsite during the observation period on average.

After we have the complete short iron condor transactions, we visualize the outcome through multiple graphs. First, we look at the total and cumulative sum of profits during the observation period.

    	
            

# get total profit/loss grouped by expiration date

df1 = transaction.groupby(by = ['Exp_date'])['Total Profit/Loss'].sum().reset_index()

 

# create subplots and show total and cumulative sum of of total profit over the years

fig = make_subplots(subplot_titles = ['Graph 2. Profit/loss from short iron condor strategy throughout the years'])

fig.add_trace(go.Bar(x=df1['Exp_date'], y=df1['Total Profit/Loss'], name = 'Total Profit'))

fig.add_trace(go.Line(x=df1['Exp_date'], y=df1['Total Profit/Loss'].cumsum(), name = 'Cumulative sum of total profit'))

 

fig.update_layout(height=500, width=1100)

fig.show()

The graph above illustrates that the short iron condor strategy on NDX with 10%/20% OTM legs was primarily positive in terms of the cumulative sum of profits. There were three big plunges in the cumulative sum of the profits. First, was caused by the stock market tank in December 2018 caused by the interest rate hikes by Federal Reserve System, which resulted in increased volatility. Volatility spikes caused the following two profit drops in May 2020 and December 2020 because of the impact of COVID on stock markets. After the two latter crashes, the cumulative sum of profits became negative, which regained during the following periods when markets became more stable.

Further, we look at the total profits and the ones attributed to Contract price and Exercise outcome components annually.

    	
            

# create a dataframe of profits from Contract price and Exercise outcome grouped by year

newdf = transaction.groupby(by = ['Year'])['Contract price','Exercise outcome', 'Total Profit/Loss' ].sum().reset_index()

 

# plot the dataframe

fig = make_subplots(subplot_titles = ['Graph 3. Profit/loss from exercised and expired worthless contracts'])

fig.add_trace(go.Bar(x=newdf['Year'], y=newdf['Contract price'], name = 'Contract price'))

fig.add_trace(go.Bar(x=newdf['Year'], y=newdf['Exercise outcome'], name = 'Exercise outcome'))

fig.add_trace(go.Line(x=newdf['Year'], y=newdf['Total Profit/Loss'], name = 'Total Profit'))

 

fig.update_layout(height=500, width=1100)

fig.show()

Here we experience a similar picture to the one from the graph above regarding the total profits. Particularly, we observe negative total profits for 2018 and 2020, and the profit is reaching its maximum in 2021. As expected, we can see that the profits are generated from contract price differences, whereas Exercised options resulted in losses. This is because one benefits from the short iron condor strategy when the price is between strike prices of short positions and options become worthless at expiration.

Next, we also look at the number of exercised and expired worthless contracts along with total profits.

    	
            

df2 = transaction.groupby(by = ['Year', 'Outcome', 'Trans_Date', 'Exp_date', 'Option type', 'Position'])['Total Profit/Loss'].sum().reset_index()

 

# create separate dataframes for number of erxercised, expired options and profits grouped by year

exer = df2.groupby('Year')['Outcome'].apply(lambda x: x[x == 'Exercised option'].count()).reset_index()

exp = df2.groupby('Year')['Outcome'].apply(lambda x: x[x == 'Expired worthless'].count()).reset_index()

prof = df2.groupby(by = ['Year'])['Total Profit/Loss'].sum().reset_index()

 

# plot the results using barplots and lineplot

fig = make_subplots(specs=[[{"secondary_y": True}]], subplot_titles = ['Graph 4. Number of exercised and expired worthless contracts along with total profit'])

fig.add_trace(go.Bar(x = exp['Year'], y = exp['Outcome'], name = "Number of Expired worthless options"), secondary_y = False)

fig.add_trace(go.Bar(x = exer['Year'], y = exer['Outcome'], name = "Number of Exercised options"), secondary_y = False)

fig.add_trace(go.Line(x = prof['Year'], y = prof['Total Profit/Loss'], name = "Total Profit"), secondary_y = True)

fig.show()

We can see from the graph above that most of the options expired worthless. Only 1-3 options contracts were exercised, resulting in losses presented in the previous graph. Particularly, three exercised options from 2020 resulted in losses of $128,818.

Further, we deep dive and look at the individual components of iron condor strategy, such as profits from call options versus put options, short positions versus long positions, and profits from 4 different legs of the strategy. First, we create the dataframes as shown in the cell below and then plot the results.

    	
            

# create a dataframe for profits from exercised versus expired options 

df3 = df2.groupby(by = ['Year', 'Outcome'])['Total Profit/Loss'].sum().reset_index()

exer = df3.loc[df3["Outcome"] == "Exercised option"]

exp = df3.loc[df3["Outcome"] == "Expired worthless"]

 

# create a dataframe for profits from call versus put options 

df4 = df2.groupby(by = ['Year', 'Option type'])['Total Profit/Loss'].sum().reset_index()

call = df4.loc[df4["Option type"] == "call"]

put = df4.loc[df4["Option type"] == "put"]

 

# create a dataframe for profits from short versus long positions 

df5 = df2.groupby(by = ['Year', 'Position'])['Total Profit/Loss'].sum().reset_index()

short = df5.loc[df5["Position"] == "short"]

long = df5.loc[df5["Position"] == "long"]

 

# create a dataframe for profits from different legs of short iron condor strategy

df6 = df2.groupby(by = ['Year', 'Position', 'Option type'])['Total Profit/Loss'].sum().reset_index()

df6['Option'] = df6['Position'] + df6['Option type']

Long_call = df6.loc[df6["Option"] == "longcall"]

long_put = df6.loc[df6["Option"] == "longput"]

short_call = df6.loc[df6["Option"] == "shortcall"]

short_put = df6.loc[df6["Option"] == "shortput"]

    	
            

fig = make_subplots(

    rows=2, cols=2, subplot_titles=[

        'Graph 5.1. Exersiced versus Expired options',

        'Graph 5.2. Call versus put options',

        'Graph 5.3. Short versus Long transactions',

        'Graph 5.4. Four legs of iron condor'])

 

fig.add_trace(go.Line(x=exp["Year"], y=exp["Total Profit/Loss"],

                      name='Expired worthless'), row=1, col=1)

fig.add_trace(go.Line(x=exer["Year"], y=exer["Total Profit/Loss"],

                      name='Exercised option'), row=1, col=1)

 

fig.add_trace(go.Line(x=call["Year"], y=call["Total Profit/Loss"],

                      name='Call'), row=1, col=2)

fig.add_trace(go.Line(x=put["Year"], y=put["Total Profit/Loss"],

                      name='Put'), row=1,col=2)

 

fig.add_trace(go.Line(x=short["Year"], y=short["Total Profit/Loss"],

                      name='short'), row=2, col=1)

fig.add_trace(go.Line(x=long["Year"], y=long["Total Profit/Loss"],

                      name='long'), row=2,col=1)

 

fig.add_trace(go.Line(x=Long_call["Year"], y=Long_call["Total Profit/Loss"],

                      name='Long Call'), row=2, col=2)

fig.add_trace(go.Line(x=long_put["Year"], y=long_put["Total Profit/Loss"],

                      name='Long Put'),  row=2,col=2)

fig.add_trace(go.Line(x=short_call["Year"], y=short_call["Total Profit/Loss"],

                      name='Short Call'), row=2, col=2)

fig.add_trace(go.Line(x=short_put["Year"], y=short_put["Total Profit/Loss"],

                      name='Short Put'),  row=2,col=2)

 

fig.update_layout(height=800, width=1100)

fig.for_each_yaxis(lambda axis: axis.title.update(

    font=dict(color='blue', size=5)))

 

fig.show()

Looking at the graphs one by one, we can observe that, as expected, expired options resulted in most of the profits and exercised one's losses. Moving to the profits from call versus put options, we can claim that put options mainly were profitable, and call options resulted in losses with an exception from 2018. Additionally, short transactions when we received option premium resulted in profits and long positions to lose, which is expected since most contracts expired worthless. Finally, short put options contributed most to the strategy's returns, whereas long put and short call legs mostly resulted in losses. This is because the market mainly moved upwards during the observation period.

Finally, we plot the returns from the strategy per month to see whether there is a tendency for the short iron condor strategy to be profitable or losable during certain months regularly.

    	
            

fig = make_subplots(rows=4, cols=3,

        subplot_titles = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] )

 

n = 0

for i in range(1,5):

    for j in range(1, 4):

        n += 1

        x = df1['Exp_date'].loc[(df1['Exp_date'].dt.month==n)]

        y = df1['Total Profit/Loss'].loc[(df1['Exp_date'].dt.month==n)]

        fig.add_trace(

            go.Bar(x=x , y=y,showlegend=False),

            row=i, col=j

        )

fig.update_layout(height=1000, width=1100)

 

fig.show()

Summary

This article walked through the backtesting of a short iron condor strategy with 10%/20% legs for option contracts written on NDX as an underlying. The estimates are done from 2015 to 2021 October included. The most important and, at the same time, challenging part of the backtesting was the reconstruction of expired option RICs as those are not easily accessible through a single API call. We illustrated the construction process and built functions to reconstruct and validate RICs for OPRA exchange-traded indices and equities monthly options. It is worth mentioning that the reconstruction part can be used as a standalone product for other use cases different than strategy backtesting.

Further, moving to the actual backtesting, we built functions to create transactions for short iron condor strategy legs and offset those with opposite transactions in case of a market trigger. As a market trigger, we used the three-day MA change. Finally, we calculated the strategy outcome and visualized the results with different graphs. Although all the processes above are showcased for NDX, we run the backtesting for SPX, IBM, TWTR and AAPL as well. The datasets of options transactions during 2015-2021 for those assets are included in the GitHub folder.

It is also worth mentioning the limitations of the current backtesting tool. First of all, the expired option RIC reconstruction is not easily scalable. One may not learn and incorporate exchange and Refinitiv rules for other types of assets, such as FX, and other expiration periods, such as weekly. Next, the solution involves many API requests and depending on the option and number of years to request, one may hit the daily limit. Thus, it is advised to request data for relatively short periods and export the retrieved output to excel. Then the outputs can be merged, and visualizations run for the entire period of observation. Finally, at larger OTMs and for certain assets, the market may not be very liquid, and one may end up having no options contracts to trade for some months.

  • Register or Log in to applaud this article
  • Let the author know how much this article helped you
If you require assistance, please contact us here