# Hierarchical Risk Parity ## Introduction Investment managers face the daily challenge of constructing portfolios that reflect their views and predictions on risks and returns. Hierarchical Risk Parity (HRP) is an innovative portfolio allocation method designed to address this challenge. **Key Idea:** HRP combines machine learning techniques with financial theory to build diversified portfolios that aim to perform well under various market conditions. ### Strengths of HRP * **Robustness:** Doesn't require the covariance matrix to be invertible, unlike traditional mean-variance optimization. * **Handles Ill-Conditioned Data:** Can work with singular or nearly singular covariance matrices. * **Reduced Risk:** Empirical studies have shown HRP often produces portfolios with lower out-of-sample variance compared to other methods like the Critical Line Algorithm (CLA). * **Strong Performance:** HRP portfolios have historically outperformed traditional approaches in various market scenarios. ## HRP Algorithm: A Three-Stage Process 1. **Tree Clustering:** Groups assets based on their correlation, creating a hierarchical structure resembling a tree. 2. **Quasi-Diagonalization:** Reorganizes the covariance matrix to place highly correlated assets together, aiding in risk management. 3. **Recursive Bisection:** Allocates weights to assets within each cluster, balancing risk and return based on an inverse-variance approach. ### Tree Clustering This stage groups assets with similar risk profiles together. It uses a distance metric based on pairwise correlations: #### Algorithm :::success <font color=blue>Algorithm: Tree Clustering </font> Input: $T \times N$ matrix $X$ of observations (e.g., returns series of $N$ variables over $T$ periods) Output: Hierarchical structure of clusters 1. Compute $N \times N$ correlation matrix $\rho$: $\rho = (\text{corr}(X_i, X_j))_{i,j = 1,\dots,N}$ 2. Compute $N \times N$ distance matrix $D$: $D = (d_{ij})_{i,j = 1,...,N}$ where $d_{ij} = \sqrt{\frac{1 - \rho_{ij}}{2}}$ 3. Compute pairwise Euclidean distances $\tilde{d}$: $\tilde{d}_{ij} = \sqrt{\sum_{k=1}^N (d_{ki} - d_{kj})^2}$ 4. Initialize clusters: $C = \{\{1\}, \{2\}, \dots, \{N\}\}$ 5. While $|C| > 1$: a. Find $(i^*, j^*) = \arg\min_{i,j, i \neq j} {\tilde{d}_{ij}}$ b. Create new cluster $u = C[i^*] ∪ C[j^*]$ c. Update distances: For each cluster $v$ in $C$, $v \neq C[i^*]$, $v \neq C[j^*]$: $\tilde{d}[u,v] = \min(\tilde{d}[C[i^*],v], \tilde{d}[C[j^*],v])$ d. Update $C$: $C = C \backslash \{C[i^*], C[j^*]\} \cup \{u\}$ e. Update $\tilde{d}$ matrix: - Remove rows and columns for $C[i^*]$ and $C[j^*]$ - Add row and column for u 6. Return final cluster hierarchy ::: #### Code Implementation ```python= def correlDist(corr): # A distance matrix based on correlation, where 0<=d[i,j]<=1 # This is a proper distance metric dist=((1-corr)/2.)**.5 # distance matrix return dist dist=correlDist(corr) condensed_dist = squareform(dist) link=sch.linkage(condensed_dist,'single') ``` **Linkage Matrix:** This matrix describes the hierarchical clustering process, capturing which assets are merged at each step and their corresponding distance. **Dendrogram Interpretation:** The dendrogram is a tree-like structure where the leaves represent the individual data points, and the branches represent the merges. The height of the merge indicates the dissimilarity between the merged clusters. ![005-visualizing-dendrograms-cutree-1](https://hackmd.io/_uploads/rkMGe7eZC.png) #### Linkage Methods * **Complete Linkage:** Maximum distance between points in two clusters. $$\max_{x \in C_i,\,y \in C_j}d(x, y)$$ * **Single Linkage:** Maximum distance between points in two clusters. $$\min_{x \in C_i,\,y \in C_j}d(x, y)$$ * **Ward's Method:** Minimizes the increase in within-cluster variance when merging clusters. $$\sum_{x \in C_i \bigcup C_j}d(x, \mu_{C_i \bigcup C_j})$$ ### Quasi-Diagonalization This step reorders the rows and columns of the covariance matrix. The goal is to place the largest elements along the diagonal, which helps to visualize clusters and relationships between assets. **Key Benefit:** Quasi-diagonalization places similar investments together and dissimilar investments further apart. This can be helpful in understanding risk concentrations within a portfolio. #### Algorithm :::success <font color=blue>Algorithm: Quasi-Diagonalization</font> Input: - Linkage matrix `Y` from hierarchical clustering - Original covariance matrix `Sigma` Output: - Reordered covariance matrix `Sigma'` 1. Initialize order list `L = []` 2. Function `QuasiDiagonalize(cluster)`: * If `cluster` is a leaf node (original asset): * Append cluster to `L` * Else: * `QuasiDiagonalize(cluster.left_child)` * `QuasiDiagonalize(cluster.right_child)` 3. Start with the root cluster (last row of $Y$): * `QuasiDiagonalize((Y[N-1, 1], Y[N-1, 2]))` 4. Reorder `Sigma` based on `L`: `Sigma' = Sigma[L, :][:, L]` 5. Return `Sigma'` ::: #### Code Implementation ```python= def getQuasiDiag(link): # Sort clustered items by distance link=link.astype(int) sortIx=pd.Series([link[-1,0],link[-1,1]]) numItems=link[-1,3] # number of original items while sortIx.max()>=numItems: sortIx.index=range(0,sortIx.shape[0]*2,2) # make space df0=sortIx[sortIx>=numItems] # find clusters i=df0.index;j=df0.values-numItems sortIx[i]=link[j,0] # item 1 df0=pd.Series(link[j,1],index=i+1) sortIx=sortIx.append(df0) # item 2 sortIx=sortIx.sort_index() # re-sort sortIx.index=range(sortIx.shape[0]) # re-index return sortIx.tolist() ``` ## Recursive Bisection This is where the actual asset weights are determined. The algorithm recursively divides the asset clusters into two equal subsets and rebalances the weights using an inverse-variance approach. **Constraints:** The recursive bisection algorithm is flexible and can easily incorporate constraints on individual asset weights or groups of assets. :::success <font color=blue>Algorithm: Recursive Bisection</font> Input: - Covariance matrix $V$ - Quasi-diagonalized order of assets $L$ Output: - Portfolio weights $w$ 1. Initialize: * $L = \{L_0\}$, where $L_0 = \{1, \dots, N\}$ * $w_n = 1$ for $n = 1, \dots, N$ 2. While any $|L_i| > 1$ for $L_i \in L$: * For each $L_i \in L$ with $|L_i| > 1$: * Bisect $L_i$ into $L^{(1)}_i$ and $L^{(2)}_i$: * $L^{(1)}_i$ is the first $\text{int}(\frac{|L_i|}{2})$ elements of $|L_i|$ * $L^{(2)}_i$ is the remaining elements of $L_i$ * Compute inverse-variance weights for each subset $j = 1, 2$: * $V^{(j)}_i$ is the covariance matrix between the constituents of the $L^{(j)}_i$ bisection * $\tilde{W}^{(j)}_i = \frac{\text{diag}[V^{(j)}_i ]^{−1}}{\text{tr}[\text{diag}[V^{(j)}_i]^{−1}]}$ * $\tilde{v}^{(j)}_i ≡ (\widetilde{W}^{(j)}_i)^T V^{(j)}_i \widetilde{W}^{(j)}_i$ * Compute split factor: $\alpha = 1 - \frac{\tilde{v}^{(1)}_i}{\tilde{v}^{(1)}_i + \tilde{v}^{(2)}_i}$ $\in [0,1]$ * Update weights: * For $n \in L^{(1)}_i$: $w_n \leftarrow \alpha w_n$ * For $n \in L^{(2)}_i$: $w_n \leftarrow (1 - \alpha) w_n$ * Update $L$: $L = L \backslash \{L_i\} \cup \{L^{(1)}_i, L^{(2)}_i\}$ 3. Return $W$ ::: ### Code Implementation ```python= def getIVP(cov,**kargs): # Compute the inverse-variance portfolio ivp=1./np.diag(cov) ivp/=ivp.sum() return ivp def getClusterVar(cov,cItems): # Compute variance per cluster cov_=cov.loc[cItems,cItems] # matrix slice w_=getIVP(cov_).reshape(-1,1) cVar=np.dot(np.dot(w_.T,cov_),w_)[0,0] return cVar def getRecBipart(cov,sortIx): # Compute HRP alloc w=pd.Series(1,index=sortIx) cItems=[sortIx] # initialize all items in one cluster while len(cItems)>0: cItems=[i[j:k] for i in cItems for j,k in ((0,len(i)/2),\ (len(i)/2,len(i))) if len(i)>1] # bi-section for i in xrange(0,len(cItems),2): # parse in pairs cItems0=cItems[i] # cluster 1 cItems1=cItems[i+1] # cluster 2 cVar0=getClusterVar(cov,cItems0) cVar1=getClusterVar(cov,cItems1) alpha=1-cVar0/(cVar0+cVar1) w[cItems0]*=alpha # weight 1 w[cItems1]*=1-alpha # weight 2 return w ``` ### Covariance Predictors We can see that only the method of predicting the covariance matrix directly affects the calculation of each asset weight. Here we will introduce two difference covariance predictors: Rolling Window and Exponentially Weighted Moving Average (EWMA) #### Rolling Window ```python= def rolling_window(returns, memory, min_periods=20): rollw=pd.DataFrame() min_periods = max(min_periods, 1) times = returns.index assets = returns.columns returns = returns.values Sigmas = np.zeros((returns.shape[0], returns.shape[1], returns.shape[1])) Sigmas[0] = np.outer(returns[0], returns[0]) for t in range(1, returns.shape[0]): alpha_old = 1 / min(t + 1, memory) alpha_new = 1 / min(t + 2, memory) if t >= memory: Sigmas[t] = alpha_new / alpha_old * Sigmas[t - 1] + alpha_new * ( np.outer(returns[t], returns[t]) - np.outer(returns[t - memory], returns[t - memory]) ) else: Sigmas[t] = alpha_new / alpha_old * Sigmas[t - 1] + alpha_new * ( np.outer(returns[t], returns[t]) ) Sigmas = Sigmas[min_periods - 1 :] times = times[min_periods - 1 :] rollw=pd.concat([pd.DataFrame(Sigmas[t], index=assets, columns=assets) for t in range(len(times))], keys=[times[t].date() for t in range(len(times))]) return rollw ``` #### Exponentially Weighted Moving Average (EWMA) ``` ``` ## Example: US Finance Stocks and Futures Portfolio ```python= import matplotlib.pyplot as mpl import scipy.cluster.hierarchy as sch from scipy.spatial.distance import squareform import numpy as np import pandas as pd import yfinance as yf # Yahoo Finance API from datetime import timedelta ,date from dateutil.relativedelta import relativedelta ``` To showcase the effectiveness of HRP, we constructed a portfolio consisting of 35 diversified assets: * **US Stocks:** `AAPL`, `ABT`, `ADBE`, `AMAT`, `AMZN`, `AVY`, `BALL`, `BAX`, `BDX`, `CMI`, `CPB`, `CSX`, `GILD`, `HAS`, `INSM`, `KO`, `MCD`, `MMM`, `MSFT`, `NVDA`, `PFE`, `TGT`, `TJX`, `TSM`, `WFC`, `XOM`, `YUM` * **Futures Contracts:** `GC=F` (Gold), `HG=F` (Copper), `SI=F` (Silver), `^FVX` (CBOE Volatility Index), `^GSPC` (S&P 500), `^NDX` (Nasdaq 100), `^TNX` (10-Year Treasury Yield), `^TYX` (30-Year Treasury Yield) The portfolio was rebalanced monthly using an 8-month lookback period, spanning from September 2000 to May 2024. ```python= class Stocks(): def __init__(self, arg_stocks_list=[], arg_begin='2015-01-01', arg_end='2023-10-01'): """ Initialize the Stocks class. Parameters: - arg_stocks_list: List of stock symbols. For example: ['2330.TW','2337.TW','2357.TW','2454.TW','3231.TW','3443.TW'] or ['TSM','AAPL','GOOG','MSFT','AMZN','GOOGL'] - arg_begin: Start date for the stock data. - arg_end: End date for the stock data. Returns: - None: This function doesn't return anything but initializes the class instance. """ self.stocks_list = arg_stocks_list # List of stock symbols self.stocks_list.sort() self.start_date = arg_begin # Start date self.end_date = arg_end # End date self.N = len(self.stocks_list) # Number of stocks self.data = None self.set_data() self.M = len(self.data) # The number of rows in the data self.rtns = pd.DataFrame() self.log_rtns = pd.DataFrame() self.set_rtns() def set_data(self): self.data = pd.DataFrame() # Initialize an empty DataFrame to store data # Iterate over each stock in the list of stocks for stock in self.stocks_list: # Download the stock data using the yfinance library df = yf.download(stock, start=self.start_date, end=self.end_date) # Select only the adjusted closing price from the downloaded data df = df[['Adj Close']] # Rename the column to the stock symbol for clarity df = df.rename(columns = {'Adj Close': stock}) # Check if the main DataFrame is empty if self.data.empty: # If it is, assign the downloaded data to the main DataFrame self.data = df else: # If it's not, concatenate the downloaded data to the main DataFrame self.data = pd.concat([self.data, df], axis=1) return def set_rtns(self): self.rtns = self.data.pct_change() + 1 self.log_rtns = np.log(self.data.pct_change() + 1) self.rtns.fillna(1, inplace=True) self.log_rtns.fillna(1, inplace=True) return def plotComparePrice(self,use_log_rtns=True): mpl.figure(figsize=(20,12)) mpl.title('Normalized Prices', fontsize=20) mpl.xticks(fontsize=16) mpl.yticks(fontsize=16) if use_log_rtns: mpl.ylabel('Logarithmic Price', fontsize=16) mpl.plot(1+ np.log(self.data/self.data.loc[self.data.index[0]]),label=self.data.columns) else: mpl.ylabel('Price', fontsize=16) mpl.plot((self.data/self.data.loc[self.data.index[0]]),label=self.data.columns) mpl.legend(loc='upper left', bbox_to_anchor=(1, 1), fontsize=12) mpl.savefig('ComparePrice.png', format='png', bbox_inches='tight') mpl.clf();mpl.close() return stocks = Stocks(stocks_list,start_date ,end_date) stocks.plotComparePrice() ``` ![ComparePrice](https://i.imgur.com/mRLDHTN.png) --- ```python= def testPerformance(): # data start_date = date(2000,1,1) end_date = date(2024,6,1) td = (end_date - start_date)/timedelta(days=1) stocks_list=['AAPL', 'ABT', 'ADBE', 'AMAT', 'AMZN', 'AVY', 'BALL', 'BAX', 'BDX', 'CMI', 'CPB', 'CSX','GC=F', 'GILD', 'HAS','HG=F', 'INSM', 'KO', 'MCD', 'MMM', 'MSFT', 'NVDA', 'PFE','SI=F', 'TGT', 'TJX', 'TSM', 'WFC', 'XOM', 'YUM', '^FVX', '^GSPC', '^NDX', '^TNX', '^TYX'] stocks = Stocks(stocks_list,start_date ,end_date) weight_history=pd.DataFrame() tm=8 assetHRP=1 assetEW=1 assetRandom_1=1 assetRandom_2=1 assetRandom_3=1 xl=[start_date + relativedelta(months=+(tm))] y_HRP=[1] y_EW=[1] y_Random_1=[1] y_Random_2=[1] y_Random_3=[1] np.random.seed(0) rollw=rolling_window(stocks.rtns, memory=168, min_periods=168) for i in range(int((td/30)-tm-4)): x=stocks.rtns[start_date + relativedelta(months=+i) : start_date + relativedelta(months=+(tm+i))] dt=start_date + relativedelta(months=+(tm+i)) temp=rollw[dt:dt] while temp.empty: dt=dt + relativedelta(days=+1) temp=rollw[dt:dt] cov=pd.DataFrame(temp.values, index=temp.columns, columns=temp.columns) corr=x.corr() # cluster dist=correlDist(corr) condensed_dist = squareform(dist) link=sch.linkage(condensed_dist,'single') sortIx=getQuasiDiag(link) sortIx=corr.index[sortIx].tolist() # recover labels # Capital allocation hrp=getRecBipart(cov,sortIx) weight_history=pd.concat([weight_history,pd.DataFrame([hrp],index=[start_date + relativedelta(months=+(tm+i))])]) tempHRP=hrp.copy() tempEW=0 tempRandom_1=hrp.copy() rd_w_1=random_weight(len(stocks.rtns.columns)) tempRandom_2=hrp.copy() rd_w_2=random_weight(len(stocks.rtns.columns)) tempRandom_3=hrp.copy() rd_w_3=random_weight(len(stocks.rtns.columns)) k=len(stocks.rtns.columns)-1 for key in stocks_list: rtn=float(stocks.rtns[start_date + relativedelta(months=+(tm+i)): start_date + relativedelta(months=+(tm+1+i))].cumprod(axis = 0).iloc[-1][key]) tempHRP[key]*=rtn tempRandom_1[key]=rd_w_1[k]*rtn tempRandom_2[key]=rd_w_2[k]*rtn tempRandom_3[key]=rd_w_3[k]*rtn k-=1 tempEW+=rtn tempEW/=len(stocks_list) assetHRP*=tempHRP.sum() assetEW*=tempEW assetRandom_1*=tempRandom_1.sum() assetRandom_2*=tempRandom_2.sum() assetRandom_3*=tempRandom_3.sum() xl.append(start_date + relativedelta(months=+(tm+i))) y_HRP.append(assetHRP) y_EW.append(assetEW) y_Random_1.append(assetRandom_1) y_Random_2.append(assetRandom_2) y_Random_3.append(assetRandom_3) plotPerformance(xl,y_HRP.copy(),y_EW.copy(),y_Random_1.copy(),y_Random_2.copy(),y_Random_3.copy()) plotHistoryWeight(weight_history) summary_stat(y_HRP.copy()) summary_stat(y_EW.copy()) summary_stat(y_Random_1.copy()) summary_stat(y_Random_2.copy()) summary_stat(y_Random_3.copy()) plotDrawdown(xl,y_HRP.copy(),y_EW.copy(),y_Random_1.copy(),y_Random_2.copy(),y_Random_3.copy()) return def random_weight(arg_len): rd = np.random.random(size=arg_len-1) rd.sort() rd_w=rd.copy() rd_w=np.append(rd_w,1) for k in range(len(rd)): rd_w[k+1]=rd_w[k+1]-rd[k] return rd_w ``` ### Tree Clustering The dendrogram below illustrates the hierarchical clustering of assets, revealing natural groupings based on their correlation structure. ```python= def plotDendrogram(path,linkage,labels=None): if labels is None:labels=[] p = len(labels) mpl.figure(figsize=(12,6)) mpl.title('Hierarchical Clustering', fontsize=20) mpl.ylabel('stock symbol', fontsize=16) mpl.xlabel('distance', fontsize=16) # call dendrogram to get the returned dictionary # (plotting parameters can be ignored at this point) R = sch.dendrogram( linkage, truncate_mode='lastp', # show only the last p merged clusters p=p, # show only the last p merged clusters no_plot=True, orientation='right' ) # create a label dictionary temp = {R["leaves"][ii]: labels[ii] for ii in range(len(R["leaves"]))} def llf(xx): return "{}".format(temp[xx]) sch.dendrogram( linkage, truncate_mode='lastp', # show only the last p merged clusters p=p, # show only the last p merged clusters color_threshold=0.35, # If color_threshold is None or ‘default’,the threshold is set to 0.7*max(Z[:,2]) leaf_label_func=llf, # leaf_rotation=60., leaf_font_size=12., show_contracted=True, # to get a distribution impression in truncated branches orientation='right', ) mpl.savefig(path, format='png', bbox_inches='tight') mpl.clf();mpl.close() # reset pylab return ``` Use `color_threshold=0.35` let the height less than 0.35 be same color, which also meaning they are highly correlated assets ![HRP_Dendrogram_single](https://i.imgur.com/z8zPHJ8.png) ### Quasi-Diagonalization By reordering the correlation matrix based on the clustering, we obtain a quasi-diagonalized matrix. This visually highlights blocks of highly correlated assets, aiding in risk assessment. ```python= def plotCorrMatrix(path,corr,labels=None): # Heatmap of the correlation matrix if labels is None:labels=[] mpl.figure(figsize=(8,6)) mpl.pcolor(corr) mpl.colorbar() mpl.yticks(np.arange(.5,corr.shape[0]+.5),labels) mpl.xticks(np.arange(.5,corr.shape[0]+.5),labels,rotation = 60.) mpl.savefig(path) mpl.clf();mpl.close() # reset pylab return plotCorrMatrix('HRP_corr.png',corr,labels=corr.columns) ``` **Correlation matrix** ![HRP3_corr0](https://hackmd.io/_uploads/HkHRzruL0.png) ```python= sortIx=getQuasiDiag(link) sortIx=corr.index[sortIx].tolist() # recover labels df0=corr.loc[sortIx,sortIx] # reorder plotCorrMatrix('HRP_corr_Q_diag.png',df0,labels=df0.columns) ``` **Quasi-diagonalisation of the correlation matrix** ![HRP3_corr1](https://hackmd.io/_uploads/Sy5RMB_UR.png) ### Recursive Bisection and Performance: The final HRP portfolio weights were determined through recursive bisection. Here we use Covariance Predictors-Rolling Window to Predict the covariance matrix of rebalance month. **History Weight** ```python= def plotHistoryWeight(w_history): tot = np.zeros(len(w_history)) mpl.figure(figsize=(30,13)) mpl.title('History of weight values', fontsize=30) mpl.xticks(fontsize=16) mpl.yticks(fontsize=16) mpl.ylabel('Weights', fontsize=24) for col_idx in w_history: mpl.fill_between(x=w_history.index.tolist(), y1=w_history[col_idx].values+tot,y2=tot,step='post',label=col_idx) tot += w_history[col_idx].values mpl.legend(loc='upper left', bbox_to_anchor=(1, 1), fontsize=12) mpl.savefig('HistoryWeight.png', format='png', bbox_inches='tight') mpl.clf();mpl.close() return ``` **With Rolling Window** ![History of weight values](https://i.imgur.com/MBA6b8a.png) **With EWMA** ![]() **Compare Performance** The resulting portfolio's performance is compared against equal-weighted (EW), Buy and hold portfolio (B&H) and randomly weighted portfolios in the figures below. ```python= def plotPerformance(d,target1,target2,target3,target4,target5): mpl.figure(figsize=(12,6)) mpl.plot(d, target1, 'r',label='HRP') mpl.plot(d, target2, 'b',label='EW') mpl.plot(d, target3, 'g',label='Random_1') mpl.plot(d, target4, 'm',label='Random_2') mpl.plot(d, target5, 'c',label='Random_3') mpl.title('Performance', fontsize=20) mpl.xticks(rotation = 60.) mpl.ylabel('cumprod', fontsize=16) mpl.legend() mpl.savefig('Performance.png', format='png', bbox_inches='tight') mpl.clf();mpl.close() return ``` ![performance](https://i.imgur.com/KCytkSS_d.webp?maxwidth=760&fidelity=grand) **History Drawdown** ```python= def historyDrawdown(target): history_drawdown=[] max_price=target[0] for price in target: if price > max_price: max_price=price history_drawdown.append((price - max_price) *100 / max_price) return history_drawdown def plotDrawdown(d,target1,target2,target3,target4,target5): drawdown_HRP=historyDrawdown(target1) drawdown_EW=historyDrawdown(target2) drawdown_Random_1=historyDrawdown(target3) drawdown_Random_2=historyDrawdown(target4) drawdown_Random_3=historyDrawdown(target5) mpl.figure(figsize=(12,6)) mpl.title('History Drawdown(%)', fontsize=16) mpl.plot(d, drawdown_HRP, 'r',label='HRP Drawdown(%)') mpl.plot(d, drawdown_EW, 'b',label='EW Drawdown(%)') mpl.plot(d, drawdown_Random_1, 'g',label='Random_1 Drawdown(%)') mpl.plot(d, drawdown_Random_2, 'm',label='Random_2 Drawdown(%)') mpl.plot(d, drawdown_Random_3, 'c',label='Random_3 Drawdown(%)') mpl.ylabel('Drawdown(%)', fontsize=16) mpl.legend() mpl.savefig('HistoryDrawdown.png', format='png', bbox_inches='tight') mpl.clf();mpl.close() return ``` ![Drawdown](https://i.imgur.com/Zm9CeQ0.png) ### Key Observations * **Risk Management:** HRP demonstrates lower volatility and smaller maximum drawdowns, particularly during the 2008 financial crisis and the 2020 pandemic, suggesting superior risk-adjusted performance. * **Competitive Returns:** - HRP achieved a CAGR of 16.87%, slightly outperforming the equally weighted (EW) portfolio with a CAGR of 16.26% - HRP’s volatility was 15.78%, slightly lower than the EW portfolio’s 15.88% , lower than all random portfolios. - HRP’s Sharpe ratio was higher than EW and all random portfolios. - HRP experienced a maximum drawdown of 36.14%, which was slightly better than the EW portfolio’s 37.18%, better than Random Portfolio 2 and 3. * **Summary Statistics:** The table below summarizes key performance metrics: ```python= def summary_stat(target): max_drawdown = 0 max_price=target[0] for price in target: if price > max_price: max_price=price drawdown = (price - max_price) / max_price if drawdown < max_drawdown: max_drawdown = drawdown pct_rtn=(np.array(target[1:])/np.array(target[:-1]))-1 print('Compound annual growth rate',(target[-1]/target[0])**(12/len(target))-1) print('Vol.(annual)', (pct_rtn.std())*(12**(1/2))) print('Sharpe ratio(annual)',(pct_rtn.mean())*(12**(1/2))/(pct_rtn.std())) print('max_drawdown',max_drawdown) print('Sortino ratio(annual)',(pct_rtn.mean())*(12**(1/2))/(pct_rtn[pct_rtn<0].std())) print('Calmar ratio(annual)',(pct_rtn.mean())*(12**(1/2))/max_drawdown) return ``` | | HRP with Rolling window | EW | Random 1 | Random 2 | Random 3 | 1/n B&H | HRP with EWMA | |:----------------------:|:-----------------------:|:-------:|:--------:|:--------:|:--------:|:-------:|:-------------:| | CAGR | 16.87% | 16.26% | 18.62% | 15.36% | 16.35% | | | | Volatility(annualized) | 15.78% | 15.88% | 16.46% | 17.10% | 17.18% | | | | Sharpe ratio | 1.077 | 1.038 | 1.131 | 0.929 | 0.976 | | | | Maximum drawdown | -36.14% | -37.18% | -31.14% | -37.82% | -37.11% | | | --- ## **Reference** * [Marcos López de Prado](https://www.quantresearch.org/index.html) * [Building Diversified Portfolios that Outperform Out-of-Sample](https://doi.org/10.3905/jpm.2016.42.4.059) * [HRP.py.txt](https://www.quantresearch.org/HRP.py.txt) * [HRP_MC.py.txt](https://www.quantresearch.org/HRP_MC.py.txt) * [Hierarchical Construction of Investment Portfolios Using Clustered Machine Learning](https://patents.google.com/patent/US20180089762A1) * [Asset Allocation - Hierarchical Risk Parity](https://www.mathworks.com/videos/asset-allocation-hierarchical-risk-parity-1577957794663.html) * [Covariance/Correlation Matrix HRP-Clustering](https://youtu.be/wtd3K-Ubr1g?si=rQomxWT2Xinw8UXo) * [Towards Robust Portfolios](https://www.quantresearch.org/FIVE_VROBUST_Index__Research-EN.pdf) * [Hierarchical Risk Parity on RAPIDS: An ML Approach to Portfolio Allocation](https://developer.nvidia.com/blog/hierarchical-risk-parity-on-rapids-an-ml-approach-to-portfolio-allocation/) * [Asset Allocation - Hierarchical Risk Parity](https://youtu.be/e21MfMe5vtU?si=hS23MDNGDQlSmD-A) * [Covariance Predictor](https://hackmd.io/@Howard531/B1DphEqXR)