Licensed Image from Adobe Stock

This article builds upon my previous piece, US Stock Options Data API Has Arrived! If you are unfamiliar with the concepts of stock options, it serves as a good starting point.

In this article, I will evaluate three beginner strategies: Long Call, Long Put, and Covered Call. For each strategy, I will explain what it entails, how it works, the results of backtesting, and provide a visual representation.

Please note, API data availability depends on your subscription plan. Some data isn’t included in the free plan. Visit their pricing page to find the plan that fits your needs.

Register & Get Data

Data Gathering

During my testing I did discover a “gotcha” with the Options API. In my previous article I explained that a “limit” and “offset” had been added which I liked. There is however a potential problem or at least limitation to be aware of. Options API restricts you to retrieving 100 days of data per call which is fine. So in theory to retrieve 100 days of Apple’s AAPL options data, it should in theory be this API call: https://eodhd.com/api/v2/options/AAPL.US?from=2024-09-11&to=2024-12-20&api_token=<YOUR_API_KEY>. The problem is omitting the “offset” and “limit” defaults to 0 and 1000. So in theory this should work right? <a href=”https://eodhd.com/api/v2/options/AAPL.US?from=2024-09-11&to=2024-12-20&page[offset]=0&page[limit]=100000&api_token=https://eodhd.com/api/v2/options/AAPL.US?from=2024-09-11&to=2024-12-20&page[offset]=0&page[limit]=10000&api_token=<YOUR_API_KEY>. Wrong. There is a limitation that the API will only return 10000 entries. So if the 100 days exceeds 10000 data points it will fail. It should really be one restriction, not both.

I still want to get a reasonable amount of options data to work with. I’ve come up with a plan and I will share it with you. I’m going to retrieve APPL options data between “2024-11-01” and “2025-02-07” in 1000 increments. I’ll then use the “next” key to iterate through the pages. I’ll then capture the key I’m really interested in which is “data” and create a dataset called “data/options_data.csv”. My file has 141734 data points in it.

import os
import csv
import requests
from dotenv import load_dotenv

load_dotenv()
api_token = os.getenv("EODHD_API_TOKEN")


def fetch_options_data_to_csv(initial_url, csv_filename):
    next_url = initial_url
    fieldnames = None
    first_page = True

    while next_url:
        next_url = f"{next_url}&api_token={api_token}"

        print(f"Fetching data from: {next_url}")
        response = requests.get(next_url)
        if response.status_code != 200:
            print(f"Failed to retrieve data (HTTP {response.status_code}). Exiting.")
            break

        try:
            json_response = response.json()
        except ValueError:
            print("Error decoding JSON response. Exiting.")
            break

        records = json_response.get("data", [])
        if not records:
            print("No records found on this page. Exiting loop.")
            break

        if first_page:
            fieldnames = list(records[0].keys())
            mode = "w"
            first_page = False
        else:
            mode = "a"

        with open(csv_filename, mode, newline="", encoding="utf-8") as csvfile:
            writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
            if mode == "w":
                writer.writeheader()
            for record in records:
                writer.writerow(record)

        next_url = json_response.get("links", {}).get("next")

    print("Data fetching completed.")


if __name__ == "__main__":
    start_date = "2024-11-01"
    end_date = "2025-02-07"
    base_url = "https://eodhd.com/api/v2/options/AAPL.US"

    initial_url = (
        f"{base_url}?from={start_date}&to={end_date}"
        f"&page[offset]=0&page[limit]=1000"
    )

    csv_file = "data/options_data.csv"

    fetch_options_data_to_csv(initial_url, csv_file)

Preprocessing

I now have my “data/options_data.csv”. I loaded that into a Pandas dataframe to inspect it. There are a number of date fields and they aren’t sorted. I also noticed that the API doesn’t currently allow you to sort on all of them. I want to use “tradetime” for my time series. On closer inspection of this feature I also noticed that there were NaN entries. What I’ve done is removed the rows with the NaN entries, cast the column as a date field, and sorted the dataframe in ascending order.

import pandas as pd


