Overall Statistics
Total Orders
106
Average Win
2.18%
Average Loss
-1.59%
Compounding Annual Return
10.252%
Drawdown
27.900%
Expectancy
0.673
Start Equity
100000
End Equity
162843.28
Net Profit
62.843%
Sharpe Ratio
0.268
Sortino Ratio
0.31
Probabilistic Sharpe Ratio
10.031%
Loss Rate
29%
Win Rate
71%
Profit-Loss Ratio
1.37
Alpha
-0.018
Beta
1.03
Annual Standard Deviation
0.149
Annual Variance
0.022
Information Ratio
-0.542
Tracking Error
0.029
Treynor Ratio
0.039
Total Fees
$125.04
Estimated Strategy Capacity
$2100000000.00
Lowest Capacity Asset
QQQ RIWIV7K5Z9LX
Portfolio Turnover
2.75%
Drawdown Recovery
791
# Dual momentum ETF rotation with Monte Carlo weight optimization.
# Every month it measures long-term momentum across equity and defensive ETFs,
# uses SPY as a regime filter (risk-on equities vs risk-off defensives), then
# sizes the chosen sleeve with Monte Carlo weights that maximize historical Sharpe.

# region imports
from AlgorithmImports import *
# endregion


class FinalProjectStrategy(QCAlgorithm):

    def initialize(self):
        self.set_start_date(self.end_date - timedelta(5 * 365))
        self.set_cash(100_000)
        self.set_benchmark("SPY")
        self._risky = [self.add_equity(ticker, Resolution.DAILY).symbol for ticker in ["SPY", "QQQ", "IWM", "EEM"]]
        self._safe = [self.add_equity(ticker, Resolution.DAILY).symbol for ticker in ["TLT", "GLD", "IEF", "BIL"]]
        self._all = self._risky + self._safe
        self._lookback = 756
        self.schedule.on(self.date_rules.week_start("SPY"), self.time_rules.at(8, 0), self._rebalance)
        self.set_warm_up(self._lookback + 30)

    def on_warmup_finished(self):
        self._rebalance()

    def _rebalance(self):
        if self.is_warming_up:
            return
        history = self.history(self._all, self._lookback + 30, Resolution.DAILY)
        if history.empty:
            return
        closes = history["close"].unstack(level=0)
        # Score each asset by its return from three years ago to ten months ago.
        scores = {}
        for symbol in self._all:
            if symbol not in closes.columns:
                continue
            prices = closes[symbol].dropna()
            if len(prices) >= self._lookback:
                scores[symbol] = float(prices.iloc[-200] / prices.iloc[-self._lookback] - 1)
        if not scores:
            return
        # Gate the regime on SPY momentum, then rank the two best names within that sleeve.
        if scores.get(self._risky[0], 0) > 0:
            regime_scores = {symbol: scores[symbol] for symbol in self._risky if symbol in scores}
        else:
            regime_scores = {symbol: scores[symbol] for symbol in self._safe if symbol in scores}
        chosen = sorted(regime_scores, key=lambda k: regime_scores[k])[-2:]
        if not chosen:
            return
        weights = self._monte_carlo_optimize(closes[chosen].pct_change().dropna().iloc[-252:])
        for symbol in self._all:
            if symbol not in weights:
                self.liquidate(symbol)
        for symbol, weight in weights.items():
            self.set_holdings(symbol, weight)

    def _monte_carlo_optimize(self, returns_df):
        cols = list(returns_df.columns)
        n = len(cols)
        if n == 0:
            return {}
        if n == 1 or len(returns_df) < 30:
            return {symbol: 1.0 / n for symbol in cols}
        # Annualize mean returns and covariance for the Sharpe objective.
        mu = returns_df.mean().values * 252
        cov = returns_df.cov().values * 252
        best_sharpe = -np.inf
        best_w = np.ones(n) / n
        rng = np.random.default_rng(42)
        for _ in range(1_000):
            w = rng.random(n)
            w /= w.sum()
            vol = np.sqrt(w @ cov @ w)
            if vol < 1e-9:
                continue
            sharpe = (w @ mu) / vol
            if sharpe > best_sharpe:
                best_sharpe = sharpe
                best_w = w.copy()
        best_w = best_w / best_w.sum()
        return {cols[i]: float(best_w[i]) for i in range(n)}