Overall Statistics
Total Orders
234
Average Win
2.57%
Average Loss
-2.25%
Compounding Annual Return
32.634%
Drawdown
30.000%
Expectancy
0.428
Start Equity
100000
End Equity
410676.39
Net Profit
310.676%
Sharpe Ratio
0.958
Sortino Ratio
1.034
Probabilistic Sharpe Ratio
50.884%
Loss Rate
33%
Win Rate
67%
Profit-Loss Ratio
1.14
Alpha
0.168
Beta
0.564
Annual Standard Deviation
0.217
Annual Variance
0.047
Information Ratio
0.647
Tracking Error
0.211
Treynor Ratio
0.368
Total Fees
$851.45
Estimated Strategy Capacity
$6700000.00
Lowest Capacity Asset
REMX UR58SK7J5PPH
Portfolio Turnover
2.84%
Drawdown Recovery
611
# region imports
from AlgorithmImports import *
from arch import arch_model
# endregion


class GarchVolRotation(QCAlgorithm):

    def initialize(self):
        # self.set_start_date(self.end_date - timedelta(60))
        self.set_start_date(2021, 1, 1)
        self.set_end_date(2026, 1, 1)
        self.settings.seed_initial_prices = True
        self.settings.automatic_indicator_warm_up = True
        self.set_cash(100_000)
        self._lookback = 252
        self._horizon = 21
        spy = self.add_equity("SPY", Resolution.DAILY)
        spy.session.size = self._lookback
        spy.momp = self.momp(spy, self._lookback)
        self._spy = spy

        etf_tickers = [
            "XLK", "IYW",
            "XLV", "VHT",
            "XLF", "VFH",
            "ERTH", "GRID", "PRN",
            "REMX", "SLVP", "COPX",
            "FALN", "INDS", "SRV",
            "IXC", "IYE", "IGE",
            "GLD", "IAU", "GDXJ",
            "SLV", "SIVR", "AGQ",
        ]

        self._securities = []
        for ticker in etf_tickers:
            security = self.add_equity(ticker, Resolution.DAILY)
            security.session.size = self._lookback
            security.momp = self.momp(security, self._lookback)
            self._securities.append(security)

        self._date_rule = self.date_rules.month_start("SPY")
        self.set_warm_up(timedelta(365))

    def on_warmup_finished(self):
        time_rule = self.time_rules.at(8, 0)
        self.schedule.on(self._date_rule, time_rule, self._rebalance)
        if self.live_mode:
            self._rebalance()
        else:
            self.schedule.on(self.date_rules.today, time_rule, self._rebalance)

    def _rebalance(self):
        if not self._spy.momp.is_ready:
            return
        spy_momentum = self._spy.momp.current.value
        acceleration_by_security = {}

        for security in self._securities:
            if not security.momp.is_ready or not security.session.is_ready:
                continue
            if security.momp.current.value <= spy_momentum:
                continue
            # Session iterates newest-first; reverse to oldest-first for np.diff.
            closes = np.array([x.close for x in security.session])[::-1]
            pct_returns = np.diff(closes) / closes[:-1]
            if len(pct_returns) < 200:
                continue
            model = arch_model(pct_returns * 100, vol="Garch", p=1, q=1, dist="t")
            res = model.fit(disp="off")
            forecast = res.forecast(horizon=self._horizon)
            forecast_vol = np.sqrt(forecast.variance.values[-1].mean())
            realized_vol = np.std(pct_returns) * np.sqrt(252)
            if forecast_vol <= realized_vol:
                continue
            acceleration_by_security[security] = forecast_vol / realized_vol
        if len(acceleration_by_security) < 3:
            return
        top_securities = [s for s, _ in sorted(acceleration_by_security.items(), key=lambda x: x[1])[:3]]
        targets = [PortfolioTarget(s, 1.0 / 3) for s in top_securities]
        self.set_holdings(targets, True)