#!/usr/bin/env python3

# Strukturbruecke.py
"""
Kapitel Bruecke: Derselbe Code, zwei Welten - der Beweis statt der Behauptung.

Der Teil-Auftakt stellt eine Tabelle auf: "Ressourcen auf Produkte verteilen"
entspreche "Kapital auf Anlagen verteilen", "gegen den Worst Case absichern"
entspreche "Absicherung gegen Kursabstuerze". Solche Tabellen stehen in vielen
Buechern. Sie sind billig - und man kann sie pruefen.

Dieses Programm prueft sie. Es fuettert zweimal DENSELBEN Code mit Daten aus
zwei Welten und zeigt die Ergebnisse nebeneinander:

  1. Die Allokation als LP. Das Domaenenmodell aus or_kern.py, einmal mit einer
     Schreinerei (Montagestunden, Plattenmaterial) und einmal mit einem Depot
     (Kapital, Risikobudget). Gleiche Klasse, gleicher Modellbauer, gleiche
     Abnahmepruefung - und der Schattenpreis heisst in der einen Welt
     "Wert einer zusaetzlichen Montagestunde" und in der anderen "Preis des
     Risikos".
  2. Die Absicherung gegen den schlechtesten Fall als CVaR. EINE Funktion,
     einmal mit Kursrenditen und einmal mit Lieferverzuegen.
  3. Was NICHT hinueberreicht. Der ehrliche Teil: Drei Unterschiede, die die
     Analogie begrenzen - und die man kennen muss, bevor man Methoden aus
     der einen Welt in die andere traegt.

ZUR SOLVERWAHL: Teil 1 benutzt 'loese_mit_scipy' und nicht 'loese_mit_glop'.
Das ist kein Zufall - CVXPY laedt fuer Teil 2 highspy, und ortools vertraegt
sich damit nicht im selben Prozess (Kapitel Oekosystem). Wer hier GLOP nimmt,
bekommt beim cvxpy-Import eine Fehlermeldung ueber ein 'undefined symbol'.
Genau deshalb laedt or_kern.py seine Solver erst beim Aufruf.

Benoetigt: numpy, cvxpy, scipy und pydantic (ueber or_kern)
"""

from __future__ import annotations

import numpy as np

from or_kern import (Produkt, Produktionsproblem, loese_mit_scipy,
                     pruefe_loesung)

SAAT = 7
SZENARIEN = 500
ALPHA = 0.95              # die schlechtesten 5 % der Faelle
MAX_ANTEIL = 0.40         # Streuungsgebot in beiden Welten


# --- Teil 1: Dieselbe Klasse, zwei Welten ----------------------------------

def schreinerei() -> Produktionsproblem:
    """Montagestunden und Plattenmaterial auf Tische und Stuehle verteilen."""
    return Produktionsproblem(
        produkte=[
            Produkt(name="Tisch", deckungsbeitrag=240.0,
                    verbrauch={"Montagestunden": 3.0, "Plattenmaterial": 6.0}),
            Produkt(name="Stuhl", deckungsbeitrag=60.0,
                    verbrauch={"Montagestunden": 1.0, "Plattenmaterial": 1.0}),
            Produkt(name="Regal", deckungsbeitrag=130.0,
                    verbrauch={"Montagestunden": 2.0, "Plattenmaterial": 4.0}),
        ],
        kapazitaeten={"Montagestunden": 150.0, "Plattenmaterial": 240.0})


def depot() -> Produktionsproblem:
    """Kapital und Risikobudget auf Anlageklassen verteilen.

    Eine "Einheit" ist hier 1.000 EUR Anlagesumme. Der Deckungsbeitrag ist der
    erwartete Jahresertrag dieser Einheit, der Verbrauch die beanspruchte
    Kapital- und Risikomenge. Das ist keine Analogie, sondern buchstaeblich
    dasselbe Modell - deshalb passt es in dieselbe Klasse.
    """
    return Produktionsproblem(
        produkte=[
            Produkt(name="Aktien Welt", deckungsbeitrag=75.0,
                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 1.00}),
            Produkt(name="Anleihen", deckungsbeitrag=28.0,
                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.22}),
            Produkt(name="Immobilienfonds", deckungsbeitrag=46.0,
                    verbrauch={"Kapital (Tsd. EUR)": 1.0, "Risikobudget": 0.55}),
        ],
        kapazitaeten={"Kapital (Tsd. EUR)": 150.0, "Risikobudget": 90.0})