def get_options_data():
    return pd.read_csv("data/options_data.csv", low_memory=False)


if __name__ == "__main__":
    df = get_options_data()

    df_cleaned = df.dropna(subset=["tradetime"]).copy()
    df_cleaned["tradetime"] = pd.to_datetime(df_cleaned["tradetime"], errors="coerce")

    df_sorted = df_cleaned.sort_values(by="tradetime", ascending=True).copy()
    df_sorted.reset_index(drop=True, inplace=True)

    df["tradetime"] = pd.to_datetime(df["tradetime"])
    df_sorted.index = df_sorted["tradetime"]

    df_sorted.to_csv("data/options_data_preprocessed.csv", index=True)

I think we’re in good shape now to apply our strategies. Our sorted pre-processed sorted data is in “data/options_data_preprocessed.csv” now.

Long Call Strategy

A Long Call strategy involves buying call options with the expectation that the underlying stock price will rise. This strategy benefits from upward price movements, with the potential for unlimited profit and a loss limited to the premium paid.

Buy Signal:

  • Triggered when a significant upward trend is detected in the stock price, such as when the current price is above a moving average (e.g., 20-day SMA) and is increasing. Working off a 20-day SMA is simplistic for real-world trading, but this is a basic strategy.

Sell Signal:

  • Triggered if the stock price starts to decline below a threshold, such as crossing below a short-term moving average (e.g., 10-day SMA), or at a predefined profit target or expiration of the option. The sell signal I had to adjust to make the strategy profitable. The techniques I’ve used are more advanced but without this it’s highly unlikely this would be profitable. I implemented a stop loss, target profit, and trailing stop loss.

How Trigger Works in Code:
In the dataset:

  • A “Buy Call” signal is represented in the Signal column when the conditions for upward momentum are met. I will create the Signal column.
  • The code assumes the Outcome column identifies whether the trade was profitable (“Win”) or not.

Before I show you the code, I want to explain a few parts:

  1. Loaded our sorted pre-processed CSV.
  2. Ensured the bid and ask columns are numeric.
  3. Replaced missing or erroneous entries with 0.
  4. Used interpolation and smoothing for bid values.
  5. Backtest the strategy with £10,000.
  6. Return the buy/sell transactions.
  7. Return a line graph with the buys and sells represented.
  8. Return the win/loss ratio and ROI.
  9. Return the data gaps with missing or very low activity.

When I coded this basic strategy and back tested it, it was terrible. The entire £10,000 investment was wiped out. This strategy places thousands of buy and sell signals in quick succession. It does not allow for the order to gain enough momentum before selling (in most cases at a loss).

I’ve carried out a lot of experiments like this and the key is a “smart sell”, not the buy. In most strategies the buy is usually pretty good but it’s the sell that sells to early before locking in each profit. What I’ve done is applied some of my experience to the basic strategy to make it hugely profitable. The trick is to make use of stop losses in both directions, and a trailing stop loss.

This is a summary of what I’ve done to the strategy to turn it into something you can actually use.

Entry Conditions:

  • Signal: When no trade is open and the current bid is above the 20-period simple moving average (sma20).
  • Minimum Price Check: Trades are only entered if the bid price exceeds a set minimum (e.g. £1) to avoid buying extremely low-priced assets.
  • Order Size: Only 1% of the current account balance is allocated per trade. I did experiment with higher values but 1% seemed to provide the best performance. Feel free to adjust the code below to try it out yourself.
  • Quantity Calculation: The number of units to buy is determined by dividing the allocated amount by the current bid, with a cap on maximum units to prevent oversized positions.

Exit Conditions:

  • Target Profit Exit: The trade is closed immediately if the bid price rises to 10% above the purchase price.
  • Hard Stop Loss: The trade is closed if the bid price drops to 3% below the purchase price.
  • Trailing Stop Loss:
    • The strategy tracks the best price reached after entering the trade.
    • If the current bid falls by more than 3% from this best price, the trade is exited.
  • Final Liquidation: Any open trade at the end of the data is closed at the last available price, determining a win or loss based on whether the exit price exceeds the purchase price.

