Overall Statistics
Total Orders
280
Average Win
1.96%
Average Loss
-1.13%
Compounding Annual Return
8.757%
Drawdown
18.700%
Expectancy
1.071
Start Equity
100000
End Equity
535698.31
Net Profit
435.698%
Sharpe Ratio
0.44
Sortino Ratio
0.454
Probabilistic Sharpe Ratio
3.685%
Loss Rate
24%
Win Rate
76%
Profit-Loss Ratio
1.74
Alpha
0.016
Beta
0.396
Annual Standard Deviation
0.094
Annual Variance
0.009
Information Ratio
-0.201
Tracking Error
0.119
Treynor Ratio
0.105
Total Fees
$821.50
Estimated Strategy Capacity
$2300000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
1.00%
Drawdown Recovery
713
from AlgorithmImports import *


class ExecutionGuard:
    """
    Avoid tiny trades and force MarketOnOpen to prevent QC daily-market-order warnings.
    """
    def __init__(self, minOrderValue: float = 2000, minWeightChange: float = 0.01) -> None:
        self.minOrderValue = minOrderValue
        self.minWeightChange = minWeightChange

    def ApplyTargets(self, algo: QCAlgorithm, targets: dict) -> None:
        """
        targets: dict[Symbol] -> target portfolio weight
        """
        total = float(algo.Portfolio.TotalPortfolioValue)
        if total <= 0:
            return

        # Liquidate anything not in targets
        keep = set(targets.keys())
        for kvp in algo.Portfolio:
            sym = kvp.Key
            if kvp.Value.Invested and sym not in keep:
                algo.Liquidate(sym)

        # Place changes with guards
        for sym, w in targets.items():
            if not algo.Securities.ContainsKey(sym):
                continue
            sec = algo.Securities[sym]
            if not sec.IsTradable or sec.Price is None or sec.Price <= 0:
                continue

            curWeight = float(algo.Portfolio[sym].HoldingsValue) / total if algo.Portfolio[sym].Invested else 0.0
            if abs(w - curWeight) < self.minWeightChange:
                continue

            targetValue = total * w
            if abs(targetValue - algo.Portfolio[sym].HoldingsValue) < self.minOrderValue:
                continue

            # Use MarketOnOpen explicitly for daily data
            algo.SetHoldings(sym, w, tag="MOO rebal")
# region imports
from AlgorithmImports import *
# endregion

import math
from execution import ExecutionGuard