def berichte_allokation(titel: str, problem: Produktionsproblem,
                        einheit: str, ertragsname: str) -> None:
    """Ein Bericht fuer beide Welten - nur die Beschriftung wechselt."""
    loesung = loese_mit_scipy(problem)
    beanstandungen = pruefe_loesung(problem, loesung)

    print(f"  {titel}")
    print(f"    {loesung.als_bericht()}")
    for produkt in problem.produkte:
        print(f"      {produkt.name:<18} {loesung.werte[produkt.name]:8.2f} {einheit}")
    print(f"      {ertragsname:<18} {loesung.zielwert:8.2f} EUR")
    for ressource, preis in loesung.schattenpreise.items():
        print(f"      Schattenpreis {ressource:<24} {preis:7.2f} EUR")
    print(f"      Abnahmepruefung: "
          f"{'bestanden' if not beanstandungen else beanstandungen}")


# --- Teil 2: Eine CVaR-Funktion, zwei Welten -------------------------------

def optimiere_cvar(verluste: np.ndarray, ertrag: np.ndarray,
                   mindestertrag: float, alpha: float = ALPHA,
                   max_anteil: float = MAX_ANTEIL):
    """Minimiert den CVaR der Verluste unter einer Mindestertragsbedingung.

    'verluste' hat die Form (Szenarien x Optionen) und enthaelt, was in
    Szenario s passiert, wenn eine Einheit in Option j steckt. Was ein
    "Verlust" ist, entscheidet allein die Einheit der Matrix: Prozentpunkte
    Kursverlust oder Tage Lieferverzug - die Formel sieht keinen Unterschied.

    Das ist die Rockafellar-Uryasev-Formulierung aus dem Kapitel CVaR, hier
    ohne jede Aenderung wiederverwendet.
    """
    import cvxpy as cp

    anzahl_szenarien, anzahl_optionen = verluste.shape
    anteil = cp.Variable(anzahl_optionen, nonneg=True)
    schwelle = cp.Variable()                       # wird im Optimum zum VaR
    ueberschuss = cp.Variable(anzahl_szenarien, nonneg=True)

    cvar = schwelle + (1.0 / (anzahl_szenarien * (1 - alpha))) * cp.sum(ueberschuss)
    problem = cp.Problem(
        cp.Minimize(cvar),
        [ueberschuss >= verluste @ anteil - schwelle,
         cp.sum(anteil) == 1,
         anteil <= max_anteil,
         ertrag @ anteil >= mindestertrag])
    problem.solve()
    if problem.status not in ("optimal", "optimal_inaccurate"):
        raise SystemExit(f"CVaR-Problem nicht loesbar: {problem.status}")
    return anteil.value, float(cvar.value), float(schwelle.value)


def kursszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
    """Taegliche Verluste (negative Renditen) von sechs Anlageklassen."""
    namen = ["Aktien Welt", "Aktien EU", "Schwellenlaender",
             "Staatsanleihen", "Unternehmensanl.", "Rohstoffe"]
    rendite_pa = np.array([0.080, 0.065, 0.110, 0.025, 0.045, 0.070])
    schwankung = np.array([0.180, 0.160, 0.260, 0.040, 0.075, 0.210])
    taeglich = rng.normal(rendite_pa / 252, schwankung / np.sqrt(252),
                          (SZENARIEN, len(namen)))
    return -taeglich * 100.0, rendite_pa * 100.0, namen


