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:
commit
b7af2f1d9a
468 changed files with 262141 additions and 0 deletions
263
OR_HTML_04/programme/Markowitz_CVXPY.py
Normal file
263
OR_HTML_04/programme/Markowitz_CVXPY.py
Normal 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)
|
||||
Loading…
Reference in a new issue