| 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}")