def lieferszenarien(rng) -> tuple[np.ndarray, np.ndarray, list[str]]:
    """Lieferverzug in Tagen bei sechs Lieferanten.

    Der Aufbau ist bewusst anders als bei den Kursen: Hier gibt es einen
    normalen Verzug UND seltene Totalausfaelle, die 14 Tage kosten. Das ist
    genau die Art fetter Raender, wegen der man in beiden Welten CVaR statt
    Standardabweichung benutzt.
    """
    namen = ["Nordwerk", "Sued-Metall", "Fernost A", "Lokalzulieferer",
             "Fernost B", "Osteuropa"]
    zuverlaessigkeit = np.array([0.94, 0.97, 0.90, 0.99, 0.92, 0.95])
    verzugsstreuung = np.array([3.5, 1.8, 5.0, 0.9, 4.2, 2.8])
    normaler_verzug = np.maximum(0.0, rng.normal(0.5, 1.0, (SZENARIEN, len(namen)))
                                 * verzugsstreuung)
    totalausfall = (rng.random((SZENARIEN, len(namen))) > zuverlaessigkeit) * 14.0
    return normaler_verzug + totalausfall, zuverlaessigkeit, namen


def berichte_cvar(titel: str, verluste, ertrag, namen, mindestertrag,
                  einheit: str, ertragsname: str):
    anteile, cvar, var = optimiere_cvar(verluste, ertrag, mindestertrag)
    mittel = float((verluste @ anteile).mean())
    print(f"  {titel}")
    print(f"    VaR  {ALPHA:.0%}: {var:8.3f} {einheit}")
    print(f"    CVaR {ALPHA:.0%}: {cvar:8.3f} {einheit}      "
          f"(Mittelwert ueber alle Szenarien: {mittel:.3f})")
    print(f"    {ertragsname}: {float(ertrag @ anteile):.3f}")
    print("    Aufteilung:")
    for name, anteil in zip(namen, anteile):
        balken = "#" * int(round(anteil * 40))
        grenze = "  <- an der Streuungsgrenze" if anteil > MAX_ANTEIL - 1e-4 else ""
        print(f"      {name:<18} {anteil:6.1%}  {balken}{grenze}")
    return anteile


