| Overall Statistics |
|
Total Orders 714 Average Win 0.55% Average Loss -0.41% Compounding Annual Return 8.629% Drawdown 16.300% Expectancy 0.300 Start Equity 100000 End Equity 165508.77 Net Profit 65.509% Sharpe Ratio 0.296 Sortino Ratio 0.316 Probabilistic Sharpe Ratio 9.470% Loss Rate 44% Win Rate 56% Profit-Loss Ratio 1.34 Alpha 0.005 Beta 0.333 Annual Standard Deviation 0.115 Annual Variance 0.013 Information Ratio -0.347 Tracking Error 0.151 Treynor Ratio 0.102 Total Fees $0.00 Estimated Strategy Capacity $0 Lowest Capacity Asset SHY SGNKIKYGE9NP Portfolio Turnover 3.54% Drawdown Recovery 803 |
# region imports
from AlgorithmImports import *
# endregion
import numpy as np
import pandas as pd
from scipy.optimize import minimize
import statsmodels.api as sm
class DynamicBlackLittermanWithWarmUp(QCAlgorithm):
def Initialize(self):
# 1. Basics & WarmUp
self.SetStartDate(2020, 1, 1)
self.SetCash(100000)
# Warm up the algorithm for 252 trading days to fill history
self.SetWarmUp(252, Resolution.Daily)
# 2. Universe Settings
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.CoarseSelection, self.FineSelection)
# 3. Reference Symbols
self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
self.shy = self.AddEquity("SHY", Resolution.Daily).Symbol # Defensive Bond ETF
self.lookback = 252
self.active_symbols = []
self.Settings.MinAbsolutePortfolioTargetPercentage = 0.001
# 4. Schedule Rebalance
self.Schedule.On(self.DateRules.MonthStart("SPY"),
self.TimeRules.AfterMarketOpen("SPY", 30),
self.Rebalance)
self.set_brokerage_model(BrokerageName.ALPACA)
def CoarseSelection(self, coarse):
# Liquidity filter
sorted_by_volume = sorted([x for x in coarse if x.HasFundamentalData and x.Price > 10],
key=lambda x: x.DollarVolume, reverse=True)
return [x.Symbol for x in sorted_by_volume[:100]]
def FineSelection(self, fine):
# Select top 25 by Market Cap
sorted_by_mcap = sorted(fine, key=lambda x: x.MarketCap, reverse=True)
return [x.Symbol for x in sorted_by_mcap[:25]]
def OnSecuritiesChanged(self, changes):
for security in changes.AddedSecurities:
if security.Symbol not in self.active_symbols:
self.active_symbols.append(security.Symbol)
for security in changes.RemovedSecurities:
if security.Symbol in self.active_symbols:
self.active_symbols.remove(security.Symbol)
def Rebalance(self):
# Skip if still warming up
if self.IsWarmingUp: return
if not self.active_symbols: return
# 1. Market Regime Filter (200-day SMA on SPY)
spy_hist = self.History(self.spy, 200, Resolution.Daily)
if spy_hist.empty: return
current_spy_price = self.Securities[self.spy].Price
spy_sma = spy_hist['close'].mean()
# If SPY is below 200 SMA, move to SHY and exit others
if current_spy_price < spy_sma:
self.Log("Bear Market Detected: Moving to SHY")
self.SetHoldings(self.shy, 1.0)
for symbol in self.active_symbols:
if symbol != self.spy: self.Liquidate(symbol)
return
# 2. Fetch History for BL Model
history = self.History(self.active_symbols + [self.spy], self.lookback, Resolution.Daily)
if history.empty: return
history_unstacked = history['close'].unstack(level=0)
returns_df = history_unstacked.pct_change().dropna()
valid_symbols = [s for s in self.active_symbols if str(s) in returns_df.columns]
if len(valid_symbols) < 10: return
cov_matrix = returns_df[list(map(str, valid_symbols))].cov().values * 252
market_caps = [self.Securities[s].Fundamentals.MarketCap for s in valid_symbols]
mcap_weights = np.array(market_caps) / sum(market_caps)
# 3. Hybrid Views (Regression Alpha + RSI Adjustment)
spy_ret = returns_df[str(self.spy)]
views = []
for symbol in valid_symbols:
s_ret = returns_df[str(symbol)]
model = sm.OLS(s_ret, sm.add_constant(spy_ret)).fit()
alpha = model.params[0] * 252
# 14-day RSI
prices = history_unstacked[str(symbol)].tail(14)
diff = prices.diff()
gain = (diff.where(diff > 0, 0)).mean()
loss = (-diff.where(diff < 0, 0)).mean()
rsi = 100 - (100 / (1 + (gain / (loss + 1e-9))))
# Penalize overbought, reward oversold
view_adj = alpha * 0.5 if rsi > 70 else (alpha * 1.5 if rsi < 30 else alpha)
views.append(view_adj)
# 4. Black-Litterman Optimization
opt = BlackLittermanOptimizer(valid_symbols, mcap_weights, cov_matrix, views)
target_dict = opt.calculate_weights()
# 5. Build Portfolio Targets
targets = []
# Liquidate SHY if we were in defensive mode
if self.Portfolio[self.shy].Invested:
targets.append(PortfolioTarget(self.shy, 0))
for symbol in valid_symbols:
weight = target_dict.get(symbol, 0)
targets.append(PortfolioTarget(symbol, weight if weight > 0.005 else 0))
# Liquidate securities no longer in universe
for holding in self.Portfolio.Values:
if holding.Symbol not in valid_symbols and holding.Symbol != self.shy and holding.Invested:
targets.append(PortfolioTarget(holding.Symbol, 0))
if targets:
self.SetHoldings(targets)
class BlackLittermanOptimizer:
def __init__(self, symbols, mcap_weights, cov, views, tau=0.025, delta=2.5):
self.symbols, self.w_mkt, self.sigma, self.Q = symbols, mcap_weights, cov, np.array(views)
self.tau, self.delta = tau, delta
def calculate_weights(self):
n = len(self.symbols)
pi = self.delta * np.dot(self.sigma, self.w_mkt)
P = np.eye(n)
omega = np.diag(np.diag(np.dot(np.dot(P, self.tau * self.sigma), P.T)))
# BL Posterior
term1 = np.linalg.inv(np.linalg.inv(self.tau * self.sigma) + np.dot(np.dot(P.T, np.linalg.inv(omega)), P))
term2 = np.dot(np.linalg.inv(self.tau * self.sigma), pi) + np.dot(np.dot(P.T, np.linalg.inv(omega)), self.Q)
er = np.dot(term1, term2)
def obj(w):
port_return = np.dot(w, er)
port_vol = np.sqrt(np.dot(w.T, np.dot(self.sigma, w)))
return -port_return / (port_vol + 1e-9)
res = minimize(obj, self.w_mkt, bounds=[(0, 0.15) for _ in range(n)],
constraints={'type': 'eq', 'fun': lambda x: np.sum(x) - 1})
return dict(zip(self.symbols, res.x))