Kapitel 21: Die vollständige quantitative Handelsmaschine

📌 Kapitel auf einen Blick

Worum geht es? Um das Zusammenführen von allem: Datenpipeline, Signal, Optimierung, Rebalancing, Kostenverbuchung und ein Backtest, der nicht lügt.

Voraussetzungen: Kapitel 18 bis Kapitel 20.

Danach können Sie: Eine Walk-Forward-Backtest-Architektur bauen, Lookahead-Bias vermeiden, Ergebnisse gegen eine Benchmark bewerten und typische Backtest-Fallen erkennen.

Zeitbedarf: ca. 6 Stunden.

Programme:
QuantitativeTradingEngine.py
Backtest_Fallen.py
Data_Snooping.py

⚠️ Keine Anlageberatung. Dieses Kapitel demonstriert Methodik an realen Daten, keine handelbare Strategie. Abschnitt 21.7 erklärt ausführlich, warum ein guter Backtest noch lange keine funktionierende Strategie ist.

Notebook: handelsmaschine.ipynb
In Google Colab öffnen


21.1 In 5 Minuten gelöst

🚀 In 5 Minuten gelöst: Dieselben Tage, ein anderes Schicksal

Zwei Strategien über zehn Jahre. Sie bestehen aus exakt denselben Tagesrenditen — nur in anderer Reihenfolge. Bei der einen sind die 250 schlechtesten Tage über den ganzen Zeitraum verstreut, bei der anderen liegen sie am Stück.

import numpy as np

rng = np.random.default_rng(1)
tage = rng.normal(0.0005, 0.010, 2520)          # zehn Jahre Tagesrenditen

gestreut = tage.copy()
rng.shuffle(gestreut)

sortiert = np.sort(tage)                         # dieselben Tage, neu geordnet:
schlecht, rest = sortiert[:250], sortiert[250:]  # die 250 schlechtesten ...
rng.shuffle(rest)
geballt = np.concatenate([rest[:1000], schlecht, rest[1000:]])   # ... am Stueck

def kennzahlen(r):
    verlauf = np.cumprod(1 + r)
    rueckschlag = (verlauf / np.maximum.accumulate(verlauf) - 1).min()
    return verlauf[-1]**(252/len(r)) - 1, r.mean()/r.std()*np.sqrt(252), rueckschlag

for name, r in [("gestreut", gestreut), ("geballt", geballt)]:
    jahr, sharpe, rueckschlag = kennzahlen(r)
    print(f"{name:9} Jahresrendite {jahr*100:5.2f} %  Sharpe {sharpe:4.2f}  "
          f"max. Rueckschlag {rueckschlag*100:6.1f} %")

Ausgabe:

gestreut  Jahresrendite  8.88 %  Sharpe 0.61  max. Rueckschlag  -19.0 %
geballt   Jahresrendite  8.88 %  Sharpe 0.61  max. Rueckschlag  -98.7 %

Rendite und Sharpe-Kennzahl sind identisch — sie müssen es sein, denn beide Reihen enthalten dieselben Zahlen. Nur die Reihenfolge unterscheidet sich.

Der maximale Rückschlag beträgt trotzdem 19,0 % gegen 98,7 %. Die zweite Strategie verliert zwischenzeitlich fast das gesamte Kapital und erholt sich danach wieder auf denselben Endstand.

Rechnerisch sind beide gleichwertig. Praktisch ist die zweite tot: Bei −98 % gibt es keinen Anleger mehr, kein Mandat und kein Handelsdesk, das die Erholung noch erlebt.

🎯 Merksatz Rendite und Sharpe-Kennzahl mitteln über die Zeit und verlieren dabei die Reihenfolge. Genau in der Reihenfolge steckt aber, ob eine Strategie überlebt. Der maximale Rückschlag misst nicht das statistische Risiko, sondern das menschliche: Wie schlimm sah es zwischendurch aus? Berichten Sie ihn immer mit — eine Rendite allein ist keine Aussage.

Dasselbe gilt weit außerhalb der Finanzwelt. Ein Produktionsplan, der im Jahresmittel optimal ist, aber im März drei Wochen lang die Liefertreue reißt, wird im April abgeschaltet — bevor sich der Mittelwert einstellen kann. Wo immer Sie einen Durchschnitt berichten, berichten Sie auch den schlimmsten Zwischenzustand.


21.2 Lernziele

Nach diesem Kapitel können Sie …

  1. … die sechs Bausteine einer quantitativen Engine benennen und ihre Reihenfolge begründen.
  2. … einen Walk-Forward-Backtest ohne Lookahead-Bias implementieren.
  3. … Rebalancing-Termine korrekt auf Handelstage abbilden.
  4. … Transaktionskosten und Gewichtsdrift realistisch verbuchen.
  5. … Backtest-Ergebnisse kritisch prüfen und die typischen Selbsttäuschungen erkennen.
  6. … den maximalen Rückschlag als eigenständige Kennzahl berichten und begründen, warum Rendite und Sharpe allein nichts über den Verlauf aussagen.
  7. … die Zahl der ausprobierten Varianten protokollieren und die Signifikanzschwelle entsprechend korrigieren.

21.3 Die Architektur

Abb. 21.1: Überblick der quantitativen Engine
# Baustein Aufgabe Kapitel
1 Datenpipeline Kurse laden, bereinigen, Reihenfolge sichern 11
2 Signal (Alpha) Renditeerwartung \boldsymbol{\mu} schätzen 11
3 Risikomodell Kovarianz bzw. Szenarien bereitstellen 11
4 Optimierungskern Gewichte unter Restriktionen bestimmen 12, 13
5 Rebalancing Umsetzen, Kosten verbuchen, Drift fortschreiben 13
6 Auswertung Kennzahlen, Benchmark-Vergleich, Diagnose 14

🎯 Die goldene Regel des Backtestens Zum Zeitpunkt t darf ausschließlich Information verwendet werden, die zum Zeitpunkt t tatsächlich vorlag. Jede Verletzung — und sei sie einen einzigen Tag groß — macht Ergebnisse systematisch zu gut. Der Fehler ist besonders tückisch, weil er keine Fehlermeldung erzeugt, sondern nur die Rendite verbessert.


21.4 Rebalancing-Termine richtig bestimmen

⚠️ Ein stiller, häufiger Fehler

self.rebalance_dates = self.prices.resample("MS").first().index
...
if current_date in self.rebalance_dates:

