This is the second strategy in my series testing whether backtested strategies hold up over extended periods. After finding that the January Effect strategy fell apart over 27 years, I wanted to test a more sophisticated approach.

ney-torres_1773281977.jpg

What This Strategy Does:

The Drawdown Regime Gold Hedge uses a Hidden Markov Model (HMM) to detect market drawdown regimes in SPY. When the model detects a high-drawdown regime, it rotates the portfolio from SPY into GLD as a hedge. When the regime shifts back to normal, it returns to SPY. The strategy rebalances weekly and uses minute-resolution data for both assets.

Key parameters include a 50-week history lookback for HMM fitting and a 20-week drawdown lookback window.

6-Year Backtest (2019-2025) - "Dancing Violet Badger":
- Sharpe Ratio: 0.823
- PSR: 43.781%
- Sortino: 0.881
- CAGR: 19.787%
- Net Profit: 195.39%
- Max Drawdown: 27.1%
- Win Rate: 79%
- Total Orders: 414

20-Year Backtest (2005-2025) - "Adaptable Brown Owl":
- Sharpe Ratio: 0.469
- PSR: 1.337%
- Sortino: 0.495
- CAGR: 12.197%
- Net Profit: 1015.99%
- Max Drawdown: 41%
- Win Rate: 70%
- Total Orders: 1450

Key Observations:

1. Unlike the January Effect, this strategy actually survives the extended test. A 12.2% CAGR over 20 years turning $1M into $11.1M is meaningful.

2. However, the Sharpe dropped from 0.823 to 0.469 and max drawdown increased from 27% to 41% - the 6-year window was clearly a favorable period.

3. The PSR dropped dramatically from 43.8% to 1.3%, suggesting the statistical significance of the Sharpe is weak over the longer period.

4. Win rate decreased from 79% to 70%, and expectancy dropped from 0.533 to 0.378.

5. QC flags this as "Likely Overfitting" due to 16 parameters, which is a valid concern for an HMM-based approach.

Lessons Learned:

- A strategy that still makes money over 20 years is far more trustworthy than one that only works in a short window
- The degradation in metrics from short to long periods tells you how much of the performance was regime-specific vs structural
- HMM-based regime detection adds value but is sensitive to parameter choices
- Always test the longest period your data allows before trusting any strategy

Full code attached below. Would love to hear thoughts on improving the robustness of regime-detection approaches.

 

Here is the full Python code:

# Drawdown Regime Gold Hedge
# Defensive hedge strategy that monitors SPY drawdown using rolling windows to detect market stress periods.
# When significant drawdowns are detected, the strategy shifts allocation from SPY to GLD (gold ETF) as a safe haven hedge.
# Weekly rebalancing adjusts positions based on current drawdown regime analysis, aiming to protect capital during market downturns
# while maintaining exposure during normal conditions.

# region imports
from AlgorithmImports import *
from scipy.optimize import minimize
from hmmlearn.hmm import GMMHMM
# endregion
np.random.seed(70)

class DrawdownRegimeGoldHedgeAlgorithm(QCAlgorithm):
    def initialize(self) -> None:
        self.set_start_date(2005, 1, 1)
        # self.set_end_date(2025, 1, 1)  # Commented out to backtest to present
        self.set_cash(1000000)
        self.set_security_initializer(BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))

        # Determine the lookback window (in weeks).
        self.history_lookback = self.get_parameter("history_lookback", 50)
        self.drawdown_lookback = self.get_parameter("drawdown_lookback", 20)

        # Request SPY as market representative for trading and HMM fitting.
        self.spy = self.add_equity("SPY", Resolution.MINUTE).symbol
        # Request GLD as hedge asset for trading.
        self.gold = self.add_equity("GLD", Resolution.MINUTE).symbol
        self.set_benchmark(self.spy)

        # Schdeuled a weekly rebalance.
        self.schedule.on(self.date_rules.week_start(self.spy), self.time_rules.after_market_open(self.spy, 1), self.rebalance)
    
    def rebalance(self) -> None:
        # Get the drawdown as the input to the drawdown regime. Since we're rebalancing weekly, we resample to study weekly drawdown.
        history = self.history(self.spy, self.history_lookback*5, Resolution.DAILY).unstack(0).close.resample('W').last()
        drawdown = history.rolling(self.drawdown_lookback).apply(lambda a: (a.iloc[-1] - a.max()) / a.max()).dropna()

        try:
            # Initialize the HMM, then fit by the drawdown data, as we're interested in the downside risk regime.
            # McLachlan & Peel (2000) suggested 2-3 components are used in GMMs to capture the main distribution and the tail to balance between complexity and characteristics capture.
            # By studying the ACF and PACF plots, the 1-lag drawdown series is suitable to supplement as exogenous variable.
            inputs = np.concatenate([drawdown[[self.spy]].iloc[1:].values, drawdown[[self.spy]].diff().iloc[1:].values], axis=1)
            model = GMMHMM(n_components=2, n_mix=3, covariance_type='tied', n_iter=100, random_state=0).fit(inputs)
            # Obtain the current market regime.
            regime_probs = model.predict_proba(inputs)
            current_regime_prob = regime_probs[-1]
            regime = 0 if current_regime_prob[0] > current_regime_prob[1] else 1

            # Determine the regime number: the higher the coefficient, the larger the drawdown in this state.
            high_regime = 1 if model.means_[0][1][0] < model.means_[1][1][0] else 0
            # Check the transitional probability of the next regime being the high volatility regime.
            # Calculated by the probability of the current regime being 1/0, then multiplied by the posterior probabilities of each scenario.
            next_prob_zero = current_regime_prob @ model.transmat_[:, 0]
            next_prob_high = round(next_prob_zero if high_regime == 0 else 1 - next_prob_zero, 2)

            # Buy more Gold and less SPY if the current regime is easier to have large drawdown.
            # Fund will shift to hedge asset like gold to drive up its price.
            # Weighted by the posterior probabilities.
            self.set_holdings([PortfolioTarget(self.gold, next_prob_high), PortfolioTarget(self.spy, 1 - next_prob_high)])

        except:
            pass