Risk Management:

  • Smaller Position Size: Using only 1% of the balance per trade limits risk exposure on any single trade.
  • Multiple Stop Losses: Combining a hard stop loss with a trailing stop loss helps to cut losses quickly and protect profits.

I also wanted to graph the data and put a green triangle on the graph when buys occur and a red triangle on the graph were sells occur. This didn’t work out the way I had hoped. There are just too many buy and sell signals and the graph looks like a mess. I did include the code for you to try out anyway. It’s useful code for visually inspecting strategies.

Visualisation:

  • Time Series Plot: The bid price is plotted over time.
  • Trade Markers: Green triangles mark buy orders and red triangles mark sell orders.
  • Downsampling: The dataset is downsampled (if necessary) to ensure efficient and responsive plotting.

The results look like this:

Final Balance: 62205.64
Wins: 10360, Losses: 14754
Win/Loss Ratio: 0.70
ROI: 522.06%

As you can see I my modifications helped 🙂

One thing you may wonder is why the final balance and ROI is so high yet there are so many losses compared with wins. The answer is the strategy now makes wins a lot more profitable, and the loss from losses is limited to the minimum. It’s also not factoring in transactional fees and slippage.

Key issues to consider for optimising this basic strategy:

  1. The SMA-20 strategy alone generates too many trades (over trading)
  2. There is no volatility or implied volatility consideration, this would reduce buying overpriced calls
  3. There is no consideration of time decay (theta)
  4. And most importantly it’s not factoring in real-world costs
  5. The stop loss and trailing stop loss triggers could be experimented with

I’ve covered the first strategy now… let’s move into the next one!

Long Put Strategy

The next topic I want to cover is the differences between a Long Call (Long Buy) strategy and a Long Put strategy.

Differences Between Long Call and Long Put Strategies

  • Long Call (Long Buy) Strategy:
    • Objective: Profit from an increase in the underlying asset’s price.
    • Entry Condition: Enter the position when the underlying price is strong (e.g. above a moving average).
    • Profit: Increases as the underlying rises above the purchase price.
    • Risk: Limited to the premium paid; losses occur if the price does not rise.
    • Exit: Typically exit when a target profit is reached, a stop loss is hit, or when a trailing stop loss triggers after a price reversal.
  • Long Put Strategy:
    • Objective: Profit from a decrease in the underlying asset’s price.
    • Entry Condition: Enter the position when the underlying price is weak (e.g. below a moving average).
    • Profit: Increases as the underlying falls further below the purchase price.
    • Risk: Also limited to the premium paid; losses occur if the price does not fall.
    • Exit: Exit when a target profit (i.e. a further drop) is reached, if a hard stop loss is hit (if the price moves upward too much), or via a trailing stop that locks in profits as the price falls and then reverses upward.

I’ve made some changes to my Long Call code to test out this strategy.

  • Entry: The strategy enters a long put when no trade is open and the current bid is below the 20-period moving average (sma20).
  • Exit Conditions:
    • Target Profit: Exit when the bid falls further by a set percentage (e.g. 10% below the purchase price).
    • Hard Stop Loss: Exit if the bid rises above a set threshold (e.g. 3% above the purchase price).
    • Trailing Stop Loss: The strategy tracks the lowest price (i.e. the “worst” price for the underlying) since entry. If the bid then rises by more than 3% from that low, the trade is closed.

I had a feeling this strategy would perform better on the dataset I have prepared based on how the previous strategy performed. The results were way too good. I’ve had to re-run the numbers several times as they seemed unrealistic to me. I’ve come to the conclusion the numbers may actually be correct. There are over 10,000 buy and sell opportunities. This would result in a very high ROI if real-world factors like slippage and transactional costs are not factored in. The basic backtesting code I’ve written doesn’t factor in fees and slippage.

This is what the code looks like…

import sys
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

df = pd.read_csv("data/options_data_preprocessed.csv", low_memory=False)
df["bid"] = pd.to_numeric(df["bid"], errors="coerce")
df["ask"] = pd.to_numeric(df["ask"], errors="coerce")
df["bid"] = df["bid"].replace(0, np.nan).astype("float64")
df["bid"] = df["bid"].interpolate(method="linear", limit_direction="both")
df["bid"] = df["bid"].ffill().bfill()

