Overall Statistics
Total Orders
41
Average Win
0.14%
Average Loss
-0.36%
Compounding Annual Return
28.530%
Drawdown
4.800%
Expectancy
-0.102
Start Equity
100000
End Equity
115830.28
Net Profit
15.830%
Sharpe Ratio
1.658
Sortino Ratio
1.75
Probabilistic Sharpe Ratio
83.405%
Loss Rate
35%
Win Rate
65%
Profit-Loss Ratio
0.38
Alpha
-0.001
Beta
0.932
Annual Standard Deviation
0.084
Annual Variance
0.007
Information Ratio
-1.007
Tracking Error
0.011
Treynor Ratio
0.149
Total Fees
$41.00
Estimated Strategy Capacity
$890000.00
Lowest Capacity Asset
SPY YYI8Q1PWZ0YU|SPY R735QTJ8XC9X
Portfolio Turnover
0.50%
Drawdown Recovery
55
# region imports
from AlgorithmImports import *
from datetime import timedelta
import math
# endregion


class ImprovedCoveredCallAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2025, 6, 1)
        self.set_end_date(2025, 12, 31)
        self.set_cash(100_000)

        self.settings.seed_initial_prices = True
        seeder = FuncSecuritySeeder(self.get_last_known_prices)
        self.add_security_initializer(lambda s: seeder.seed_security(s))

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

        # Strategy parameters
        self._target_weight       = 1.0
        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   # buy back when 75% of premium captured
        self._stop_loss_mult      = 3.0    # buy back if call 3x'd in value (cap loss)

        # State
        self._short_call            = None
        self._short_call_entry_price = None
        self._short_call_strike     = None
        self._short_call_expiry     = None
        self._spy_fully_allocated   = False  # avoid redundant set_holdings calls

        # Trend filter: skip selling calls when SPY is in a downtrend
        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)

        self.schedule.on(
            self.date_rules.every(DayOfWeek.MONDAY),
            self.time_rules.after_market_open(self._spy, 5),
            self.sell_covered_call
        )

        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

        # Already have an open short call — don't stack
        if self._short_call is not None and self.portfolio[self._short_call].invested:
            return

        underlying_price = self.securities[self._spy].price
        if underlying_price <= 0:
            return

        # Trend filter: skip selling calls when SPY is BELOW its slow SMA
        # (downtrend — premium doesn't offset drawdown risk)
        if not self._sma_fast.is_ready or not self._sma_slow.is_ready:
            return

        if self._sma_fast.current.value < self._sma_slow.current.value * 0.99:
            self.debug("Skipping covered call: SPY in downtrend.")
            return

        # Buy SPY only if not already allocated (avoid repeated market orders)
        if not self._spy_fully_allocated:
            self.set_holdings(self._spy, self._target_weight)
            self._spy_fully_allocated = True

        # Pull option chain
        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
            exp = contract.expiry.date()
            if exp < min_expiry or exp > max_expiry:
                continue
            # OTM only — at least 1% above spot
            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

        def contract_score(c):
            # Prefer contracts closest to target delta; fall back to moneyness
            if c.greeks is not None and c.greeks.delta is not None:
                return abs(abs(c.greeks.delta) - self._target_delta)
            # Fallback: target ~3% OTM as a proxy for ~0.30 delta
            return abs((c.strike / underlying_price) - 1.03) * 10  # scaled to same range

        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

        self.market_order(option_symbol, -contracts_to_sell)

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

        self.debug(
            f"Sold {contracts_to_sell} covered call(s): "
            f"strike={selected.strike}, expiry={selected.expiry.date()}, mid={mid:.2f}"
        )

    # ------------------------------------------------------------------
    def manage_position(self):
        if self.is_warming_up or 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

        entry = self._short_call_entry_price

        # --- Profit take: option decayed to (1 - profit_take_pct) of entry ---
        if option_price <= entry * (1 - self._profit_take_pct):
            self.debug(f"Profit target hit: option at {option_price:.2f} vs entry {entry:.2f}")
            self.liquidate(self._short_call, tag="Profit target")
            self.reset_short_call_tracking()
            return

        # --- Stop loss: option has 3x'd — cap the pain ---
        if option_price >= entry * self._stop_loss_mult:
            self.debug(f"Stop loss hit: option at {option_price:.2f} vs entry {entry:.2f}")
            self.liquidate(self._short_call, tag="Stop loss")
            self.reset_short_call_tracking()
            return

        # --- Close ITM calls at expiration to avoid assignment ---
        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 call at expiration.")
            self.liquidate(self._short_call, tag="ITM at expiry")
            self.reset_short_call_tracking()
            return

        # --- Roll early: if DTE <= 2 and call is OTM, close and re-sell Monday ---
        if days_to_expiry <= 2 and underlying_price < self._short_call_strike:
            self.debug("Rolling OTM call early (low DTE).")
            self.liquidate(self._short_call, tag="Early roll OTM")
            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