Overall Statistics
Total Orders
291
Average Win
0.67%
Average Loss
-3.23%
Compounding Annual Return
5.820%
Drawdown
20.300%
Expectancy
-0.136
Start Equity
100000
End Equity
182793.95
Net Profit
82.794%
Sharpe Ratio
0.219
Sortino Ratio
0.158
Probabilistic Sharpe Ratio
0.101%
Loss Rate
28%
Win Rate
72%
Profit-Loss Ratio
0.21
Alpha
-0.022
Beta
0.428
Annual Standard Deviation
0.073
Annual Variance
0.005
Information Ratio
-0.781
Tracking Error
0.092
Treynor Ratio
0.037
Total Fees
$271.05
Estimated Strategy Capacity
$0
Lowest Capacity Asset
SPY 333UQSTAVNL46|SPY R735QTJ8XC9X
Portfolio Turnover
0.80%
Drawdown Recovery
348
# region imports
from AlgorithmImports import *
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Optional, List, Dict, Any
# endregion

PHASE_PUT_SELLING = "PUT_SELLING"
PHASE_COVERED_CALL = "COVERED_CALL"

MAX_IV_FOR_ENTRY = 0.45       # skip new put entries when ATM IV is extremely elevated
PROFIT_TAKE_FRACTION = 0.50   # buy back a short option at 50% of the premium received
ROLL_DTE = 21                 # roll OTM shorts at 21 DTE; ITM shorts ride to assignment
PUT_MIN_DTE = 25
PUT_MAX_DTE = 35
CALL_MIN_DTE = 20
CALL_MAX_DTE = 35
TARGET_PUT_DELTA = -0.30
TARGET_CALL_DELTA = 0.30
CASH_USAGE = 0.90             # fraction of cash reserved for cash-secured puts


@dataclass
class Campaign:
    start_date: Optional[date] = None
    premiums: float = 0.0
    share_cost_basis: float = 0.0
    share_count: int = 0


@dataclass
class ShortLeg:
    symbol: Symbol
    entry_premium: float   # per-contract premium at sale (reference price)
    contracts: int
    strike: float
    expiry: datetime


class WheelSpyAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2016, 1, 1)
        self.set_cash(100000)

        self._spy = self.add_equity("SPY", Resolution.DAILY).symbol
        option = self.add_option("SPY", Resolution.DAILY)
        option.set_filter(lambda u: u.strikes(-20, 20).expiration(timedelta(0), timedelta(45)))
        self._option_symbol = option.symbol

        self._phase = PHASE_PUT_SELLING
        self._campaign = Campaign()
        self._history: List[Dict[str, Any]] = []
        self._short_leg: Optional[ShortLeg] = None
        self._total_premiums = 0.0

    # ------------------------------------------------------------------ data

    def on_data(self, data: Slice):
        if self._option_symbol not in data.option_chains:
            return
        chain = data.option_chains[self._option_symbol]

        self._settle_leg()

        if self._short_leg is not None:
            self._manage_leg(chain)
        elif self._phase == PHASE_PUT_SELLING:
            self._try_sell_put(chain)
        elif self._phase == PHASE_COVERED_CALL:
            self._try_sell_covered_call(chain)

        self.plot("Wheel", "Portfolio Value", self.portfolio.total_portfolio_value)
        self.plot("Wheel", "Total Premiums", self._total_premiums)

    def on_order_event(self, order_event: OrderEvent):
        if order_event.status != OrderStatus.FILLED:
            return
        if not order_event.is_assignment:
            return
        leg = self._short_leg
        if leg is None or order_event.symbol != leg.symbol:
            return
        self._process_assignment(leg)

    def on_end_of_algorithm(self):
        self.log(f"Completed campaigns: {len(self._history)}")
        for i, record in enumerate(self._history, start=1):
            self.log(f"Campaign {i}: {record}")
        self.log(f"Total premiums collected (all campaigns): {self._total_premiums:.2f}")

    # -------------------------------------------------------------- position

    def _settle_leg(self):
        """Handle a tracked short leg whose position reached zero (expiry)."""
        leg = self._short_leg
        if leg is None:
            return
        if self.portfolio[leg.symbol].quantity != 0:
            return
        dte = (leg.expiry.date() - self.time.date()).days
        if dte > 0:
            # Flat before expiry (e.g. a rejected closing order): drop tracking.
            self.log(f"Leg {leg.symbol} flat before expiry; clearing tracking")
            self._short_leg = None
            return
        spot = self.securities[self._spy].price
        right = leg.symbol.id.option_right
        itm = spot < leg.strike if right == OptionRight.PUT else spot > leg.strike
        if itm:
            self._process_assignment(leg)
        else:
            self.log(
                f"Short {right} {leg.strike} expired worthless; "
                f"premium kept {leg.entry_premium * 100 * leg.contracts:.2f}"
            )
            self._realize_premium(leg, leg.entry_premium)
            self._short_leg = None

    def _manage_leg(self, chain):
        leg = self._short_leg
        if self.portfolio[leg.symbol].quantity <= 0:
            return
        contract = next((c for c in chain if c.symbol == leg.symbol), None)
        if contract is None:
            return
        dte = (leg.expiry.date() - self.time.date()).days
        if dte <= 0:
            # Expiry day: let assignment / worthless-expiry handling take over.
            return
        price = contract.close
        if price <= 0:
            price = (contract.bid_price + contract.ask_price) / 2
        if price <= 0:
            return
        right = leg.symbol.id.option_right
        if price <= PROFIT_TAKE_FRACTION * leg.entry_premium:
            self._buy_back(leg, price, "profit take at 50% of premium")
            return
        if dte <= ROLL_DTE:
            spot = self.securities[self._spy].price
            otm = spot > leg.strike if right == OptionRight.PUT else spot < leg.strike
            if otm:
                self._buy_back(leg, price, f"roll at {dte} DTE")
                # A new leg is sold on the next daily decision to avoid
                # simultaneous pending orders competing for buying power.

    def _buy_back(self, leg: ShortLeg, price: float, reason: str):
        realized = (leg.entry_premium - price) * 100 * leg.contracts
        self.market_order(leg.symbol, leg.contracts, tag=f"Buy to close: {reason}")
        self._realize_premium(leg, leg.entry_premium - price)
        self.log(f"Bought back {leg.symbol} ({reason}) at {price:.2f}; realized {realized:.2f}")
        self._short_leg = None

    # ------------------------------------------------------------ new entries

    def _try_sell_put(self, chain):
        spot = self.securities[self._spy].price
        if spot <= 0:
            return
        if not self._iv_ok(chain, spot):
            self.log(f"Put entry skipped: ATM IV above {MAX_IV_FOR_ENTRY}")
            return
        candidates = [
            c for c in chain
            if c.symbol.id.option_right == OptionRight.PUT
            and PUT_MIN_DTE <= (c.expiry.date() - self.time.date()).days <= PUT_MAX_DTE
        ]
        if not candidates:
            return
        expiry = min(c.expiry for c in candidates)
        same_expiry = [c for c in candidates if c.expiry == expiry]
        target = self._pick_by_delta(same_expiry, TARGET_PUT_DELTA, spot * 0.95)
        strike = target.symbol.id.strike_price
        contracts = int(self.portfolio.cash * CASH_USAGE / (strike * 100))
        if contracts < 1:
            return
        self.market_order(target.symbol, -contracts, tag="Sell cash-secured put")
        if self._campaign.start_date is None:
            self._campaign.start_date = self.time.date()
        self._short_leg = ShortLeg(target.symbol, target.close, contracts, strike, target.expiry)
        self.log(
            f"Sold {contracts} SPY {strike} put exp {target.expiry.date()} "
            f"at {target.close:.2f} (spot {spot:.2f})"
        )

    def _try_sell_covered_call(self, chain):
        shares = self.portfolio[self._spy].quantity
        contracts = int(shares // 100)
        if contracts < 1:
            return
        spot = self.securities[self._spy].price
        candidates = [
            c for c in chain
            if c.symbol.id.option_right == OptionRight.CALL
            and CALL_MIN_DTE <= (c.expiry.date() - self.time.date()).days <= CALL_MAX_DTE
        ]
        if not candidates:
            return
        expiry = min(c.expiry for c in candidates)
        same_expiry = [c for c in candidates if c.expiry == expiry]
        target = self._pick_by_delta(same_expiry, TARGET_CALL_DELTA, spot * 1.05)
        self.market_order(target.symbol, -contracts, tag="Sell covered call")
        self._short_leg = ShortLeg(
            target.symbol, target.close, contracts, target.symbol.id.strike_price, target.expiry
        )
        self.log(
            f"Sold {contracts} SPY {target.symbol.id.strike_price} call exp {target.expiry.date()} "
            f"at {target.close:.2f} (spot {spot:.2f})"
        )

    # ------------------------------------------------------------- assignment

    def _process_assignment(self, leg: ShortLeg):
        right = leg.symbol.id.option_right
        if right == OptionRight.PUT and self._phase == PHASE_PUT_SELLING:
            self._realize_premium(leg, leg.entry_premium)
            self._campaign.share_cost_basis = leg.strike * 100 * leg.contracts
            self._campaign.share_count = leg.contracts * 100
            self._phase = PHASE_COVERED_CALL
            self.log(
                f"PUT assigned at {leg.strike}: holding {self._campaign.share_count} shares, "
                f"cost basis {self._campaign.share_cost_basis:.2f}"
            )
        elif right == OptionRight.CALL and self._phase == PHASE_COVERED_CALL:
            self._realize_premium(leg, leg.entry_premium)
            self._complete_campaign("called away", leg.strike)
        self._short_leg = None

    def _complete_campaign(self, outcome: str, call_strike: Optional[float] = None):
        share_pnl = 0.0
        if call_strike is not None:
            share_pnl = call_strike * 100 * (self._campaign.share_count // 100) \
                - self._campaign.share_cost_basis
        record = {
            "start": self._campaign.start_date,
            "end": self.time.date(),
            "premiums": round(self._campaign.premiums, 2),
            "share_cost_basis": round(self._campaign.share_cost_basis, 2),
            "share_pnl": round(share_pnl, 2),
            "total_pnl": round(self._campaign.premiums + share_pnl, 2),
            "outcome": outcome,
        }
        self._history.append(record)
        self.log(f"Campaign complete ({outcome}): {record}")
        self._campaign = Campaign()
        self._phase = PHASE_PUT_SELLING

    # ---------------------------------------------------------------- helpers

    def _realize_premium(self, leg: ShortLeg, per_contract_premium: float):
        amount = per_contract_premium * 100 * leg.contracts
        self._campaign.premiums += amount
        self._total_premiums += amount

    def _iv_ok(self, chain, spot: float) -> bool:
        ivs = [
            c.implied_volatility for c in chain
            if abs(c.symbol.id.strike_price - spot) <= 10 and c.implied_volatility > 0
        ]
        if not ivs:
            return True
        return (sum(ivs) / len(ivs)) <= MAX_IV_FOR_ENTRY

    def _pick_by_delta(self, contracts, target_delta: float, fallback_strike: float):
        def delta_of(c):
            greeks = c.greeks
            return greeks.delta if greeks is not None else 0.0

        with_delta = [c for c in contracts if delta_of(c) != 0.0]
        if with_delta:
            return min(with_delta, key=lambda c: abs(delta_of(c) - target_delta))
        return min(contracts, key=lambda c: abs(c.symbol.id.strike_price - fallback_strike))