class RobustCoreDefensiveOverlay(QCAlgorithm):
    """
    Core: SPY
    Defensive assets: SHY + GLD (stable crisis ballast)
    Optional tilt: XLE + ITA only in high-stress (rare)
    Regime:
      - Risk-on: SPY above 200SMA -> hold mostly SPY, vol-targeted
      - Risk-off: SPY below 200SMA -> hold defensive basket
    Rebalance monthly at market open using MOO behavior
    """

    def Initialize(self) -> None:
        self.SetStartDate(2006, 1, 1)
        self.SetCash(100000)

        self.UniverseSettings.Resolution = Resolution.DAILY
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.ADJUSTED

        # Assets
        self.spy = self.AddEquity("SPY", Resolution.DAILY).Symbol
        self.shy = self.AddEquity("SHY", Resolution.DAILY).Symbol   # 1-3y Treasuries
        self.gld = self.AddEquity("GLD", Resolution.DAILY).Symbol   # Gold
        self.xle = self.AddEquity("XLE", Resolution.DAILY).Symbol   # Energy
        self.ita = self.AddEquity("ITA", Resolution.DAILY).Symbol   # Defense

        # Indicators
        self.spySma200 = self.SMA(self.spy, 200, Resolution.DAILY)

        # Risk + sizing
        self.targetAnnVolRiskOn = 0.12
        self.targetAnnVolRiskOff = 0.06
        self.volLookback = 20

        # Stress tilt trigger (optional, rare)
        self.energySpikeLookback = 20
        self.energySpikeThreshold = 0.10  # +10% over 20d
        self.volHighThreshold = 0.020     # daily vol

        # Weights (base, before vol targeting)
        self.baseRiskOnSpy = 1.00
        self.baseRiskOff = {self.shy: 0.70, self.gld: 0.30}

        # In stress-on within risk-on, add small tilt
        self.stressTilt = {self.xle: 0.07, self.ita: 0.07}  # taken out of SPY

        # Execution guard to prevent tiny trades
        self.exec = ExecutionGuard(minOrderValue=2500, minWeightChange=0.01)

        # Monthly rebalance flag
        self.rebalanceFlag = True
        self.lastRebalanceMonth = None
        self.Schedule.On(
            self.DateRules.MonthStart("SPY"),
            self.TimeRules.AfterMarketOpen("SPY", 1),
            self.SetRebalanceFlag
        )

        self.SetWarmUp(260, Resolution.DAILY)

    def SetRebalanceFlag(self) -> None:
        self.rebalanceFlag = True

    def OnData(self, data: Slice) -> None:
        if self.IsWarmingUp:
            return

        if not self.rebalanceFlag:
            return

        monthKey = (self.Time.year, self.Time.month)
        if self.lastRebalanceMonth == monthKey:
            return
        self.lastRebalanceMonth = monthKey
        self.rebalanceFlag = False

        # Decide regime
        riskOn = self.spySma200.IsReady and self.Securities[self.spy].Price > self.spySma200.Current.Value

        if not riskOn:
            # Risk-off: defensive basket, vol-targeted
            targets = self.VolTarget(self.baseRiskOff, self.targetAnnVolRiskOff)
            self.exec.ApplyTargets(self, targets)
            return

        # Risk-on: mostly SPY, possibly small stress tilt
        targets = {self.spy: self.baseRiskOnSpy}

        if self.IsHighStress():
            # Take tilt weights from SPY
            take = sum(self.stressTilt.values())
            targets[self.spy] = max(0.0, 1.0 - take)
            for s, w in self.stressTilt.items():
                targets[s] = w

        # Vol target the whole portfolio
        targets = self.VolTarget(targets, self.targetAnnVolRiskOn)
        self.exec.ApplyTargets(self, targets)

    def IsHighStress(self) -> bool:
        """
        High stress proxy: oil/energy spike OR elevated SPY realized vol.
        """
        needed = max(self.energySpikeLookback + 2, self.volLookback + 2)
        hist = self.History([self.spy, self.xle], needed, Resolution.DAILY)
        if hist.empty:
            return False

        try:
            spyC = hist.loc[self.spy]["close"].dropna()
            xleC = hist.loc[self.xle]["close"].dropna()
        except Exception:
            return False

        if len(spyC) < needed or len(xleC) < needed:
            return False

        # SPY realized vol
        spyRet = spyC.pct_change().dropna()
        if len(spyRet) < self.volLookback:
            return False
        vol = float(spyRet.iloc[-self.volLookback:].std())
        volHigh = vol >= self.volHighThreshold

        # Energy spike
        xle20 = (float(xleC.iloc[-1]) / float(xleC.iloc[-1 - self.energySpikeLookback])) - 1.0
        energySpike = xle20 >= self.energySpikeThreshold

        return volHigh or energySpike

    def VolTarget(self, targets: dict, targetAnnVol: float) -> dict:
        """
        Scale weights to match target annual vol using diagonal covariance approximation:
        portDailyVol ~= sqrt(sum(w^2 * vol_i^2))
        """
        syms = list(targets.keys())
        if not syms:
            return {}

        hist = self.History(syms, self.volLookback + 2, Resolution.DAILY)
        if hist.empty:
            return targets

        vols = {}
        for s in syms:
            try:
                c = hist.loc[s]["close"].dropna()
                r = c.pct_change().dropna()
                if len(r) < self.volLookback:
                    continue
                vols[s] = float(r.iloc[-self.volLookback:].std())
            except Exception:
                continue

        # If missing vols, just return unscaled
        if len(vols) < max(1, len(syms) // 2):
            return targets

        portDailyVol = 0.0
        for s, w in targets.items():
            v = vols.get(s, None)
            if v is None or v <= 0:
                continue
            portDailyVol += (w * v) ** 2
        portDailyVol = math.sqrt(portDailyVol)

        if portDailyVol <= 0:
            return targets

        targetDaily = targetAnnVol / math.sqrt(252.0)
        scale = min(1.0, targetDaily / portDailyVol)

        scaled = {s: w * scale for s, w in targets.items()}

        # Normalize to <= 1 gross (keep conservative)
        ssum = sum(scaled.values())
        if ssum > 1.0 and ssum > 0:
            for k in list(scaled.keys()):
                scaled[k] /= ssum

        return scaled