operations_research/OR_HTML_04/Notebooks_04/dynamische-programmierung.ipynb
dschlueter b7af2f1d9a 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

368 lines
16 KiB
Text

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Kapitel 13: Dynamische Programmierung — Die Bellman-Gleichung und Order-Execution\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": [
"## Die Bellman-Gleichung\n",
"\n",
"`Bellman_Minimalbeispiel.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Bellman_Minimalbeispiel.py\n",
"\"\"\"\n",
"Kapitel Dynamische Programmierung: Die Handrechnung zur Rueckwaertsinduktion als Code.\n",
"Zeigt die Wertfunktionstabelle und die optimale Politik Schritt fuer Schritt.\n",
"\"\"\"\n",
"\n",
"import numpy as np\n",
"\n",
"GESAMT = 3 # zu verkaufende Einheiten\n",
"PERIODEN = 2 # Anzahl Verkaufsperioden\n",
"\n",
"\n",
"def kosten(menge: int) -> float:\n",
" \"\"\"Ueberproportionale Marktauswirkung: doppelte Menge kostet vierfach.\"\"\"\n",
" return float(menge ** 2)\n",
"\n",
"\n",
"def loese_rueckwaerts():\n",
" # V[t, x] = minimale Restkosten, wenn zu Beginn von Periode t noch x Stueck offen sind\n",
" V = np.full((PERIODEN + 1, GESAMT + 1), np.inf)\n",
" politik = np.zeros((PERIODEN, GESAMT + 1), dtype=int)\n",
"\n",
" # Endbedingung: nach der letzten Periode darf nichts mehr offen sein\n",
" V[PERIODEN, 0] = 0.0\n",
"\n",
" print(\"=\" * 70)\n",
" print(\" RUECKWAERTSINDUKTION SCHRITT FUER SCHRITT\")\n",
" print(\"=\" * 70)\n",
"\n",
" for t in range(PERIODEN - 1, -1, -1):\n",
" letzte_periode = (t == PERIODEN - 1)\n",
" print(f\"\\nStufe t = {t}\" + (\" (letzte Periode: alles muss weg)\" if letzte_periode\n",
" else \" (freie Wahl der Menge)\"))\n",
" print(f\" {'Zustand x':>10} | {'beste Aktion':>12} | {'Sofortkosten':>13} | \"\n",
" f\"{'V[t+1]':>9} | {'V[t]':>8}\")\n",
" print(\" \" + \"-\" * 62)\n",
"\n",
" for x in range(GESAMT + 1):\n",
" aktionen = [x] if letzte_periode else range(x + 1)\n",
" bester_wert, beste_aktion, beste_teile = np.inf, 0, (0.0, 0.0)\n",
"\n",
" for n in aktionen:\n",
" rest = x - n\n",
" sofort = kosten(n)\n",
" zukunft = V[t + 1, rest]\n",
" gesamt = sofort + zukunft\n",
" if gesamt < bester_wert:\n",
" bester_wert, beste_aktion = gesamt, n\n",
" beste_teile = (sofort, zukunft)\n",
"\n",
" V[t, x] = bester_wert\n",
" politik[t, x] = beste_aktion\n",
" print(f\" {x:>10} | {beste_aktion:>12} | {beste_teile[0]:>13.1f} | \"\n",
" f\"{beste_teile[1]:>9.1f} | {bester_wert:>8.1f}\")\n",
"\n",
" return V, politik\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" V, politik = loese_rueckwaerts()\n",
"\n",
" # --- Vorwaertspfad: der optimalen Politik folgen ---------------------\n",
" print(\"\\n\" + \"=\" * 70)\n",
" print(\" OPTIMALER PFAD (Vorwaertssimulation)\")\n",
" print(\"=\" * 70)\n",
" bestand = GESAMT\n",
" gesamtkosten = 0.0\n",
" for t in range(PERIODEN):\n",
" aktion = politik[t, bestand]\n",
" gesamtkosten += kosten(aktion)\n",
" print(f\" Periode {t}: Bestand {bestand} -> verkaufe {aktion} \"\n",
" f\"(Kosten {kosten(aktion):.1f}) -> Rest {bestand - aktion}\")\n",
" bestand -= aktion\n",
"\n",
" print(f\"\\n Gesamtkosten: {gesamtkosten:.1f} (V[0, {GESAMT}] = {V[0, GESAMT]:.1f})\")\n",
" assert abs(gesamtkosten - V[0, GESAMT]) < 1e-9, \"Pfadkosten != Wertfunktion!\"\n",
"\n",
" # --- Vergleich mit naiven Strategien ---------------------------------\n",
" alles_sofort = kosten(GESAMT)\n",
" print(f\"\\n Zum Vergleich - alles in Periode 0 verkaufen: {alles_sofort:.1f}\")\n",
" print(f\" Ersparnis durch Stueckelung: {alles_sofort - gesamtkosten:.1f} \"\n",
" f\"({(1 - gesamtkosten/alles_sofort)*100:.0f} %)\")\n",
" print(\"=\" * 70)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Das Almgren-Chriss-Problem: optimale Orderausführung\n",
"\n",
"`Mehrperiodige_Order_Execution.py`\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"#!/usr/bin/env python3\n",
"\n",
"# Mehrperiodige_Order_Execution.py\n",
"\"\"\"\n",
"Kapitel Dynamische Programmierung: Dynamische Programmierung fuer optimale Orderausfuehrung\n",
"(Almgren-Chriss-Rahmen, geloest per Rueckwaertsinduktion).\n",
"\n",
"Eigenschaften:\n",
" * Risikoterm sauber hergeleitet ueber den Aktienkurs P0 (Einheiten: EUR),\n",
" ohne undokumentierte Skalierungsfaktoren\n",
" * Vergleich mit der analytischen Almgren-Chriss-Loesung\n",
" * Vergleich mit naiven Strategien (alles sofort / gleichmaessig)\n",
" * Sensitivitaet gegenueber der Risikoaversion\n",
"\"\"\"\n",
"\n",
"import os\n",
"\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib\n",
"matplotlib.use(\"Agg\")\n",
"import matplotlib.pyplot as plt\n",
"\n",
"OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"output\")\n",
"os.makedirs(OUTPUT_DIR, exist_ok=True)\n",
"\n",
"# --- Parameter -------------------------------------------------------------\n",
"GESAMTBESTAND = 100_000 # X_0, zu verkaufende Aktien\n",
"PERIODEN = 5 # T Handelsperioden\n",
"KURS = 50.0 # P_0 in EUR, zur Skalierung des Risikoterms\n",
"ETA = 2.5e-6 # EUR je Stueck^2 (Slippage-Koeffizient)\n",
"VOLA_JAHR = 0.30 # 30 % p.a.\n",
"HANDELSSTUNDEN_JAHR = 252 * 6.5\n",
"RISIKOAVERSION = 1e-5 # 1/EUR; kalibriert, siehe Kommentar unten\n",
"SCHRITTWEITE = 1000 # Diskretisierung des Zustandsraums\n",
"\n",
"# Kalibrierungshinweis: Die dimensionslose Kennzahl des Modells ist\n",
"# kappa_tilde^2 = lambda * sigma_periode^2 * P0^2 / eta\n",
"# Sie entscheidet ueber den Charakter der Loesung:\n",
"# << 1 -> praktisch gleichmaessige Aufteilung (Risiko spielt keine Rolle)\n",
"# ~ 1 -> ausgewogener Kompromiss <- hier: 0.55\n",
"# >> 1 -> fast alles sofort verkaufen\n",
"# Genau diese Interpretierbarkeit geht mit einem undokumentierten\n",
"# Skalierungsfaktor verloren.\n",
"\n",
"VOLA_PERIODE = VOLA_JAHR / np.sqrt(HANDELSSTUNDEN_JAHR)\n",
"\n",
"\n",
"def periodenkosten(verkauf: float, restbestand: float) -> float:\n",
" \"\"\"\n",
" Sofortkosten einer Periode in EUR:\n",
" (1) Marktauswirkung: eta * n^2\n",
" (2) Risiko des Restbestands: lambda/2 * sigma^2 * P0^2 * X^2\n",
" -> P0^2 macht aus \"Stueck^2\" einen EUR^2-Wert; lambda hat damit\n",
" die Einheit 1/EUR und ist interpretierbar.\n",
" \"\"\"\n",
" marktauswirkung = ETA * verkauf ** 2\n",
" wertvarianz = (VOLA_PERIODE ** 2) * (KURS ** 2) * (restbestand ** 2)\n",
" risiko = 0.5 * RISIKOAVERSION * wertvarianz\n",
" return marktauswirkung + risiko\n",
"\n",
"\n",
"def loese_dp():\n",
" \"\"\"Rueckwaertsinduktion ueber den diskretisierten Zustandsraum.\"\"\"\n",
" zustaende = np.arange(0, GESAMTBESTAND + SCHRITTWEITE, SCHRITTWEITE)\n",
" anzahl = len(zustaende)\n",
"\n",
" V = np.full((PERIODEN + 1, anzahl), np.inf)\n",
" politik = np.zeros((PERIODEN, anzahl), dtype=int)\n",
" V[PERIODEN, 0] = 0.0 # am Ende muss alles verkauft sein\n",
"\n",
" for t in range(PERIODEN - 1, -1, -1):\n",
" for idx, bestand in enumerate(zustaende):\n",
" if t == PERIODEN - 1:\n",
" moegliche = [bestand] # letzte Periode: Rest muss weg\n",
" else:\n",
" moegliche = zustaende[zustaende <= bestand]\n",
"\n",
" bester_wert, beste_aktion = np.inf, 0\n",
" for verkauf in moegliche:\n",
" rest = bestand - verkauf\n",
" rest_idx = int(round(rest / SCHRITTWEITE))\n",
" gesamt = periodenkosten(verkauf, rest) + V[t + 1, rest_idx]\n",
" if gesamt < bester_wert:\n",
" bester_wert, beste_aktion = gesamt, int(verkauf)\n",
"\n",
" V[t, idx] = bester_wert\n",
" politik[t, idx] = beste_aktion\n",
"\n",
" return zustaende, V, politik\n",
"\n",
"\n",
"def analytische_loesung():\n",
" \"\"\"\n",
" Geschlossene Almgren-Chriss-Loesung fuer den kontinuierlichen Fall.\n",
" Der optimale Pfad ist X_t = X_0 * sinh(kappa*(T-t)) / sinh(kappa*T)\n",
" mit kappa = arccosh(tilde_kappa^2/2 + 1), tilde_kappa^2 = lambda*sigma^2*P0^2/eta.\n",
" Dient hier als unabhaengige Kontrolle des DP-Ergebnisses.\n",
" \"\"\"\n",
" kappa_tilde_quadrat = (RISIKOAVERSION * (VOLA_PERIODE ** 2) * (KURS ** 2)) / ETA\n",
" kappa = np.arccosh(kappa_tilde_quadrat / 2.0 + 1.0)\n",
" if kappa < 1e-12: # Grenzfall: risikoneutral -> linear\n",
" return np.linspace(GESAMTBESTAND, 0, PERIODEN + 1)\n",
" t = np.arange(PERIODEN + 1)\n",
" return GESAMTBESTAND * np.sinh(kappa * (PERIODEN - t)) / np.sinh(kappa * PERIODEN)\n",
"\n",
"\n",
"def bewerte_pfad(bestaende):\n",
" \"\"\"Gesamtkosten eines beliebigen Bestandspfades.\"\"\"\n",
" summe = 0.0\n",
" for t in range(len(bestaende) - 1):\n",
" verkauf = bestaende[t] - bestaende[t + 1]\n",
" summe += periodenkosten(verkauf, bestaende[t + 1])\n",
" return summe\n",
"\n",
"\n",
"if __name__ == \"__main__\":\n",
" zustaende, V, politik = loese_dp()\n",
"\n",
" # --- Vorwaertspfad der optimalen Politik -----------------------------\n",
" bestand = GESAMTBESTAND\n",
" verlauf = [bestand]\n",
" verkaeufe = []\n",
" for t in range(PERIODEN):\n",
" idx = int(round(bestand / SCHRITTWEITE))\n",
" verkauf = politik[t, idx]\n",
" verkaeufe.append(verkauf)\n",
" bestand -= verkauf\n",
" verlauf.append(bestand)\n",
"\n",
" print(\"=\" * 84)\n",
" print(\" OPTIMALE MEHRPERIODIGE ORDER-EXECUTION (BELLMAN DP)\")\n",
" print(\"=\" * 84)\n",
" print(f\"Gesamtvolumen: {GESAMTBESTAND:,} Stueck zu {KURS:.2f} EUR \"\n",
" f\"= {GESAMTBESTAND*KURS:,.0f} EUR Positionswert\")\n",
" print(f\"Zeithorizont: {PERIODEN} Handelsperioden\")\n",
" print(f\"Volatilitaet: {VOLA_JAHR*100:.0f} % p.a. \"\n",
" f\"= {VOLA_PERIODE*100:.3f} % je Periode\")\n",
" print(f\"Slippage eta: {ETA:.2e} EUR/Stueck^2\")\n",
" print(f\"Risikoaversion: {RISIKOAVERSION:.2e} 1/EUR\")\n",
" print(f\"Erwartete Gesamtreibung: {V[0, -1]:,.2f} EUR \"\n",
" f\"({V[0, -1]/(GESAMTBESTAND*KURS)*10000:.1f} Basispunkte)\\n\")\n",
"\n",
" plan = pd.DataFrame([{\n",
" \"Periode\": f\"t = {t} -> {t+1}\",\n",
" \"Startbestand\": f\"{verlauf[t]:,}\",\n",
" \"Verkauf n_t\": f\"{verkaeufe[t]:,}\",\n",
" \"Restbestand\": f\"{verlauf[t+1]:,}\",\n",
" \"Anteil\": f\"{verkaeufe[t]/GESAMTBESTAND*100:5.1f} %\",\n",
" \"Kosten (EUR)\": f\"{periodenkosten(verkaeufe[t], verlauf[t+1]):,.0f}\",\n",
" } for t in range(PERIODEN)])\n",
" print(plan.to_string(index=False))\n",
"\n",
" # --- Vergleich mit Alternativen und der analytischen Loesung ---------\n",
" sofort = [GESAMTBESTAND] + [0] * PERIODEN\n",
" gleichmaessig = [GESAMTBESTAND * (1 - t / PERIODEN) for t in range(PERIODEN + 1)]\n",
" analytisch = analytische_loesung()\n",
"\n",
" print(\"\\n\" + \"-\" * 84)\n",
" print(f\"{'Strategie':<34} {'Kosten (EUR)':>15} {'Basispunkte':>13} \"\n",
" f\"{'ggue. Optimum':>16}\")\n",
" print(\"-\" * 84)\n",
" optimum = V[0, -1]\n",
" for name, pfad in [(\"DP-Optimum\", verlauf),\n",
" (\"Analytisch (Almgren-Chriss)\", list(analytisch)),\n",
" (\"Gleichmaessig (TWAP)\", gleichmaessig),\n",
" (\"Alles sofort\", sofort)]:\n",
" kosten = bewerte_pfad(pfad)\n",
" bp = kosten / (GESAMTBESTAND * KURS) * 10000\n",
" print(f\"{name:<34} {kosten:>15,.0f} {bp:>12.1f} \"\n",
" f\"{kosten - optimum:>+15,.0f}\")\n",
"\n",
" print(\"-\" * 84)\n",
" abweichung = abs(bewerte_pfad(list(analytisch)) - optimum) / optimum\n",
" print(f\"Abweichung DP zur analytischen Loesung: {abweichung*100:.3f} % \"\n",
" f\"(Diskretisierung: {SCHRITTWEITE} Stueck)\")\n",
"\n",
" # --- Sensitivitaet gegenueber der Risikoaversion ---------------------\n",
" print(\"\\n--- Wie wirkt die Risikoaversion? ---\")\n",
" print(f\"{'lambda':>10} | {'Verkauf in Periode 0':>22} | {'Charakter':<28}\")\n",
" print(\"-\" * 70)\n",
" for lam in [1e-7, 1e-6, 1e-5, 1e-4, 1e-3]:\n",
" globals()[\"RISIKOAVERSION\"] = lam\n",
" _, V_l, pol_l = loese_dp()\n",
" erste = pol_l[0, -1]\n",
" anteil = erste / GESAMTBESTAND * 100\n",
" charakter = (\"nahezu gleichmaessig\" if anteil < 25 else\n",
" \"front-loaded\" if anteil < 60 else \"fast alles sofort\")\n",
" print(f\"{lam:>10.0e} | {erste:>13,} ({anteil:5.1f} %) | {charakter:<28}\")\n",
" globals()[\"RISIKOAVERSION\"] = 1e-5 # zuruecksetzen\n",
"\n",
" # --- Diagramm ---------------------------------------------------------\n",
" plt.figure(figsize=(10, 5.5))\n",
" plt.plot(range(PERIODEN + 1), verlauf, \"o-\", lw=2.5, label=\"DP-Optimum\")\n",
" plt.plot(range(PERIODEN + 1), analytisch, \"s--\", lw=1.8, alpha=0.8,\n",
" label=\"Analytisch (Almgren-Chriss)\")\n",
" plt.plot(range(PERIODEN + 1), gleichmaessig, \"^:\", lw=1.8, alpha=0.8,\n",
" label=\"Gleichmaessig (TWAP)\")\n",
" plt.bar(range(PERIODEN), verkaeufe, alpha=0.25, color=\"orange\", width=0.45,\n",
" label=\"Verkaufstranche $n_t$\")\n",
" plt.title(\"Optimaler Liquidationspfad ueber diskrete Perioden\", fontsize=12)\n",
" plt.xlabel(\"Handelsperiode $t$\")\n",
" plt.ylabel(\"Verbleibender Bestand $X_t$\")\n",
" plt.xticks(range(PERIODEN + 1))\n",
" plt.grid(True, linestyle=\":\", alpha=0.6)\n",
" plt.legend()\n",
" plt.tight_layout()\n",
" ziel = os.path.join(OUTPUT_DIR, \"optimal_execution_dp.png\")\n",
" plt.savefig(ziel, dpi=150)\n",
" print(f\"\\nDiagramm gespeichert unter '{ziel}'\")\n",
" print(\"=\" * 84)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11"
}
},
"nbformat": 4,
"nbformat_minor": 5
}