| 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)}