| Overall Statistics |
|
Total Orders 23 Average Win 3.48% Average Loss -0.86% Compounding Annual Return 14.609% Drawdown 11.100% Expectancy 2.531 Start Equity 100000 End Equity 133113.18 Net Profit 33.113% Sharpe Ratio 0.565 Sortino Ratio 0.59 Probabilistic Sharpe Ratio 60.954% Loss Rate 30% Win Rate 70% Profit-Loss Ratio 4.04 Alpha 0 Beta 0 Annual Standard Deviation 0.084 Annual Variance 0.007 Information Ratio 1.217 Tracking Error 0.084 Treynor Ratio 0 Total Fees $50.86 Estimated Strategy Capacity $0 Lowest Capacity Asset QQQ RIWIV7K5Z9LX Portfolio Turnover 1.00% Drawdown Recovery 147 |
from AlgorithmImports import *
import numpy as np
import math
class SystematicPortfolioRotation(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2024, 1, 1)
self.SetCash(100000)
self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)
# Stage 1: Universe Selection
self.risk_on_tickers = ["SPY", "QQQ", "USMV", "EFAV", "EEMV", "GLD", "TLT"]
self.defensive_allocation = {"SHY": 0.50, "BIL": 0.25, "GLD": 0.25}
self.symbols = []
# Add Equities to the universe
for ticker in self.risk_on_tickers + list(self.defensive_allocation.keys()):
symbol = self.AddEquity(ticker, Resolution.Daily).Symbol
if symbol not in self.symbols:
self.symbols.append(symbol)
# Proxies for Dual Momentum Regime Filter
self.market_proxy = self.Symbol("SPY")
self.risk_free_proxy = self.Symbol("BIL")
self.lookback_days = 252 # Approx. 12 months of trading days
# Stage 4 Setup: Quarterly Rebalancing
self.rebalance_months = [1, 4, 7, 10]
self.Schedule.On(
self.DateRules.MonthStart("SPY"),
self.TimeRules.AfterMarketOpen("SPY", 30),
self.ExecuteRebalance
)
def ExecuteRebalance(self):
# Enforce quarterly schedule
if self.Time.month not in self.rebalance_months:
return
# Fetch historical daily close prices
history = self.History(self.symbols, self.lookback_days, Resolution.Daily)
if history.empty:
return
# Calculate 12-month momentum (returns)
returns = {}
for symbol in self.symbols:
if symbol in history.index.levels[0]:
prices = history.loc[symbol]['close']
if not prices.empty and len(prices) > 0:
returns[symbol] = (prices.iloc[-1] / prices.iloc[0]) - 1
if self.market_proxy not in returns or self.risk_free_proxy not in returns:
return
# Stage 2: Regime Detection (Absolute Momentum Proxy)
market_return = returns[self.market_proxy]
rf_return = returns[self.risk_free_proxy]
# If Market > T-Bills, we are in a Bullish/Risk-On state
is_risk_on = market_return > rf_return
target_weights = {}
# Stage 3: Allocation
if not is_risk_on:
self.Debug(f"{self.Time}: Bearish Regime Detected. Shifting to Defensive Basket.")
for ticker, weight in self.defensive_allocation.items():
target_weights[self.Symbol(ticker)] = weight
else:
self.Debug(f"{self.Time}: Bullish Regime Detected. Applying Median Selection.")
ro_returns = {s: returns[s] for s in returns if s.Value in self.risk_on_tickers}
# Sort risk-on assets by 12-month return
sorted_assets = sorted(ro_returns.items(), key=lambda x: x[1])
# The Median Anomaly: Exclude extreme winners/losers, select the middle
n = len(sorted_assets)
mid_idx = n // 2
# Select the middle 3 assets to represent the median cluster
median_symbols = [sorted_assets[i][0] for i in range(mid_idx - 1, mid_idx + 2)]
# Equal weight the median assets (Proxy for Hierarchical Risk Parity)
weight_per_asset = 1.0 / len(median_symbols)
for symbol in median_symbols:
target_weights[symbol] = weight_per_asset
# Stage 4: Execution with Turnover Control (5% Threshold)
for symbol, target_weight in target_weights.items():
current_weight = 0
if self.Portfolio.TotalPortfolioValue > 0:
current_weight = self.Portfolio[symbol].HoldingsValue / self.Portfolio.TotalPortfolioValue
# Only trade if the required allocation change is greater than 5%
if abs(current_weight - target_weight) >= 0.05:
self.SetHoldings(symbol, target_weight)
# Liquidate assets no longer in the target portfolio
for holding in self.Portfolio.Values:
if holding.Symbol not in target_weights and holding.Invested:
self.Liquidate(holding.Symbol)