Overall Statistics
Total Orders
670
Average Win
0.33%
Average Loss
-0.27%
Compounding Annual Return
-2.388%
Drawdown
5.600%
Expectancy
-0.065
Start Equity
100000
End Equity
94517.44
Net Profit
-5.483%
Sharpe Ratio
-4.049
Sortino Ratio
-5.907
Probabilistic Sharpe Ratio
0.203%
Loss Rate
58%
Win Rate
42%
Profit-Loss Ratio
1.21
Alpha
-0.067
Beta
-0.048
Annual Standard Deviation
0.018
Annual Variance
0
Information Ratio
-1.187
Tracking Error
0.141
Treynor Ratio
1.49
Total Fees
$670.00
Estimated Strategy Capacity
$840000.00
Lowest Capacity Asset
SPY 330ZCUZJF6LGM|SPY R735QTJ8XC9X
Portfolio Turnover
4.85%
Drawdown Recovery
441
# region imports
from AlgorithmImports import *
# endregion


class ContractData:

    def __init__(self, symbol: Symbol, right: OptionRight, expiry, underlying: Symbol):
        self.symbol = symbol
        self.right = right
        self.expiry = expiry
        self.underlying = underlying


class StraddleSelector:

    def __init__(self, min_dte: int, max_dte: int, target_dte: int):
        self._min_dte = min_dte
        self._max_dte = max_dte
        self._target_dte = target_dte

    def select(self, chain, underlying_price, today):
        valid_contracts = [x for x in chain if self._min_dte <= (x.id.date.date() - today).days <= self._max_dte]
        if not valid_contracts:
            return None
        target_expiry = sorted(set(x.id.date for x in valid_contracts), key=lambda d: abs((d.date() - today).days - self._target_dte))[0]
        valid_contracts = [x for x in valid_contracts if x.id.date == target_expiry]
        if underlying_price == 0:
            return None
        atm_strike = min(set(x.id.strike_price for x in valid_contracts), key=lambda s: abs(s - underlying_price))
        calls_list = [x for x in valid_contracts if x.id.strike_price == atm_strike and x.id.option_right == OptionRight.CALL]
        atm_call = calls_list[0] if calls_list else None
        puts_list = [x for x in valid_contracts if x.id.strike_price == atm_strike and x.id.option_right == OptionRight.PUT]
        atm_put = puts_list[0] if puts_list else None
        if not atm_call or not atm_put:
            return None
        return atm_call, atm_put, target_expiry, atm_strike


class DeltaHedger:

    def __init__(self, threshold: float):
        self._threshold = threshold

    def compute_shares(self, algorithm, contracts, current_slice, underlying):
        total_delta = 0.0
        for cd in contracts:
            holding = algorithm.portfolio[cd.symbol]
            if not holding.invested:
                continue
            chain = current_slice.option_chains.get(cd.symbol.canonical)
            if chain is None:
                continue
            contract = chain.contracts.get(cd.symbol)
            if contract is None:
                continue
            total_delta += contract.greeks.delta * holding.quantity * 100
        if abs(total_delta) < self._threshold:
            return 0, None
        shares_trade = -round(total_delta) - int(algorithm.portfolio[underlying].quantity)
        return shares_trade, total_delta
# region imports
from AlgorithmImports import *
from helpers import ContractData, StraddleSelector, DeltaHedger
# endregion


