Overall Statistics
Total Orders
104
Average Win
12.88%
Average Loss
-10.39%
Compounding Annual Return
19.326%
Drawdown
47.600%
Expectancy
0.292
Start Equity
100000
End Equity
285887.4
Net Profit
185.887%
Sharpe Ratio
0.484
Sortino Ratio
0.549
Probabilistic Sharpe Ratio
8.001%
Loss Rate
42%
Win Rate
58%
Profit-Loss Ratio
1.24
Alpha
0.08
Beta
1.056
Annual Standard Deviation
0.356
Annual Variance
0.127
Information Ratio
0.278
Tracking Error
0.306
Treynor Ratio
0.163
Total Fees
$2577.60
Estimated Strategy Capacity
$1000.00
Lowest Capacity Asset
TQQQ YYFADOHY9B2E|TQQQ UK280CGTCB51
Portfolio Turnover
0.74%
Drawdown Recovery
598
# region imports
from AlgorithmImports import *
import numpy as np
from datetime import timedelta
# endregion


class MassiveCompoundingPAOptions(QCAlgorithm):
    """
    PA Options Strategy — Max Compounding

    Long-only deep ITM SPY call strategy:
    - Enter when 15D annualized HV >= 10% and 20D momentum > 0
    - Buy ~5% ITM calls with expiry just beyond the 35-day hold period
    - Use sequential, non-overlapping trades
    - Allocate 100% of portfolio value to each trade (premium budget)
    """

    def Initialize(self):
        self.SetStartDate(2020, 1, 1)
        self.SetEndDate(2026, 3, 1)
        self.SetCash(100000)

        # -----------------------------
        # PARAMETERS
        # -----------------------------
        self.ticker = "TQQQ"
        self.hv_lookback = 15
        self.mom_lookback = 20
        self.holding_period = 35
        self.target_moneyness = 0.95      # 5% ITM call
        self.hv_threshold = 0.10
        self.risk_per_trade = 0.15       # 100% premium budget
        self.max_open_positions = 1       # sequential only

        # execution guards
        self.min_dte = self.holding_period
        self.max_dte = self.holding_period + 25
        self.min_open_interest = 10
        self.max_bid_ask_spread_pct = 0.10

        # -----------------------------
        # UNDERLYING
        # -----------------------------
        equity = self.AddEquity(self.ticker, Resolution.Minute)
        equity.SetDataNormalizationMode(DataNormalizationMode.Raw)
        self.symbol = equity.Symbol

        # -----------------------------
        # OPTIONS
        # -----------------------------
        option = self.AddOption(self.ticker, Resolution.Minute)
        option.SetFilter(self.OptionFilter)
        self.option_symbol = option.Symbol  # canonical symbol

        # -----------------------------
        # DAILY CLOSE WINDOW
        # -----------------------------
        self.daily_close_window = RollingWindow[float](max(self.hv_lookback + 1, self.mom_lookback + 1))

        history = self.History(self.symbol, max(self.hv_lookback + 5, self.mom_lookback + 5), Resolution.Daily)
        if not history.empty:
            for _, row in history.loc[self.symbol].iterrows():
                self.daily_close_window.Add(float(row["close"]))

        self.consolidator = TradeBarConsolidator(timedelta(days=1))
        self.consolidator.DataConsolidated += self.OnDailyBar
        self.SubscriptionManager.AddConsolidator(self.symbol, self.consolidator)

        # -----------------------------
        # STATE
        # -----------------------------
        self.latest_chain = None
        self.active_positions = {}   # option_symbol -> entry_time

        self.SetWarmUp(max(self.hv_lookback + 1, self.mom_lookback + 1), Resolution.Daily)

        self.Schedule.On(
            self.DateRules.EveryDay(self.symbol),
            self.TimeRules.AfterMarketOpen(self.symbol, 30),
            self.TradeLogic
        )

    def OptionFilter(self, universe):
        return (
            universe.IncludeWeeklys()
                    .CallsOnly()
                    .Strikes(-15, -2)
                    .Expiration(timedelta(days=self.min_dte), timedelta(days=self.max_dte))
        )

    def OnDailyBar(self, sender, bar):
        self.daily_close_window.Add(float(bar.Close))

    def OnData(self, slice: Slice):
        chain = slice.OptionChains.get(self.option_symbol)
        if chain:
            self.latest_chain = chain

    def TradeLogic(self):
        if self.IsWarmingUp:
            return

        if self.daily_close_window.Count < max(self.hv_lookback + 1, self.mom_lookback + 1):
            return

        current_price = self.Securities[self.symbol].Price
        if current_price <= 0:
            return

        # 1) exits first
        self.ManageExits()

        # enforce non-overlapping trades
        if len(self.active_positions) >= self.max_open_positions:
            return

        # 2) compute features
        hv_annual = self.ComputeHistoricalVolatility()
        momentum = self.ComputeMomentum()

        if hv_annual is None or momentum is None:
            return

        # 3) entry
        if hv_annual >= self.hv_threshold and momentum > 0:
            self.EnterPosition(current_price, hv_annual, momentum)

    def ManageExits(self):
        to_close = []

        for opt_symbol, entry_time in list(self.active_positions.items()):
            days_held = (self.Time.date() - entry_time.date()).days
            expiry_date = opt_symbol.ID.Date.date()

            if days_held >= self.holding_period:
                to_close.append((opt_symbol, "Holding period expired"))
            elif expiry_date <= (self.Time + timedelta(days=2)).date():
                to_close.append((opt_symbol, "Near expiration"))

        for opt_symbol, reason in to_close:
            if self.Portfolio[opt_symbol].Invested:
                self.Liquidate(opt_symbol, reason)
            if opt_symbol in self.active_positions:
                del self.active_positions[opt_symbol]

    def ComputeHistoricalVolatility(self):
        """
        Annualized HV from daily log returns over hv_lookback days.
        """
        closes = [self.daily_close_window[i] for i in range(self.hv_lookback, -1, -1)]
        if len(closes) < self.hv_lookback + 1:
            return None

        returns = []
        for i in range(1, len(closes)):
            prev_close = closes[i - 1]
            curr_close = closes[i]
            if prev_close <= 0 or curr_close <= 0:
                return None
            returns.append(np.log(curr_close / prev_close))

        if len(returns) < self.hv_lookback:
            return None

        daily_std = float(np.std(returns, ddof=1))
        return daily_std * np.sqrt(252)

    def ComputeMomentum(self):
        """
        20-day ROC.
        """
        if self.daily_close_window.Count <= self.mom_lookback:
            return None

        current_close = self.daily_close_window[0]
        past_close = self.daily_close_window[self.mom_lookback]

        if past_close <= 0:
            return None

        return (current_close / past_close) - 1.0

    def EnterPosition(self, current_price, hv_annual, momentum):
        if self.latest_chain is None:
            return

        contracts = list(self.latest_chain)
        if not contracts:
            return

        target_strike = current_price * self.target_moneyness

        filtered = []
        for contract in contracts:
            if contract.Right != OptionRight.Call:
                continue

            dte = (contract.Expiry.date() - self.Time.date()).days
            if dte < self.min_dte or dte > self.max_dte:
                continue

            bid = float(contract.BidPrice) if contract.BidPrice is not None else 0.0
            ask = float(contract.AskPrice) if contract.AskPrice is not None else 0.0
            if bid <= 0 or ask <= 0 or ask < bid:
                continue

            mid = 0.5 * (bid + ask)
            if mid <= 0:
                continue

            spread_pct = (ask - bid) / mid
            if spread_pct > self.max_bid_ask_spread_pct:
                continue

            if hasattr(contract, "OpenInterest") and contract.OpenInterest is not None:
                if contract.OpenInterest < self.min_open_interest:
                    continue

            if contract.Symbol in self.active_positions:
                continue

            filtered.append(contract)

        if not filtered:
            return

        # closest to 5% ITM strike, then earliest valid expiry
        filtered.sort(key=lambda c: (abs(c.Strike - target_strike), c.Expiry))
        best_contract = filtered[0]
        contract_symbol = best_contract.Symbol

        if contract_symbol not in self.Securities:
            self.AddOptionContract(contract_symbol, Resolution.Minute)

        security = self.Securities[contract_symbol]
        multiplier = float(security.SymbolProperties.ContractMultiplier)

        ask_price = float(best_contract.AskPrice)
        cost_per_contract = ask_price * multiplier
        if cost_per_contract <= 0:
            return

        cash_to_invest = self.Portfolio.TotalPortfolioValue * self.risk_per_trade
        quantity = int(cash_to_invest / cost_per_contract)

        # stay within available cash
        max_affordable = int(self.Portfolio.Cash / cost_per_contract)
        quantity = min(quantity, max_affordable)

        if quantity <= 0:
            return

        ticket = self.MarketOrder(contract_symbol, quantity, tag="Massive Compounding Entry")
        if ticket is not None:
            self.active_positions[contract_symbol] = self.Time
            self.Debug(
                f"ENTRY | {quantity}x {contract_symbol.Value} | "
                f"Ask={ask_price:.2f} Spot={current_price:.2f} "
                f"HV={hv_annual:.2%} Mom={momentum:.2%} "
                f"Portfolio={self.Portfolio.TotalPortfolioValue:,.2f}"
            )

    def OnOrderEvent(self, orderEvent: OrderEvent):
        if orderEvent.Status == OrderStatus.Filled:
            self.Debug(
                f"FILLED | {orderEvent.Symbol.Value} | "
                f"Qty={orderEvent.FillQuantity} Fill={orderEvent.FillPrice:.2f}"
            )

    def OnSecuritiesChanged(self, changes: SecurityChanges):
        for removed in changes.RemovedSecurities:
            symbol = removed.Symbol
            if symbol in self.active_positions and not self.Portfolio[symbol].Invested:
                del self.active_positions[symbol]

    def OnEndOfAlgorithm(self):
        self.Debug(f"Final Portfolio Value: {self.Portfolio.TotalPortfolioValue:.2f}")