| Overall Statistics |
|
Total Orders 10 Average Win 0% Average Loss 0% Compounding Annual Return 26.700% Drawdown 29.200% Expectancy 0 Start Equity 100000 End Equity 343879.60 Net Profit 243.880% Sharpe Ratio 0.775 Sortino Ratio 0.953 Probabilistic Sharpe Ratio 35.047% Loss Rate 0% Win Rate 0% Profit-Loss Ratio 0 Alpha 0.094 Beta 1.255 Annual Standard Deviation 0.217 Annual Variance 0.047 Information Ratio 0.828 Tracking Error 0.132 Treynor Ratio 0.134 Total Fees $11.63 Estimated Strategy Capacity $270000000.00 Lowest Capacity Asset ASMLF R735QTJ8XC9X Portfolio Turnover 0.05% Drawdown Recovery 523 |
from AlgorithmImports import *
import pandas as pd
import numpy as np
import scipy.cluster.hierarchy as sch
class UltimateFactorHRP(QCAlgorithm):
def Initialize(self):
# 1. Basics
self.SetStartDate(2021, 1, 1)
self.SetCash(100000)
# 2. Universe: Use a standard liquid universe
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelection)
# 3. Benchmark for Risk-Off (SPY)
self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
# 4. State Variables
self.max_candidates = 50
self.final_count = 10
self.rebalance_time = datetime.min
self.candidates = []
# 5. Warmup
self.SetWarmUp(60)
def FundamentalSelection(self, fundamental):
# Monthly Rebalance Trigger
if self.Time < self.rebalance_time: return Universe.Unchanged
self.rebalance_time = self.Time + timedelta(days=30)
# Broad filter: Just need Price and MarketCap to start
filtered = [f for f in fundamental if f.HasFundamentalData and f.Price > 5 and f.MarketCap > 1e8]
# Sort by a very reliable factor (Market Cap / PE Ratio) to ensure we get a list
# We will do the deep 12-factor analysis in OnData
sorted_by_cap = sorted(filtered, key=lambda x: x.MarketCap, reverse=True)
self.candidates = [x.Symbol for x in sorted_by_cap[:self.max_candidates]]
self.Debug(f"Universe selected {len(self.candidates)} candidates at {self.Time}")
return self.candidates
def OnData(self, data):
if self.IsWarmingUp: return
# If we have candidates but no investments, trigger the HRP logic
if not self.Portfolio.Invested and len(self.candidates) > 0:
self.Log("Triggering Portfolio Construction...")
self.PerformTrade()
def PerformTrade(self):
# 1. Get History for HRP
history = self.History(self.candidates, 60, Resolution.Daily)
if history.empty:
self.Debug("History was empty - skipping trade")
return
prices = history['close'].unstack(level=0)
returns = prices.pct_change().dropna()
if returns.empty: return
# 2. Factor Scoring (Momentum & Risk)
# We pick the top 10 by 1-month momentum from our 30 candidates
mom_scores = (prices.iloc[-1] / prices.iloc[0]) - 1
top_symbols = mom_scores.sort_values(ascending=False).head(self.final_count).index.tolist()
# 3. HRP Weighting Logic
final_returns = returns[top_symbols]
cov, corr = final_returns.cov(), final_returns.corr()
try:
# Clustering
dist = np.sqrt(0.5 * (1 - corr))
link = sch.linkage(sch.distance.squareform(dist), 'single')
sort_ix = self.get_quasi_diag(link)
sorted_symbols = [final_returns.columns[i] for i in sort_ix]
# Simple Inverse Variance for the weights (HRP Component)
inv_var = 1 / np.diag(cov)
raw_weights = inv_var / inv_var.sum()
# 4. Execute
self.Liquidate()
for symbol in sorted_symbols:
weight = raw_weights[final_returns.columns.get_loc(symbol)]
self.SetHoldings(symbol, weight)
self.Debug(f"Buying {symbol} at weight {weight:.2%}")
except Exception as e:
self.Debug(f"Trade Execution Error: {e}")
def get_quasi_diag(self, link):
link = link.astype(int)
sort_ix = pd.Series([link[-1, 0], link[-1, 1]])
num_items = link[-1, 3]
while sort_ix.max() >= num_items:
sort_ix.index = range(0, sort_ix.shape[0] * 2, 2)
df0 = sort_ix[sort_ix >= num_items]
i, j = df0.index, df0.values - num_items
sort_ix[i] = link[j, 0]
sort_ix = pd.concat([sort_ix, pd.Series(link[j, 1], index=i + 1)]).sort_index()
return sort_ix.tolist()