# Calculate moving averages
df["sma10"] = df["bid"].rolling(window=10).mean()
df["sma20"] = df["bid"].rolling(window=20).mean()

# Convert tradetime column to datetime objects
df["tradetime"] = pd.to_datetime(df["tradetime"])


def backtest_long_put_trailing_fixed(
    df,
    initial_investment,
    allocation_pct=0.01,
    target_profit_pct=0.10,
    stop_loss_pct=0.03,
    trailing_stop_pct=0.03,
    min_bid_threshold=1.0,
    max_quantity=1e6,
):

    # Use a fixed allocation for every trade.
    fixed_allocation = initial_investment * allocation_pct

    balance = initial_investment
    win_count = 0
    loss_count = 0
    trades = []
    open_trade = None  # This will hold details of the open trade, if any.

    for i in range(len(df)):
        current_bid = df["bid"].iloc[i]
        current_ask = df["ask"].iloc[i]
        current_time = df["tradetime"].iloc[i]
        sma20 = df["sma20"].iloc[i]

        # Skip if current_bid is missing or below our threshold.
        if pd.isna(current_bid) or current_bid < min_bid_threshold:
            continue

        # ENTRY: If no trade is open and current_bid is below sma20, enter a long put.
        if open_trade is None and current_bid < sma20:
            allocation = fixed_allocation  # Fixed amount per trade.
            quantity = allocation / current_bid
            if quantity > max_quantity:
                quantity = max_quantity
                allocation = quantity * current_bid
            # Deduct the fixed allocation from the balance.
            balance -= allocation
            open_trade = {
                "purchase_price": current_bid,
                "quantity": quantity,
                "allocation": allocation,
                "best_price": current_bid,  # For a put, a higher price is better.
            }
            df.at[i, "Signal"] = "Buy Put"
            trades.append((current_time, current_bid, "Buy", "N/A"))

        # If a trade is open, update the best (highest) price and check exit conditions.
        elif open_trade is not None:
            purchase_price = open_trade["purchase_price"]
            allocation = open_trade["allocation"]
            quantity = open_trade["quantity"]
            best_price = open_trade["best_price"]

            # Update best_price if current_bid is higher.
            if current_bid > best_price:
                best_price = current_bid
                open_trade["best_price"] = best_price

            # We'll use current_ask as the exit price.
            exit_price = current_ask

            # Calculate profit (or loss) for the trade:
            # profit_loss = allocation * ((exit_price / purchase_price) - 1)
            if current_bid >= purchase_price * (1 + target_profit_pct):
                profit_loss = allocation * ((exit_price / purchase_price) - 1)
                balance += profit_loss  # Add only the profit (loss will be negative)
                win_count += 1
                df.at[i, "Signal"] = "Sell Put (Target)"
                trades.append((current_time, exit_price, "Sell", "Win"))
                open_trade = None

            elif current_bid <= purchase_price * (1 - stop_loss_pct):
                profit_loss = allocation * ((exit_price / purchase_price) - 1)
                balance += profit_loss
                loss_count += 1
                df.at[i, "Signal"] = "Sell Put (Stop Loss)"
                trades.append((current_time, exit_price, "Sell", "Loss"))
                open_trade = None

            elif current_bid < best_price * (1 - trailing_stop_pct):
                profit_loss = allocation * ((exit_price / purchase_price) - 1)
                # Determine win or loss based on exit price vs. purchase price.
                if exit_price >= purchase_price:
                    win_count += 1
                    result = "Win"
                else:
                    loss_count += 1
                    result = "Loss"
                balance += profit_loss
                df.at[i, "Signal"] = "Sell Put (Trailing Stop)"
                trades.append((current_time, exit_price, "Sell", result))
                open_trade = None

    # FINAL LIQUIDATION: If a trade remains open at the end of the data, exit it.
    if open_trade is not None:
        last_ask = df["ask"].iloc[-1]
        profit_loss = fixed_allocation * ((last_ask / open_trade["purchase_price"]) - 1)
        if last_ask >= open_trade["purchase_price"]:
            win_count += 1
            result = "Win"
        else:
            loss_count += 1
            result = "Loss"
        trades.append((df["tradetime"].iloc[-1], last_ask, "Sell (End)", result))
        balance += profit_loss
        open_trade = None

    return balance, win_count, loss_count, trades


