| Overall Statistics |
|
Total Orders 69 Average Win 6.56% Average Loss -5.84% Compounding Annual Return 3.895% Drawdown 33.600% Expectancy 0.374 Start Equity 100000 End Equity 177444.12 Net Profit 77.444% Sharpe Ratio 0.176 Sortino Ratio 0.181 Probabilistic Sharpe Ratio 0.024% Loss Rate 35% Win Rate 65% Profit-Loss Ratio 1.12 Alpha 0 Beta 0 Annual Standard Deviation 0.155 Annual Variance 0.024 Information Ratio 0.253 Tracking Error 0.155 Treynor Ratio 0 Total Fees $721.53 Estimated Strategy Capacity $0 Lowest Capacity Asset IEF SGNKIKYGE9NP Portfolio Turnover 1.26% Drawdown Recovery 871 |
# region imports
from AlgorithmImports import *
# endregion
class DualMomentumRotation(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2008, 1, 1)
self.SetEndDate(2022, 12, 31)
self.SetCash(100000)
self.SetBrokerageModel(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
# Universe: US (SPY), Developed ex-US (EFA), Emerging (EEM) with bond fallback (IEF)
self.risk_assets = ["SPY", "EFA", "EEM"]
self.safe_asset = "IEF"
self.symbols = {t: self.AddEquity(t, Resolution.Daily).Symbol for t in self.risk_assets + [self.safe_asset]}
# 12-month momentum, evaluated monthly at month end
self.lookback_months = 12
self.SetWarmUp(self.lookback_months * 31, Resolution.Daily)
# Schedule monthly on last trading day, after market open for stability
self.Schedule.On(self.DateRules.MonthEnd(self.symbols[self.risk_assets[0]], 0),
self.TimeRules.AfterMarketOpen(self.symbols[self.risk_assets[0]], 30),
self.Rebalance)
# Absolute momentum threshold vs. cash/bonds
self.absolute_momentum_threshold = 0.0
def Rebalance(self):
if self.IsWarmingUp:
return
# Pull multi-symbol history once to avoid per-ticker calls
bars = self.History(list(self.symbols.values()), self.lookback_months * 31, Resolution.Daily)
if bars is None or bars.empty:
return
rets = {}
for ticker in self.risk_assets + [self.safe_asset]:
sym = self.symbols[ticker]
df = None
try:
df = bars.xs(sym, level=0)
except Exception:
# If not multi-indexed, try filtering by column
if isinstance(bars, pd.DataFrame) and 'symbol' in bars.columns:
df = bars[bars['symbol'] == sym]
else:
df = bars
if df is None or df.empty:
continue
series = df['close'] if 'close' in df else df.Close
monthly = series.resample('M').last()
if len(monthly) < self.lookback_months + 1:
continue
past = monthly.iloc[-(self.lookback_months+1)]
now = monthly.iloc[-1]
if past and past != 0:
rets[ticker] = (now / past) - 1.0
# Need all returns computed
if set(rets.keys()) != set(self.risk_assets + [self.safe_asset]):
return
ranked = sorted(self.risk_assets, key=lambda t: rets[t], reverse=True)
top = ranked[0]
target = top if rets[top] > self.absolute_momentum_threshold else self.safe_asset
targets = []
for t in self.risk_assets + [self.safe_asset]:
sym = self.symbols[t]
weight = 1.0 if t == target else 0.0
targets.append(PortfolioTarget(sym, weight))
self.Debug(f"{self.Time.date()} Momentum: " + ", ".join([f"{k}:{rets[k]:.2%}" for k in rets]))
self.SetHoldings(targets)
def OnSecuritiesChanged(self, changes):
pass