if __name__ == "__main__":
    rng = np.random.default_rng(SAAT)

    print("=" * 80)
    print("  DIESELBE STRUKTUR, ZWEI WELTEN")
    print("=" * 80)

    # --- Teil 1 ----------------------------------------------------------
    print("\n1. Allokation als LP - EINE Klasse, EIN Modellbauer\n")
    berichte_allokation("Werkstatt: Produktionsprogramm", schreinerei(),
                        "Stueck", "Deckungsbeitrag")
    print()
    berichte_allokation("Depot: Anlageaufteilung", depot(),
                        "Tsd.  ", "Erwarteter Ertrag")

    print("\n  Beide Ausgaben stammen aus derselben Funktion "
          "'berichte_allokation'.")
    print("  Ausgetauscht wurden nur die Daten und die Beschriftungen -")
    print("  keine Zeile Modellcode.")
    print()
    print("  Lesen Sie die Schattenpreise nebeneinander: In der Werkstatt sagt")
    print("  er, was eine zusaetzliche Montagestunde wert waere. Im Depot sagt")
    print("  dieselbe Zahl, was eine zusaetzliche Einheit Risikobudget wert")
    print("  waere - der PREIS DES RISIKOS. Das ist kein Sprachbild, sondern")
    print("  derselbe Dualwert derselben Nebenbedingung.")

    # --- Teil 2 ----------------------------------------------------------
    print("\n" + "-" * 80)
    print("2. Absicherung gegen den schlechtesten Fall - EINE CVaR-Funktion\n")

    kurse, rendite, anlagen = kursszenarien(rng)
    anteile_depot = berichte_cvar(
                  "Depot: die schlechtesten 5 % der Handelstage",
                  kurse, rendite, anlagen, 5.5,
                  "% je Tag    ", "Erwartete Jahresrendite (%)")
    print()
    lieferung, zuverlaessig, lieferanten = lieferszenarien(rng)
    anteile_einkauf = berichte_cvar("Einkauf: die schlechtesten 5 % der Bestellungen",
                  lieferung, zuverlaessig, lieferanten, 0.945,
                  "Tage Verzug  ", "Mittlere Zuverlaessigkeit")

    print("\n  Auch hier: eine Funktion, zwei Aufrufe. Die Zielfunktion")
    print("  interessiert sich nicht dafuer, ob in der Matrix Prozentpunkte")
    print("  oder Tage stehen.")
    print()
    print(f"  Interessant ist, WO die Streuungsgrenze von {MAX_ANTEIL:.0%} bindet:")
    print(f"    Depot   : {(anteile_depot > MAX_ANTEIL - 1e-4).sum()} von "
          f"{len(anteile_depot)} Posten am Anschlag")
    print(f"    Einkauf : {(anteile_einkauf > MAX_ANTEIL - 1e-4).sum()} von "
          f"{len(anteile_einkauf)} Posten am Anschlag")
    print()
    print("  Im Einkauf zieht es die Loesung an den sicheren Lokalzulieferer,")
    print("  bis die Grenze sie stoppt. Im Depot nicht - dort verhindert die")
    print("  Mindestrendite, dass alles in Anleihen wandert. Zwei verschiedene")
    print("  Bremsen also, und sie stehen an verschiedenen Stellen des Modells:")
    print("  einmal in einer Nebenbedingung ueber die Anteile, einmal in einer")
    print("  ueber den Ertrag. Wer eine Struktur uebertraegt, uebertraegt eben")
    print("  nicht automatisch mit, WELCHE Bedingung am Ende bindet.")

    # --- Teil 3: Der ehrliche Teil ---------------------------------------
    print("\n" + "=" * 80)
    print("  WAS NICHT HINUEBERREICHT")
    print("=" * 80)
    print("Die Struktur traegt. Drei Unterschiede tragen NICHT mit, und wer sie")
    print("uebersieht, macht aus einer nuetzlichen Analogie einen Fehler:")
    print()
    print("1. WOHER DIE ZAHLEN KOMMEN.")
    print("   In der Werkstatt ist der Verbrauch je Tisch gemessen - drei")
    print("   Montagestunden sind drei Montagestunden. Im Depot ist die")
    print("   erwartete Rendite GESCHAETZT, und zwar mit einem Fehler, der")
    print("   groesser sein kann als die Unterschiede zwischen den Anlagen")
    print("   (Kapitel Markowitz, Renditeschaetzung_Falle.py). Dieselbe")
    print("   Optimierung ist im einen Fall Planung und im anderen")
    print("   Fehlerverstaerkung.")
    print()
    print("2. OB DIE VERGANGENHEIT ETWAS UEBER DIE ZUKUNFT SAGT.")
    print("   Lieferzeiten haben physikalische Ursachen: Entfernung, Zoll,")
    print("   Kapazitaet. Sie aendern sich langsam und nachvollziehbar.")
    print("   Kursrenditen entstehen aus dem Verhalten von Marktteilnehmern,")
    print("   die selbst auf Modelle reagieren - dort verschwindet ein")
    print("   erkanntes Muster oft genau deshalb, weil es erkannt wurde")
    print("   (Kapitel Handelsmaschine, Data_Snooping.py).")
    print()
    print("3. OB TEILBARKEIT ERLAUBT IST.")
    print("   37,4 % eines Aktienfonds sind ein normaler Auftrag. 37,4 % eines")
    print("   Lieferanten sind es nicht - Vertraege, Mindestabnahmen und")
    print("   Ruestzeiten machen Einkaufsentscheidungen ganzzahlig. Genau")
    print("   deshalb ist Teil II voller MILP und Teil V fast frei davon.")
    print()
    print("Die Bruecke traegt also die MODELLE, nicht die Annahmen. Wer sie")
    print("benutzt, spart sich das Lernen der Methoden - nicht das Nachdenken")
    print("ueber die Daten.")
    print("=" * 80)