initial_investment = 10000
balance_long_put, wins_long_put, losses_long_put, trades_long_put = (
    backtest_long_put_trailing_fixed(
        df,
        initial_investment,
        allocation_pct=0.01,
        target_profit_pct=0.10,
        stop_loss_pct=0.03,
        trailing_stop_pct=0.03,
        min_bid_threshold=1.0,
        max_quantity=1e6,
    )
)
win_loss_ratio_put = wins_long_put / (losses_long_put if losses_long_put else 1)
roi_put = ((balance_long_put - initial_investment) / initial_investment) * 100

print("Long Put Transaction Log:")
for trade in trades_long_put:
    date, price, action, result = trade
    print(f"Date: {date}, Action: {action}, Price: {price:.2f}, Result: {result}")

print(f"\nFinal Balance: {balance_long_put:.2f}")
print(f"Wins: {wins_long_put}, Losses: {losses_long_put}")
print(f"Win/Loss Ratio: {win_loss_ratio_put:.2f}")
print(f"ROI: {roi_put:.2f}%")

n_points = 1000
if len(df) > n_points:
    df_plot = df.iloc[:: len(df) // n_points].copy()
else:
    df_plot = df.copy()

plt.figure(figsize=(16, 8))
plt.plot(df_plot["tradetime"], df_plot["bid"], label="Bid Price", color="blue")

buy_dates, buy_prices = [], []
sell_dates, sell_prices = [], []
for trade in trades_long_put:
    date, price, action, _ = trade
    if action.startswith("Buy"):
        buy_dates.append(date)
        buy_prices.append(price)
    elif action.startswith("Sell"):
        sell_dates.append(date)
        sell_prices.append(price)

plt.scatter(buy_dates, buy_prices, marker="^", color="green", s=100, label="Buy")
plt.scatter(sell_dates, sell_prices, marker="v", color="red", s=100, label="Sell")

plt.title("Long Put Strategy Backtest with Fixed Allocation")
plt.xlabel("Tradetime")
plt.ylabel("Price")
plt.legend(loc="upper left", bbox_to_anchor=(1, 1))
plt.grid()
plt.tight_layout()
plt.show()

And the results surprisingly look like this…

Final Balance: 4587135.26
Wins: 12382, Losses: 11614
Win/Loss Ratio: 1.07
ROI: 45771.35%

You can see that this strategy performed better than the previous on in terms of the win/loss ratio. The final balance and ROI still seems insanely high to me but I’ve checked it over severals times and I believe it’s likely correct and this high purely because fees and slippage is not factored in.

This has very similar problems with the first strategy. Far too many buy signals in quick succession, no volatility-based entry conditions, and minimum trade hold periods to reduce overtrading. Options specific techniques like Greeks (Delta, Theta, Vega, and Gamma) may help here too. This moves more into advanced techniques but even these basic strategies could be improved further like I did with the sell signals.

This bring bring us onto the last beginner strategy…

Covered Call

Just for clarity let’s recap the previous two strategies and see what a Covered Call is.

  • Long Call
    • What it is: Buying a call option outright.
    • Goal: Profit from a rise in the underlying asset’s price.
    • Risk/Reward: Limited risk (the premium paid) and unlimited upside (in theory).
  • Long Put
    • What it is: Buying a put option outright.
    • Goal: Profit from a fall in the underlying asset’s price.
    • Risk/Reward: Limited risk (the premium paid) and significant upside if the underlying falls dramatically.
  • Covered Call
    • What it is: Owning shares of the underlying asset (a “long stock” position) while selling call options on that same asset.
    • Goal: Generate extra income (the call premium) on top of any capital gains in the stock. The premium cushions a drop in the stock price but limits upside because if the stock rises above the strike, the shares are called away.
    • Risk/Reward: You hold the stock (with its inherent risks) but earn option premiums that enhance overall returns. If the stock rallies too high, your profit is capped at the strike price (plus premium).

I’ve been trying to get this strategy to return a positive result and no luck. I think maybe the market is too volatile. Through many iterations of trial and error I’ve managed to reduce the loss as much as I can. I’ve still not been able to get it to break even or show a positive result.

I also changed the what is graphed as the previous two graphs above didn’t really show anything useful. I’ve changed it now to show the portfolio balance over time. I think this is better for all three strategies but you can decide for yourself.

This is the code…

import sys
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

df = pd.read_csv("data/options_data_preprocessed.csv", low_memory=False)
df["bid"] = pd.to_numeric(df["bid"], errors="coerce")
df["ask"] = pd.to_numeric(df["ask"], errors="coerce")
df["bid"] = df["bid"].replace(0, np.nan).astype("float64")
df["bid"] = df["bid"].interpolate(method="linear", limit_direction="both")
df["bid"] = df["bid"].ffill().bfill()

# Calculate a 20-period SMA for our signal.
df["sma20"] = df["bid"].rolling(window=20).mean()

# Compute a 20-day rolling standard deviation and relative volatility.
df["vol_std"] = df["bid"].rolling(window=20).std()
df["rel_vol"] = df["vol_std"] / df["sma20"]

# Convert tradetime to datetime objects.
df["tradetime"] = pd.to_datetime(df["tradetime"])


def backtest_covered_call_partial_optimized(
    df,
    initial_investment,
    trade_size=100,  # Fixed notional per trade (£100)
    target_profit_pct=0.05,  # Call strike 5% above entry price.
    stop_loss_pct=0.60,  # Stop loss if price falls 60% below entry.
    option_duration=pd.Timedelta("30D"),  # Option held for 30 days.
    premium_pct=0.06,  # Premium equals 6% of trade value.
    min_price_threshold=1.0,
    min_trade_interval=pd.Timedelta("10D"),  # Minimum 10 days between trades.
    max_rel_volatility=0.10,  # Only enter trade if relative volatility < 10%.
):
    balance = initial_investment
    trade_log = []
    portfolio_history = []
    active_trade = None
    last_trade_time = None

    for i in range(len(df)):
        current_time = df["tradetime"].iloc[i]
        current_price = df["bid"].iloc[i]
        sma20 = df["sma20"].iloc[i]
        rel_vol = df["rel_vol"].iloc[i]

        # Skip if current price is invalid.
        if pd.isna(current_price) or current_price < min_price_threshold:
            continue

        # Only enter trades if relative volatility is below our threshold.
        if pd.isna(rel_vol) or rel_vol > max_rel_volatility:
            if active_trade is None:
                portfolio_history.append((current_time, balance))
            else:
                mtm = active_trade["shares"] * current_price
                portfolio_history.append((current_time, balance + mtm))
            continue

        # Record portfolio value.
        if active_trade is None:
            portfolio_history.append((current_time, balance))
        else:
            mtm = active_trade["shares"] * current_price
            portfolio_history.append((current_time, balance + mtm))

        # Enforce minimum trade interval.
        if (
            last_trade_time is not None
            and (current_time - last_trade_time) < min_trade_interval
        ):
            if active_trade is None:
                continue

        # If an active trade exists, check exit conditions.
        if active_trade is not None:
            entry_price = active_trade["entry_price"]
            shares = active_trade["shares"]
            expiry = active_trade["expiry"]
            strike = active_trade["strike"]
            premium = active_trade["premium"]

            # Stop Loss: Exit if current price falls to or below entry_price * (1 - stop_loss_pct).
            if current_price <= entry_price * (1 - stop_loss_pct):
                proceeds = shares * current_price
                profit = proceeds - trade_size
                trade_log.append(
                    (current_time, current_price, "Stop Loss", f"Profit: {profit:.2f}")
                )
                balance += profit
                active_trade = None
                last_trade_time = current_time
                continue

            # Option Expiry: If current_time >= expiry, settle the trade.
            if current_time >= expiry:
                if current_price >= strike:
                    proceeds = shares * strike + premium
                    action_str = "Call Exercised"
                else:
                    proceeds = shares * current_price + premium
                    action_str = "Call Expired"
                profit = proceeds - trade_size
                trade_log.append(
                    (current_time, current_price, action_str, f"Profit: {profit:.2f}")
                )
                balance += profit
                active_trade = None
                last_trade_time = current_time
                continue

            continue

        # If no active trade, check for an entry signal.
        if active_trade is None and current_price > sma20:
            allocation = trade_size
            shares = allocation / current_price
            entry_price = current_price
            strike = current_price * (1 + target_profit_pct)
            premium = shares * current_price * premium_pct
            expiry = current_time + option_duration
            active_trade = {
                "entry_time": current_time,
                "entry_price": entry_price,
                "shares": shares,
                "allocation": allocation,
                "strike": strike,
                "premium": premium,
                "expiry": expiry,
            }
            balance -= allocation
            trade_log.append(
                (
                    current_time,
                    current_price,
                    "Enter Trade",
                    f"Trade Size: {allocation:.2f}, Strike: {strike:.2f}, Premium: {premium:.2f}, Expiry: {expiry}",
                )
            )
            last_trade_time = current_time

    # Final liquidation if a trade is still active.
    if active_trade is not None:
        final_price = df["bid"].iloc[-1]
        proceeds = active_trade["shares"] * final_price
        profit = proceeds - trade_size
        trade_log.append(
            (
                df["tradetime"].iloc[-1],
                final_price,
                "Liquidate End",
                f"Profit: {profit:.2f}",
            )
        )
        balance += profit
        active_trade = None
        portfolio_history.append((df["tradetime"].iloc[-1], balance))

    return balance, portfolio_history, trade_log


initial_investment = 10000
final_balance, portfolio_history, trade_log = backtest_covered_call_partial_optimized(
    df,
    initial_investment,
    trade_size=100,  # Fixed £100 per trade.
    target_profit_pct=0.05,  # 5% target profit.
    stop_loss_pct=0.60,  # 60% stop loss.
    option_duration=pd.Timedelta("30D"),  # Hold option for 30 days.
    premium_pct=0.06,  # 6% premium.
    min_price_threshold=1.0,
    min_trade_interval=pd.Timedelta("10D"),  # Minimum 10 days between trades.
    max_rel_volatility=0.10,  # Only trade if 20-day relative volatility < 10%.
)
roi = ((final_balance - initial_investment) / initial_investment) * 100

print("Final Optimized Partial Covered Call Trade Log:")
for rec in trade_log:
    time, price, action, details = rec
    print(f"Date: {time}, Price: {price:.2f}, Action: {action}, Details: {details}")

print(f"\nFinal Portfolio Balance: {final_balance:.2f}")
print(f"ROI: {roi:.2f}%")

times, values = zip(*portfolio_history)
plt.figure(figsize=(16, 8))
plt.plot(times, values, label="Portfolio Value", color="blue")
plt.title("Final Optimized Partial Covered Call Portfolio Value Over Time")
plt.xlabel("Tradetime")
plt.ylabel("Portfolio Value (£)")
plt.legend(loc="upper left")
plt.grid()
plt.tight_layout()
plt.show()

And the result looks like this…

Final Portfolio Balance: 8702.58
ROI: -12.97%

It’s a loss but all strategies being profitable just isn’t realistic. At least we have a comparison between all three.

Although this strategy at first glance performed the worst, it is probably the most well-established income-generating strategy. The poor performance could be due to the stock selection mostly due to high market volatility, and option strikes and premiums not being optimised. There are more advanced techniques like rolling of options that may help here.

I hope you liked the article and found it interesting. The next article will look at more advanced strategies like Straddle, Strangle, Vertical Spreads, and Iron Condor.

Do you enjoy our articles?

We can send new ones right to your email box