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
275
bilder_04/erzeuge_effizienzlinie.py
Normal file
275
bilder_04/erzeuge_effizienzlinie.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
# erzeuge_effizienzlinie.py
|
||||
"""
|
||||
Erzeugt die Figur "Was die Hausregeln kosten" zum Kapitel Markowitz:
|
||||
|
||||
bilder_04/kap_markowitz_restriktionen.svg statisch, fuer das PDF
|
||||
bilder_04/kap_markowitz_restriktionen.png Rasterfassung
|
||||
bilder_04/plotly/kap_markowitz_restriktionen.html interaktiv, fuer die Website
|
||||
|
||||
Gezeigt werden zwei Effizienzlinien desselben Universums: einmal ohne
|
||||
Beschraenkungen, einmal mit den institutionellen Regeln aus dem Kapitel
|
||||
(hoechstens 20 % je Titel, Sektorgrenzen). Der senkrechte Abstand zwischen
|
||||
beiden Kurven IST der Preis der Hausregeln - in Renditepunkten, bei gleichem
|
||||
Risiko.
|
||||
|
||||
Warum eine feste Instanz und nicht die Kursdaten aus Markowitz_CVXPY.py:
|
||||
Jenes Programm laedt Livedaten. Ein Diagramm daraus saehe an jedem Tag anders
|
||||
aus, und der abgedruckte Text koennte nicht dazu passen. Hier wird deshalb ein
|
||||
FESTES Universum aus einem Faktormodell erzeugt (Marktfaktor, vier
|
||||
Sektorfaktoren, titelspezifisches Rauschen) - dieselbe Struktur wie im Kapitel,
|
||||
dieselben Regeln, aber reproduzierbar. Die Zahlen des Livelaufs weichen ab, die
|
||||
Aussage nicht.
|
||||
|
||||
Die interaktive Fassung zeigt im Hover, was das statische Bild nicht kann: die
|
||||
GEWICHTE des jeweiligen Portfolios. Damit wird sichtbar, wie sich die
|
||||
Zusammensetzung entlang der Linie verschiebt - und woran die beschraenkte
|
||||
Fassung scheitert, sobald ein einzelner Titel an seine 20-%-Grenze stoesst.
|
||||
|
||||
Aufruf (aus dem Repository-Wurzelverzeichnis):
|
||||
python3 bilder_04/erzeuge_effizienzlinie.py
|
||||
|
||||
Benoetigt: numpy, cvxpy, matplotlib, plotly
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import cvxpy as cp
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import plotly.graph_objects as go
|
||||
|
||||
# Reproduzierbare SVG-Ausgabe (siehe erzeuge_titelseite.py)
|
||||
plt.rcParams["svg.hashsalt"] = "or-mit-python-v04"
|
||||
|
||||
BASIS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BILDER = os.path.join(BASIS, "bilder_04")
|
||||
PLOTLY_VERZ = os.path.join(BILDER, "plotly")
|
||||
|
||||
INDIGO, CYAN, AMBER, GRUEN = "#4338ca", "#0891b2", "#b45309", "#059669"
|
||||
|
||||
# Struktur wie in Markowitz_CVXPY.py - zehn Titel in vier Sektoren
|
||||
TITEL = ["AAPL", "MSFT", "NVDA", "AMZN", "JNJ", "PFE", "JPM", "GS", "XOM", "CVX"]
|
||||
SEKTOR_VON = ["Technologie"] * 4 + ["Gesundheit"] * 2 + ["Finanzen"] * 2 + ["Energie"] * 2
|
||||
SEKTORGRENZEN = {"Technologie": 0.35, "Gesundheit": 0.40,
|
||||
"Finanzen": 0.40, "Energie": 0.40}
|
||||
MAX_GEWICHT = 0.20
|
||||
PUNKTE = 40
|
||||
|
||||
|
||||
def universum(saat: int = 3) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Erwartete Renditen und Kovarianzmatrix aus einem Faktormodell.
|
||||
|
||||
Jeder Titel haengt am Markt, an seinem Sektor und an eigenem Rauschen.
|
||||
Genau diese Struktur macht Diversifikation ueberhaupt moeglich: Titel
|
||||
desselben Sektors laufen staerker im Gleichschritt als Titel verschiedener
|
||||
Sektoren - und nur der zweite Fall traegt.
|
||||
"""
|
||||
rng = np.random.default_rng(saat)
|
||||
n = len(TITEL)
|
||||
sektoren = sorted(set(SEKTOR_VON))
|
||||
ladung_markt = rng.uniform(0.7, 1.3, n)
|
||||
ladung_sektor = rng.uniform(0.4, 0.9, n)
|
||||
eigen = rng.uniform(0.10, 0.26, n)
|
||||
|
||||
sigma_markt, sigma_sektor = 0.16, 0.12
|
||||
kovarianz = np.outer(ladung_markt, ladung_markt) * sigma_markt ** 2
|
||||
for sektor in sektoren:
|
||||
maske = np.array([s == sektor for s in SEKTOR_VON], dtype=float)
|
||||
gewicht = ladung_sektor * maske
|
||||
kovarianz += np.outer(gewicht, gewicht) * sigma_sektor ** 2
|
||||
kovarianz += np.diag(eigen ** 2)
|
||||
|
||||
# Erwartete Renditen: Marktprämie plus etwas titelspezifische Streuung
|
||||
mu = 0.03 + 0.055 * ladung_markt + rng.normal(0, 0.012, n)
|
||||
return mu, kovarianz
|
||||
|
||||
|
||||
def sektormasken() -> dict[str, np.ndarray]:
|
||||
return {sektor: np.array([s == sektor for s in SEKTOR_VON], dtype=float)
|
||||
for sektor in SEKTORGRENZEN}
|
||||
|
||||
|
||||
def linie(mu: np.ndarray, kovarianz: np.ndarray, beschraenkt: bool):
|
||||
"""Effizienzlinie ueber das epsilon-Constraint-Verfahren.
|
||||
|
||||
Fuer jede Zielrendite wird das minimale Risiko gesucht. Das ist dasselbe
|
||||
Verfahren, mit dem im Kapitel Mehrziel die Pareto-Front entsteht - eine
|
||||
Effizienzlinie IST eine Pareto-Front aus Rendite und Risiko.
|
||||
"""
|
||||
n = len(mu)
|
||||
w = cp.Variable(n, nonneg=True)
|
||||
ziel_rendite = cp.Parameter(nonneg=True)
|
||||
|
||||
# Cholesky statt quad_form: die 2-Norm eines affinen Ausdrucks ist
|
||||
# zweifelsfrei DCP (siehe die Fehlerdiagnose zu DCPError).
|
||||
wurzel = np.linalg.cholesky(kovarianz + 1e-12 * np.eye(n))
|
||||
|
||||
bedingungen = [cp.sum(w) == 1, mu @ w >= ziel_rendite]
|
||||
if beschraenkt:
|
||||
bedingungen.append(w <= MAX_GEWICHT)
|
||||
for sektor, maske in sektormasken().items():
|
||||
bedingungen.append(maske @ w <= SEKTORGRENZEN[sektor])
|
||||
|
||||
problem = cp.Problem(cp.Minimize(cp.norm2(wurzel.T @ w)), bedingungen)
|
||||
|
||||
# Der obere Rand ist NICHT max(mu): Unter Restriktionen ist der beste
|
||||
# Einzeltitel gar nicht voll kaufbar. Also wird er ausgerechnet.
|
||||
w_max = cp.Variable(n, nonneg=True)
|
||||
obere = [cp.sum(w_max) == 1]
|
||||
if beschraenkt:
|
||||
obere.append(w_max <= MAX_GEWICHT)
|
||||
for sektor, maske in sektormasken().items():
|
||||
obere.append(maske @ w_max <= SEKTORGRENZEN[sektor])
|
||||
cp.Problem(cp.Maximize(mu @ w_max), obere).solve()
|
||||
hoechste = float(mu @ w_max.value)
|
||||
|
||||
# Untere Grenze: die Rendite des Minimum-Varianz-Portfolios
|
||||
ziel_rendite.value = 0.0
|
||||
problem.solve()
|
||||
kleinste = float(mu @ w.value)
|
||||
|
||||
punkte = []
|
||||
for ziel in np.linspace(kleinste, hoechste, PUNKTE):
|
||||
ziel_rendite.value = float(ziel)
|
||||
problem.solve()
|
||||
if problem.status not in (cp.OPTIMAL, cp.OPTIMAL_INACCURATE):
|
||||
continue
|
||||
gewichte = np.asarray(w.value).ravel()
|
||||
risiko = float(np.sqrt(gewichte @ kovarianz @ gewichte))
|
||||
punkte.append((risiko, float(mu @ gewichte), gewichte))
|
||||
return punkte
|
||||
|
||||
|
||||
def aufpreis(frei, gebunden) -> tuple[float, float]:
|
||||
"""Groesster senkrechter Abstand: Wo kosten die Regeln am meisten Rendite?
|
||||
|
||||
Verglichen wird bei GLEICHEM Risiko - alles andere waere kein Preis,
|
||||
sondern nur ein anderer Punkt auf der Kurve.
|
||||
"""
|
||||
risiken_frei = np.array([p[0] for p in frei])
|
||||
renditen_frei = np.array([p[1] for p in frei])
|
||||
bester_abstand, bestes_risiko = 0.0, 0.0
|
||||
for risiko, rendite, _ in gebunden:
|
||||
if risiko < risiken_frei.min() or risiko > risiken_frei.max():
|
||||
continue
|
||||
vergleich = float(np.interp(risiko, risiken_frei, renditen_frei))
|
||||
if vergleich - rendite > bester_abstand:
|
||||
bester_abstand, bestes_risiko = vergleich - rendite, risiko
|
||||
return bester_abstand, bestes_risiko
|
||||
|
||||
|
||||
def zeichne_statisch(frei, gebunden, abstand, risiko_stelle) -> None:
|
||||
figur, achse = plt.subplots(figsize=(7.4, 4.6))
|
||||
for punkte, farbe, name, stil in (
|
||||
(frei, INDIGO, "ohne Beschränkungen", "-"),
|
||||
(gebunden, AMBER, "mit Hausregeln (20 % je Titel, Sektorgrenzen)", "-")):
|
||||
x = [p[0] * 100 for p in punkte]
|
||||
y = [p[1] * 100 for p in punkte]
|
||||
achse.plot(x, y, stil, color=farbe, linewidth=2.0, label=name)
|
||||
|
||||
if abstand > 0:
|
||||
oben = float(np.interp(risiko_stelle,
|
||||
[p[0] for p in frei], [p[1] for p in frei]))
|
||||
unten = oben - abstand
|
||||
achse.annotate("", xy=(risiko_stelle * 100, oben * 100),
|
||||
xytext=(risiko_stelle * 100, unten * 100),
|
||||
arrowprops=dict(arrowstyle="<->", color=GRUEN, lw=1.8))
|
||||
text = f"{abstand * 100:.2f} Prozentpunkte".replace(".", ",")
|
||||
achse.annotate(f"{text}\nRendite, bei gleichem Risiko",
|
||||
(risiko_stelle * 100, (oben + unten) / 2 * 100),
|
||||
textcoords="offset points", xytext=(10, 0),
|
||||
fontsize=8.5, color=GRUEN, va="center")
|
||||
|
||||
# Der teuerste Effekt ist nicht der senkrechte Abstand, sondern die Decke:
|
||||
# Die beschraenkte Linie endet frueher. Alles rechts davon ist mit den
|
||||
# Hausregeln ueberhaupt nicht erreichbar - egal, wie viel Risiko man traegt.
|
||||
ende_gebunden, ende_frei = gebunden[-1], frei[-1]
|
||||
achse.scatter([ende_gebunden[0] * 100], [ende_gebunden[1] * 100], s=110,
|
||||
facecolors="none", edgecolors=AMBER, linewidths=2.2, zorder=5)
|
||||
verlust = (ende_frei[1] - ende_gebunden[1]) * 100
|
||||
achse.annotate(f"hier ist Schluss: {ende_gebunden[1] * 100:.2f} % statt "
|
||||
f"{ende_frei[1] * 100:.2f} %\n"
|
||||
f"— {verlust:.2f} Punkte, die kein Risiko mehr kauft".replace(".", ","),
|
||||
(ende_gebunden[0] * 100, ende_gebunden[1] * 100),
|
||||
textcoords="offset points", xytext=(8, -22),
|
||||
fontsize=8.5, color=AMBER)
|
||||
|
||||
achse.set_xlabel("Volatilität $\\sigma$ (% p. a.)")
|
||||
achse.set_ylabel("erwartete Rendite $\\mu$ (% p. a.)")
|
||||
achse.set_title("Was die Hausregeln kosten", fontsize=11)
|
||||
achse.grid(linestyle=":", alpha=0.5)
|
||||
achse.legend(fontsize=8.5, loc="lower right", frameon=False)
|
||||
for rand in ("top", "right"):
|
||||
achse.spines[rand].set_visible(False)
|
||||
figur.tight_layout()
|
||||
|
||||
os.makedirs(BILDER, exist_ok=True)
|
||||
for endung in ("svg", "png"):
|
||||
pfad = os.path.join(BILDER, f"kap_markowitz_restriktionen.{endung}")
|
||||
figur.savefig(pfad, format=endung, dpi=160,
|
||||
metadata={"Date": None} if endung == "svg" else None)
|
||||
print(f"geschrieben: {pfad}")
|
||||
plt.close(figur)
|
||||
|
||||
|
||||
def zeichne_interaktiv(frei, gebunden) -> None:
|
||||
figur = go.Figure()
|
||||
|
||||
def beschriftung(punkt):
|
||||
risiko, rendite, gewichte = punkt
|
||||
reihenfolge = np.argsort(-gewichte)[:4]
|
||||
anteile = "<br>".join(
|
||||
f" {TITEL[i]} ({SEKTOR_VON[i]}): {gewichte[i] * 100:.1f} %"
|
||||
for i in reihenfolge if gewichte[i] > 0.005)
|
||||
sektoren = "<br>".join(
|
||||
f" {sektor}: {float(maske @ gewichte) * 100:.1f} %"
|
||||
for sektor, maske in sektormasken().items())
|
||||
return (f"<b>{rendite * 100:.2f} % Rendite</b><br>"
|
||||
f"{risiko * 100:.2f} % Volatilität<br>"
|
||||
f"Sharpe {(rendite - 0.03) / risiko:.2f}<br><br>"
|
||||
f"<b>größte Positionen</b><br>{anteile}<br><br>"
|
||||
f"<b>je Sektor</b><br>{sektoren}<extra></extra>")
|
||||
|
||||
for punkte, farbe, name in ((frei, INDIGO, "ohne Beschränkungen"),
|
||||
(gebunden, AMBER, "mit Hausregeln")):
|
||||
figur.add_trace(go.Scatter(
|
||||
x=[p[0] * 100 for p in punkte], y=[p[1] * 100 for p in punkte],
|
||||
mode="lines+markers", name=name,
|
||||
line=dict(color=farbe, width=2.2), marker=dict(size=6),
|
||||
hovertemplate=[beschriftung(p) for p in punkte]))
|
||||
|
||||
figur.update_layout(
|
||||
title="Effizienzlinie mit und ohne Hausregeln — Gewichte im Hover",
|
||||
xaxis=dict(title="Volatilität σ (% p. a.)"),
|
||||
yaxis=dict(title="erwartete Rendite μ (% p. a.)"),
|
||||
height=460, margin=dict(l=70, r=20, t=55, b=50),
|
||||
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
|
||||
legend=dict(orientation="h", y=-0.18), hovermode="closest")
|
||||
|
||||
os.makedirs(PLOTLY_VERZ, exist_ok=True)
|
||||
pfad = os.path.join(PLOTLY_VERZ, "kap_markowitz_restriktionen.html")
|
||||
with open(pfad, "w", encoding="utf-8") as datei:
|
||||
datei.write(figur.to_html(full_html=False, include_plotlyjs=False,
|
||||
div_id="kap-markowitz-restriktionen"))
|
||||
print(f"geschrieben: {pfad}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mu, kovarianz = universum()
|
||||
frei = linie(mu, kovarianz, beschraenkt=False)
|
||||
gebunden = linie(mu, kovarianz, beschraenkt=True)
|
||||
abstand, stelle = aufpreis(frei, gebunden)
|
||||
print(f"frei: {len(frei)} Punkte, Rendite "
|
||||
f"{frei[0][1] * 100:.2f} bis {frei[-1][1] * 100:.2f} %")
|
||||
print(f"gebunden: {len(gebunden)} Punkte, Rendite "
|
||||
f"{gebunden[0][1] * 100:.2f} bis {gebunden[-1][1] * 100:.2f} %")
|
||||
print(f"groesster Abstand: {abstand * 100:.2f} Prozentpunkte "
|
||||
f"bei sigma = {stelle * 100:.2f} %")
|
||||
zeichne_statisch(frei, gebunden, abstand, stelle)
|
||||
zeichne_interaktiv(frei, gebunden)
|
||||
Loading…
Reference in a new issue