resample("MS") erzeugt Kalender-Monatsanfänge — den 1. Januar, den 1. Februar und so fort. Der Kursindex enthält aber nur Handelstage. Fällt der Monatserste auf ein Wochenende oder einen Feiertag, ist er kein Element des Index, die Bedingung wird nie wahr, und das Rebalancing entfällt kommentarlos.

Messung auf einem reinen Wochentagskalender über fünf Jahre:

Termine
geplant 61
tatsächlich ausgeführt 44 (72 %)
stillschweigend verworfen 17 (28 %)

Mit echten Börsenfeiertagen fallen weitere Termine aus. Der Backtest testete also eine andere Strategie als die beschriebene — und lieferte trotzdem plausibel aussehende Kennzahlen.

Die Korrektur:

monat = self.kurse.index.to_period("M")
self.rebalancing_termine = self.kurse.index[~monat.duplicated()]   # erster HANDELStag
assert self.rebalancing_termine.isin(self.kurse.index).all()

~monat.duplicated() markiert den jeweils ersten vorhandenen Tag jedes Monats — also garantiert einen Handelstag.

🎯 Die allgemeine Lehre Der gefährlichste Fehler ist der, der nichts kaputt macht. Ein Absturz wird bemerkt. Ein Backtest, der stillschweigend 28 % seiner Entscheidungen auslässt, wird gelobt. Bauen Sie deshalb in jeden Backtest Zähler ein: Wie viele Rebalancings waren geplant, wie viele wurden ausgeführt? Weichen die Zahlen ab, stimmt etwas nicht.


21.5 Die Engine

#!/usr/bin/env python3

# QuantitativeTradingEngine.py
"""
Kapitel Handelsmaschine: Vollstaendige quantitative Handelsmaschine mit
Walk-Forward-Backtest und OR-Optimierungsschicht.

Eigenschaften:
  * Rebalancing-Termine sind garantiert Handelstage; die Zahl der
    tatsaechlich ausgefuehrten Umschichtungen wird ausgewiesen
  * Spaltenreihenfolge erzwungen
  * Einheiten konsistent (taegliche Groessen im Modell)
  * Szenario-Nebenbedingungen vektorisiert
  * Lookahead-Selbsttest eingebaut
  * Zusaetzliche Kennzahlen: Calmar, Trefferquote, Turnover, Kostenanteil
"""

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)

HANDELSTAGE = 252


