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>
This commit is contained in:
dschlueter 2026-09-08 01:20:09 +02:00
commit b7af2f1d9a
468 changed files with 262141 additions and 0 deletions

View file

@ -0,0 +1,263 @@
#!/usr/bin/env python3
# Markowitz_CVXPY.py
"""
Kapitel Markowitz: Markowitz-Mean-Variance-Optimierung mit CVXPY.
Enthaelt GMV, Maximum Sharpe (Korn-Transformation), Effizienzgrenze
und institutionelle Restriktionen.
Eigenschaften:
* Spaltenreihenfolge erzwungen; Sektoren ueber NAMEN statt Indizes
* Effizienzgrenze bis zur TATSAECHLICH erreichbaren Maximalrendite
(nicht etwa max(mu)*0.95 - das waere unter Restriktionen oft
unerreichbar und die Kurve wuerde stillschweigend vorzeitig abbrechen)
* Pruefung aller Restriktionen nach dem Loesen
* Vergleich mit Gleichgewichtung als Realitaetscheck
"""
import os
import cvxpy as cp
import numpy as np
import pandas as pd
import yfinance as yf
from sklearn.covariance import LedoitWolf
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output")
os.makedirs(OUTPUT_DIR, exist_ok=True)
TICKER = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
# Sektoren ueber Namen definiert, nicht ueber Positionen
SEKTOREN = {
"Technologie": ["AAPL", "MSFT", "NVDA", "AMZN"],
"Gesundheit": ["JNJ", "PFE"],
"Finanzen": ["JPM", "GS"],
"Energie": ["XOM", "CVX"],
}
SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
"Finanzen": 0.40, "Energie": 0.40}
MAX_GEWICHT = 0.20 # hoechstens 20 % je Einzeltitel
RISIKOFREI = 0.03 # 3 % p.a.
HANDELSTAGE = 252
def lade_daten():
"""Laedt Kurse und garantiert die Spaltenreihenfolge."""
ende = pd.Timestamp.today().normalize()
start = ende - pd.DateOffset(years=2)
roh = yf.download(TICKER, start=start, end=ende, auto_adjust=True, progress=False)
if roh.empty:
raise SystemExit("Download fehlgeschlagen (Netz, Ticker oder Rate-Limit pruefen).")
if isinstance(roh.columns, pd.MultiIndex):
kurse = roh["Close"][TICKER].dropna() # [TICKER] erzwingt die Reihenfolge
else:
kurse = roh[["Close"]].dropna()
kurse.columns = TICKER
assert list(kurse.columns) == TICKER, "Spaltenreihenfolge weicht ab!"
renditen = kurse.pct_change().dropna()
mu = renditen.mean().values * HANDELSTAGE
sigma = LedoitWolf().fit(renditen.values).covariance_ * HANDELSTAGE
return kurse, renditen, mu, sigma
def sektor_indizes():
"""Uebersetzt Sektornamen einmalig in Positionsindizes - mit Pruefung."""
ergebnis = {}
for sektor, titel in SEKTOREN.items():
fehlend = [t for t in titel if t not in TICKER]
if fehlend:
raise ValueError(f"Sektor '{sektor}': Ticker {fehlend} nicht im Universum.")
ergebnis[sektor] = [TICKER.index(t) for t in titel]
return ergebnis
def basis_restriktionen(w, skala=None):
"""
Standardrestriktionen. Bei der Korn-Transformation muessen ALLE Grenzen
mit kappa mitskaliert werden - dafuer dient der Parameter 'skala'.
"""
eins = 1.0 if skala is None else skala
idx = sektor_indizes()
bedingungen = [w >= 0, w <= MAX_GEWICHT * eins]
for sektor, positionen in idx.items():
bedingungen.append(cp.sum(w[positionen]) <= SEKTORGRENZEN[sektor] * eins)
return bedingungen
def loese_gmv(sigma):
"""Global Minimum Variance: minimiere Risiko, ignoriere Rendite."""
w = cp.Variable(len(sigma))
problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(w, sigma)),
[cp.sum(w) == 1] + basis_restriktionen(w))
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"GMV nicht loesbar: {problem.status}")
return w.value
def loese_max_sharpe(mu, sigma, r_f):
"""Maximum Sharpe Ratio ueber die Korn-Transformation."""
n = len(mu)
ueberrendite = mu - r_f
if np.all(ueberrendite <= 0):
raise SystemExit("Kein Titel schlaegt den risikofreien Zins - "
"Max-Sharpe-Portfolio existiert nicht.")
y = cp.Variable(n)
kappa = cp.Variable(nonneg=True)
bedingungen = ([ueberrendite @ y == 1, cp.sum(y) == kappa]
+ basis_restriktionen(y, skala=kappa))
problem = cp.Problem(cp.Minimize(0.5 * cp.quad_form(y, sigma)), bedingungen)
problem.solve()
if problem.status not in ("optimal", "optimal_inaccurate"):
raise SystemExit(f"Max-Sharpe nicht loesbar: {problem.status}")
return y.value / kappa.value
def max_erreichbare_rendite(mu, sigma):
"""
Achtung: max(mu)*0.95 als obere Grenze der Frontier anzunehmen, ist unter
Positions- und Sektorgrenzen oft unerreichbar; die Kurve wuerde dann
stillschweigend abbrechen. Hier wird die tatsaechliche Obergrenze berechnet.
"""
w = cp.Variable(len(mu))
problem = cp.Problem(cp.Maximize(mu @ w), [cp.sum(w) == 1] + basis_restriktionen(w))
problem.solve()
return float(problem.value)
def berechne_frontier(mu, sigma, ret_min, ret_max, punkte=40):
"""Effizienzgrenze durch Variation der Mindestrendite."""
n = len(mu)
w = cp.Variable(n)
ziel_rendite = cp.Parameter()
problem = cp.Problem(
cp.Minimize(0.5 * cp.quad_form(w, sigma)),
[cp.sum(w) == 1, mu @ w >= ziel_rendite] + basis_restriktionen(w))
volas, renditen, uebersprungen = [], [], 0
for ziel in np.linspace(ret_min, ret_max, punkte):
ziel_rendite.value = ziel
problem.solve()
if problem.status in ("optimal", "optimal_inaccurate"):
volas.append(float(np.sqrt(w.value @ sigma @ w.value)))
renditen.append(float(mu @ w.value))
else:
uebersprungen += 1
if uebersprungen:
print(f" Hinweis: {uebersprungen} Zielrenditen waren nicht erreichbar.")
return np.array(volas), np.array(renditen)
def pruefe_restriktionen(w, bezeichnung):
"""Nach dem Loesen: haelt die Loesung wirklich alle Regeln ein?"""
assert abs(w.sum() - 1) < 1e-6, f"{bezeichnung}: Summe != 1"
assert w.min() > -1e-6, f"{bezeichnung}: negatives Gewicht"
assert w.max() < MAX_GEWICHT + 1e-6, f"{bezeichnung}: Positionsgrenze verletzt"
for sektor, positionen in sektor_indizes().items():
anteil = w[positionen].sum()
assert anteil < SEKTORGRENZEN[sektor] + 1e-6, \
f"{bezeichnung}: Sektor {sektor} bei {anteil:.3f} ueber Grenze"
def kennzahlen(w, mu, sigma, r_f):
rendite = float(mu @ w)
vola = float(np.sqrt(w @ sigma @ w))
return rendite, vola, (rendite - r_f) / vola
if __name__ == "__main__":
kurse, renditen, mu, sigma = lade_daten()
n = len(TICKER)
w_gmv = loese_gmv(sigma)
w_sharpe = loese_max_sharpe(mu, sigma, RISIKOFREI)
w_gleich = np.ones(n) / n
pruefe_restriktionen(w_gmv, "GMV")
pruefe_restriktionen(w_sharpe, "Max Sharpe")
print("=" * 88)
print(" ERGEBNISSE DER MEAN-VARIANCE-OPTIMIERUNG")
print("=" * 88)
print(f"Datenbasis: {len(renditen)} Handelstage, {n} Titel, "
f"risikofreier Zins {RISIKOFREI*100:.1f} %")
print(f"Restriktionen: max. {MAX_GEWICHT*100:.0f} % je Titel, "
f"Sektorgrenzen {SEKTORGRENZEN}\n")
print(f"{'Portfolio':<26} {'Rendite':>10} {'Volatilitaet':>13} {'Sharpe':>9}")
print("-" * 88)
for name, w in [("Global Minimum Variance", w_gmv),
("Maximum Sharpe Ratio", w_sharpe),
("Gleichgewichtung (1/N)", w_gleich)]:
r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
print(f"{name:<26} {r*100:>9.2f} % {v*100:>12.2f} % {sr:>9.2f}")
# --- Gewichte je Titel ------------------------------------------------
print("\n--- Optimierte Portfoliogewichte ---")
sektor_je_titel = {t: s for s, titel in SEKTOREN.items() for t in titel}
tabelle = pd.DataFrame({
"Ticker": TICKER,
"Sektor": [sektor_je_titel[t] for t in TICKER],
"Rendite p.a.": [f"{r*100:+6.1f} %" for r in mu],
"Vola p.a.": [f"{np.sqrt(sigma[i, i])*100:5.1f} %" for i in range(n)],
"GMV": [f"{w*100:5.1f} %" for w in w_gmv],
"Max Sharpe": [f"{w*100:5.1f} %" for w in w_sharpe],
})
print(tabelle.to_string(index=False))
print("\n--- Sektoraufteilung (Kontrolle) ---")
print(f"{'Sektor':<14} {'Grenze':>8} {'GMV':>9} {'Max Sharpe':>12}")
for sektor, positionen in sektor_indizes().items():
print(f"{sektor:<14} {SEKTORGRENZEN[sektor]*100:>7.0f} % "
f"{w_gmv[positionen].sum()*100:>8.1f} % "
f"{w_sharpe[positionen].sum()*100:>11.1f} %")
# --- Effizienzgrenze --------------------------------------------------
ret_gmv = float(mu @ w_gmv)
ret_max = max_erreichbare_rendite(mu, sigma)
print(f"\nEffizienzgrenze von {ret_gmv*100:.2f} % bis {ret_max*100:.2f} % "
f"(unter Restriktionen tatsaechlich erreichbar)")
print(f" Zum Vergleich: bester Einzeltitel {mu.max()*100:.2f} % - "
f"durch die Grenzen nicht erreichbar.")
volas, rets = berechne_frontier(mu, sigma, ret_gmv, ret_max)
# --- Diagramm ---------------------------------------------------------
plt.figure(figsize=(11, 6.5))
plt.plot(volas * 100, rets * 100, "b-", lw=2.5, label="Effizienzgrenze (restringiert)")
for w, farbe, marker, groesse, name in [
(w_gmv, "green", "o", 150, "GMV"),
(w_sharpe, "red", "*", 260, "Max Sharpe"),
(w_gleich, "purple", "D", 110, "Gleichgewichtung")]:
r, v, sr = kennzahlen(w, mu, sigma, RISIKOFREI)
plt.scatter([v * 100], [r * 100], color=farbe, marker=marker, s=groesse,
zorder=5, label=f"{name} (SR={sr:.2f})")
# Kapitalmarktlinie durch den risikofreien Zins und das Tangentialportfolio
r_s, v_s, _ = kennzahlen(w_sharpe, mu, sigma, RISIKOFREI)
x_linie = np.array([0, v_s * 1.25])
plt.plot(x_linie * 100, (RISIKOFREI + (r_s - RISIKOFREI) / v_s * x_linie) * 100,
color="orange", ls=":", lw=2, label="Kapitalmarktlinie")
for i, t in enumerate(TICKER):
plt.scatter(np.sqrt(sigma[i, i]) * 100, mu[i] * 100, color="gray", alpha=0.5, s=40)
plt.annotate(t, (np.sqrt(sigma[i, i]) * 100 + 0.4, mu[i] * 100), fontsize=8)
plt.title("Markowitz-Effizienzgrenze mit Sektor- und Positionsgrenzen", fontsize=12)
plt.xlabel("Annualisierte Volatilitaet [%]")
plt.ylabel("Annualisierte erwartete Rendite [%]")
plt.grid(True, linestyle=":", alpha=0.6)
plt.legend(loc="best")
plt.tight_layout()
ziel = os.path.join(OUTPUT_DIR, "markowitz_efficient_frontier.png")
plt.savefig(ziel, dpi=150)
print(f"\nDiagramm gespeichert unter '{ziel}'")
print("=" * 88)