| Overall Statistics |
|
Total Orders 634 Average Win 0.58% Average Loss -0.26% Compounding Annual Return 21.830% Drawdown 18.600% Expectancy 1.274 Start Equity 100000 End Equity 268493.11 Net Profit 168.493% Sharpe Ratio 0.869 Sortino Ratio 1.007 Probabilistic Sharpe Ratio 61.198% Loss Rate 30% Win Rate 70% Profit-Loss Ratio 2.27 Alpha 0.072 Beta 0.757 Annual Standard Deviation 0.131 Annual Variance 0.017 Information Ratio 0.707 Tracking Error 0.082 Treynor Ratio 0.15 Total Fees $724.54 Estimated Strategy Capacity $130000000.00 Lowest Capacity Asset QUAL VIBZ5HTB7N8L Portfolio Turnover 2.68% Drawdown Recovery 227 |
# region imports
from AlgorithmImports import *
# endregion
class IhaventdecidedTUESDAY(QCAlgorithm):
_SHORT_LB = 5
_LONG_LB = 30
_FF_ENTER_RISK_OFF = 0.20
_FF_ENTER_RISK_ON = 0.05
_MOM_LOOKBACK_D = 90
_MOM_REBALANCE_DAYS = 30
_CORE_REBALANCE_DAYS = 30
_SLEEVE_NEUTRAL = 0.10
_OPT_LOOKBACK_D = 126
_OPT_MAX_WEIGHT = 0.50
_SMA_LONG = 200
_CASH_BUFFER = 0.98
def initialize(self):
self.set_start_date(self.end_date - timedelta(5 * 365))
self.set_cash(100000)
np.random.seed(7)
self._spy = self.add_equity("SPY", Resolution.DAILY)
self._splv = self.add_equity("SPLV", Resolution.DAILY)
self._qual = self.add_equity("QUAL", Resolution.DAILY)
self._qqq = self.add_equity("QQQ", Resolution.DAILY)
self._nvda = self.add_equity("NVDA", Resolution.DAILY)
self._app = self.add_equity("APP", Resolution.DAILY)
self._gld = self.add_equity("GLD", Resolution.DAILY)
self._ief = self.add_equity("IEF", Resolution.DAILY)
self._shy = self.add_equity("SHY", Resolution.DAILY)
self._cls = self.add_equity("CLS", Resolution.DAILY)
self._fix = self.add_equity("FIX", Resolution.DAILY)
self._core_symbols = {
"SPY": self._spy, "SPLV": self._splv, "QUAL": self._qual,
"QQQ": self._qqq, "NVDA": self._nvda, "APP": self._app,
"GLD": self._gld, "IEF": self._ief, "SHY": self._shy,
"CLS": self._cls, "FIX": self._fix
}
self._spy_prev_close = None
self._spy_ret_short = []
self._spy_ret_long = []
self._current_ff = None
self._momo_symbols = [
self.add_equity(ticker, Resolution.DAILY) for ticker in
["AAPL", "MSFT", "GOOGL", "AMZN", "META", "QQQ", "NVDA", "AVGO", "ORCL", "COST", "LLY"]
]
self._short_sma = self.sma(self._spy, 50, Resolution.DAILY)
self._long_sma = self.sma(self._spy, self._SMA_LONG, Resolution.DAILY)
self._next_core_rebalance = self.time
self._next_momentum_rebalance = self.time
self._momo_high = {}
self._momo_hold = []
self._last_selection = []
self.set_warm_up(max(self._LONG_LB + 2, self._SMA_LONG + 5))
self.set_benchmark(self._spy)
# Prime rolling return windows from history to avoid a cold start.
history = self.history([self._spy.symbol], self._LONG_LB + 2, Resolution.DAILY)
if not history.empty:
symbol_key = str(self._spy.symbol)
if symbol_key in history.index.get_level_values(0):
closes = list(history.loc[symbol_key]["close"])
for i in range(1, len(closes)):
if len(self._spy_ret_long) >= self._LONG_LB:
self._spy_ret_long.pop(0)
self._spy_ret_long.append(closes[i] / closes[i - 1] - 1.0)
if len(self._spy_ret_long) >= self._SHORT_LB:
self._spy_ret_short = self._spy_ret_long[-self._SHORT_LB:]
if closes:
self._spy_prev_close = closes[-1]
self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.at(8, 0), self._scheduled_rebalance)
def on_warmup_finished(self):
self._next_core_rebalance = self.time
self._next_momentum_rebalance = self.time
if not self._is_tradable(self._spy):
return
self._rebalance_core()
self._rebalance_momentum(force=True)
self._next_core_rebalance = self.time + timedelta(days=self._CORE_REBALANCE_DAYS)
self._next_momentum_rebalance = self.time + timedelta(days=self._MOM_REBALANCE_DAYS)
def on_data(self, data: Slice):
if self._spy.symbol in data.bars:
close = data.bars[self._spy.symbol].close
if self._spy_prev_close is not None and self._spy_prev_close > 0:
spy_return = close / self._spy_prev_close - 1.0
if len(self._spy_ret_long) >= self._LONG_LB:
self._spy_ret_long.pop(0)
self._spy_ret_long.append(spy_return)
if len(self._spy_ret_short) >= self._SHORT_LB:
self._spy_ret_short.pop(0)
self._spy_ret_short.append(spy_return)
self._spy_prev_close = close
# Recompute the Fama-French volatility-ratio regime factor inline.
if len(self._spy_ret_short) < self._SHORT_LB or len(self._spy_ret_long) < self._LONG_LB:
self._current_ff = None
else:
sigma_short = np.std(self._spy_ret_short, ddof=1)
self._current_ff = None if sigma_short <= 0 else (np.std(self._spy_ret_long, ddof=1) - sigma_short) / sigma_short
if not self.is_warming_up and self._momo_hold:
# Update trailing highs and liquidate any holding that breaches its stop.
for security in list(self._momo_hold):
if not self._is_tradable(security):
continue
price = security.price
self._momo_high[security] = max(self._momo_high.get(security, price), price)
if price <= self._momo_high[security] * (1.0 - 0.20):
self.liquidate(security)
self._momo_hold.remove(security)
if security in self._momo_high:
del self._momo_high[security]
def _current_sleeve_size(self):
if self._current_ff is None:
return self._SLEEVE_NEUTRAL
if self._current_ff >= self._FF_ENTER_RISK_OFF:
return 0.00
if self._current_ff < self._FF_ENTER_RISK_ON:
return 0.30
return self._SLEEVE_NEUTRAL
def _rebalance_core(self):
# Pick the FF-regime weight set inline and normalize it to sum to one.
if self._current_ff is None or self._FF_ENTER_RISK_ON <= self._current_ff < self._FF_ENTER_RISK_OFF:
weights = {
"SPY": 0.51, "SPLV": 0.20, "QUAL": 0.08, "QQQ": 0.06, "NVDA": 0.04,
"APP": 0.02, "GLD": 0.03, "IEF": 0.01, "SHY": 0.00, "CLS": 0.04, "FIX": 0.01
}
elif self._current_ff >= self._FF_ENTER_RISK_OFF:
weights = {
"SPY": 0.15, "SPLV": 0.25, "QUAL": 0.05, "QQQ": 0.00, "NVDA": 0.00,
"APP": 0.00, "GLD": 0.25, "IEF": 0.20, "SHY": 0.10, "CLS": 0.00, "FIX": 0.00
}
else:
weights = {
"SPY": 0.64, "SPLV": 0.00, "QUAL": 0.08, "QQQ": 0.10, "NVDA": 0.07, "APP": 0.03,
"GLD": 0.00, "IEF": 0.00, "SHY": 0.00, "CLS": 0.06, "FIX": 0.02
}
total_weight = sum(weights.values())
if total_weight > 0:
for ticker in weights:
weights[ticker] = weights[ticker] / total_weight
core_scale = (1.0 - self._current_sleeve_size()) * self._CASH_BUFFER
# Keep only tradeable core names, then scale by the non-sleeve budget.
targets = {}
for ticker in weights:
security = self._core_symbols[ticker]
if self._is_tradable(security):
targets[security] = weights[ticker]
if not targets:
targets[self._spy] = 1.0
total_target = sum(targets.values())
if total_target <= 0:
targets = {self._spy: 1.0}
total_target = 1.0
for security in targets:
targets[security] = targets[security] / total_target * core_scale
# Liquidate dropped names, then set the scaled core targets.
for security in self._core_symbols.values():
if security not in targets and security.holdings.invested:
self.liquidate(security)
for security in targets:
if self._is_tradable(security):
self.set_holdings(security, targets[security])
def _scheduled_rebalance(self):
if self.is_warming_up:
return
if not self._is_tradable(self._spy):
return
if self.time >= self._next_core_rebalance:
self._rebalance_core()
self._next_core_rebalance = self.time + timedelta(days=self._CORE_REBALANCE_DAYS)
if self.time >= self._next_momentum_rebalance:
self._rebalance_momentum(force=False)
self._next_momentum_rebalance = self.time + timedelta(days=self._MOM_REBALANCE_DAYS)
def _rebalance_momentum(self, force: bool):
if self.is_warming_up and not force:
return
sleeve = self._current_sleeve_size()
# Liquidate and clear the sleeve when fully risk-off or the SPY trend is weak.
if sleeve <= 0 or not (self._short_sma.is_ready and self._long_sma.is_ready and self._short_sma.current.value >= self._long_sma.current.value):
for security in list(self._momo_hold):
if security.holdings.invested:
self.liquidate(security)
self._momo_hold = []
self._momo_high = {}
self._last_selection = []
return
# Rank the momentum universe by annualized log-price regression slope.
momentum = {}
for security in self._momo_symbols:
history = self.history(security.symbol, self._MOM_LOOKBACK_D, Resolution.DAILY)
if history.empty:
continue
closes = history["close"].values
if len(closes) < self._MOM_LOOKBACK_D * 0.8:
continue
close_array = np.asarray(closes, dtype=float)
momentum[security] = float(np.polyfit(np.arange(len(close_array), dtype=float), np.log(np.maximum(close_array, 1e-8)), 1)[0] * 252.0)
ranked = [security for security, score in sorted(momentum.items(), key=lambda item: item[1], reverse=True)]
if not ranked:
return
selection = ranked[:min(4, len(ranked))]
if set(selection) == set(self._last_selection) and all(security.holdings.invested for security in selection) and not force:
return
# Size the selection with a Monte Carlo Sharpe search over recent returns.
price_data = []
for security in selection:
history = self.history(security.symbol, self._OPT_LOOKBACK_D + 1, Resolution.DAILY)
if history.empty:
continue
closes = history["close"].values
if len(closes) < self._OPT_LOOKBACK_D:
continue
returns = [closes[i] / closes[i - 1] - 1.0 for i in range(1, len(closes)) if closes[i - 1] > 0]
if len(returns) < self._OPT_LOOKBACK_D * 0.8:
continue
price_data.append((security, returns))
if not price_data:
optimized_weights = {security: sleeve * self._CASH_BUFFER / len(selection) for security in selection}
else:
securities = [security for security, returns in price_data]
returns_list = [returns for security, returns in price_data]
min_len = min(len(returns) for returns in returns_list)
returns_matrix = np.asarray([returns[-min_len:] for returns in returns_list], dtype=float).T
best_score = -1000000
best_weights = None
count = len(securities)
for i in range(4000):
weights = np.random.dirichlet(np.ones(count))
for j in range(count):
if weights[j] > self._OPT_MAX_WEIGHT:
weights[j] = self._OPT_MAX_WEIGHT
total_weight = np.sum(weights)
if total_weight <= 0:
continue
weights = weights / total_weight
portfolio_returns = np.dot(returns_matrix, weights)
volatility = np.std(portfolio_returns, ddof=1) * np.sqrt(252)
if volatility <= 0:
continue
score = (np.mean(portfolio_returns) * 252) / volatility
if score > best_score:
best_score = score
best_weights = weights
if best_weights is None:
optimized_weights = {security: sleeve * self._CASH_BUFFER / len(securities) for security in securities}
else:
optimized_weights = {securities[i]: float(best_weights[i]) * sleeve * self._CASH_BUFFER for i in range(len(securities))}
for security in list(self._momo_hold):
if security not in selection:
self.liquidate(security)
self._momo_hold.remove(security)
if security in self._momo_high:
del self._momo_high[security]
for security in optimized_weights:
if not self._is_tradable(security):
continue
self.set_holdings(security, optimized_weights[security])
price = security.price
self._momo_high[security] = max(self._momo_high.get(security, price), price)
if security not in self._momo_hold:
self._momo_hold.append(security)
self._last_selection = selection
def _is_tradable(self, security):
return security.has_data and security.is_tradable and security.price > 0 and not np.isnan(security.price)