class QuantitativeHandelsmaschine:
    """Walk-Forward-Backtest mit CVaR-Optimierung und Transaktionskosten."""

    def __init__(self, universum, benchmark="SPY", jahre=5,
                 startkapital=100_000.0, gebuehrensatz=0.0015,
                 max_gewicht=0.20, alpha=0.95, risikoaversion=1.2,
                 warmup=HANDELSTAGE):
        self.universum = list(universum)
        self.benchmark = benchmark
        self.jahre = jahre
        self.startkapital = startkapital
        self.gebuehrensatz = gebuehrensatz
        self.max_gewicht = max_gewicht
        self.alpha = alpha
        self.risikoaversion = risikoaversion
        self.warmup = warmup

        self.kurse = None
        self.benchmark_kurse = None
        self.renditen = None
        self.rebalancing_termine = None

    # -- 1. Datenpipeline ------------------------------------------------
    def lade_daten(self):
        ende = pd.Timestamp.today().normalize()
        start = ende - pd.DateOffset(years=self.jahre)
        alle = self.universum + [self.benchmark]

        print(f"Lade {len(self.universum)} Titel + Benchmark ({self.benchmark}) "
              f"ueber {self.jahre} Jahre ...")
        roh = yf.download(alle, start=start, end=ende, auto_adjust=True, progress=False)
        if roh.empty:
            raise SystemExit("Download fehlgeschlagen (Netz/Ticker/Rate-Limit pruefen).")

        if isinstance(roh.columns, pd.MultiIndex):
            daten = roh["Close"][alle].dropna()      # erzwingt eigene Spaltenreihenfolge
        else:
            daten = roh[["Close"]].dropna()
            daten.columns = alle
        assert list(daten.columns) == alle, "Spaltenreihenfolge weicht ab!"

        self.kurse = daten[self.universum]
        self.benchmark_kurse = daten[self.benchmark]
        self.renditen = self.kurse.pct_change().dropna()

        # --- erster HANDELStag je Monat, nicht Kalendermonatsanfang -----
        monat = self.kurse.index.to_period("M")
        self.rebalancing_termine = self.kurse.index[~monat.duplicated()]
        assert self.rebalancing_termine.isin(self.kurse.index).all(), \
            "Rebalancing-Termine sind keine Handelstage!"

        print(f"  {len(self.kurse)} Handelstage, "
              f"{len(self.rebalancing_termine)} Rebalancing-Termine "
              f"(alle sind Handelstage)")

    # -- 2. Signal --------------------------------------------------------
    def alpha_signal(self, stichtag, rueckblick=HANDELSTAGE):
        """
        12-1-Momentum: Kursentwicklung der letzten 12 Monate unter Auslassung
        des letzten Monats (kurzfristige Umkehr herausrechnen).
        Rueckgabe: TAEGLICHE Renditeerwartung.
        """
        historie = self.kurse.loc[:stichtag]
        if len(historie) < rueckblick:
            return np.zeros(len(self.universum))

        p_alt = historie.iloc[-rueckblick]
        p_neu = historie.iloc[-21]                    # vor ca. einem Monat
        momentum = (p_neu / p_alt - 1.0).values

        # Z-Score, damit das Signal skalenfrei wird
        streuung = momentum.std()
        score = (momentum - momentum.mean()) / (streuung + 1e-9)

        # In eine taegliche Renditeerwartung uebersetzen:
        # Basis 4 % p.a. plus 6 % p.a. je Standardabweichung Momentum.
        mu_jaehrlich = np.maximum(0.04 + 0.06 * score, 0.0)
        return mu_jaehrlich / HANDELSTAGE

    # -- 3./4. Risikomodell und Optimierung -------------------------------
    def optimiere(self, stichtag, w_alt, rueckblick=HANDELSTAGE):
        historie = self.renditen.loc[:stichtag].iloc[-rueckblick:]
        if len(historie) < 100:
            return w_alt, False

        R = historie.values
        S, N = R.shape
        mu_taeglich = self.alpha_signal(stichtag, rueckblick)

        w = cp.Variable(N, nonneg=True)
        gamma = cp.Variable()
        u = cp.Variable(S, nonneg=True)

        cvar = gamma + (1.0 / (S * (1.0 - self.alpha))) * cp.sum(u)
        kosten = self.gebuehrensatz * cp.norm1(w - w_alt)
        ziel = cp.Maximize(mu_taeglich @ w - self.risikoaversion * cvar - kosten)

        bedingungen = [cp.sum(w) == 1,
                       w <= self.max_gewicht,
                       u >= -(R @ w) - gamma]          # vektorisiert
        problem = cp.Problem(ziel, bedingungen)
        try:
            problem.solve(solver=cp.CLARABEL)
        except cp.error.SolverError:
            return w_alt, False

        if problem.status not in ("optimal", "optimal_inaccurate") or w.value is None:
            return w_alt, False

        gewichte = np.maximum(w.value, 0.0)
        return gewichte / gewichte.sum(), True

    # -- 5. Backtest -------------------------------------------------------
    def backtest(self):
        print("Starte Walk-Forward-Backtest ...")
        N = len(self.universum)
        gewichte = np.ones(N) / N

        werte = [self.startkapital]
        datumsliste = [self.renditen.index[self.warmup]]
        gewichtshistorie = []
        geplant = versucht = ausgefuehrt = 0
        kosten_gesamt = 0.0
        turnover_gesamt = 0.0

        for t in range(self.warmup, len(self.renditen) - 1):
            heute = self.renditen.index[t]
            morgen = self.renditen.index[t + 1]

            ist_termin = heute in self.rebalancing_termine or t == self.warmup
            if ist_termin:
                geplant += 1
                versucht += 1
                neue_gewichte, erfolg = self.optimiere(heute, gewichte)
                if erfolg:
                    ausgefuehrt += 1
                    turnover = float(np.abs(neue_gewichte - gewichte).sum())
                    gebuehr = werte[-1] * turnover * self.gebuehrensatz
                    werte[-1] -= gebuehr
                    kosten_gesamt += gebuehr
                    turnover_gesamt += turnover
                    gewichte = neue_gewichte
                    gewichtshistorie.append((heute, gewichte.copy()))

            # Rendite von MORGEN auf die HEUTE festgelegten Gewichte anwenden
            tagesrenditen = self.renditen.iloc[t + 1].values
            portfoliorendite = float(gewichte @ tagesrenditen)
            werte.append(werte[-1] * (1.0 + portfoliorendite))
            datumsliste.append(morgen)

            # Gewichte driften mit den Kursen weiter
            gedriftet = gewichte * (1.0 + tagesrenditen)
            gewichte = gedriftet / gedriftet.sum()

        print(f"  Rebalancing: {geplant} geplant, {ausgefuehrt} ausgefuehrt "
              f"({ausgefuehrt/max(geplant,1)*100:.0f} %)")
        if ausgefuehrt < geplant:
            print(f"  WARNUNG: {geplant - ausgefuehrt} Optimierungen sind "
                  f"fehlgeschlagen - Ursache pruefen!")
        print(f"  Kumulierter Turnover: {turnover_gesamt*100:.0f} % | "
              f"Transaktionskosten insgesamt: {kosten_gesamt:,.2f} EUR")

        ergebnis = pd.DataFrame({"Portfolio": werte}, index=datumsliste)
        benchmark = self.benchmark_kurse.loc[datumsliste]
        ergebnis["Benchmark"] = benchmark / benchmark.iloc[0] * self.startkapital
        return ergebnis, gewichtshistorie, kosten_gesamt

    # -- 6. Auswertung -----------------------------------------------------
    @staticmethod
    def kennzahlen(reihe, risikofrei=0.02):
        renditen = reihe.pct_change().dropna()
        jahre = (reihe.index[-1] - reihe.index[0]).days / 365.25
        cagr = (reihe.iloc[-1] / reihe.iloc[0]) ** (1 / jahre) - 1
        vola = renditen.std() * np.sqrt(HANDELSTAGE)
        sharpe = (cagr - risikofrei) / vola if vola > 0 else np.nan
        drawdown = ((reihe - reihe.cummax()) / reihe.cummax()).min()
        calmar = cagr / abs(drawdown) if drawdown < 0 else np.nan
        trefferquote = float((renditen > 0).mean())
        return {"CAGR": cagr, "Volatilitaet": vola, "Sharpe": sharpe,
                "Max Drawdown": drawdown, "Calmar": calmar,
                "Trefferquote": trefferquote, "Jahre": jahre}

    def bericht(self, ergebnis, kosten_gesamt):
        k_port = self.kennzahlen(ergebnis["Portfolio"])
        k_bench = self.kennzahlen(ergebnis["Benchmark"])

        print("\n" + "=" * 84)
        print("          LEISTUNGSREPORT DER QUANTITATIVEN ENGINE")
        print("=" * 84)
        print(f"Zeitraum: {ergebnis.index[0]:%Y-%m-%d} bis {ergebnis.index[-1]:%Y-%m-%d} "
              f"({k_port['Jahre']:.1f} Jahre) | Startkapital "
              f"{self.startkapital:,.0f} EUR\n")

        zeilen = [
            ("Endkapital", f"{ergebnis['Portfolio'].iloc[-1]:,.0f} EUR",
             f"{ergebnis['Benchmark'].iloc[-1]:,.0f} EUR"),
            ("CAGR (Jahresrendite)", f"{k_port['CAGR']*100:6.2f} %",
             f"{k_bench['CAGR']*100:6.2f} %"),
            ("Volatilitaet p.a.", f"{k_port['Volatilitaet']*100:6.2f} %",
             f"{k_bench['Volatilitaet']*100:6.2f} %"),
            ("Sharpe Ratio (rf=2 %)", f"{k_port['Sharpe']:6.2f}",
             f"{k_bench['Sharpe']:6.2f}"),
            ("Maximum Drawdown", f"{k_port['Max Drawdown']*100:6.2f} %",
             f"{k_bench['Max Drawdown']*100:6.2f} %"),
            ("Calmar Ratio", f"{k_port['Calmar']:6.2f}", f"{k_bench['Calmar']:6.2f}"),
            ("Trefferquote (Tage)", f"{k_port['Trefferquote']*100:6.1f} %",
             f"{k_bench['Trefferquote']*100:6.1f} %"),
        ]
        print(pd.DataFrame(zeilen, columns=["Kennzahl", "OR-Strategie", "Benchmark"])
              .to_string(index=False))

        anteil = kosten_gesamt / self.startkapital
        print(f"\nTransaktionskosten: {kosten_gesamt:,.0f} EUR "
              f"= {anteil*100:.2f} % des Startkapitals "
              f"= {anteil/k_port['Jahre']*100:.2f} % p.a.")
        print("Ohne diese Kosten waere die ausgewiesene Rendite entsprechend hoeher -")
        print("genau deshalb gehoeren sie in den Backtest und nicht daneben.")
        print("=" * 84)
        return k_port, k_bench

    def zeichne(self, ergebnis):
        plt.figure(figsize=(11, 6))
        plt.plot(ergebnis.index, ergebnis["Portfolio"], "b-", lw=2.2,
                 label="OR-Handelsmaschine")
        plt.plot(ergebnis.index, ergebnis["Benchmark"], "k--", lw=1.5, alpha=0.75,
                 label=f"Benchmark ({self.benchmark})")
        plt.title("Walk-Forward-Backtest: OR-Strategie gegen Benchmark", fontsize=12)
        plt.xlabel("Datum")
        plt.ylabel("Depotwert in EUR")
        plt.grid(True, linestyle=":", alpha=0.6)
        plt.legend(loc="upper left")
        plt.tight_layout()
        ziel = os.path.join(OUTPUT_DIR, "trading_engine_backtest.png")
        plt.savefig(ziel, dpi=150)
        print(f"Chart gespeichert unter '{ziel}'")


