Overall Statistics
Total Orders
7
Average Win
0.05%
Average Loss
0%
Compounding Annual Return
12.656%
Drawdown
2.100%
Expectancy
0
Start Equity
100000
End Equity
104053.75
Net Profit
4.054%
Sharpe Ratio
0.672
Sortino Ratio
0.651
Probabilistic Sharpe Ratio
68.557%
Loss Rate
0%
Win Rate
100%
Profit-Loss Ratio
0
Alpha
0.011
Beta
0.421
Annual Standard Deviation
0.048
Annual Variance
0.002
Information Ratio
-0.277
Tracking Error
0.064
Treynor Ratio
0.077
Total Fees
$7.00
Estimated Strategy Capacity
$88000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
0.44%
Drawdown Recovery
20
# region imports
from AlgorithmImports import *
from datetime import timedelta
import math
# endregion


class ImprovedCoveredCallAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2024, 9, 1)
        self.set_end_date(2024, 12, 31)
        self.set_cash(100000)

        self.settings.seed_initial_prices = True

        seeder = FuncSecuritySeeder(self.get_last_known_prices)
        self.add_security_initializer(lambda security: seeder.seed_security(security))

        self._spy = self.add_equity(
            "SPY",
            data_normalization_mode=DataNormalizationMode.RAW
        ).symbol

        # Strategy parameters
        self._target_weight = 0.50
        self._target_delta = 0.30
        self._min_dte = 4
        self._max_dte = 10
        self._min_open_interest = 100
        self._max_bid_ask_spread = 0.15
        self._profit_take_pct = 0.75

        # Track short call
        self._short_call = None
        self._short_call_entry_price = None
        self._short_call_strike = None
        self._short_call_expiry = None

        # Trend filter
        self._sma_fast = self.sma(self._spy, 20, Resolution.DAILY)
        self._sma_slow = self.sma(self._spy, 50, Resolution.DAILY)

        self.set_warm_up(60, Resolution.DAILY)

        # Sell covered call weekly
        self.schedule.on(
            self.date_rules.every(DayOfWeek.MONDAY),
            self.time_rules.after_market_open(self._spy, 5),
            self.sell_covered_call
        )

        # Manage open short call daily
        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.after_market_open(self._spy, 30),
            self.manage_position
        )

    def sell_covered_call(self):
        if self.is_warming_up:
            return

        # Do not sell a new call if one is already open
        if self._short_call is not None and self.portfolio[self._short_call].invested:
            return

        underlying_price = self.securities[self._spy].price

        # Trend filter: avoid selling calls during strong uptrend
        if self._sma_fast.current.value > self._sma_slow.current.value * 1.02:
            self.debug("Skipping covered call: SPY is in strong uptrend.")
            return

        # Buy/adjust SPY position to target allocation
        self.set_holdings(self._spy, self._target_weight)

        chain = self.option_chain(self._spy)
        if chain is None:
            return

        min_expiry = self.time.date() + timedelta(days=self._min_dte)
        max_expiry = self.time.date() + timedelta(days=self._max_dte)

        contracts = []

        for contract in chain:
            if contract.right != OptionRight.CALL:
                continue

            if contract.expiry.date() < min_expiry or contract.expiry.date() > max_expiry:
                continue

            # OTM calls only
            if contract.strike <= underlying_price * 1.01:
                continue

            bid = contract.bid_price
            ask = contract.ask_price

            if bid <= 0 or ask <= 0:
                continue

            if ask - bid > self._max_bid_ask_spread:
                continue

            if contract.open_interest < self._min_open_interest:
                continue

            contracts.append(contract)

        if not contracts:
            self.debug("No suitable covered call found.")
            return

        # Prefer 30-delta call if Greeks exist; otherwise choose nearest 2-5% OTM
        def contract_score(contract):
            delta_score = 10

            if contract.greeks is not None and contract.greeks.delta is not None:
                delta_score = abs(abs(contract.greeks.delta) - self._target_delta)

            moneyness_score = abs((contract.strike / underlying_price) - 1.03)

            return delta_score + moneyness_score

        selected = sorted(contracts, key=contract_score)[0]

        option_symbol = selected.symbol
        option_security = self.add_option_contract(option_symbol, Resolution.MINUTE)

        multiplier = option_security.contract_multiplier

        shares_held = self.portfolio[self._spy].quantity
        contracts_to_sell = math.floor(shares_held / multiplier)

        if contracts_to_sell <= 0:
            self.debug("Not enough SPY shares to sell covered call.")
            return

        quantity = -contracts_to_sell

        self.market_order(option_symbol, quantity)

        self._short_call = option_symbol
        self._short_call_entry_price = (selected.bid_price + selected.ask_price) / 2
        self._short_call_strike = selected.strike
        self._short_call_expiry = selected.expiry

        self.debug(
            f"Sold {abs(quantity)} covered call(s): "
            f"{option_symbol}, strike={selected.strike}, expiry={selected.expiry.date()}"
        )

    def manage_position(self):
        if self.is_warming_up:
            return

        if self._short_call is None:
            return

        if not self.portfolio[self._short_call].invested:
            self.reset_short_call_tracking()
            return

        if not self.securities.contains_key(self._short_call):
            return

        option_price = self.securities[self._short_call].price
        underlying_price = self.securities[self._spy].price

        if option_price <= 0 or self._short_call_entry_price is None:
            return

        # Take profit if option has lost 75% of its entry value
        profit_target_price = self._short_call_entry_price * (1 - self._profit_take_pct)

        if option_price <= profit_target_price:
            self.debug("Taking profit on short call.")
            self.liquidate(self._short_call, tag="Covered call profit target hit")
            self.reset_short_call_tracking()
            return

        # Roll/close if call is ITM near expiration
        days_to_expiry = (self._short_call_expiry.date() - self.time.date()).days

        if days_to_expiry <= 1 and underlying_price > self._short_call_strike:
            self.debug("Closing ITM short call near expiration.")
            self.liquidate(self._short_call, tag="Closing ITM covered call")
            self.reset_short_call_tracking()
            return

    def reset_short_call_tracking(self):
        self._short_call = None
        self._short_call_entry_price = None
        self._short_call_strike = None
        self._short_call_expiry = None