operations_research/OR_HTML_04/Notebooks_04/finanzdaten.ipynb

541 lines
24 KiB
Text
Raw Normal View History

Version 04 als eigenes Repository Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 18: Finanzdaten-Modellierung — Renditen, Kovarianz und Shrinkage\n",
"\n",
"Begleitnotebook zu *Optimierte Entscheidungsfindung mit Python*. Die Codezellen sind identisch mit den im Buch abgedruckten Programmen.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Einmalig ausfuehren: installiert alle im Buch verwendeten Pakete.\n",
"# Lokal in einer virtuellen Umgebung genauso gueltig wie in Google Colab.\n",
"%pip install --quiet ortools highspy cvxpy scipy numpy pandas polars \\\n",
" scikit-learn matplotlib plotly pyomo linopy pymoo pydantic openpyxl"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Logarithmische Rendite{idx:Logarithmische Rendite}\n",
Version 04 als eigenes Repository Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00
"\n",
"`Renditen_Vergleich.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Renditen_Vergleich.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Diskrete vs. logarithmische Renditen.\n",
"Zeigt an einem simulierten Kursverlauf, welche Eigenschaft wo gilt -\n",
"und warum das arithmetische Mittel diskreter Renditen in die Irre fuehrt.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"if __name__ == \"__main__\":\n",
" kurse = np.array([100.0, 150.0, 100.0, 120.0, 90.0, 135.0])\n",
" tage = [f\"t{i}\" for i in range(len(kurse))]\n",
"\n",
" diskret = kurse[1:] / kurse[:-1] - 1.0\n",
" logarithmisch = np.log(kurse[1:] / kurse[:-1])\n",
"\n",
" print(\"=\" * 74)\n",
" print(\" DISKRETE UND LOGARITHMISCHE RENDITEN IM VERGLEICH\")\n",
" print(\"=\" * 74)\n",
" tabelle = pd.DataFrame({\n",
" \"Periode\": [f\"{tage[i]} -> {tage[i+1]}\" for i in range(len(diskret))],\n",
" \"Kurs von\": kurse[:-1],\n",
" \"Kurs bis\": kurse[1:],\n",
" \"diskret R\": [f\"{r*100:+7.2f} %\" for r in diskret],\n",
" \"log r\": [f\"{r:+8.4f}\" for r in logarithmisch],\n",
" })\n",
" print(tabelle.to_string(index=False))\n",
"\n",
" # --- Zeitliche Aggregation ------------------------------------------\n",
" gesamt_wahr = kurse[-1] / kurse[0] - 1.0\n",
" summe_log = logarithmisch.sum()\n",
" aus_log_zurueck = np.exp(summe_log) - 1.0\n",
" summe_diskret = diskret.sum()\n",
"\n",
" print(\"\\n--- Aggregation ueber die Zeit ---\")\n",
" print(f\" Tatsaechliche Gesamtrendite: {gesamt_wahr*100:+8.2f} %\")\n",
" print(f\" Summe der Log-Renditen -> exp()-1: {aus_log_zurueck*100:+8.2f} % \"\n",
" f\"{'KORREKT' if abs(aus_log_zurueck - gesamt_wahr) < 1e-9 else 'falsch'}\")\n",
" print(f\" Summe der diskreten Renditen: {summe_diskret*100:+8.2f} % FALSCH\")\n",
"\n",
" # --- Mittelwerte ------------------------------------------------------\n",
" arithmetisch = diskret.mean()\n",
" geometrisch = np.prod(1 + diskret) ** (1 / len(diskret)) - 1\n",
" aus_log = np.exp(logarithmisch.mean()) - 1\n",
"\n",
" print(\"\\n--- Welcher Mittelwert ist der richtige? ---\")\n",
" print(f\" Arithmetisches Mittel (diskret): {arithmetisch*100:+8.2f} % \"\n",
" f\"-> ueberschaetzt\")\n",
" print(f\" Geometrisches Mittel: {geometrisch*100:+8.2f} % -> korrekt\")\n",
" print(f\" exp(Mittel der Log-Renditen) - 1: {aus_log*100:+8.2f} % \"\n",
" f\"-> identisch zum geometrischen\")\n",
"\n",
" # Probe: Endkapital mit dem jeweiligen Mittelwert hochgerechnet\n",
" n = len(diskret)\n",
" print(f\"\\n Probe - Startkapital 100 EUR ueber {n} Perioden:\")\n",
" print(f\" tatsaechlich: {kurse[-1]:8.2f} EUR\")\n",
" print(f\" mit arithm. Mittel hochgerechnet: {100*(1+arithmetisch)**n:8.2f} EUR\")\n",
" print(f\" mit geom. Mittel hochgerechnet: {100*(1+geometrisch)**n:8.2f} EUR\")\n",
"\n",
" print(\"\\n--- Annualisierung (252 Handelstage) ---\")\n",
" print(f\" Volatilitaet aus Log-Renditen: \"\n",
" f\"{logarithmisch.std(ddof=1)*np.sqrt(252)*100:.2f} % p.a.\")\n",
" print(\" (Die Wurzel-Zeit-Regel gilt nur bei unabhaengigen Renditen -\")\n",
" print(\" fuer reale Maerkte ist sie eine Naeherung.)\")\n",
" print(\"=\" * 74)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Schätzfehler{idx:Schätzfehler}-Problem\n",
Version 04 als eigenes Repository Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00
"\n",
"`Schaetzrauschen_Demo.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Schaetzrauschen_Demo.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Der Error-Maximizer-Effekt im Experiment.\n",
"\n",
"Aufbau: Wir KENNEN die wahre Kovarianzmatrix, weil wir die Daten selbst\n",
"erzeugen. Dann schaetzen wir sie aus endlich vielen Beobachtungen und\n",
"vergleichen, wie gut das daraus optimierte Portfolio in der WAHREN Welt\n",
"abschneidet - mit und ohne Shrinkage.\n",
"\n",
"Das ist der einzige saubere Weg, Schaetzfehler zu messen: Man braucht eine\n",
"Wahrheit zum Vergleich, und die gibt es nur in der Simulation.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"from sklearn.covariance import LedoitWolf\n",
"\n",
"\n",
"def erzeuge_wahre_kovarianz(n, rng):\n",
" \"\"\"Ein-Faktor-Modell: gemeinsamer Marktfaktor plus titelspezifisches Rauschen.\"\"\"\n",
" beta = rng.uniform(0.6, 1.4, size=n) # Marktsensitivitaeten\n",
" var_markt = 0.04 # Marktvarianz p.a.\n",
" var_spezifisch = rng.uniform(0.01, 0.09, size=n) # idiosynkratische Varianz\n",
" return np.outer(beta, beta) * var_markt + np.diag(var_spezifisch)\n",
"\n",
"\n",
"def minimum_varianz_gewichte(sigma):\n",
" \"\"\"Analytische GMV-Loesung: w = Sigma^-1 1 / (1' Sigma^-1 1). Ohne Restriktionen.\"\"\"\n",
" eins = np.ones(len(sigma))\n",
" try:\n",
" loesung = np.linalg.solve(sigma, eins)\n",
" except np.linalg.LinAlgError:\n",
" loesung = np.linalg.pinv(sigma) @ eins # falls singulaer\n",
" return loesung / loesung.sum()\n",
"\n",
"\n",
"def portfoliovolatilitaet(w, sigma):\n",
" return float(np.sqrt(w @ sigma @ w))\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" rng = np.random.default_rng(2026)\n",
" N = 40 # Anzahl Titel\n",
" WIEDERHOLUNGEN = 200\n",
"\n",
" sigma_wahr = erzeuge_wahre_kovarianz(N, rng)\n",
" w_ideal = minimum_varianz_gewichte(sigma_wahr)\n",
" vola_ideal = portfoliovolatilitaet(w_ideal, sigma_wahr)\n",
"\n",
" print(\"=\" * 92)\n",
" print(\" ERROR-MAXIMIZER: WIE TEUER IST SCHAETZRAUSCHEN?\")\n",
" print(\"=\" * 92)\n",
" print(f\"Aufbau: {N} Titel, wahre Kovarianz bekannt (Ein-Faktor-Modell).\")\n",
" print(f\"Bestmoegliche Volatilitaet bei perfektem Wissen: \"\n",
" f\"{vola_ideal*100:.2f} % p.a.\\n\")\n",
"\n",
" print(f\"{'T (Tage)':>9} | {'T/N':>5} | {'Stichprobe':>22} | {'Ledoit-Wolf':>22} | \"\n",
" f\"{'Shrink':>7}\")\n",
" print(f\"{'':>9} | {'':>5} | {'Vola Aufschlag':>22} | \"\n",
" f\"{'Vola Aufschlag':>22} | {'delta':>7}\")\n",
" print(\"-\" * 92)\n",
"\n",
" for T in [60, 120, 252, 504, 1260]:\n",
" vola_stichprobe, vola_lw, deltas = [], [], []\n",
"\n",
" for _ in range(WIEDERHOLUNGEN):\n",
" # Daten aus der WAHREN Verteilung ziehen (taeglich)\n",
" daten = rng.multivariate_normal(np.zeros(N), sigma_wahr / 252, size=T)\n",
"\n",
" # (a) Stichproben-Kovarianz\n",
" s_stich = np.cov(daten, rowvar=False, ddof=1) * 252\n",
" w_stich = minimum_varianz_gewichte(s_stich)\n",
"\n",
" # (b) Ledoit-Wolf-Shrinkage\n",
" lw = LedoitWolf().fit(daten)\n",
" s_lw = lw.covariance_ * 252\n",
" w_lw = minimum_varianz_gewichte(s_lw)\n",
" deltas.append(lw.shrinkage_)\n",
"\n",
" # Bewertung IMMER mit der wahren Kovarianz - das ist der Punkt\n",
" vola_stichprobe.append(portfoliovolatilitaet(w_stich, sigma_wahr))\n",
" vola_lw.append(portfoliovolatilitaet(w_lw, sigma_wahr))\n",
"\n",
" m_stich, m_lw = np.mean(vola_stichprobe), np.mean(vola_lw)\n",
" print(f\"{T:>9} | {T/N:>5.1f} | {m_stich*100:>8.2f} % \"\n",
" f\"{(m_stich/vola_ideal-1)*100:>+9.1f} % | \"\n",
" f\"{m_lw*100:>8.2f} % {(m_lw/vola_ideal-1)*100:>+9.1f} % | \"\n",
" f\"{np.mean(deltas):>7.3f}\")\n",
"\n",
" print(\"-\" * 92)\n",
" print(\"'Aufschlag' = wie viel mehr Risiko das Portfolio in der WAHREN Welt traegt,\")\n",
" print(\"verglichen mit dem Portfolio bei perfektem Wissen.\")\n",
" print(\"Lesart: Je kleiner T/N, desto teurer das Schaetzrauschen - und desto mehr\")\n",
" print(\"schrumpft Ledoit-Wolf (delta steigt).\")\n",
" print(\"=\" * 92)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Praxis: Datenpipeline mit korrekter Spaltenreihenfolge\n",
"\n",
"`Finanzdaten_Ledoit_Wolf.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Finanzdaten_Ledoit_Wolf.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Automatisierte Finanzdaten-Pipeline und Kovarianz-Shrinkage.\n",
"\n",
"Eigenschaften:\n",
" * Spaltenreihenfolge wird erzwungen und per assert geprueft\n",
" * beide Shrinkage-Ziele werden berechnet und verglichen\n",
" * Datenqualitaetspruefungen (Luecken, Ausreisser, Mindestlaenge)\n",
" * Eigenwertspektrum wird ausgewiesen, nicht nur die Konditionszahl\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import yfinance as yf\n",
"from sklearn.covariance import LedoitWolf\n",
"\n",
"HANDELSTAGE = 252\n",
"\n",
"\n",
"def lade_kurse(tickers: list[str], start, ende) -> pd.DataFrame:\n",
" \"\"\"Laedt bereinigte Schlusskurse und garantiert die Spaltenreihenfolge.\"\"\"\n",
" print(f\"Lade {len(tickers)} Ticker von {start} bis {ende} ...\")\n",
" roh = yf.download(tickers, start=start, end=ende, auto_adjust=True, progress=False)\n",
" if roh.empty:\n",
" raise SystemExit(\"Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).\")\n",
"\n",
" if isinstance(roh.columns, pd.MultiIndex):\n",
" # [tickers] erzwingt die gewuenschte Spaltenreihenfolge\n",
" kurse = roh[\"Close\"][tickers].dropna()\n",
" else:\n",
" kurse = roh[[\"Close\"]].dropna()\n",
" kurse.columns = tickers\n",
"\n",
" assert list(kurse.columns) == tickers, (\n",
" f\"Spaltenreihenfolge weicht ab!\\n erwartet: {tickers}\\n\"\n",
" f\" erhalten: {list(kurse.columns)}\")\n",
" return kurse\n",
"\n",
"\n",
"def pruefe_datenqualitaet(kurse: pd.DataFrame, mindest_tage: int = 200) -> None:\n",
" \"\"\"Faengt die haeufigsten Datenprobleme ab, bevor sie ins Modell gelangen.\"\"\"\n",
" T, N = kurse.shape\n",
" print(f\"\\nDatenqualitaet: {T} Handelstage, {N} Titel (T/N = {T/N:.1f})\")\n",
" if T < mindest_tage:\n",
" raise SystemExit(f\"Zu wenige Beobachtungen ({T} < {mindest_tage}).\")\n",
" if T < N:\n",
" print(\" WARNUNG: T < N - die Stichprobenkovarianz ist singulaer!\")\n",
" elif T < 3 * N:\n",
" print(\" WARNUNG: T < 3N - erhebliches Schaetzrauschen zu erwarten.\")\n",
"\n",
" renditen = kurse.pct_change().dropna()\n",
" extreme = (renditen.abs() > 0.25).sum().sum()\n",
" if extreme:\n",
" print(f\" Hinweis: {extreme} Tagesrenditen ueber 25 % \"\n",
" f\"(Splits, Sondersituationen oder Datenfehler pruefen).\")\n",
" luecken = kurse.isna().sum().sum()\n",
" print(f\" Fehlende Werte nach dropna: {luecken}\")\n",
"\n",
"\n",
"def ziel_konstante_korrelation(renditen: np.ndarray) -> np.ndarray:\n",
" \"\"\"\n",
" Shrinkage-Ziel nach Ledoit/Wolf 2003: Einzelvarianzen behalten,\n",
" alle Korrelationen durch ihren Mittelwert ersetzen.\n",
" (sklearn nutzt stattdessen die skalierte Einheitsmatrix - siehe oben.)\n",
" \"\"\"\n",
" S = np.cov(renditen, rowvar=False, ddof=1)\n",
" d = np.sqrt(np.diag(S))\n",
" korrelation = S / np.outer(d, d)\n",
" n = len(S)\n",
" r_quer = (korrelation.sum() - n) / (n * (n - 1)) # Mittel ohne Diagonale\n",
" F = r_quer * np.outer(d, d)\n",
" np.fill_diagonal(F, np.diag(S))\n",
" return F\n",
"\n",
"\n",
"def analysiere(kurse: pd.DataFrame):\n",
" renditen = kurse.pct_change().dropna()\n",
" T, N = renditen.shape\n",
"\n",
" mu = renditen.mean().values * HANDELSTAGE\n",
" sigma_stichprobe = renditen.cov().values * HANDELSTAGE\n",
"\n",
" lw = LedoitWolf(assume_centered=False).fit(renditen.values)\n",
" sigma_lw = lw.covariance_ * HANDELSTAGE\n",
" delta = lw.shrinkage_\n",
"\n",
" print(\"\\n\" + \"=\" * 84)\n",
" print(\" FINANZDATEN-PIPELINE UND MATRIX-KONDITIONIERUNG\")\n",
" print(\"=\" * 84)\n",
" print(f\"Beobachtungen T: {T}\")\n",
" print(f\"Titel N: {N}\")\n",
" print(f\"Optimales Shrinkage delta: {delta:.4f} \"\n",
" f\"({delta*100:.1f} % Gewicht auf dem strukturierten Ziel)\")\n",
"\n",
" eig_stich = np.linalg.eigvalsh(sigma_stichprobe)\n",
" eig_lw = np.linalg.eigvalsh(sigma_lw)\n",
"\n",
" print(\"\\n--- Eigenwertspektrum (annualisiert) ---\")\n",
" print(f\"{'':<14} {'kleinster':>12} {'Median':>12} {'groesster':>12} \"\n",
" f\"{'Kondition':>12}\")\n",
" for name, eig in [(\"Stichprobe\", eig_stich), (\"Ledoit-Wolf\", eig_lw)]:\n",
" kondition = eig.max() / max(eig.min(), 1e-12)\n",
" print(f\"{name:<14} {eig.min():>12.6f} {np.median(eig):>12.6f} \"\n",
" f\"{eig.max():>12.6f} {kondition:>12.1f}\")\n",
"\n",
" print(\"\\nDie Konditionszahl misst, wie stark sich kleine Datenaenderungen auf\")\n",
" print(\"die Inverse auswirken. Je kleiner, desto stabiler die Portfoliogewichte.\")\n",
"\n",
" # --- Vergleich der beiden Shrinkage-Ziele ----------------------------\n",
" S_taeglich = np.cov(renditen.values, rowvar=False, ddof=1)\n",
" F_identitaet = np.eye(N) * np.trace(S_taeglich) / N\n",
" F_korrelation = ziel_konstante_korrelation(renditen.values)\n",
"\n",
" print(\"\\n--- Die beiden Shrinkage-Ziele im Vergleich ---\")\n",
" for name, F in [(\"skalierte Einheitsmatrix (sklearn)\", F_identitaet),\n",
" (\"konstante Korrelation (LW 2003)\", F_korrelation)]:\n",
" gemischt = ((1 - delta) * S_taeglich + delta * F) * HANDELSTAGE\n",
" eig = np.linalg.eigvalsh(gemischt)\n",
" print(f\" {name:<36}: Kondition {eig.max()/max(eig.min(),1e-12):8.1f}, \"\n",
" f\"kleinster EW {eig.min():.6f}\")\n",
"\n",
" print(\"\\n--- Erwartete Renditen (annualisiert) ---\")\n",
" uebersicht = pd.DataFrame({\n",
" \"Ticker\": kurse.columns,\n",
" \"Rendite p.a.\": [f\"{r*100:+7.2f} %\" for r in mu],\n",
" \"Volatilitaet p.a.\": [f\"{np.sqrt(sigma_lw[i, i])*100:6.2f} %\" for i in range(N)],\n",
" })\n",
" print(uebersicht.to_string(index=False))\n",
" print(\"=\" * 84)\n",
" return renditen, mu, sigma_stichprobe, sigma_lw\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" UNIVERSUM = [\"AAPL\", \"MSFT\", \"NVDA\", \"AMZN\", # Technologie\n",
" \"JNJ\", \"PFE\", # Gesundheit\n",
" \"JPM\", \"GS\", # Banken\n",
" \"XOM\", \"CVX\"] # Energie\n",
"\n",
" # Relatives 2-Jahres-Fenster, damit das Beispiel nicht \"altert\"\n",
" ende = pd.Timestamp.today().normalize()\n",
" start = ende - pd.DateOffset(years=2)\n",
"\n",
" kurse = lade_kurse(UNIVERSUM, start.strftime(\"%Y-%m-%d\"), ende.strftime(\"%Y-%m-%d\"))\n",
" pruefe_datenqualitaet(kurse)\n",
" analysiere(kurse)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Finde den Denkfehler\n",
"\n",
"`Kovarianz_Falle.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Kovarianz_Falle.py\n",
"\"\"\"\n",
"Kapitel Finanzdaten: Warum die historische Kovarianzmatrix den Optimierer belaeugt.\n",
"\n",
"Ein Portfolio aus 30 Anlagen soll das Risiko minimieren. Geschaetzt wird die\n",
"Kovarianzmatrix aus 20 Beobachtungen - in der Praxis ein voellig ueblicher\n",
"Fall (30 Titel, ein Quartal Wochendaten).\n",
"\n",
"Das Ergebnis ist ein Portfolio mit einem gemessenen Risiko von 0,000 %. Der\n",
"Optimierer haelt es fuer risikofrei. Ausserhalb des Schaetzzeitraums ist es\n",
"das schlechteste der drei untersuchten Portfolios - schlechter sogar als\n",
"blosse Gleichgewichtung.\n",
"\n",
"Der Grund ist nicht Zufall, sondern Struktur: Bei N Anlagen und T < N\n",
"Beobachtungen hat die Stichproben-Kovarianzmatrix hoechstens Rang T-1. Es gibt\n",
"dann ganze Richtungen im Gewichtsraum, in denen sie exakt null Varianz misst -\n",
"Richtungen, die es in Wirklichkeit nicht gibt. Der Optimierer findet sie\n",
"zuverlaessig, denn er sucht ja genau danach.\n",
"\n",
"Die Daten sind synthetisch (ein Marktfaktor plus Eigenrauschen), damit das\n",
"Ergebnis reproduzierbar ist und kein Internetzugang noetig wird. Mit echten\n",
"Kursdaten sieht es genauso aus.\n",
"\n",
"Benoetigt: numpy, cvxpy, scikit-learn\n",
"\"\"\"\n",
"\n",
"from __future__ import annotations\n",
"\n",
"import numpy as np\n",
"import cvxpy as cp\n",
"from sklearn.covariance import LedoitWolf\n",
"\n",
"ANZAHL_ANLAGEN = 30\n",
"BEOBACHTUNGEN_SCHAETZUNG = 20 # weniger als Anlagen - genau darum geht es\n",
"BEOBACHTUNGEN_TEST = 2000 # der \"spaetere Verlauf\"\n",
"\n",
"RNG = np.random.default_rng(11)\n",
"# Jede Anlage reagiert unterschiedlich stark auf den Gesamtmarkt.\n",
"BETA = RNG.uniform(0.6, 1.4, ANZAHL_ANLAGEN)\n",
"\n",
"\n",
"def erzeuge_renditen(perioden: int) -> np.ndarray:\n",
" \"\"\"Ein Marktfaktor, auf den alle reagieren, plus Eigenrauschen je Anlage.\"\"\"\n",
" markt = RNG.normal(0.0, 0.010, perioden)\n",
" eigen = RNG.normal(0.0, 0.012, (perioden, ANZAHL_ANLAGEN))\n",
" return np.outer(markt, BETA) + eigen\n",
"\n",
"\n",
"def minimales_risiko(kovarianz: np.ndarray) -> tuple[np.ndarray, float]:\n",
" \"\"\"Minimum-Varianz-Portfolio: Gewichte summieren sich zu 1, sonst frei.\n",
"\n",
" cp.psd_wrap() sagt CVXPY: 'Ich weiss, dass diese Matrix numerisch nicht\n",
" exakt positiv semidefinit ist - rechne trotzdem.' Das ist hier bewusst so\n",
" gewaehlt, denn genau diese Eigenschaft wollen wir vorfuehren. Im\n",
" Produktivbetrieb waere die DCP-Fehlermeldung das Warnsignal, dem man\n",
Sechster und siebter Fund: die letzten handgeschriebenen Nummern Der Loesungsanhang trug 98 Marken der Bauart "**9.2 — Ansatz waehlen.**", saemtlich aus Version 03, wo Unsicherheit Kapitel 9 war. Heute ist es Kapitel 12, und die Aufgabe wird korrekt als "Aufgabe 12.2" gesetzt. Das war keine Schoenheitsfrage: "Aufgabe 9.2" existiert wirklich - sie gehoert zum Kapitel Metaheuristiken ("Zuggroesse und Temperatur"). Wer die Loesung zu 9.2 nachschlug, landete beim falschen Thema. Beide bisherigen Pruefungen liefen vorbei: Vor der Zahl steht kein Schluesselwort, und es ist keine Tabellenzelle. Zwei Messungen machten die Reparatur billig. In allen 15 nummerierten Abschnitten gab es exakt so viele Loesungen wie Aufgaben, lueckenlos 1..n - nur der Kapitelteil war falsch. Und die acht in Phase 3 ergaenzten Kapitel machten es laengst richtig (**Titel.** ohne Nummer); der Anhang wurde also nicht auf etwas Neues umgestellt, sondern auf das, was seine neueren Teile schon taten. nummeriere_marken() vergibt die Marken jetzt selbst. Der Praefix kommt NICHT aus "# Anhang A:" - der Anhang ist eine Ueberschrift, seine Abschnitte gehoeren aber zu 23 verschiedenen Kapiteln. Er kommt aus {#sec:loesungen-X} -> {#kap:X}; diese Zuordnung gilt geprueft fuer alle 23. Im Quelltext steht "**{loesung} — Titel.**". Siebter Fund unterwegs: neun Verweise auf Denkfehler-Nummern. Saetze wie "siehe Denkfehler 8.1" standen in alter Zaehlung - "8" war in Version 03 das QP/NLP-Kapitel, heute ist es Graphen. Eine blosse Umnummerierung haette sie stillschweigend woanders hin zeigen lassen; das Ziel wurde deshalb fuer jeden einzelnen aus dem Zusammenhang bestimmt und auf {ref:sec:<kapitel>-denkfehler} umgestellt. Einer steckte in einem Programm-Docstring und bekam nach Regel 12 den Kapitelnamen statt eines Verweises. Eine bewusste Abweichung vom Plan: Der Denkfehler bekommt GAR KEINE Nummer. Es gibt je Kapitel genau einen, die zweite Stelle waere immer .1, und er steht ohnehin unter einer nummerierten Ueberschrift. Eine Nummer, die nie variiert, holte nur die Fragilitaet zurueck, die hier beseitigt wird. Auch das folgt den acht neuen Kapiteln, die ihren Denkfehler schon vorher nur mit dem Titel des Raetsels ankuendigen. --check bewacht jetzt alle Familien (Denkfehler N.M, Micro-Quiz N, **N.M — im Anhang) und zaehlt zusaetzlich ab, dass jeder Loesungsabschnitt so viele {loesung}-Marken hat wie sein Kapitel Aufgaben - das faengt eine vergessene oder doppelte Loesung, was keine Textsuche leisten kann. Gegengetestet mit vier kuenstlichen Fehlern auf einmal: alle vier gemeldet, nach dem Rueckbau null. Gegenprobe der Umstellung: Gesamtdokument vorher gesichert, nachher verglichen. 168 geaenderte Zeilen, restlos einer Familie zuzuordnen - 118 Loesungsmarken (138 gesamt minus 20, die in den Kapiteln 1 bis 3 zufaellig schon stimmten), 15 Anhang-Ueberschriften, 15 Kapitel-Callouts, 12 Micro-Quiz, 7 Zeilen mit Verweisen, 1 Docstring. Keine verschobene oder inhaltlich veraenderte Zeile. Ergebnis: 138 Aufgaben, 138 Loesungen, keine ohne Gegenstueck. Micro-Quiz tragen die echten Kapitelnummern. 293 Abschnitte, 720 Querverweise, 327 Indexmarken, PDF unveraendert 725 Seiten, 33 pytest-Tests, 67 netzfreie Programme fehlerfrei (die vier yfinance-Programme scheiterten am Rate-Limit des Anbieters, nicht an dieser Aenderung). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:52:41 +02:00
" nachgehen muss (siehe den Denkfehler im Kapitel Quadratische und\n",
" nichtlineare Optimierung).\n",
Version 04 als eigenes Repository Erster Commit des Strangs "Optimierte Entscheidungsfindung mit Python" (Version 04). Die Historie der 71 Commits bis zur Trennung bleibt im uebergeordneten Repository OR_mit_Python liegen, das ab jetzt nur noch Version_03 (eingefroren) verwaltet und Version_04/ ignoriert. Bewusst kein "git subtree split": Der Pfad Version_04/ existiert erst seit der Verzeichnistrennung, ein Split braechte daher nur 7 der 41 einschlaegigen Commits - eine Teilhistorie, die vollstaendig aussieht und es nicht ist. Stand: 5 Teile, 23 Kapitel, 5 Anhaenge, 292 Abschnitte, 703 Querverweise, 325 Indexmarken, 73 Beispielprogramme, 32 SVGs, 4 Plotly-Figuren, 25 Notebooks, PDF mit 715 Seiten. Zusaetzlich in diesem Commit: * pyproject.toml mit Abhaengigkeitsgruppen finance, large-scale, api, figures, dev, empfehlungen. Die abgedruckte requirements.txt bleibt unveraendert daneben bestehen. ortools steht in der Grundausstattung, highspy erst in [large-scale] - so kann der HiGHS-Symbolkonflikt bei der schlanken Installation gar nicht erst auftreten. * Dabei zwei Funde: graphviz wird von erzeuge_architektur_diagramme.py importiert, fehlt aber in requirements.txt (jetzt in [figures]); pymoo steht in requirements.txt, wird aber von keinem Programm importiert, sondern nur im Kapitel Metaheuristiken empfohlen (jetzt in [empfehlungen]). * NEUER_TITEL.md nach Kritik_und_Verbesserungsvorschlaege/ verschoben - es ist die Vorlage des Titelblatts, kein Bestandteil des Werks. Die beiden Fundstellen in PROGRESS.md und erzeuge_titelseite.py nachgezogen. * PROGRESS.md nannte noch den Untertitel der ersten Fassung; auf den tatsaechlichen aus erzeuge_titelseite.py korrigiert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:20:09 +02:00
" \"\"\"\n",
" gewichte = cp.Variable(ANZAHL_ANLAGEN)\n",
" problem = cp.Problem(cp.Minimize(cp.quad_form(gewichte, cp.psd_wrap(kovarianz))),\n",
" [cp.sum(gewichte) == 1])\n",
" problem.solve()\n",
" return np.array(gewichte.value).ravel(), max(problem.value, 0.0)\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" schaetzzeitraum = erzeuge_renditen(BEOBACHTUNGEN_SCHAETZUNG)\n",
" spaeterer_verlauf = erzeuge_renditen(BEOBACHTUNGEN_TEST)\n",
"\n",
" stichprobe = np.cov(schaetzzeitraum, rowvar=False)\n",
" ledoit_wolf = LedoitWolf().fit(schaetzzeitraum)\n",
"\n",
" print(\"=\" * 78)\n",
" print(\" DIE KOVARIANZ-FALLE\")\n",
" print(\"=\" * 78)\n",
" print(f\"{ANZAHL_ANLAGEN} Anlagen, geschaetzt aus {BEOBACHTUNGEN_SCHAETZUNG} \"\n",
" f\"Beobachtungen.\")\n",
" print(f\"Rang der Stichprobenmatrix: \"\n",
" f\"{np.linalg.matrix_rank(stichprobe)} - noetig waeren {ANZAHL_ANLAGEN}.\")\n",
" print(f\"Kleinster Eigenwert: {np.linalg.eigvalsh(stichprobe).min():.2e}\")\n",
" print()\n",
" print(\"Es gibt also Richtungen, in denen diese Matrix EXAKT null Varianz\")\n",
" print(\"misst. Genau dort sucht ein Minimum-Varianz-Optimierer.\")\n",
" print()\n",
"\n",
" print(f\"{'Schaetzer':<26} {'Risiko im Zeitraum':>19} \"\n",
" f\"{'Risiko spaeter':>16} {'Hebel':>7}\")\n",
" print(\"-\" * 78)\n",
"\n",
" ergebnisse = {}\n",
" for name, matrix in [\n",
" (\"Stichprobe (roh)\", stichprobe),\n",
" (f\"Ledoit-Wolf (d={ledoit_wolf.shrinkage_:.2f})\", ledoit_wolf.covariance_)]:\n",
" gewichte, varianz_intern = minimales_risiko(matrix)\n",
" risiko_spaeter = float(np.std(spaeterer_verlauf @ gewichte))\n",
" hebel = float(np.abs(gewichte).sum())\n",
" ergebnisse[name] = (risiko_spaeter, gewichte)\n",
" print(f\"{name:<26} {np.sqrt(varianz_intern) * 100:>18.3f} % \"\n",
" f\"{risiko_spaeter * 100:>15.3f} % {hebel:>7.1f}\")\n",
"\n",
" gleich = np.full(ANZAHL_ANLAGEN, 1.0 / ANZAHL_ANLAGEN)\n",
" print(f\"{'Gleichgewichtung 1/N':<26} \"\n",
" f\"{float(np.std(schaetzzeitraum @ gleich)) * 100:>18.3f} % \"\n",
" f\"{float(np.std(spaeterer_verlauf @ gleich)) * 100:>15.3f} % {1.0:>7.1f}\")\n",
" print(\"-\" * 78)\n",
"\n",
" print(\"\\nDrei Beobachtungen:\")\n",
" print(\"1. Die rohe Schaetzung meldet 0,000 % Risiko - und liefert spaeter das\")\n",
" print(\" SCHLECHTESTE Ergebnis der drei. Sie hat kein Portfolio optimiert,\")\n",
" print(\" sondern eine Luecke in den eigenen Daten gefunden.\")\n",
" print(\"2. Die Gleichgewichtung, die gar nicht optimiert, schlaegt den rohen\")\n",
" print(\" Optimierer deutlich. Das ist kein Zufallsbefund, sondern in der\")\n",
" print(\" Literatur breit belegt.\")\n",
" print(\"3. Ledoit-Wolf ist im Schaetzzeitraum EHRLICHER (0,441 statt 0,000 %)\")\n",
" print(\" und spaeter am besten. Der Hebel faellt von 3,8 auf 1,8 - das\")\n",
" print(\" Portfolio wird auch praktisch handelbarer.\")\n",
" print()\n",
" print(\"Merksatz: Ein Optimierer glaubt seinen Eingabedaten vollstaendig.\")\n",
" print(\"Je mehr Freiheit Sie ihm geben, desto gruendlicher findet er deren\")\n",
" print(\"Fehler. Das gilt fuer Kovarianzmatrizen wie fuer Lieferzeiten,\")\n",
" print(\"Ausfallraten und jede andere geschaetzte Groesse.\")\n",
" print(\"=\" * 78)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}