Intro.
Covered calls are one of the most popular options trading strategies among retail and professional traders. The idea is simple:
👉 You own a stock (or take it synthetically in backtest) and sell call options against it every month.
This way, you collect premium income but cap your upside potential.
In this blog, we’ll write a Python backtester for covered calls using:
- BreezeConnect API (ICICI Direct’s API for historical market data)
- nsepython (to fetch strike prices and NSE option chain data)
- Pandas (to structure & analyze results)
By the end, we’ll have a spreadsheet of monthly P&L results for our strategy.
🛠️ Step 1: Authenticate with Breeze API
We start by connecting to the Breeze API using our credentials.
from breeze_connect import BreezeConnect
# Connect Breeze API
breeze = BreezeConnect(api_key="YOUR_API_KEY")
breeze.generate_session(
api_secret="YOUR_API_SECRET",
session_token="YOUR_SESSION_TOKEN"
)
📌 Explanation: Breeze requires three pieces of information: API Key, API Secret, and Session Token.
Once authenticated, we can fetch historical stock prices and option prices.
📊 Step 2: Get Strike Prices
We need strike prices to know which ATM (At-The-Money) option to sell each month.
from nsepython import *
def get_strikes(symbol):
oi_data, _, _ = oi_chain_builder(symbol, "latest", "compact")
return oi_data["Strike Price"]
👉 This function uses nsepython to fetch the option chain and returns all available strikes.
🧹 Step 3: Helper Function for Data Cleaning
Breeze API returns datetime in different formats (datetime, date, timestamp).
We’ll standardize it into one format and ensure numeric values are correctly parsed.
import pandas as pd
def normalize_df(df):
"""Standardize datetime index and numeric columns."""
for col in ["datetime", "date", "timestamp"]:
if col in df.columns:
df["date"] = pd.to_datetime(df[col])
break
df.set_index("date", inplace=True)
for col in ["open", "high", "low", "close", "volume"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df
📅 Step 4: Define Expiry Dates & Stock Data
We will test the strategy on Maruti Suzuki (MARUTI), selling calls every month.
from Stock_code import get_exchange_code_and_lotsize
import datetime as dt
# Stock details
symbol, lot_size = get_exchange_code_and_lotsize("maruti")
strikes = get_strikes(symbol)
# Expiry list (can also fetch dynamically)
expiry_list = [
dt.datetime(2024, 12, 26), dt.datetime(2025, 1, 30),
dt.datetime(2025, 2, 27), dt.datetime(2025, 3, 27),
dt.datetime(2025, 4, 24), dt.datetime(2025, 5, 29),
dt.datetime(2025, 6, 26), dt.datetime(2025, 7, 31),
dt.datetime(2025, 8, 28)
]
Now, fetch stock data for the test period:
stock_data = breeze.get_historical_data(
interval="1day",
from_date="2024-12-01T07:00:00.000Z",
to_date="2025-08-18T18:00:00.000Z",
stock_code=symbol, exchange_code="NSE", product_type="cash"
)
df_stock = normalize_df(pd.DataFrame(stock_data["Success"]))
df_stock["date1"] = df_stock.index.date
💡 Step 5: Covered Call Logic
For each expiry date:
- Take spot price at start of the month.
- Select nearest ATM strike.
- Fetch call option premium.
- Calculate payoff at expiry.
results, capital = [], 0
for expiry_date in expiry_list:
# Find entry date (first trading day of the month)
start_of_month = pd.Timestamp(expiry_date.year, expiry_date.month, 1)
try:
entry_date = df_stock[df_stock.index.to_period("M") == start_of_month.to_period("M")].iloc[0].name
spot = df_stock.loc[entry_date, "close"]
except IndexError:
continue
# Approximate ATM strike
atm_strike = strikes.iloc[(strikes - round(spot/50)*50).abs().argmin()]
expiry_string = expiry_date.strftime("%Y-%m-%dT%H:%M:%S.000Z")
# Option premium data
opt_data = breeze.get_historical_data(
interval="1day",
from_date=entry_date.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
to_date=expiry_string,
stock_code=symbol, exchange_code="NFO",
product_type="options", expiry_date=expiry_string,
right="call", strike_price=str(atm_strike)
)
if not opt_data or "Success" not in opt_data: continue
df_opt = normalize_df(pd.DataFrame(opt_data["Success"]))
if df_opt.empty: continue
# Entry premium
entry_price = df_opt.iloc[0]["close"]
# Spot price at expiry
last_spot = df_stock[df_stock["date1"] == expiry_date.date()]["close"].iloc[0]
# P&L calculations
intrinsic = max(last_spot - atm_strike, 0)
call_pnl = (entry_price - intrinsic) * lot_size
stock_pnl = (last_spot - spot) * lot_size
net_pnl = call_pnl + stock_pnl
capital += net_pnl
results.append([entry_date.strftime("%Y-%m"), spot, atm_strike, entry_price,
last_spot, call_pnl, stock_pnl, net_pnl, capital])
📑 Step 6: Save Results
Finally, we export results to Excel for analysis.
df_res = pd.DataFrame(results, columns=[
"Month", "Spot Entry", "ATM Strike", "Call Premium",
"Spot Exit", "Call P&L", "Stock P&L", "Net P&L", "Cumulative P&L"
])
print(df_res)
df_res.to_excel("covered_call_backtest.xlsx", index=False)
print("\n✅ Backtest Completed. Results saved to covered_call_backtest.xlsx")
📌 Output looks like this:

✅ Conclusion
With just a few lines of Python, we have:
- Automated data fetching from Breeze & NSE
- Backtested a covered call strategy
- Exported results for analysis
This framework can be extended to:
- Test different strikes (OTM / ITM calls)
- Try other stocks
- Add rolling strategies or stop-loss rules
Covered calls may look safe, but as you can see in the results, capped upside and potential losses make risk management crucial.
