#!/usr/bin/env python3 # Kovarianz_Falle.py """ Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt. Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher Fall (30 Titel, ein Quartal Wochendaten). Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es das schlechteste der drei untersuchten Portfolios - schlechter sogar als blosse Gleichgewichtung. Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst - Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie zuverlaessig, denn er sucht ja genau danach. Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten Kursdaten sieht es genauso aus. Benoetigt: numpy, cvxpy, scikit-learn """ from __future__ import annotations import numpy as np import cvxpy as cp from sklearn.covariance import LedoitWolf ANZAHL_ANLAGEN = 30 BEOBACHTUNGEN_SCHAETZUNG = 20 # weniger als Anlagen - genau darum geht es BEOBACHTUNGEN_TEST = 2000 # der "spaetere Verlauf" RNG = np.random.default_rng(11) # Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt. BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN) def erzeuge_renditen(perioden: int) -> np.ndarray: """Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage.""" markt = RNG.normal(0.0, 0.010, perioden) eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN)) return np.outer(markt, BETA) + eigen def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]: """Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei. cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man nachgehen muss (siehe Denkfehler 8.1). """ gewichte = cp.Variable(ANZAHL_ANLAGEN) problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))), [cp.sum(gewichte) == 1]) problem.solve() return np.array(gewichte.value).ravel(), max(problem.value, 0.0) if __name__ == "__main__": schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG) spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST) stichprobe = np.cov(schaetzzeitraum, rowvar=False) ledoit_wolf = LedoitWolf().fit(schaetzzeitraum) print("=" * 78) print(" DIE KOVARIANZ-FALLE") print("=" * 78) print(f"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} " f"Beobachtungen.") print(f"Rang der Stichprobenmatrix: " f"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.") print(f"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}") print() print("Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz") print("misst. Genau dort sucht ein Minimum-Varianz-Optimierer.") print() print(f"{'Schaetzer':<26} {'Risiko im Zeitraum':>19} " f"{'Risiko spaeter':>16} {'Hebel':>7}") print("-" * 78) ergebnisse = {} for name, matrix in [ ("Stichprobe (roh)", stichprobe), (f"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})", ledoit_wolf.covariance_)]: gewichte, varianz_intern = minimales_risiko(matrix) risiko_spaeter = float(np.std(spaeterer_verlauf @ gewichte)) hebel = float(np.abs(gewichte).sum()) ergebnisse[name] = (risiko_spaeter, gewichte) print(f"{name:<26} {np.sqrt(varianz_intern) * 100:>18.3f} % " f"{risiko_spaeter * 100:>15.3f} % {hebel:>7.1f}") gleich = np.full(ANZAHL_ANLAGEN, 1.0 / ANZAHL_ANLAGEN) print(f"{'Gleichgewichtung 1/N':<26} " f"{float(np.std(schaetzzeitraum @ gleich)) * 100:>18.3f} % " f"{float(np.std(spaeterer_verlauf @ gleich)) * 100:>15.3f} % {1.0:>7.1f}") print("-" * 78) print("\nDrei Beobachtungen:") print("1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das") print(" SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,") print(" sondern eine Luecke in den eigenen Daten gefunden.") print("2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen") print(" Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der") print(" Literatur breit belegt.") print("3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)") print(" und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das") print(" Portfolio wird auch praktisch handelbarer.") print() print("Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.") print("Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren") print("Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,") print("Ausfallraten und jede andere geschaetzte Groesse.") print("=" * 78)