#!/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 den Denkfehler im Kapitel Quadratische und
    nichtlineare Optimierung).
    """
    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)