def lookahead_selbsttest(maschine):
    """
    Prueft, dass das Signal zum Zeitpunkt t NUR Daten bis t verwendet.
    Methode: kuenstlich die Zukunft veraendern und pruefen, ob sich das
    Signal von heute dadurch aendert. Tut es das, liegt Lookahead vor.
    """
    stichtag = maschine.kurse.index[len(maschine.kurse) // 2]
    signal_original = maschine.alpha_signal(stichtag)

    original_kurse = maschine.kurse.copy()
    zukunft = maschine.kurse.index > stichtag
    maschine.kurse.loc[zukunft] *= 3.0            # Zukunft massiv veraendern
    signal_manipuliert = maschine.alpha_signal(stichtag)
    maschine.kurse = original_kurse               # zuruecksetzen

    identisch = np.allclose(signal_original, signal_manipuliert)
    print(f"Lookahead-Selbsttest: Signal bleibt bei manipulierter Zukunft "
          f"{'UNVERAENDERT (gut)' if identisch else 'VERAENDERT - LOOKAHEAD-BIAS!'}")
    assert identisch, "Das Signal verwendet Zukunftsdaten!"


if __name__ == "__main__":
    UNIVERSUM = ["AAPL", "MSFT", "NVDA", "GOOGL",
                 "JNJ", "UNH", "PFE",
                 "JPM", "BAC", "GS",
                 "XOM", "CVX",
                 "PG", "KO", "COST"]

    maschine = QuantitativeHandelsmaschine(UNIVERSUM, benchmark="SPY", jahre=5)
    maschine.lade_daten()
    lookahead_selbsttest(maschine)
    ergebnis, gewichte, kosten = maschine.backtest()
    maschine.bericht(ergebnis, kosten)
    maschine.zeichne(ergebnis)

💻 Code-Durchgang: die vier kritischen Stellen

1. Rebalancing-Kalender.

monat = self.kurse.index.to_period("M")
self.rebalancing_termine = self.kurse.index[~monat.duplicated()]

to_period("M") bildet jeden Handelstag auf seinen Monat ab; ~duplicated() behält jeweils den ersten — also garantiert einen realen Handelstag.

2. Die Zeitachse im Backtest.

tagesrenditen = self.renditen.iloc[t + 1].values      # MORGEN
portfoliorendite = gewichte @ tagesrenditen            # HEUTE festgelegt

Die Gewichte werden am Tag t aus Daten bis t bestimmt und auf die Rendite von t+1 angewendet. Diese eine Indexverschiebung ist der Unterschied zwischen einem ehrlichen und einem geschönten Backtest.

3. Der Lookahead-Selbsttest. Er verdreifacht künstlich alle Kurse nach dem Stichtag und prüft, ob sich das Signal von vor dem Stichtag dadurch ändert. Ändert es sich, greift der Code auf die Zukunft zu. Dieser Test kostet zehn Zeilen und findet eine Fehlerklasse, die sonst praktisch unsichtbar bleibt. Übernehmen Sie ihn in jedes Backtest-Projekt.

4. Gewichtsdrift.

gedriftet = gewichte * (1.0 + tagesrenditen)
gewichte = gedriftet / gedriftet.sum()

Zwischen zwei Rebalancings verändern sich die Gewichte von selbst — gestiegene Titel bekommen mehr Gewicht. Wer das weglässt, unterstellt implizit tägliches kostenloses Rebalancing und überschätzt die Strategie.

⚠️ Zur Reproduzierbarkeit Dieses Programm lädt Live-Daten über ein relatives Fünfjahresfenster. Ihre Zahlen werden von jeder abgedruckten Zahl abweichen — je nach Abrufdatum und Marktlage. Bewerten Sie deshalb nicht die absoluten Werte, sondern die Struktur: Wurden alle Rebalancings ausgeführt? Besteht der Lookahead-Test? Sind die Transaktionskosten plausibel?


21.6 Die fünf Selbsttäuschungen des Backtestens

#!/usr/bin/env python3

# Backtest_Fallen.py
"""
Kapitel Handelsmaschine: Wie leicht man sich selbst betruegt.

Demonstriert an SIMULIERTEN Daten (also ohne jede echte Prognosekraft),
wie gross die scheinbare Ueberrendite durch typische Backtest-Fehler wird.
Weil die Daten reines Rauschen sind, ist JEDE positive Ueberrendite ein Artefakt.
"""

import numpy as np
import pandas as pd


def erzeuge_zufallsmarkt(tage=1260, titel=15, seed=7):
    """Reines Rauschen: kein Titel hat echte Prognosekraft."""
    rng = np.random.default_rng(seed)
    renditen = rng.normal(0.0003, 0.015, size=(tage, titel))
    index = pd.bdate_range("2020-01-01", periods=tage)
    return pd.DataFrame(renditen, index=index,
                        columns=[f"T{i:02d}" for i in range(titel)])


def strategie(renditen, lookahead=False, survivorship=False,
              ohne_kosten=False, gebuehr=0.0015):
    """Einfache Momentum-Strategie mit optional eingebauten Fehlern."""
    fenster = 60
    kapital = [100.0]
    gewichte = np.ones(renditen.shape[1]) / renditen.shape[1]

    daten = renditen
    if survivorship:
        # FEHLER: nur die im Nachhinein besten Titel ins Universum lassen
        beste = renditen.sum().nlargest(renditen.shape[1] // 2).index
        daten = renditen[beste]
        gewichte = np.ones(len(beste)) / len(beste)

    for t in range(fenster, len(daten) - 1):
        if t % 21 == 0:                                  # monatlich
            if lookahead:
                # FEHLER: Signal nutzt die Rendite von MORGEN
                signal = daten.iloc[t + 1].values
            else:
                signal = daten.iloc[t - fenster:t].mean().values

            neue = (signal > np.median(signal)).astype(float)
            neue = neue / neue.sum() if neue.sum() > 0 else gewichte
            if not ohne_kosten:
                kapital[-1] *= (1 - np.abs(neue - gewichte).sum() * gebuehr)
            gewichte = neue

        kapital.append(kapital[-1] * (1 + float(gewichte @ daten.iloc[t + 1].values)))
    return np.array(kapital)


def cagr(verlauf, jahre):
    return (verlauf[-1] / verlauf[0]) ** (1 / jahre) - 1


if __name__ == "__main__":
    renditen = erzeuge_zufallsmarkt()
    jahre = len(renditen) / 252
    markt = 100 * (1 + renditen.mean(axis=1)).cumprod().values

    print("=" * 84)
    print("  DIE FUENF SELBSTTAEUSCHUNGEN - GEMESSEN AN REINEM RAUSCHEN")
    print("=" * 84)
    print("Die Daten sind zufaellig erzeugt. Es gibt KEINE echte Prognosekraft.")
    print("Jede Ueberrendite unten ist daher ein reines Artefakt.\n")
    print(f"{'Variante':<44} {'CAGR':>9} {'ggue. Markt':>13}")
    print("-" * 84)

    basis = cagr(markt, jahre)
    print(f"{'Markt (Gleichgewichtung, Referenz)':<44} {basis*100:>8.2f} % "
          f"{0.0:>12.2f} %")

    varianten = [
        ("Ehrlich: Signal aus Vergangenheit, mit Kosten", {}),
        ("FEHLER 1: Lookahead (Signal kennt morgen)", {"lookahead": True}),
        ("FEHLER 2: Survivorship (nur Gewinner im Universum)", {"survivorship": True}),
        ("FEHLER 3: Transaktionskosten ignoriert", {"ohne_kosten": True}),
        ("FEHLER 1+2+3 kombiniert", {"lookahead": True, "survivorship": True,
                                     "ohne_kosten": True}),
    ]
    for name, argumente in varianten:
        verlauf = strategie(renditen, **argumente)
        wert = cagr(verlauf, jahre)
        print(f"{name:<44} {wert*100:>8.2f} % {(wert-basis)*100:>+12.2f} %")

    print("-" * 84)
    print("FEHLER 4 (Overfitting) und FEHLER 5 (Data Snooping) lassen sich so nicht")
    print("zeigen - sie entstehen erst durch WIEDERHOLTES Probieren. Faustregel:")
    print("Wer 20 Strategien testet, findet auch in reinem Rauschen eine mit")
    print("Signifikanz auf dem 5-%-Niveau. Genau das ist der Punkt.")
    print("=" * 84)

Erwartete Ausgabe:

====================================================================================
  DIE FUENF SELBSTTAEUSCHUNGEN - GEMESSEN AN REINEM RAUSCHEN
====================================================================================
Die Daten sind zufaellig erzeugt. Es gibt KEINE echte Prognosekraft.
Jede Ueberrendite unten ist daher ein reines Artefakt.

Variante                                          CAGR   ggue. Markt
------------------------------------------------------------------------------------
Markt (Gleichgewichtung, Referenz)                5.61 %         0.00 %
Ehrlich: Signal aus Vergangenheit, mit Kosten     6.71 %        +1.10 %
FEHLER 1: Lookahead (Signal kennt morgen)        20.96 %       +15.35 %
FEHLER 2: Survivorship (nur Gewinner im Univ.)   18.71 %       +13.11 %
FEHLER 3: Transaktionskosten ignoriert            7.77 %        +2.16 %
FEHLER 1+2+3 kombiniert                          25.41 %       +19.80 %
------------------------------------------------------------------------------------

Lesen Sie diese Tabelle sehr genau. Die zugrunde liegenden Daten sind reiner Zufall — es gibt keinerlei Prognosekraft, kein Signal, keine Struktur. Trotzdem:

  • Lookahead erzeugt eine scheinbare Überrendite von 15,4 Prozentpunkten pro Jahr. Ein einziger falscher Index — iloc[t+1] statt iloc[t] — verwandelt Rauschen in eine scheinbar brillante Strategie.
  • Survivorship-Bias liefert 13,1 Prozentpunkte allein dadurch, dass man das Universum im Nachhinein auf die Gewinner beschränkt.
  • Selbst die ehrliche Variante zeigt noch +1,1 Prozentpunkte — reines Rauschen, das aber ohne Weiteres als „leichte Outperformance“ verkauft werden könnte.

Die Lehre: Wenn Sie in einem Backtest eine Überrendite von 15 % sehen, ist die erste Frage nicht „Wie kann ich das nutzen?“, sondern „Wo ist mein Fehler?“. Und wenn Sie keinen finden, suchen Sie weiter — die Wahrscheinlichkeit, dass Sie tatsächlich eine Goldader entdeckt haben, ist deutlich kleiner als die, dass ein Index verrutscht ist.

Die fünf Fallen im Einzelnen:

# Falle Was passiert Gegenmittel
1 Lookahead-Bias Entscheidungen nutzen Daten, die es noch nicht gab Zeitindex prüfen; Selbsttest wie oben
2 Survivorship-Bias Nur heute existierende Titel im Universum Historische Indexzusammensetzung verwenden
3 Kostenblindheit Gebühren, Spread, Slippage ignoriert Kosten im Modell und in der Verbuchung
4 Overfitting Parameter auf die Vergangenheit getrimmt Out-of-Sample-Zeitraum strikt zurückhalten
5 Data Snooping Viele Varianten getestet, beste berichtet Zahl der Versuche protokollieren; Deflated Sharpe Ratio

🎯 Die unbequeme Wahrheit Falle 5 ist die gefährlichste, weil sie sich nicht im Code zeigt, sondern im Arbeitsprozess. Wer zwanzig Parameterkombinationen durchprobiert und die beste berichtet, hat keine Strategie gefunden, sondern eine Zufallsschwankung ausgewählt. Marcos López de Prado hat gezeigt, dass die meisten veröffentlichten Backtests genau daran scheitern. Protokollieren Sie, wie viele Varianten Sie getestet haben — und berichten Sie diese Zahl mit.


21.7 Vom Backtest zum Betrieb

Ein guter Backtest ist eine notwendige, keine hinreichende Bedingung. Was zwischen Backtest und echtem Einsatz liegt:

Schritt Frage Typisches Ergebnis
Papierhandel Funktioniert es auf Live-Daten ohne Geld? 3–6 Monate, oft schlechter als der Backtest
Kapazitätsanalyse Bewegt meine eigene Order den Markt? Begrenzt das einsetzbare Volumen
Regimewechsel-Test Hält es in Krisen? Backtest über 2008, 2020 separat auswerten
Betriebsrisiken Was, wenn der Datenfeed ausfällt? Fallback-Strategie, Monitoring
Regulatorik MiFID II, Best Execution, Dokumentation Protokollpflichten

⚠️ Der Realitätsabschlag Als Faustregel gilt: Die reale Performance liegt deutlich unter dem Backtest — durch Slippage, verzögerte Ausführung, Datenrevisionen und schlicht dadurch, dass die Zukunft nicht die Vergangenheit ist. Wer einen Backtest mit Sharpe 1,5 hat, sollte real mit deutlich weniger rechnen. Eine Strategie, die im Backtest nur knapp die Benchmark schlägt, wird es real nicht tun.


21.8 Übungsaufgaben

Lösungen: Abschnitt A.21.

Aufgabe 21.1 ⭐ — Lookahead erkennen. Welche dieser Zeilen enthalten Lookahead-Bias? Begründen Sie: (a) signal = kurse.loc[:heute].pct_change().mean() (b) signal = kurse.pct_change().mean() (c) vola = renditen.std() (berechnet einmal über den gesamten Zeitraum) (d) w = optimiere(renditen.loc[:heute]); rendite = w @ renditen.loc[heute] (e) universum = [t for t in alle if kurse[t].notna().all()]

Aufgabe 21.2 ⭐ — Kennzahlen deuten. Strategie A: CAGR 12 %, Vola 22 %, MaxDD −35 %. Strategie B: CAGR 8 %, Vola 9 %, MaxDD −12 %. Berechnen Sie Sharpe (bei r_f = 2\,\%) und Calmar. Welche würden Sie einem Pensionsfonds empfehlen und warum?

Aufgabe 21.3 ⭐⭐ — Rebalancing-Kalender prüfen. Bauen Sie den Fehler nach: Ersetzen Sie den korrigierten Kalender durch resample("MS").first().index. Zählen Sie, wie viele Rebalancings ausfallen, und vergleichen Sie die Backtest-Kennzahlen beider Varianten.

Aufgabe 21.4 ⭐⭐ — Rebalancing-Frequenz. Variieren Sie die Frequenz: täglich, wöchentlich, monatlich, quartalsweise, jährlich. (a) Wie ändern sich Rendite, Turnover und Kosten? (b) Ab welcher Frequenz fressen die Kosten den Ertrag auf? (c) Was ist Ihre Empfehlung bei einem Gebührensatz von 0,15 %? Bei 0,5 %?

Aufgabe 21.5 ⭐⭐⭐ — Krisenverhalten. Werten Sie den Backtest getrennt für Teilzeiträume aus (z. B. je Kalenderjahr). Erstellen Sie eine Tabelle mit CAGR, Vola und MaxDD je Jahr für Strategie und Benchmark. (a) In welchen Phasen schlägt die Strategie die Benchmark? (b) Ist das Muster stabil oder zufällig? (c) Was folgt daraus für die Beurteilung der Gesamtkennzahl?

Aufgabe 21.6 ⭐⭐⭐ — Data Snooping messen. Testen Sie 30 Varianten Ihrer Strategie (verschiedene Rückblickfenster, Risikoaversionen, Positionsgrenzen). Berichten Sie: (a) die beste Sharpe Ratio, (b) die mittlere Sharpe Ratio über alle Varianten, (c) die Sharpe Ratio der besten Variante auf einem zurückgehaltenen Zeitraum. Was lernen Sie aus der Differenz zwischen (a) und (c)?


21.9 Finde den Denkfehler

Backtest_Fallen.py zeigt drei Fehler, die im Backtest stecken: Lookahead, Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen, wenn man es liest.

Die beiden übrigen Selbsttäuschungen sind anders gebaut. Sie entstehen nicht im Code, sondern in der Arbeitsweise davor — und hinterlassen im Programm keine Spur. Ein Backtest, der zwanzigmal nachjustiert wurde, sieht am Ende genauso sauber aus wie einer, der beim ersten Versuch funktioniert hat.

🐛 Finde den Denkfehler: Die Strategie mit dem hochsignifikanten Ergebnis

Eine Analystin entwickelt über mehrere Wochen eine Handelsstrategie. Sie probiert verschiedene Signalfenster, Schwellenwerte, Filter und Haltedauern durch — insgesamt rund hundert Varianten. Die beste testet sie sauber: kein Lookahead, korrektes Universum, Transaktionskosten berücksichtigt.

Das Ergebnis über fünf Jahre: Sharpe-Kennzahl 1,13, p-Wert 0,0094. Der Backtest ist handwerklich einwandfrei — jede der drei Fallen aus Backtest_Fallen.py wurde vermieden. Sie stellt die Strategie vor.

Ihre Aufgabe: (a) Der Backtest ist korrekt. Warum ist das Ergebnis trotzdem wertlos? (b) Sehen Sie sich unten die letzte Spalte der Tabelle an: Sie bleibt konstant bei 5 %, obwohl die Zahl der Versuche um das Tausendfache wächst. Wieso ist das kein Widerspruch, sondern der Kern des Problems? (c) Welche Zahl fehlt in ihrem Bericht — eine Zahl, die in keinem Backtest-Ergebnis steht und die sie selbst aufschreiben müsste? (d) Nennen Sie drei Gegenmittel und begründen Sie, warum das dritte nur einmal verwendbar ist.

Auflösung: Abschnitt A.21.

Das folgende Programm macht den Effekt messbar. Es testet Strategien, die nachweislich keinerlei Vorteil haben — ihre Tagesergebnisse sind Zufallszahlen mit Erwartungswert exakt null — und zeigt, wie gut die jeweils beste davon aussieht, allein als Funktion der Anzahl der Versuche.

#!/usr/bin/env python3

# Data_Snooping.py
"""
Kapitel Handelsmaschine: Die Selbsttaeuschung, die man einem Backtest nicht ansieht.

Backtest_Fallen.py zeigt drei Fehler, die IM Backtest stecken: Lookahead,
Survivorship, vergessene Kosten. Alle drei kann man einem Programm ansehen,
wenn man es liest.

Die beiden anderen - Overfitting und Data Snooping - entstehen nicht im
Backtest, sondern in der ARBEITSWEISE davor. Sie hinterlassen im Code keine
Spur. Ein Backtest, der zwanzigmal angepasst wurde, sieht am Ende genauso
sauber aus wie einer, der beim ersten Versuch funktioniert hat.

Dieses Programm macht den Effekt messbar. Es testet Strategien, die
NACHWEISLICH keinerlei Prognosekraft haben (ihre taeglichen Ergebnisse sind
Zufallszahlen mit Erwartungswert exakt null), und zeigt, wie gut die jeweils
BESTE davon aussieht - allein als Funktion der Anzahl der Versuche.

Benoetigt: numpy, scipy
"""

from __future__ import annotations

import numpy as np
from scipy import stats

HANDELSTAGE = 1260               # fuenf Jahre
TAGESSCHWANKUNG = 0.010          # 1 % taeglich
WIEDERHOLUNGEN = 300             # damit die Tabelle nicht vom Zufall abhaengt
NIVEAU = 0.05

RNG = np.random.default_rng(42)


def bewerte(ergebnisse: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Sharpe-Kennzahl und einseitiger p-Wert je Strategie.

    ergebnisse hat die Form (Strategien, Handelstage). Der p-Wert prueft die
    Nullhypothese 'mittleres Tagesergebnis ist null' - also genau die Frage,
    die ein Backtest beantworten soll.
    """
    mittel = ergebnisse.mean(axis=1)
    streuung = ergebnisse.std(axis=1, ddof=1)
    sharpe = mittel / streuung * np.sqrt(252)
    t_wert = mittel / (streuung / np.sqrt(ergebnisse.shape[1]))
    return sharpe, stats.t.sf(t_wert, ergebnisse.shape[1] - 1)


def probiere(anzahl_strategien: int) -> tuple[float, float, float, float]:
    """Liefert (beste Sharpe, ihr p-Wert, Anteil scheinbar signifikanter,
    Anteil, der auch die Bonferroni-Huerde nimmt) - gemittelt ueber
    WIEDERHOLUNGEN unabhaengige Durchgaenge."""
    beste_sharpe, beste_p, anteil_signifikant, ueberlebt_bonferroni = [], [], [], []

    for _ in range(WIEDERHOLUNGEN):
        # Der springende Punkt: Erwartungswert exakt 0. Keine dieser
        # Strategien hat einen echten Vorteil - per Konstruktion.
        ergebnisse = RNG.normal(0.0, TAGESSCHWANKUNG,
                                (anzahl_strategien, HANDELSTAGE))
        sharpe, p_werte = bewerte(ergebnisse)

        beste = int(sharpe.argmax())
        beste_sharpe.append(float(sharpe[beste]))
        beste_p.append(float(p_werte[beste]))
        anteil_signifikant.append(float((p_werte < NIVEAU).mean()))
        # Bonferroni: Wer n Tests macht, muss die Huerde durch n teilen.
        ueberlebt_bonferroni.append(
            float((p_werte < NIVEAU / anzahl_strategien).mean()))

    return (float(np.mean(beste_sharpe)), float(np.mean(beste_p)),
            float(np.mean(anteil_signifikant)),
            float(np.mean(ueberlebt_bonferroni)))


if __name__ == "__main__":
    print("=" * 78)
    print("  WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,")
    print("  WENN KEINE EINZIGE ETWAS TAUGT?")
    print("=" * 78)
    print(f"{HANDELSTAGE} Handelstage, taegliche Ergebnisse mit Erwartungswert "
          f"EXAKT null.")
    print(f"Jede Zeile ist der Mittelwert aus {WIEDERHOLUNGEN} unabhaengigen "
          f"Durchgaengen.\n")

    print(f"{'getestet':>9} {'beste Sharpe':>14} {'ihr p-Wert':>12} "
          f"{'scheinbar sign.':>16}")
    print("-" * 78)
    ergebnisse = {}
    for anzahl in (1, 10, 100, 1000):
        sharpe, p_wert, anteil, _ = probiere(anzahl)
        ergebnisse[anzahl] = (sharpe, p_wert, anteil)
        print(f"{anzahl:9d} {sharpe:14.2f} {p_wert:12.4f} {anteil * 100:15.1f} %")
    print("-" * 78)

    sharpe_100, p_100, _ = ergebnisse[100]
    sharpe_1000, p_1000, _ = ergebnisse[1000]

    print("\nLesen Sie die dritte Spalte von unten nach oben:")
    print(f"  Wer {1000} Varianten durchprobiert, findet eine mit p = {p_1000:.4f} -")
    print("  ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.")
    print(f"  Wer {100} probiert, findet p = {p_100:.4f} und eine Sharpe-Kennzahl")
    print(f"  von {sharpe_100:.2f} - damit geht man zum Vorgesetzten.")
    print("  Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.")
    print()
    print("Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein")
    print("Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in")
    print("5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die")
    print("ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man")
    print("von allen Fehlversuchen genau einen: den erfolgreichsten.")

    print("\n" + "=" * 78)
    print("  WAS MAN DAGEGEN TUN KANN")
    print("=" * 78)
    print("1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -")
    print("   auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl")
    print("   ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.")
    print()
    print("2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die")
    print("   Zahl der Versuche:")
    for anzahl in (10, 100, 1000):
        print(f"     bei {anzahl:5d} Versuchen: p < {NIVEAU / anzahl:.5f} statt "
              f"p < {NIVEAU:.2f}")
    print("   Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien")
    print("   gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey")
    print("   und Lopez de Prado), die dasselbe Prinzip verfolgen.")
    print()
    print("3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie")
    print("   anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist")
    print("   der einzige Test, den Sie nicht durch Probieren verderben koennen -")
    print("   und er ist genau einmal verwendbar.")
    print("=" * 78)

Erwartete Ausgabe:

==============================================================================
  WIE GUT SIEHT DIE BESTE VON N STRATEGIEN AUS,
  WENN KEINE EINZIGE ETWAS TAUGT?
==============================================================================
1260 Handelstage, taegliche Ergebnisse mit Erwartungswert EXAKT null.
Jede Zeile ist der Mittelwert aus 300 unabhaengigen Durchgaengen.

 getestet   beste Sharpe   ihr p-Wert  scheinbar sign.
------------------------------------------------------------------------------
        1          -0.00       0.5101             6.0 %
       10           0.69       0.0885             4.9 %
      100           1.13       0.0094             4.8 %
     1000           1.44       0.0010             5.0 %
------------------------------------------------------------------------------

Lesen Sie die dritte Spalte von unten nach oben:
  Wer 1000 Varianten durchprobiert, findet eine mit p = 0.0010 -
  ein Wert, den jede Zeitschrift als 'hochsignifikant' druckt.
  Wer 100 probiert, findet p = 0.0094 und eine Sharpe-Kennzahl
  von 1.13 - damit geht man zum Vorgesetzten.
  Und keine dieser Strategien hat auch nur den Hauch eines Vorteils.

Die LETZTE Spalte bleibt dagegen konstant bei rund 5 %. Das ist kein
Widerspruch, sondern die Definition: Ein Test zum 5-%-Niveau irrt in
5 % der Faelle. Was sich aendert, ist nicht der Anteil, sondern die
ANZAHL - und weil man nur die beste Strategie praesentiert, sieht man
von allen Fehlversuchen genau einen: den erfolgreichsten.

==============================================================================
  WAS MAN DAGEGEN TUN KANN
==============================================================================
1. ZAEHLEN. Notieren Sie, wie viele Varianten Sie ausprobiert haben -
   auch die verworfenen, auch die 'nur mal schnell'. Ohne diese Zahl
   ist kein p-Wert und keine Sharpe-Kennzahl interpretierbar.

2. KORRIGIEREN. Die Bonferroni-Korrektur teilt die Huerde durch die
   Zahl der Versuche:
     bei    10 Versuchen: p < 0.00500 statt p < 0.05
     bei   100 Versuchen: p < 0.00050 statt p < 0.05
     bei  1000 Versuchen: p < 0.00005 statt p < 0.05
   Sie ist konservativ, aber sie ist ehrlich. Fuer Handelsstrategien
   gibt es verfeinerte Varianten (Deflated Sharpe Ratio nach Bailey
   und Lopez de Prado), die dasselbe Prinzip verfolgen.

3. ZURUECKHALTEN. Legen Sie einen Zeitraum beiseite, BEVOR Sie
   anfangen, und ruehren Sie ihn bis zum Schluss nicht an. Er ist
   der einzige Test, den Sie nicht durch Probieren verderben koennen -
   und er ist genau einmal verwendbar.
==============================================================================

🎯 Merksatz Ein Backtest-Ergebnis ist ohne die Angabe „wie viele Varianten wurden probiert?“ nicht interpretierbar. Diese Zahl steht in keinem Programm und in keiner Kennzahl — sie existiert nur im Kopf der Person, die die Arbeit gemacht hat. Genau deshalb ist sie die erste Frage, die man einem vorgelegten Backtest stellen sollte, und die erste Zahl, die man beim eigenen mitschreiben muss.


21.10 Micro-Quiz

❓ Micro-Quiz 21: Drei Fragen zum Selbstcheck

Genau eine Antwort ist jeweils richtig. Auflösung in Anhang A.

1. Zwei Strategien haben dieselbe Jahresrendite und dieselbe Sharpe-Kennzahl. Was wissen Sie über ihren Verlauf? (a) Er ist im Wesentlichen gleich — beide Kennzahlen zusammen beschreiben ihn vollständig. (b) Nichts. Beide Kennzahlen mitteln über die Zeit und verlieren die Reihenfolge. Dieselben Tagesrenditen können zu einem Rückschlag von 19 % oder von 98 % führen. (c) Die Strategie mit der höheren Schwankung hat den größeren Rückschlag.

2. Sie haben 50 Strategievarianten getestet und die beste erreicht p = 0,01. Wie bewerten Sie das? (a) Hochsignifikant — die Wahrscheinlichkeit eines Zufallsbefunds liegt bei 1 %. (b) Bei 50 Versuchen ist ein p-Wert von 0,01 unauffällig: Schon rein zufällig erwartet man etwa 2,5 Treffer unter dem 5-%-Niveau. Die Bonferroni-Schwelle läge bei 0,001. (c) Der p-Wert ist für Handelsstrategien grundsätzlich ungeeignet.

3. Warum ist ein zurückgehaltener Testzeitraum nur einmal** verwendbar?** (a) Weil die Daten danach veraltet sind. (b) Weil man ab dem Moment, in dem man das Ergebnis kennt und daraufhin etwas ändert, wieder anfängt zu probieren — der Zeitraum ist dann Teil der Suche geworden und nicht mehr unabhängig. (c) Weil sich die Kennzahlen bei wiederholter Berechnung numerisch verschlechtern.


21.11 Selbsttest

Antworten: Anhang A.

  1. Was ist die goldene Regel des Backtestens?
  2. Warum sind Kalender-Monatsanfänge als Rebalancing-Termine falsch?
  3. Wie funktioniert der Lookahead-Selbsttest, und warum ist er so wirksam?
  4. Warum muss man Gewichtsdrift modellieren?
  5. Warum ist Data Snooping gefährlicher als ein Programmierfehler?

21.12 Zusammenfassung

  • Sechs Bausteine in fester Reihenfolge: Daten, Signal, Risiko, Optimierung, Rebalancing, Auswertung.
  • Die goldene Regel: Zum Zeitpunkt t nur Information von t. Der Selbsttest mit manipulierter Zukunft prüft das automatisch.
  • Rebalancing-Termine müssen Handelstage sein — sonst fallen bis zu 28 % still aus.
  • Zählen Sie mit: geplante gegen ausgeführte Rebalancings, kumulierter Turnover, Gesamtkosten. Abweichungen sind Fehlerhinweise.
  • Fünf Selbsttäuschungen: Lookahead, Survivorship, Kostenblindheit, Overfitting, Data Snooping. Die ersten drei stecken im Code und sind lesbar; die letzten beiden entstehen in der Arbeitsweise und hinterlassen im Programm keine Spur.
  • Ohne die Zahl der ausprobierten Varianten ist kein Backtest-Ergebnis interpretierbar. Auf reinem Rauschen liefert die Beste von hundert Versuchen im Mittel Sharpe 1,13 bei p = 0,0094. Protokollieren Sie mit, korrigieren Sie die Schwelle (Bonferroni, Deflated Sharpe) — und halten Sie einen Zeitraum zurück, den Sie genau einmal verwenden.
  • Rendite und Sharpe verlieren die Reihenfolge. Dieselben Tagesrenditen können zu einem Rückschlag von 19 % oder 98 % führen. Berichten Sie den maximalen Rückschlag immer mit.
  • Ein guter Backtest ist notwendig, nicht hinreichend. Zwischen ihm und dem Betrieb liegen Papierhandel, Kapazitätsanalyse und Betriebsrisiken.

Ausblick. Kapitel 22 zieht die Summe aus allen Teilen: die typischen Praxisfallen und der Weg vom Skript zum produktiven System.