Overall Statistics |
Total Orders 293 Average Win 3.30% Average Loss -2.16% Compounding Annual Return 10.196% Drawdown 34.600% Expectancy 0.394 Start Equity 10000 End Equity 17851.78 Net Profit 78.518% Sharpe Ratio 0.35 Sortino Ratio 0.305 Probabilistic Sharpe Ratio 6.786% Loss Rate 45% Win Rate 55% Profit-Loss Ratio 1.53 Alpha 0.043 Beta 0.133 Annual Standard Deviation 0.161 Annual Variance 0.026 Information Ratio -0.17 Tracking Error 0.263 Treynor Ratio 0.423 Total Fees $199.75 Estimated Strategy Capacity $80000000.00 Lowest Capacity Asset UNH R735QTJ8XC9X Portfolio Turnover 3.08% |
from AlgorithmImports import * import pandas as pd import numpy as np from datetime import timedelta class MonthlyMomentumHealthcare(QCAlgorithm): def Initialize(self): # --------------------------- # 1. Basic Setup # --------------------------- self.SetStartDate(2019, 1, 1) self.SetEndDate(2024, 12, 31) # Start with 10k USD in a cash account self.SetCash(10000) self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Cash) self.SetTimeZone(TimeZones.NewYork) # --------------------------- # 2. Benchmark # --------------------------- benchmark = self.AddEquity("UNH", Resolution.Daily) self.SetBenchmark(benchmark.Symbol) # --------------------------- # 3. Candidate Stocks # --------------------------- self.healthcare_stocks = [ "UNH", "JNJ", "LLY", "MRK", "ABBV", "TMO", "PFE", "DHR", "MRNA", "AMGN" ] self.symbols = [] for ticker in self.healthcare_stocks: security = self.AddEquity(ticker, Resolution.Daily) security.SetDataNormalizationMode(DataNormalizationMode.Raw) self.symbols.append(security.Symbol) # --------------------------- # 4. Monthly Rebalance # --------------------------- self.Schedule.On( self.DateRules.MonthStart(), self.TimeRules.At(0, 0), self.Rebalance ) # Track selected symbols & stop prices self.selected_symbols = [] self.stop_prices = {} self.stop_loss_pct = 0.25 # --------------------------- # 5. Risk & Strategy Params # --------------------------- self.max_drawdown = 0.30 self.initial_portfolio_value = self.Portfolio.Cash # --------------------------- # 6. Daily Stop-Loss Check # --------------------------- self.Schedule.On( self.DateRules.EveryDay(), self.TimeRules.At(0, 0), self.CheckStopLosses ) self.last_rebalance_date = None self.Debug("Algorithm initialized.") def Rebalance(self): """ Once a month, select up to 3 stocks with positive 60-day momentum. Rebalance by: 1. Liquidating all existing positions to cash. 2. Allocating the entire cash equally among the new top picks. """ if self.last_rebalance_date and \ self.last_rebalance_date.month == self.Time.month and \ self.last_rebalance_date.year == self.Time.year: return # Drawdown check current_value = self.Portfolio.TotalPortfolioValue if current_value < self.initial_portfolio_value * (1 - self.max_drawdown): self.Debug(f"Max drawdown reached, skipping rebalance. Current value: {current_value:.2f}") return # Step 1: Liquidate all positions for sym in self.symbols: if self.Portfolio[sym].Invested: self.Liquidate(sym) self.selected_symbols.clear() self.stop_prices.clear() # Select top momentum stocks top_symbols = self.SelectTopMomentumStocks(self.healthcare_stocks, lookback=60) picks_count = len(top_symbols) if picks_count == 0: self.Debug("No top picks => Holding cash.") self.last_rebalance_date = self.Time return # Step 2: Allocate equal weight to each top pick weight_each = 1.0 / picks_count new_selected = [] new_stop_prices = {} for sym in top_symbols: self.SetHoldings(sym, weight_each) if self.Portfolio[sym].Invested: new_selected.append(sym) price = self.Securities[sym].Price if price > 0: new_stop_prices[sym] = price * (1 - self.stop_loss_pct) self.selected_symbols = new_selected self.stop_prices = new_stop_prices self.last_rebalance_date = self.Time self.Debug(f"Monthly Rebalance => Top picks: {[str(s) for s in new_selected]}") def SelectTopMomentumStocks(self, tickers, lookback=60): """ Compute 60-day momentum for each ticker: momentum = (last_close / first_close) - 1 Only keep those with momentum > 0, then pick top 3. """ momentum_scores = {} for ticker in tickers: symbol = self.Symbol(ticker) hist = self.History(symbol, lookback, Resolution.Daily) if hist.empty or "close" not in hist.columns or len(hist) < 2: continue first_close = hist["close"].iloc[0] last_close = hist["close"].iloc[-1] if np.isnan(first_close) or np.isnan(last_close): continue score = (last_close / first_close) - 1 if score > 0: momentum_scores[symbol] = score if not momentum_scores: self.Debug("All symbols had non-positive momentum. Holding none.") return [] sorted_by_mom = sorted(momentum_scores.items(), key=lambda x: x[1], reverse=True) for sym, val in sorted_by_mom: self.Debug(f"Momentum {sym.Value}: {val:.4f}") top_3 = [x[0] for x in sorted_by_mom[:3]] return top_3 def CheckStopLosses(self): """ Once a day, check if any stock's price is below our stored stop price. If so, liquidate that stock. """ for sym in list(self.selected_symbols): if sym not in self.stop_prices: continue price = self.Securities[sym].Price stop_price = self.stop_prices[sym] if price <= stop_price: self.Debug(f"{sym} triggered stop loss at {price:.4f} (Stop={stop_price:.4f}). Liquidating.") self.Liquidate(sym) self.selected_symbols.remove(sym) del self.stop_prices[sym] def OnData(self, data): pass def OnOrderEvent(self, orderEvent): """ Debug any fills, partial fills, or invalid orders. """ if orderEvent.Status in [OrderStatus.Filled, OrderStatus.PartiallyFilled]: self.Debug(f"OrderEvent: {orderEvent}") if orderEvent.Status == OrderStatus.Invalid: self.Debug(f"Invalid OrderEvent: {orderEvent.Message}")