| Overall Statistics |
|
Total Orders 4636 Average Win 0.91% Average Loss -0.57% Compounding Annual Return 39.047% Drawdown 45.100% Expectancy 0.164 Start Equity 100000 End Equity 465937.57 Net Profit 365.938% Sharpe Ratio 0.814 Sortino Ratio 1.255 Probabilistic Sharpe Ratio 23.178% Loss Rate 55% Win Rate 45% Profit-Loss Ratio 1.60 Alpha 0.218 Beta 1.244 Annual Standard Deviation 0.406 Annual Variance 0.165 Information Ratio 0.7 Tracking Error 0.343 Treynor Ratio 0.266 Total Fees $43948.37 Estimated Strategy Capacity $1800000.00 Lowest Capacity Asset PCSA XI5N30EK4UP1 Portfolio Turnover 28.15% |
from AlgorithmImports import *
import numpy as np
import pandas as pd
from System import DayOfWeek
class DynamicTop20LiquidStocks(QCAlgorithm):
def Initialize(self):
# Set backtest period and cash
self.SetStartDate(2020, 1, 1)
self.SetEndDate(2024, 8, 31)
self.SetCash(100000)
# Warm-up for 252 days so that historical data is available for return calculations.
self.SetWarmUp(252)
# Use dynamic coarse universe selection to pick the top 20 stocks by volume
self.AddUniverse(self.CoarseSelectionFunction)
# Initialize variable to hold current universe symbols
self.selectedSymbols = []
# Schedule the rebalancing function for every Wednesday at 11:30 AM
self.Schedule.On(self.DateRules.Every(DayOfWeek.Wednesday),
self.TimeRules.At(11, 30),
self.RebalancePortfolio)
def CoarseSelectionFunction(self, coarse):
# Filter to stocks that have fundamental data.
filtered = [x for x in coarse if x.HasFundamentalData]
# Sort by volume in descending order and select the top 20
sorted_coarse = sorted(filtered, key=lambda x: x.Volume, reverse=True)
top20 = [x.Symbol for x in sorted_coarse[:20]]
self.selectedSymbols = top20
return top20
def RebalancePortfolio(self):
# Liquidate all existing holdings to free up cash before rebalancing.
self.Liquidate()
# Ensure we have symbols to work with.
if not self.selectedSymbols:
self.Debug("No symbols in universe selection; skipping rebalancing.")
return
# Retrieve historical daily price data for the selected symbols over the past 253 days.
history = self.History(self.selectedSymbols, 253, Resolution.Daily)
if history.empty:
self.Debug("Historical data is empty; skipping rebalancing.")
return
# Prepare a dictionary to store daily returns for each symbol.
returns_dict = {}
# Group historical data by symbol and compute daily returns.
for symbol in self.selectedSymbols:
# Check if the symbol is in the historical data index.
if symbol not in history.index.get_level_values(0):
self.Debug(f"Symbol {symbol} not found in historical data; skipping.")
continue
try:
# Get the data for the symbol and sort by time.
df = history.loc[symbol].sort_index()
# Ensure we have enough data.
if len(df) < 253:
self.Debug(f"Not enough data for {symbol}; skipping.")
continue
# Calculate daily returns: (close_today / close_yesterday) - 1.
df['return'] = df['close'].pct_change()
returns = df['return'].dropna().values
# Take only the last 252 returns.
if len(returns) >= 252:
returns_dict[symbol] = returns[-252:]
except Exception as e:
self.Debug(f"Error processing {symbol}: {e}")
# If no symbol has sufficient data, exit the rebalancing routine.
if not returns_dict:
self.Debug("Not enough historical data for any symbol; skipping rebalancing.")
return
# List of symbols with valid data.
symbols_list = list(returns_dict.keys())
n = len(symbols_list)
# Build a matrix of shape (n, 252) where each row corresponds to a symbol's returns.
returns_matrix = np.array([returns_dict[s] for s in symbols_list])
# Compute the expected daily return (average return) for each symbol.
expected_daily_returns = returns_matrix.mean(axis=1)
# Compute the covariance matrix.
cov_matrix = np.cov(returns_matrix)
# Invert the covariance matrix. If the matrix is singular, log and exit.
try:
inv_cov = np.linalg.inv(cov_matrix)
except np.linalg.LinAlgError:
self.Debug("Covariance matrix not invertible; skipping rebalancing.")
return
# Define the daily risk free rate: 2.5%/252.
risk_free_rate_daily = 0.025 / 252
# Calculate expected daily excess return by subtracting the risk free rate.
expected_excess_returns = expected_daily_returns - risk_free_rate_daily
# Compute raw weights using matrix multiplication.
raw_weights = inv_cov.dot(expected_excess_returns)
# Set any negative weights to zero.
raw_weights = np.where(raw_weights < 0, 0, raw_weights)
# Normalize the weights so that they sum to 1.
weight_sum = np.sum(raw_weights)
if weight_sum > 0:
weights = raw_weights / weight_sum
else:
weights = raw_weights # all zero weights
# Log the computed weights for debugging.
self.Debug("Rebalancing on {}:".format(self.Time))
for i, symbol in enumerate(symbols_list):
self.Debug(f"{symbol.Value}: weight = {weights[i]:.4f}")
# Set portfolio target holdings based on the calculated weights.
for i, symbol in enumerate(symbols_list):
self.SetHoldings(symbol, weights[i])