Beginner Strategies for Trading Stock Options
Explore stock options strategies: Long Call, Long Put, and Covered Call — practical insights and performance analysis!

This article continues from our previous piece, “Understanding Stock Options Data” If you are new to the concept of stock options, it is a great place to start.
In this article, we will explore three beginner-friendly strategies: Long Call, Long Put, and Covered Call. For each strategy, we will cover what it involves, how it operates, backtesting results, and include visual representations.
These strategies are intended for beginners, so manage your expectations. The primary goal is to provide an impartial assessment of each strategy, enabling you to evaluate its potential. This article focuses more on demonstrating the API, showing how to retrieve and pre-process data for analysis, rather than diving deep into specialized options metrics. More advanced strategies and metrics will be covered in upcoming articles.
Additionally, we’ve incorporated a basic backtesting method to compare strategies. However, it’s worth noting that this method does not account for real-world factors like transactional fees and slippage, which can significantly impact results, especially if frequent buy and sell signals are generated.
Data Gathering
During our testing, we encountered a “gotcha” with the Options API. In our previous article, we mentioned the addition of “limit” and “offset” parameters, which initially seemed useful. However, there is a limitation worth noting. The Options API restricts data retrieval to 100 days per call, which is generally fine.
In theory, to retrieve 100 days of Apple’s (AAPL) options data, the API call should look like this:
https://eodhd.com/api/v2/options/AAPL.US?from=2024-09-11&to=2024-12-20&api_token=<YOUR_API_KEY>
The issue arises when omitting the “offset” and “limit” parameters, as they default to 0 and 1000, respectively. So, a more detailed call might look like this:
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>
Unfortunately, this approach fails due to a hard limit of 10,000 entries per API call. If 100 days exceed 10,000 data points, the request will not succeed. Ideally, the API should impose just one of these restrictions, not both simultaneously.
To work around this limitation and gather a sufficient amount of options data, we devised a practical approach. We retrieved AAPL options data between “2024–11–01” and “2025–02–07” in 1000-entry increments. We used the “next” key to iterate through the pages and captured the data we needed, focusing on the “data” key. This allowed us to build a comprehensive dataset saved as “data/options_data.csv”, containing 141,734 data points.
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
With the “data/options_data.csv” file ready, we loaded it into a Pandas dataframe for inspection. Upon examining the data, several date fields were present, but none were sorted. Additionally, the API currently lacks the capability to sort on all date fields.
For our time series analysis, we decided to use the “tradetime” field. However, a closer look revealed NaN (Not a Number) entries within this column. To clean up the dataset, we performed the following steps:
- Removed rows containing NaN entries in the “tradetime” field.
- Converted the column to a proper date type.
- Sorted the dataframe in ascending order based on the “tradetime” column.
This preprocessing ensures that our data is clean, well-structured, and ready for the next stages of analysis.
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)
We believe our data is now well-prepared for applying the strategies. The preprocessed and sorted dataset is saved as “data/options_data_preprocessed.csv”, ensuring a solid foundation for the analysis ahead.
Long Call Strategy
A Long Call strategy involves purchasing call options when anticipating a rise in the underlying stock’s price. The strategy offers unlimited profit potential while capping losses at the premium paid.
Buy Signal:
- Triggered by a strong upward trend in stock prices.
- Example: When the current price is above a moving average (e.g., 20-day SMA) and continues to rise.
- Using a 20-day SMA is a simplified approach for this basic strategy.
Sell Signal:
- Activated if the stock price falls below a certain threshold.
- Example: When the price crosses below a short-term moving average (e.g., 10-day SMA).
- Other triggers include reaching a predefined profit target or the option’s expiration.
Refinement: To ensure profitability, I incorporated advanced techniques such as:
- Stop Loss: Limits potential losses by selling if the price drops to a certain level.
- Target Profit: Exits the trade once a specific profit threshold is met.
- Trailing Stop Loss: Protects gains by adjusting the stop price as the stock price rises.
Implementation in the Dataset:
- The Signal column will indicate a “Buy Call” when upward momentum conditions are met.
- The Outcome column will reflect trade success with entries like “Win” or “Loss”.
Key Code Elements:
- Data Preparation:
- Loaded the preprocessed CSV file.
- Ensured Bid and Ask columns are numeric.
- Replaced missing/erroneous values with 0.
- Applied interpolation and smoothing to bid values.
2. Strategy Backtesting:
- Initial investment of £10,000.
- Generated buy/sell transactions.
- Visualized trades with a line graph showing buy/sell points.
- Analyzed win/loss ratio and ROI.
- Identified data gaps with missing or low activity.
Initial Results:
- The unoptimized basic strategy performed poorly, wiping out the £10,000 investment.
- The core issue: Excessive Buy/Sell Signals in quick succession, not allowing trades to gain momentum.
Optimization:
- The real challenge in trading strategies often lies in the sell decision, not the buy.
- Applied advanced techniques to transform the strategy from a loss to significant profitability.
- The key to success: Leveraging stop losses and a trailing stop loss to secure gains and manage risks effectively.
This is a summary of what we’ve done to the strategy to turn it into something you can actually use.
Entry Conditions:
- Buy Signal: Activated when no trade is currently open and the bid price is above the 20-period simple moving average (sma20).
- Minimum Price Filter: Trades are only executed if the bid price exceeds a minimum threshold (e.g., £1), avoiding low-quality, extremely low-priced assets.
- Position Sizing:
- Allocates only 1% of the current account balance per trade.
- While higher percentages were tested, 1% offered optimal performance.
- Users can adjust this parameter to experiment with different risk levels.
4. Quantity Calculation:
- Calculates the number of units to buy by dividing the allocated amount by the current bid.
- Imposes a cap on maximum units to avoid oversized positions.
Exit Conditions:
- Target Profit Exit: Closes the trade immediately if the bid price reaches 10% above the purchase price.
- Hard Stop Loss: Sells the position if the bid price drops 3% below the purchase price.
- Trailing Stop Loss:
- Monitors the highest price reached after entering the trade.
- Exits the trade if the bid price declines by more than 3% from this peak.
4. Final Liquidation:
- Any open trade at the end of the dataset is closed at the last available price.
- Determines a win or loss based on whether the exit price is higher than the purchase price.
Risk Management:
- Limited Position Size:
- Allocating 1% of the account balance per trade minimizes exposure to any single trade.
2. Dual Stop Loss Strategy:
- Combining a hard stop loss with a trailing stop loss helps mitigate losses and secure profits.
We aimed to visualize the data by adding green triangles to indicate buy signals and red triangles for sell signals on the graph. However, the outcome wasn’t as expected. The sheer volume of buy and sell signals cluttered the graph, making it difficult to interpret. Nevertheless, we’ve included the code for those interested in experimenting with it. It can be quite helpful for visually assessing trading strategies.
- Visualization Details:
- Time Series Plot: Displays the bid price trend over time.
- Trade Markers: Green triangles denote buy orders, while red triangles signify sell orders.
- Downsampling: Applies downsampling to the dataset, if needed, to maintain efficient and clear visualization.
import sys
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# Read and preprocess data
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 to datetime objects
df["tradetime"] = pd.to_datetime(df["tradetime"])
def backtest_long_call_trailing(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):
df["Signal"] = None
open_trade = None # Holds details of an open trade
balance = initial_investment
win_count = 0
loss_count = 0
trades = []
for i in range(len(df)):
current_bid = df["bid"].iloc[i]
current_ask = df["ask"].iloc[i]
current_time = df["tradetime"].iloc[i]
sma10 = df["sma10"].iloc[i]
sma20 = df["sma20"].iloc[i]
# Skip if bid is missing or below threshold
if pd.isna(current_bid) or current_bid < min_bid_threshold:
continue
# Entry: if no trade is open and bid > sma20, buy.
if open_trade is None and current_bid > sma20:
allocation = balance * allocation_pct
quantity = allocation / current_bid
if quantity > max_quantity:
quantity = max_quantity
allocation = quantity * current_bid
open_trade = {
"purchase_price": current_bid,
"quantity": quantity,
"allocation": allocation,
"best_price": current_bid
}
balance -= allocation
df.at[i, "Signal"] = "Buy Call"
trades.append((current_time, current_bid, "Buy", "N/A"))
# If trade is open, update best price and check exits.
elif open_trade is not None:
purchase_price = open_trade["purchase_price"]
quantity = open_trade["quantity"]
best_price = open_trade["best_price"]
# Update the best price achieved.
if current_bid > best_price:
best_price = current_bid
open_trade["best_price"] = best_price
# Exit if target profit reached.
if current_bid >= purchase_price * (1 + target_profit_pct):
proceeds = quantity * current_ask
balance += proceeds
win_count += 1
df.at[i, "Signal"] = "Sell Call (Target)"
trades.append((current_time, current_ask, "Sell", "Win"))
open_trade = None
# Hard stop loss exit.
elif current_bid <= purchase_price * (1 - stop_loss_pct):
proceeds = quantity * current_ask
balance += proceeds
loss_count += 1
df.at[i, "Signal"] = "Sell Call (Stop Loss)"
trades.append((current_time, current_ask, "Sell", "Loss"))
open_trade = None
# Trailing stop loss exit.
elif current_bid < best_price * (1 - trailing_stop_pct):
proceeds = quantity * current_ask
if current_ask >= purchase_price:
win_count += 1
result = "Win"
else:
loss_count += 1
result = "Loss"
balance += proceeds
df.at[i, "Signal"] = "Sell Call (Trailing Stop)"
trades.append((current_time, current_ask, "Sell", result))
open_trade = None
# Final liquidation if a trade remains open.
if open_trade is not None:
last_bid = df["bid"].iloc[-1]
last_ask = df["ask"].iloc[-1]
proceeds = open_trade["quantity"] * last_ask
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 += proceeds
open_trade = None
return balance, win_count, loss_count, trades
# Run the updated backtest.
initial_investment = 10000
balance_long_call, wins_long_call, losses_long_call, trades_long_call = backtest_long_call_trailing(df, initial_investment)
win_loss_ratio = wins_long_call / (losses_long_call if losses_long_call else 1)
roi = ((balance_long_call - initial_investment) / initial_investment) * 100
print("Transaction Log:")
for trade in trades_long_call:
date, price, action, result = trade
print(f"Date: {date}, Action: {action}, Price: {price:.2f}, Result: {result}")
print(f"\nFinal Balance: {balance_long_call:.2f}")
print(f"Wins: {wins_long_call}, Losses: {losses_long_call}")
print(f"Win/Loss Ratio: {win_loss_ratio:.2f}")
print(f"ROI: {roi:.2f}%")
# --- Improved Plotting Section ---
# Downsample the data to at most 1000 points for the main time series.
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")
# Create lists for buy and sell markers.
buy_dates, buy_prices = [], []
sell_dates, sell_prices = [], []
for trade in trades_long_call:
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 Call Strategy Backtest with Trailing Stop Loss")
plt.xlabel("Tradetime")
plt.ylabel("Price")
plt.legend(loc="upper left", bbox_to_anchor=(1, 1))
plt.grid()
plt.tight_layout()
plt.show()
The results of the Long Call strategy with adjustments are as follows:
- Final Balance: £62,205.64
- Wins: 10,360
- Losses: 14,754
- Win/Loss Ratio: 0.70
- ROI: 522.06%
These results demonstrate the impact of my modifications to the strategy.
You might be wondering why the final balance and ROI are so high despite having more losses than wins. The answer lies in the strategy’s design: it maximizes profits on winning trades while minimizing losses on unsuccessful ones. However, it’s important to note that the results do not account for transactional fees and slippage, which could affect the real-world performance of this strategy.
Key Considerations for Optimizing the Long Call Strategy:
- Overtrading: The basic SMA-20 strategy generates an excessive number of trades.
- Volatility Management: Adding volatility or implied volatility filters could help avoid buying overpriced calls.
- Time Decay (Theta): The strategy currently does not account for time decay, which is crucial for options trading.
- Real-World Costs: Factors such as transaction fees and slippage need to be incorporated.
- Stop Loss Strategies: Experimenting with stop loss and trailing stop loss triggers could further optimize results.
With the Long Call strategy covered, let’s move on to the next approach!
Long Put Strategy
The Long Put strategy is designed to profit from a decline in the underlying asset’s price. This approach contrasts with the Long Call strategy, which aims to capitalize on upward price movements.
Differences Between Long Call and Long Put Strategies
Long Call Strategy:
- Objective: Profit from an increase in the underlying asset’s price.
- Entry Condition: Enter when the underlying price is strong (e.g., above a moving average).
- Profit Potential: Grows as the asset price rises above the purchase price.
- Risk: Limited to the premium paid; losses occur if the price does not rise.
- Exit Strategy: Exit when a target profit is achieved, a stop loss is hit, or 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 when the underlying price is weak (e.g., below a moving average).
- Profit Potential: Increases as the asset price drops further below the purchase price.
- Risk: Limited to the premium paid; losses occur if the price does not fall.
- Exit Strategy: Exit when a target profit (i.e., a further drop) is reached, a hard stop loss is triggered (if the price rises too much), or via a trailing stop that locks in profits as the price drops and then reverses upward.
For this strategy, we modified the Long Call code to suit the Long Put approach.
Entry Condition:
- 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: The trade is closed if the bid price falls further by a set percentage (e.g., 10% below the purchase price).
- Hard Stop Loss: Exit if the bid price rises above a set threshold (e.g., 3% above the purchase price).
- Trailing Stop Loss: The strategy tracks the lowest price (the “worst” price for the underlying) since entry. If the bid then rises by more than 3% from that low, the trade is closed.
Results and Observations
We anticipated that this strategy might perform better with the current dataset, given the insights from the Long Call strategy’s performance. The initial results appeared too good to be true. We reran the analysis multiple times to verify the outcomes, and the results were consistent.
The high performance may be attributed to the sheer volume of buy and sell signals — over 10,000 in total. However, this backtesting approach does not account for real-world factors such as slippage and transactional costs, which could significantly impact profitability.
The basic backtesting code lacks adjustments for fees and slippage, which in a real trading scenario would likely reduce the strategy’s profitability. Nonetheless, the promising ROI indicates the potential of the Long Put strategy under ideal conditions.
Here’s 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 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()
Results of the Long Put Strategy
The performance metrics for the Long Put strategy are as follows:
- Final Balance: 4,587,135.26
- Wins: 12,382
- Losses: 11,614
- Win/Loss Ratio: 1.07
- ROI: 45,771.35%
Analysis of the Results
The Long Put strategy outperformed the Long Call strategy, showcasing a better win/loss ratio and a dramatically high ROI. While the final balance and return on investment may seem exceptionally high, I have double-checked the calculations multiple times and believe the results are accurate — primarily due to the absence of fees and slippage in this analysis.
However, there are still notable challenges with this strategy:
- Overtrading: Similar to the Long Call strategy, this approach generates an excessive number of buy signals in rapid succession.
- Lack of Volatility Consideration: Entry conditions do not account for market volatility or implied volatility, which could help avoid purchasing overpriced puts.
- Absence of Time Decay Management: The strategy does not incorporate time decay (Theta), which is critical for options trading.
- Real-World Costs Ignored: Transactional fees and slippage are not factored into the model, potentially inflating the apparent profitability.
Potential Improvements
To refine this strategy, several advanced techniques could be implemented:
- Introduce volatility-based entry conditions to reduce overtrading.
- Implement minimum trade hold periods to avoid excessive buy/sell actions.
- Utilize options-specific metrics such as Greeks (Delta, Theta, Vega, and Gamma) to enhance decision-making.
- Further optimize sell signals, similar to the enhancements made in the Long Call strategy.
With the Long Put strategy thoroughly tested, we now move on to the final beginner strategy — the Covered Call.
Covered Call Strategy
Recap of Previous Strategies
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 (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 (premium paid) and significant upside if the underlying falls dramatically.
What is a Covered Call?
- What it is: Holding shares of the underlying asset (a “long stock” position) while selling call options on the same asset.
- Goal: Generate additional income through call premiums while maintaining potential capital gains. The premium acts as a cushion against a drop in the stock price, but the upside is capped if the stock price exceeds the strike price.
- Risk/Reward: The strategy involves holding the stock (with inherent market risks) while earning option premiums to enhance returns. If the stock price rises significantly, profits are capped at the strike price plus the premium received.
We’ve made numerous attempts to achieve a positive return with the Covered Call strategy, but the volatile market conditions made this difficult. After multiple iterations and adjustments, we managed to reduce the losses as much as possible. However, breaking even or showing a positive result has not yet been achieved.
One change we implemented was in the visualization approach. Unlike the previous strategies, where the graphs didn’t provide much insight, we now focus on showing the portfolio balance over time. This adjustment offers a clearer perspective on how the strategy performs throughout the trading period.
The results of the backtesting and analysis for this strategy will be presented next. Here’s 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()
Performance Summary
- Final Portfolio Balance: $8,702.58
- Return on Investment (ROI): -12.97%
Analysis
While this strategy resulted in a loss, it’s important to note that not all strategies will be consistently profitable — especially in volatile market conditions. The Covered Call strategy is widely regarded as a reliable income-generating approach, but its effectiveness depends on several factors:
- Market Conditions: High volatility may have negatively impacted performance.
- Stock Selection: Tesla’s volatility might not align well with this strategy’s conservative nature.
- Option Premiums and Strikes: These were not optimized, which may have contributed to the loss.
- Advanced Techniques: Strategies such as “rolling options” could potentially improve performance.
Though the Covered Call strategy underperformed compared to Long Call and Long Put strategies, it remains a valuable strategy for generating income with relatively lower risk under stable market conditions. The insights gathered here provide a useful benchmark for more advanced trading strategies.
Subscribe and don’t miss what’s next!
Please note that this article is for informational purposes only and should not be taken as financial advice. We do not bear responsibility for any trading decisions made based on the content of this article. Readers are advised to conduct their own research or consult with a qualified financial professional before making any investment decisions.
For those eager to delve deeper into such insightful articles and broaden their understanding of different strategies in financial markets, we invite you to follow our account and subscribe for email notifications.
Stay tuned for more valuable articles that aim to enhance your data science skills and market analysis capabilities.