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:

  1. Take spot price at start of the month.
  2. Select nearest ATM strike.
  3. Fetch call option premium.
  4. 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.

Leave a Reply