Die Moderne Portfoliotheorie (MPT), 1952 von Harry Markowitz begründet, formuliert die Vermögensallokation als quadratisches Optimierungsproblem mit Nebenbedingungen. Trotz ihrer mathematischen Eleganz leidet die klassische Mean-Variance Optimization (MVO) unter dem sogenannten Markowitz-Fluch: Der Optimierungsalgorithmus potenziert Schätzfehler historischer Kovarianzmatrizen, was zu extremen und unplausiblen Portfoliogewichten führt.

In diesem Beitrag analysieren wir die von Marcos López de Prado entwickelte Hierarchical Risk Parity (HRP), die graphentheoretisches Clustering nutzt, um robuste Portfolios ohne Invertierung schlecht konditionierter Kovarianzmatrizen zu konstruieren.


1. Die Instabilität inverser Kovarianzmatrizen

Für $N$ Vermögenswerte mit Renditeerwartungen $\boldsymbol{\mu} \in \mathbb{R}^N$ und Kovarianzmatrix $\boldsymbol{\Sigma} \in \mathbb{R}^{N \times N}$ lauten die Gewichte des globalen Minimum-Varianz-Portfolios $\mathbf{w}^*$:

$$\mathbf{w}^* = \frac{\boldsymbol{\Sigma}^{-1} \mathbf{1}}{\mathbf{1}^T \boldsymbol{\Sigma}^{-1} \mathbf{1}}$$

Bei hoher Multikollinearität (starke Kreuzkorrelationen in Marktkrisen) wird $\boldsymbol{\Sigma}$ nahezu singulär mit einer Konditionszahl $\kappa(\boldsymbol{\Sigma}) \to \infty$. Geringe Stichprobenabweichungen verursachen massive Sprünge in $\boldsymbol{\Sigma}^{-1}$.


2. Die drei Phasen der Hierarchical Risk Parity (HRP)

HRP ersetzt die Matrixinversion durch hierarchisches Graphenclustering in drei Phasen:

2.1 Baum-Clustering via Korrelationsdistanz

Zunächst transformieren wir die empirische Korrelationsmatrix $\mathbf{R} \in [-1, 1]^{N \times N}$ in eine metrische Distanzmatrix $\mathbf{D}$:

$$D_{i,j} = \sqrt{\frac{1 - R_{i,j}}{2}} \in [0, 1]$$

Mittels Single-Linkage-Agglomerative-Clustering erstellen wir ein hierarchisches Dendrogramm der Asset-Beziehungen.

2.2 Quasi-Diagonalisierung

Wir ordnen die Zeilen und Spalten von $\boldsymbol{\Sigma}$ so um, dass Vermögenswerte desselben Clusters contiguous entlang der Hauptdiagonale liegen, wodurch Subsektoren ohne vorherige manuelle Klassifizierung sichtbar werden.

2.3 Rekursive Bisektion & Inverse-Varianz-Allokation

Wir spalten das Dendrogramm rekursiv auf. Für jede Verzweigung in Cluster $\mathcal{L}$ und $\mathcal{R}$ berechnen wir die Clustervarianzen:

$$V_{\mathcal{L}} = \mathbf{w}_{\mathcal{L}}^T \boldsymbol{\Sigma}_{\mathcal{L}} \mathbf{w}_{\mathcal{L}}, \quad V_{\mathcal{R}} = \mathbf{w}_{\mathcal{R}}^T \boldsymbol{\Sigma}_{\mathcal{R}} \mathbf{w}_{\mathcal{R}}$$

Die Gewichtung zwischen den Teilbäumen erfolgt invers zur Clustervarianz:

$$\alpha = 1 - \frac{V_{\mathcal{L}}}{V_{\mathcal{L}} + V_{\mathcal{R}}}, \quad \mathbf{w}_{\mathcal{L}} \leftarrow \alpha \mathbf{w}_{\mathcal{L}}, \quad \mathbf{w}_{\mathcal{R}} \leftarrow (1-\alpha) \mathbf{w}_{\mathcal{R}}$$

3. Vollständige Python-Implementierung

import numpy as np
import pandas as pd
from scipy.cluster.hierarchy import linkage

def get_quasi_diag(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 = df0.index
        j = df0.values - num_items
        sort_ix[i] = link[j, 0]
        df0 = pd.Series(link[j, 1], index=i + 1)
        sort_ix = pd.concat([sort_ix, df0]).sort_index()
        sort_ix.index = range(sort_ix.shape[0])
    return sort_ix.tolist()

def get_rec_bipart(cov, sort_ix):
    w = pd.Series(1, index=sort_ix)
    c_items = [sort_ix]
    while len(c_items) > 0:
        c_items = [i[j:k] for i in c_items for j, k in ((0, len(i) // 2), (len(i) // 2, len(i))) if len(i) > 1]
        for i in range(0, len(c_items), 2):
            c_items0 = c_items[i]
            c_items1 = c_items[i + 1]
            c_var0 = get_cluster_var(cov, c_items0)
            c_var1 = get_cluster_var(cov, c_items1)
            alpha = 1 - c_var0 / (c_var0 + c_var1)
            w[c_items0] *= alpha
            w[c_items1] *= 1 - alpha
    return w

def get_cluster_var(cov, c_items):
    cov_slice = cov.loc[c_items, c_items]
    w_inv = 1 / np.diag(cov_slice)
    w = w_inv / w_inv.sum()
    return np.dot(np.dot(w, cov_slice), w)