| Overall Statistics |
|
Total Orders 110 Average Win 2.96% Average Loss -2.51% Compounding Annual Return 19.609% Drawdown 20.700% Expectancy 0.683 Start Equity 100000 End Equity 244884.06 Net Profit 144.884% Sharpe Ratio 0.736 Sortino Ratio 0.763 Probabilistic Sharpe Ratio 47.259% Loss Rate 23% Win Rate 77% Profit-Loss Ratio 1.18 Alpha 0.082 Beta 0.314 Annual Standard Deviation 0.137 Annual Variance 0.019 Information Ratio 0.263 Tracking Error 0.162 Treynor Ratio 0.321 Total Fees $244.76 Estimated Strategy Capacity $1200000000.00 Lowest Capacity Asset QQQ RIWIV7K5Z9LX Portfolio Turnover 3.80% Drawdown Recovery 557 |
# region imports
from AlgorithmImports import *
# endregion
class Top2EtfRotator(QCAlgorithm):
def initialize(self):
self.set_start_date(self.end_date - timedelta(5 * 365))
self.set_cash(100_000)
self.settings.free_portfolio_value_percentage = 0.05
self._lookback_6m = 126
self._lookback_12m = 252
self._securities = []
self._safe_bond_security = None
self._spy_security = None
for ticker in ["SPY", "QQQ", "EFA", "IEF", "TLT", "GLD", "VNQ", "DBC", "HYG", "XLU"]:
security = self.add_equity(ticker, Resolution.DAILY)
security.set_data_normalization_mode(DataNormalizationMode.TOTAL_RETURN)
self._securities.append(security)
if ticker == "IEF":
self._safe_bond_security = security
if ticker == "SPY":
self._spy_security = security
if self._safe_bond_security is None:
self._safe_bond_security = self._securities[0]
if self._spy_security is None:
self._spy_security = self._securities[0]
# Only these three risk-on ETFs are eligible for the momentum rotation.
self._risk_on_securities = [s for s in self._securities if s.symbol.value in ["SPY", "QQQ", "GLD"]]
if not self._risk_on_securities:
self._risk_on_securities = [s for s in self._securities if s != self._safe_bond_security]
self.set_warm_up(self._lookback_12m + 10)
self.schedule.on(self.date_rules.week_start(self._securities[0]), self.time_rules.at(8, 0), self._rebalance)
def on_warmup_finished(self):
self._rebalance()
def _rebalance(self):
if self.is_warming_up or not self._safe_bond_security.has_data or self._safe_bond_security.is_delisted:
return
max_lookback = self._lookback_12m + 1
history = self.history([s.symbol for s in self._securities], max_lookback, Resolution.DAILY)
if history.empty:
self._go_full_safe_bond()
return
index_level = history.index.get_level_values(0).unique()
momentum_scores = {}
# Use SPY's own 6-month momentum as an absolute-momentum regime gate.
spy_closes = history.loc[self._spy_security.symbol]["close"].astype(float) if self._spy_security.symbol in index_level else None
if spy_closes is None or len(spy_closes) < self._lookback_6m + 1 or (spy_closes.iloc[-1] / spy_closes.iloc[-self._lookback_6m - 1]) - 1.0 <= 0.0:
self._go_full_safe_bond()
return
for security in self._risk_on_securities:
if security.symbol not in index_level:
continue
closes = history.loc[security.symbol]["close"].astype(float)
if len(closes) < max_lookback or len(closes) < self._lookback_12m + 1 or len(closes) < self._lookback_6m + 1:
continue
# Score each ETF by an equal blend of 6-month and 12-month momentum.
momentum_scores[security] = 0.5 * ((closes.iloc[-1] / closes.iloc[-self._lookback_6m - 1]) - 1.0) + 0.5 * ((closes.iloc[-1] / closes.iloc[-self._lookback_12m - 1]) - 1.0)
if not momentum_scores:
self._go_full_safe_bond()
return
ranked = sorted(momentum_scores, key=lambda k: momentum_scores[k], reverse=True)
if momentum_scores[ranked[0]] <= 0.0:
self._go_full_safe_bond()
return
# Concentrate the whole book in the single strongest ETF, leaving the safe bond flat.
top_securities = ranked[:1]
target_weights = {s: 1.0 / len(top_securities) for s in top_securities}
target_weights[self._safe_bond_security] = 0.0
total_weight = sum(target_weights.values())
if total_weight > 0:
for security in target_weights:
target_weights[security] /= total_weight
targets = [PortfolioTarget(security.symbol, target_weights.get(security, 0.0)) for security in self._securities]
self.set_holdings(targets, liquidate_existing_holdings=True)
def _go_full_safe_bond(self):
targets = [PortfolioTarget(security.symbol, 1.0 if security == self._safe_bond_security else 0.0) for security in self._securities]
self.set_holdings(targets, liquidate_existing_holdings=True)