Overall Statistics |
Total Orders 248 Average Win 0.30% Average Loss -0.20% Compounding Annual Return 29.635% Drawdown 10.200% Expectancy 0.525 Start Equity 1000000 End Equity 1085181.37 Net Profit 8.518% Sharpe Ratio 0.732 Sortino Ratio 1.018 Probabilistic Sharpe Ratio 46.186% Loss Rate 39% Win Rate 61% Profit-Loss Ratio 1.51 Alpha 0.046 Beta 1.44 Annual Standard Deviation 0.244 Annual Variance 0.06 Information Ratio 0.416 Tracking Error 0.207 Treynor Ratio 0.124 Total Fees $702.01 Estimated Strategy Capacity $68000000.00 Lowest Capacity Asset WING W1BBEDOGB8MD Portfolio Turnover 6.90% |
import numpy as np from AlgorithmImports import * class AssetWeightCalculator: def __init__(self, algorithm: QCAlgorithm): self.algorithm = algorithm self.risk_free = self.algorithm.add_equity("BIL", Resolution.HOUR) def coarse_selection(self, coarse): """ Selects stonks, first filter """ # Sorts by dollar volume before taking top 200 sorted_by_volume = sorted([x for x in coarse if x.price > 10 and x.has_fundamental_data], key=lambda x: x.dollar_volume, reverse=True) return [x.symbol for x in sorted_by_volume][:200] def fine_selection(self, fine): """ Selects stonks, second filter """ filtered = [x.symbol for x in fine if x.market_cap is not None and x.market_cap > 10e9] self.algorithm.debug(f"Fine Selection: {len(filtered)} symbols passed filters") # Doing it this way makes it so that stocks are ranked on each universe update and then the macds can be redone with the scheduler in main ranked_symbols = self.rank_stocks(filtered) return ranked_symbols def calculate_sharpe_ratio(self, symbol, period=4914): # This is 3 yrs worth of trading days """ Calculates the sharpe """ try: # If a KeyValuePair was recieved only take the symbol if hasattr(symbol, "Key"): symbol = symbol.Key history = self.algorithm.history([symbol], period, Resolution.HOUR) if history.empty: self.algorithm.debug(f"No history for {symbol.value}") return None # Get risk-free rate rf_history = self.algorithm.history(self.risk_free.symbol, 1, Resolution.HOUR) risk_free_rate = rf_history['close'].iloc[-1]/100 if not rf_history.empty else 0.02 # Default to 2% if no data # Sharpe ratio logic returns = history['close'].pct_change().dropna() excess_returns = returns - (risk_free_rate/1638) mean_excess_return = excess_returns.mean() * 1638 std_dev = excess_returns.std() * np.sqrt(1638) return mean_excess_return / std_dev if std_dev != 0 else None except Exception as e: self.algorithm.debug(f"Error calculating Sharpe for {symbol.value}: {str(e)}") return None def rank_stocks(self, symbols): """ Ranks da top 50 stocks based on sharpe """ if not symbols: self.algorithm.debug("No symbols to rank") return [] self.algorithm.debug(f"Ranking {len(symbols)} symbols") # Converting from key pair if neccessary symbols = [s.Key if hasattr(s, 'Key') else s for s in symbols] scores = {symbol: self.calculate_sharpe_ratio(symbol) for symbol in symbols} valid_scores = {k: v for k, v in scores.items() if v is not None} self.algorithm.debug(f"Valid Sharpe ratios: {len(valid_scores)} out of {len(symbols)}") if not valid_scores: return [] sorted_scores = sorted(valid_scores, key=valid_scores.get, reverse=True)[:20] self.algorithm.log(f"All symbols before ranking: {[s.value for s in symbols]}") self.algorithm.log(f"Symbols after filtering: {[s.value for s in valid_scores.keys()]}") return sorted_scores def normalize_scores(self, scores): """ The list of scores from the ranking method are normalized using a z score so that an additive operation may be used in WeightCombiner() """ values = np.array(list(scores.values())) mean = np.mean(values) std_dev = np.std(values) if std_dev == 0: # If no variation in scores, assign equal normalized scores return {symbol: 0 for symbol in scores.keys()} normalized_scores = {symbol: (score - mean) / std_dev for symbol, score in scores.items()} print(normalized_scores) #To see output for debugging return normalized_scores
from AlgorithmImports import * class MACDSignalGenerator: def __init__(self, algorithm: QCAlgorithm, symbols: list, cash_buffer: float = 0.05): self.algorithm = algorithm self.symbols = symbols self.cash_buffer = cash_buffer self.macd_indicators = {} # {symbol: {variant: MACD}} # Define MACD parameters for different variants self.macd_variants = { "slow": {"fast": 12, "slow": 26, "signal": 9}, "slow-med": {"fast": 9, "slow": 19, "signal": 5}, "med-fast": {"fast": 7, "slow": 15, "signal": 3}, "fast": {"fast": 5, "slow": 12, "signal": 2}, } def remove_symbols(self, symbols: list): """ Removes MACD indicators for the specified symbols. """ for symbol in symbols: # Liquidate position before removing indicator self.algorithm.liquidate(symbol) # Unregister and delete indicators tied to each symbol if symbol in self.macd_indicators: for macd in self.macd_indicators[symbol].values(): # Better: gets MACD objects directly self.algorithm.unregister_indicator(macd) del self.macd_indicators[symbol] def add_symbols(self, new_symbols): """ Add in the new symbols that are given by AssetWeightCalculator. """ # Log initial attempt self.algorithm.debug(f"Attempting to add symbols: {[s.value for s in new_symbols]}") # Get historical data for new symbols history = self.algorithm.history([s for s in new_symbols], 35, # Longest MACD period needed Resolution.HOUR) # Log history data availability self.algorithm.debug(f"History data available for: {history.index.get_level_values(0).unique()}") self.symbols.extend(new_symbols) for symbol in new_symbols: security = self.algorithm.securities[symbol] # Detailed security check logging # self.algorithm.debug(f"Security {symbol.value} check:" # f" has_data={security.has_data}," # f" is_tradable={security.is_tradable}," # f" price={security.price}") # Checking if price is 0 if not (security.has_data and security.is_tradable and security.price > 0): self.algorithm.debug(f"Waiting for valid price data: {symbol.value}") continue # Adding the symbol if symbol not in self.macd_indicators: self.macd_indicators[symbol] = {} # Get symbol's historical data if symbol not in history.index.get_level_values(0): self.algorithm.debug(f"No history data for: {symbol.value}") continue symbol_history = history.loc[symbol] self.algorithm.debug(f"History rows for {symbol.value}: {len(symbol_history)}") for variant, params in self.macd_variants.items(): macd = self.algorithm.macd( symbol=symbol, fast_period=params["fast"], slow_period=params["slow"], signal_period=params["signal"], type=MovingAverageType.EXPONENTIAL, resolution=Resolution.HOUR, selector=Field.CLOSE ) self.macd_indicators[symbol][variant] = macd # Warm up MACD with historical data for time, row in symbol_history.iterrows(): macd.update(time, row['close']) self.macd_indicators[symbol][variant] = macd def calculate_position_sizes(self): position_sizes = {} max_position_limit = 0.1 # Check if we have any symbols to process if not self.symbols or not self.macd_indicators: self.algorithm.debug("No symbols available for position calculation") return position_sizes # Calculating the maximum one variant can be in size max_position = (1 - self.cash_buffer) / (len(self.symbols) * len(self.macd_variants)) for symbol in self.macd_indicators: position_sizes[symbol] = {} for variant, macd in self.macd_indicators[symbol].items(): if macd.is_ready: security = self.algorithm.securities[symbol] # Detailed security check logging # self.algorithm.debug(f"Position Check for {symbol.value}:" # f" has_data={security.has_data}," # f" is_tradable={security.is_tradable}," # f" price={security.price}," # f" last_data={security.get_last_data() is not None},") # More comprehensive check # if not (security.has_data and # security.is_tradable and # security.price > 0 and # security.get_last_data() is not None): # self.algorithm.debug(f"Security not ready: {symbol.value}") # continue # Distance between fast and slow distance = macd.fast.current.value - macd.slow.current.value # Normalize the distance as a percentage difference and then as a fraction of max position position_size = max_position * (distance / macd.slow.current.value) * 70 # Scalar value of max_position, the scalar integer can be though of as a form of leverage setting # Only allow positive positions, cap at maximum position_size = max(0, min(position_size, max_position_limit)) position_sizes[symbol][variant] = position_size #self.algorithm.debug(f"Calculated position for {symbol.value} {variant}: {position_size}") else: position_sizes[symbol][variant] = 0 # Running daily cause the logging is too heavy hourly if self.algorithm.time.hour == 10 and self.algorithm.time.minute == 0: rounded_positions = [(s.value, {k: round(v, 5) for k, v in sizes.items()}) for s, sizes in position_sizes.items()] #self.algorithm.debug(f"Daily position sizes proposed: {rounded_positions}") return position_sizes
from AlgorithmImports import * from ContinuousMACDSignalGenerator import MACDSignalGenerator from AssetWeightCalculator import AssetWeightCalculator class TestMACDInitializationAlgorithm(QCAlgorithm): def Initialize(self): self.set_start_date(2024, 2, 7) self.set_end_date(2024, 6, 1) self.set_cash(1000000) self.high_water_mark = self.portfolio.total_portfolio_value self.set_benchmark("SPY") self.bond_etf = self.add_equity("BIL", Resolution.HOUR) self.spy = self.add_equity("SPY", Resolution.HOUR) # Initialize 50-week SMA with historical data history = self.history([self.spy.symbol], 1750, Resolution.HOUR) # Create and warm up the SMA self.spy_sma = self.SMA(self.spy.symbol, 1750, Resolution.HOUR) if not history.empty: for time, row in history.loc[self.spy.symbol].iterrows(): self.spy_sma.update(time, row['close']) self.debug(f"SMA initialized: {self.spy_sma.is_ready}, Current Value: {self.spy_sma.current.value}") # Initialize tracking set for universe changes self.current_symbols = set() # Initialize the asset weight calculator self.asset_calculator = AssetWeightCalculator(self) # Add universe for coarse and fine selection self.spy = self.add_equity("SPY", Resolution.HOUR) self.add_universe(self.asset_calculator.coarse_selection, self.asset_calculator.fine_selection) # Universe settings self.universe_settings.Resolution = Resolution.HOUR # Initialize MACD generator self.macd_generator = MACDSignalGenerator(self, []) # Scheduled ranking update self.schedule.on(self.date_rules.week_start("SPY", 3), self.time_rules.after_market_open("SPY", 1), self.rank_and_update_symbols ) # Schedule Wednesday 11:30 rebalancing self.schedule.on( self.date_rules.week_start("SPY", 3), self.time_rules.after_market_open("SPY", 120), self.rebalance_positions ) def rank_and_update_symbols(self): # Skip during warmup if self.is_warming_up: self.debug("Skipping rank_and_update during warmup") return # Get new universe composition new_symbols = set(self.active_securities.keys) # Determine added and removed symbols removed_symbols = self.current_symbols - new_symbols added_symbols = new_symbols - self.current_symbols # Update tracking set self.current_symbols = new_symbols # Handle removals and additions if removed_symbols: self.macd_generator.remove_symbols(list(removed_symbols)) self.log(f"Weekly Update - Removed: {[x.value for x in removed_symbols]}") if added_symbols: self.macd_generator.add_symbols(list(added_symbols)) self.log(f"Weekly Update - Added: {[y.value for y in added_symbols]}") # Rank current universe and update MACD ranked_symbols = self.asset_calculator.rank_stocks(self.active_securities) self.macd_generator.symbols = ranked_symbols # self.debug(f"Weekly Update - Ranked Symbols: {[s.value for s in ranked_symbols]}") def on_data(self, data: slice): # If anything is still needing to recieve data before running then return if self.is_warming_up: return # Update high water mark self.high_water_mark = max(self.high_water_mark, self.portfolio.total_portfolio_value) # Calculate drawdown drawdown = (self.portfolio.total_portfolio_value / self.high_water_mark) - 1 # Example: Exit if drawdown exceeds threshold if drawdown < -0.15: # 15% drawdown self.high_water_mark = self.portfolio.total_portfolio_value self.liquidate() self.debug(f"Hitting stop!") self.macd_generator.calculate_position_sizes() def rebalance_positions(self): """Actual position rebalancing 119 mins after re-ranking""" if self.is_warming_up: return # Trying to use a market regime filter to reduce drawdowns if self.spy.price < self.spy_sma.current.value: # Check if we're already heavily in bonds bond_position = self.portfolio[self.bond_etf.symbol].holdings_value / self.portfolio.total_portfolio_value if bond_position >= 0.5: # Already more than 50% in bonds self.debug(f"Maintaining bond position at {bond_position:.2%}") # Liquidate any non-bond positions for symbol in self.portfolio.keys(): if symbol != self.bond_etf.symbol: self.liquidate(symbol) return self.debug(f"Bearish regime - Moving to bonds. SPY: {self.spy.price}, SMA: {self.spy_sma.current.value}") self.liquidate() self.set_holdings(self.bond_etf.symbol, 1.0) return # Bullish regime - first liquidate any bond position if self.portfolio[self.bond_etf.symbol].holdings_value > 0: self.debug("Exiting bonds and returning to MACD strategy") self.liquidate(self.bond_etf.symbol) position_sizes = self.macd_generator.calculate_position_sizes() # Apply the positions for symbol, variants in position_sizes.items(): security = self.securities[symbol] if not security.has_data or not security.is_tradable: self.debug(f"Skipping trade for {symbol.value} - not ready") continue total_size = sum(variants.values()) # Use variant values if abs(total_size) > 0.001: # Minimum position size threshold self.set_holdings(symbol, total_size) self.debug(f"Setting {symbol.value} position to {total_size}") def on_warmup_finished(self): pass