class OptimizedLongGammaStrategy(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2024, 1, 1)
        self.set_end_date(2026, 5, 1)
        self.set_cash(100000)
        self.settings.seed_initial_prices = True
        self._contracts: list[ContractData] = []
        self._straddle_entry_cost = 0.0
        self._total_straddles = 0
        self._total_hedges = 0
        self._hedge_order_ids: set[int] = set()
        self._entry_filled = False
        self._entry_date = None
        self._trailing_stop_activated = False
        self._peak_portfolio_value = 100000.0
        self._circuit_breaker_until = None
        self._straddle_selector = StraddleSelector(14, 30, 21)
        self._delta_hedger = DeltaHedger(15)
        self._current_underlying = None
        self._underlying_symbols: list[Symbol] = []
        # Daily close windows for realized vol — consolidate to daily
        # regardless of subscription resolution to guarantee correct annualization
        self._rv_windows: dict = {}
        for ticker in ["SPY", "QQQ"]:
            equity = self.add_equity(ticker, Resolution.MINUTE, data_normalization_mode=DataNormalizationMode.RAW)
            self._rv_windows[equity.symbol] = []
            self.consolidate(equity.symbol, Resolution.DAILY, self._on_equity_daily_bar)
            self._underlying_symbols.append(equity.symbol)
        self._vix = self.add_index("VIX", Resolution.MINUTE)
        # Daily consolidator keeps a proper 510-day lookback at MINUTE subscription
        # — mirrors the _rv_windows pattern for SPY/QQQ
        self._vix_closes: list = []
        self.consolidate(self._vix, Resolution.DAILY, self._on_vix_daily_bar)
        self._underlying_index = 0
        self.schedule.on(
            self.date_rules.every(DayOfWeek.MONDAY),
            self.time_rules.after_market_open(self._underlying_symbols[0], 30),
            self._maybe_enter_straddle
        )
        self.schedule.on(
            self.date_rules.every_day(self._underlying_symbols[0]),
            self.time_rules.after_market_open(self._underlying_symbols[0], 120),
            self._hedge_delta
        )
        self.schedule.on(
            self.date_rules.every_day(self._underlying_symbols[0]),
            self.time_rules.before_market_close(self._underlying_symbols[0], 60),
            self._hedge_delta
        )
        self.set_warm_up(510, Resolution.DAILY)

    def _on_equity_daily_bar(self, bar) -> None:
        window = self._rv_windows.get(bar.symbol)
        if window is None:
            return
        window.append(bar.close)
        if len(window) > 25:
            window.pop(0)

    def _on_vix_daily_bar(self, bar) -> None:
        self._vix_closes.append(bar.close)
        if len(self._vix_closes) > 510:
            self._vix_closes.pop(0)

    def on_data(self, data: Slice) -> None:
        if self.is_warming_up:
            return
        current_value = self.portfolio.total_portfolio_value
        if current_value > self._peak_portfolio_value:
            self._peak_portfolio_value = current_value
        drawdown = (self._peak_portfolio_value - current_value) / self._peak_portfolio_value
        if drawdown >= 0.05 and self._circuit_breaker_until is None:
            self._circuit_breaker_until = self.time + timedelta(days=5)
            self.log(f"[CIRCUIT BREAKER] Drawdown={drawdown:.2%}, pausing until {self._circuit_breaker_until}")
            self._liquidate_options()
        if not self._contracts:
            return
        if not self._entry_filled:
            if sum(1 for cd in self._contracts if self.portfolio[cd.symbol].invested) == len(self._contracts):
                self._entry_filled = True
                self._straddle_entry_cost = abs(sum(
                    self.portfolio[cd.symbol].holdings_value
                    for cd in self._contracts
                ))
                self.log(f"[FILLED] Entry cost=${self._straddle_entry_cost:.2f}")
            return
        if self._straddle_entry_cost != 0:
            # Exit at 7 DTE to avoid theta cliff; prevents expiry-to-zero losses
            dte = (self._contracts[0].expiry.date() - self.time.date()).days
            if dte <= 7:
                self.log(f"[DTE EXIT] {dte} DTE remaining, closing to avoid theta cliff")
                self._liquidate_options()
                return
            limits_current_value = abs(sum(
                self.portfolio[cd.symbol].holdings_value
                for cd in self._contracts
                if self.portfolio[cd.symbol].invested
            ))
            gain_multiple = limits_current_value / self._straddle_entry_cost
            if gain_multiple >= 2.0:
                self._trailing_stop_activated = True
            liquidated = False
            if self._trailing_stop_activated:
                floor_value = self._straddle_entry_cost * 1.5
                if limits_current_value < floor_value:
                    self.log(f"[TRAILING STOP] Value=${limits_current_value:.2f} fell below floor=${floor_value:.2f} (peak was {gain_multiple:.2f}x)")
                    self._liquidate_options()
                    liquidated = True
            # Tightened stop: 75% (was 70%) — cuts losses 5% sooner
            if not liquidated and limits_current_value < self._straddle_entry_cost * 0.75:
                self.log(f"[STOP LOSS] Value=${limits_current_value:.2f} vs Entry=${self._straddle_entry_cost:.2f}")
                self._liquidate_options()

    def _vix_percentile(self) -> float:
        if len(self._vix_closes) < 100:
            return 50.0
        current_vix = self._vix_closes[-1]
        return (sum(1 for v in self._vix_closes if v < current_vix) / len(self._vix_closes)) * 100

    def _realized_vol(self, underlying: Symbol) -> float:
        window = self._rv_windows.get(underlying)
        if window is None or len(window) < 22:
            return 0.0
        closes = window[-22:]
        returns = [math.log(closes[i] / closes[i - 1]) for i in range(1, len(closes))]
        if len(returns) < 20:
            return 0.0
        mean_r = sum(returns) / len(returns)
        variance = sum((r - mean_r) ** 2 for r in returns) / (len(returns) - 1)
        return math.sqrt(variance * 252) * 100

    def _maybe_enter_straddle(self) -> None:
        if self.is_warming_up or self._contracts:
            return
        if self._circuit_breaker_until and self.time < self._circuit_breaker_until:
            return
        vix_price = float(self.securities[self._vix].price) if self.securities[self._vix].has_data else 20.0
        if vix_price < 12 or vix_price > 35:
            self.log(f"[SKIP] VIX={vix_price:.2f} outside 12-35")
            return
        self._underlying_index = (self._underlying_index + 1) % len(self._underlying_symbols)
        underlying = self._underlying_symbols[self._underlying_index]
        realized_vol = self._realized_vol(underlying)
        if realized_vol > 0 and realized_vol < vix_price * 0.7:
            self.log(f"[SKIP] RVol={realized_vol:.1f}% < 70% VIX={vix_price:.1f}%")
            return
        self._liquidate_options()
        today = self.time.date()
        underlying_price = self.securities[underlying].price
        if underlying_price == 0:
            return
        chain = self.option_chain(underlying)
        res = self._straddle_selector.select(chain, underlying_price, today)
        if not res:
            self.log("[SKIP] No contracts in 14-30 DTE range")
            return
        atm_call, atm_put, target_expiry, atm_strike = res
        call_symbol = self.add_option_contract(atm_call).symbol
        put_symbol = self.add_option_contract(atm_put).symbol
        self._contracts = [
            ContractData(call_symbol, OptionRight.CALL, target_expiry, underlying),
            ContractData(put_symbol, OptionRight.PUT, target_expiry, underlying),
        ]
        self._current_underlying = underlying
        self._entry_filled = False
        self._straddle_entry_cost = 0.0
        self._entry_date = self.time.date()
        self._trailing_stop_activated = False
        straddle_price = self.securities[call_symbol].price + self.securities[put_symbol].price
        if straddle_price == 0:
            return
        num_contracts = min(max(1, int((self.portfolio.total_portfolio_value * 0.02) / (straddle_price * 100))), 10)
        if self.is_market_open(underlying):
            self.market_order(call_symbol, num_contracts)
            self.market_order(put_symbol, num_contracts)
            self._total_straddles += 1
            self.log(
                f"[ENTRY #{self._total_straddles}] {underlying} K={atm_strike} "
                f"Price={underlying_price:.2f} VIX={vix_price:.1f} "
                f"RVol={realized_vol:.1f}% DTE={(target_expiry.date() - today).days} "
                f"Qty={num_contracts} Cost={straddle_price * 100 * num_contracts:.0f}"
            )

    def _hedge_delta(self) -> None:
        if not self._contracts or not self._entry_filled:
            return
        underlying = self._current_underlying
        if underlying is None:
            return
        shares_trade, total_delta = self._delta_hedger.compute_shares(self, self._contracts, self.current_slice, underlying)
        if total_delta is None:
            return
        if shares_trade != 0 and self.is_market_open(underlying):
            self._hedge_order_ids.add(self.market_order(underlying, shares_trade, tag="Delta hedge").order_id)
            self._total_hedges += 1
            self.log(
                f"[HEDGE #{self._total_hedges}] Delta={total_delta:.1f} "
                f"Traded {shares_trade:+d} {underlying}"
            )

    def on_order_event(self, order_event: OrderEvent) -> None:
        if (order_event.symbol.security_type != SecurityType.EQUITY
                or order_event.status != OrderStatus.FILLED):
            return
        if order_event.order_id in self._hedge_order_ids:
            return
        # In backtesting, market orders fill synchronously inside market_order(), so
        # on_order_event fires before the returned order_id is added to _hedge_order_ids.
        # Fall back to tag check to handle this race condition.
        order = self.transactions.get_order_by_id(order_event.order_id)
        if order is not None and order.tag == "Delta hedge":
            self._hedge_order_ids.add(order_event.order_id)
            return
        self.liquidate(order_event.symbol, tag="Assignment liquidation")

    def _liquidate_options(self) -> None:
        for cd in self._contracts:
            if self.portfolio[cd.symbol].invested:
                self.liquidate(cd.symbol)
        for underlying in self._underlying_symbols:
            if self.portfolio[underlying].invested:
                self.liquidate(underlying)
        self._contracts = []
        self._straddle_entry_cost = 0.0
        self._entry_filled = False
        self._entry_date = None
        self._current_underlying = None
        self._trailing_stop_activated = False

    def on_end_of_algorithm(self) -> None:
        final = self.portfolio.total_portfolio_value
        self.log(
            f"\n{'='*50}\n"
            f"  Final Value  : ${final:,.2f}\n"
            f"  Total Return : {(final - 100000) / 100000 * 100:+.2f}%\n"
            f"  Straddles    : {self._total_straddles}\n"
            f"  Hedges       : {self._total_hedges}\n"
            f"{'='*50}"
        )