← Zurück zu den Artikeln
March 11, 2026
5 min read

Koordinatenabstieg vs. Bayessche Optimierung: Wer findet die besseren Parameter

Koordinatenabstieg vs. Bayessche Optimierung: Wer findet die besseren Parameter
#algotrading
#backtest
#optimization
#Optuna
#TPE
#Bayesian optimization
#coordinate descent
#hyperparameters

Dies ist der fünfte Artikel der Serie "Backtests ohne Illusionen". In den vorherigen Artikeln behandelten wir die Asymmetrie von Verlust und Gewinn, das Monte-Carlo-Bootstrap, den Einfluss von Funding Rates und den Parquet-Cache für schnellere Backtests. Jetzt sprechen wir über den Prozess der Suche nach optimalen Strategieparametern — eine Aufgabe, bei der Intuition am häufigsten versagt.

Sie haben eine Strategie mit 12 Parametern. Jeder Parameter nimmt ~9 Werte an. Sie wollen die Kombination finden, die den PnL bei begrenztem Drawdown maximiert. Wie gehen Sie vor?

Wenn Ihre Antwort lautet "ich probiere alle Kombinationen durch" — haben Sie ein Problem. Wenn Ihre Antwort lautet "ich ändere jeweils einen Parameter" — haben Sie ein anderes Problem. Dieser Artikel handelt davon, welche Probleme hinter jedem Ansatz lauern und wie man sie löst.

Warum erschöpfende Suche unmöglich ist

The curse of dimensionality: exponential growth of the search space

Der Fluch der Dimensionalität

Die erschöpfende Suche (Grid Search) testet jede Kombination von Werten für jeden Parameter. Bei zwei Parametern mit 9 Werten sind das 92=819^2 = 81 Durchläufe — vollkommen machbar. Bei drei: 93=7299^3 = 729 — noch erträglich.

Aber bei einer echten Strategie mit 12 Parametern:

Ngrid=912=282,429,536,481N_{grid} = 9^{12} = 282{,}429{,}536{,}481

Zweihundertzweiundachtzig Milliarden Durchläufe. Selbst wenn ein einzelner Backtest 1 Sekunde dauert (was bereits optimistisch ist), würde die erschöpfende Suche folgende Zeit benötigen:

T=282×1093600×24×3658,950 JahreT = \frac{282 \times 10^{9}}{3600 \times 24 \times 365} \approx 8{,}950 \text{ Jahre}

Das ist exponentielles Wachstum: Jeder neue Parameter multipliziert den Suchraum mit 9. Fügen Sie einen 13. Parameter hinzu — und statt 9.000 Jahren brauchen Sie 80.000.

import math

def grid_search_cost(n_params: int, values_per_param: int, seconds_per_trial: float) -> dict:
    """Estimate the cost of exhaustive search."""
    total_trials = values_per_param ** n_params
    total_seconds = total_trials * seconds_per_trial
    return {
        "total_trials": total_trials,
        "total_hours": total_seconds / 3600,
        "total_years": total_seconds / (3600 * 24 * 365),
    }

cost = grid_search_cost(12, 9, 1.0)
print(f"Trials: {cost['total_trials']:,.0f}")      # 282,429,536,481
print(f"Years:  {cost['total_years']:,.0f}")        # 8,950

Selbst mit Vorberechnung

Im Artikel über den Parquet-Cache haben wir gezeigt, wie die Vorberechnung von Zeitrahmen und Indikatoren einen einzelnen Backtest auf ~1 Sekunde beschleunigt. Aber selbst bei 0,1 Sekunden pro Durchlauf würde die erschöpfende Suche über 12 Parameter 895 Jahre benötigen. Vorberechnung hilft, löst aber nicht das grundlegende Problem des exponentiellen Wachstums.

Wir brauchen Methoden, die den Parameterraum intelligenter erkunden als die erschöpfende Suche.

Koordinatenabstieg und OAT: Schnell, aber blind

Parameter space exploration: OAT vs Bayesian optimization

Zwei Varianten derselben Idee

Es gibt zwei verwandte Ansätze — beide optimieren jeweils einen Parameter, unterscheiden sich aber in der Anzahl der Durchläufe:

OAT (One-at-a-Time) Sweep — ein einzelner Durchlauf durch alle Parameter. Die Werte des ersten Parameters durchgehen, den besten fixieren, zum zweiten übergehen — und so weiter. Einmal. Schnell und günstig.

Coordinate Descent — mehrere Durchläufe. Nach der Optimierung des letzten Parameters kehrt man zum ersten zurück und prüft, ob sich das Optimum geändert hat (da sich der Kontext geändert hat — andere Parameterwerte sind jetzt anders). Runden werden bis zur Konvergenz wiederholt. Teurer, aber präziser — jede Runde kann die Lösung verfeinern.

In der Praxis wird für Backtests häufiger OAT verwendet: ein einzelner Durchlauf durch 12 Parameter — 96 Durchläufe. Coordinate Descent mit 3-5 Runden — 300-500 Durchläufe, was bereits mit Optuna vergleichbar ist, jedoch ohne dessen Vorteile.

Für 12 Parameter mit jeweils ~8 Werten:

NOAT=K×N=12×8=96 Durchla¨ufeN_{OAT} = K \times N = 12 \times 8 = 96 \text{ Durchläufe}

Vergleichen Sie das mit 282×109282 \times 10^9 für die Grid Search. OAT ist linear: O(KN)O(K \cdot N) statt O(NK)O(N^K). Das ist sowohl sein Hauptvorteil als auch sein Hauptproblem.

def oat_sweep(
    param_grid: dict[str, list],
    run_backtest_fn,
    initial_params: dict,
    metric: str = "effective_score",
) -> dict:
    """
    OAT sweep: single pass, optimizing one parameter at a time.

    param_grid: {"htf_entry_sell": [0.0, 0.005, ..., 0.05], ...}
    initial_params: starting values for all parameters
    metric: metric to optimize (effective_score recommended —
            PnL per active time extrapolated to a year)
    """
    best_params = initial_params.copy()
    best_score = run_backtest_fn(**best_params)[metric]

    for param_name, values in param_grid.items():
        param_best_val = best_params[param_name]
        param_best_score = best_score

        for val in values:
            candidate = best_params.copy()
            candidate[param_name] = val
            result = run_backtest_fn(**candidate)
            score = result[metric]

            if score > param_best_score:
                param_best_score = score
                param_best_val = val

        best_params[param_name] = param_best_val
        best_score = param_best_score
        print(f"{param_name}: best={param_best_val}, score={param_best_score:.4f}")

    return best_params

Welche Metrik sollte man zur Optimierung wählen? Statt des rohen PnL oder PnL@MaxLev wird empfohlen, den Effective Score — PnL pro aktiver Zeit extrapoliert auf ein Jahr zu verwenden. Diese Metrik berücksichtigt die Zeit in Position und erlaubt einen korrekten Vergleich von Strategien mit unterschiedlicher Handelsfrequenz.

Der blinde Fleck: Parameter-Wechselwirkungen

OAT geht davon aus, dass der Effekt jedes Parameters additiv ist — d. h., der optimale Wert eines Parameters hängt nicht von den Werten der anderen ab. Diese Annahme gilt für manche Parameter, bricht aber bei gekoppelten zusammen.

Additive vs. gekoppelte Parameter

Vor der Optimierung ist es sinnvoll, Parameter zu klassifizieren:

Additiv (unabhängig) — der optimale Wert des einen hängt nicht vom anderen ab. Sie können günstig einzeln optimiert werden:

  • htf_entry_sell und htf_entry_buy — Eintrittsschwellen für unterschiedliche Richtungen (Sell/Buy) auf demselben Zeitrahmen. Die Sell-Schwelle filtert Short-Signale, die Buy-Schwelle — Longs. Sie wirken auf nicht überlappende Teilmengen von Trades.
  • tp_target und be_trigger — Take-Profit und Breakeven, sofern sie keine widersprüchlichen Ausstiegsbedingungen erzeugen.

Gekoppelt (interaktiv) — der optimale Wert des einen hängt vom anderen ab. Gemeinsame Optimierung ist erforderlich:

  • htf_entry_sell und mtf_entry_sell — Schwellen für dieselbe Richtung (Sell) auf unterschiedlichen Zeitrahmen. HTF bestimmt, welche Signale den MTF erreichen, und die MTF-Schwelle bestimmt die Filterwirksamkeit. Das HTF-Optimum verschiebt sich, wenn sich MTF ändert.
  • ltf_entry_sell, mtf_entry_sell, htf_entry_sell — die gesamte Schwellenkette für eine Richtung.
  • partial_frac und tp_target — die Größe des Teilausstiegs hängt vom TP-Niveau ab.

Praktischer Ansatz: zunächst additive Parameter günstig via OAT optimieren. Dann gekoppelte Gruppen via Optuna optimieren. Das reduziert das Budget: statt 12 Parametern in Optuna schicken wir nur 6-8 gekoppelte, während der Rest bereits fixiert ist.

Beispiel: Wie OAT eine Wechselwirkung übersieht

Betrachten wir zwei gekoppelte Schwellen:

  • htf_entry_sell — Schwelle auf dem höheren Zeitrahmen (Sell-Richtung)
  • mtf_entry_sell — Schwelle auf dem mittleren Zeitrahmen (Sell-Richtung)

OAT fixiert mtf_entry_sell = 0.01 (Anfangswert) und durchläuft htf_entry_sell. Findet den besten Wert: htf_entry_sell = 0.02. Fixiert ihn und geht zum nächsten Parameter über — kehrt nie zurück.

Hier ist, was OAT übersehen hat:

htf_entry_sell mtf_entry_sell PnL
0.02 0.01 +42%
0.02 0.02 +38%
0.03 0.02 +51%
0.03 0.01 +35%

Die Kombination (0.03, 0.02) ergibt einen PnL von +51%, aber OAT wird sie nie in Betracht ziehen, weil bei fixiertem mtf_entry_sell = 0.01 der Wert htf_entry_sell = 0.03 nur +35% ergibt. OAT ist im lokalen Optimum (0.02, 0.01) "stecken geblieben" und kann das globale Optimum (0.03, 0.02) nicht sehen.

Das ist ein klassisches Problem: Wenn die Landschaft der Zielfunktion diagonale Grate enthält (wenn sich das Optimum eines Parameters verschiebt, während sich ein anderer ändert), übersieht OAT sie.

Formalisierung des Problems

Sei f(θ1,θ2,,θK)f(\theta_1, \theta_2, \ldots, \theta_K) die Zielfunktion (PnL). OAT findet einen Punkt, an dem gilt:

fθi=0i\frac{\partial f}{\partial \theta_i} = 0 \quad \forall i

Das ist jedoch eine notwendige, keine hinreichende Bedingung für ein globales Optimum. Wenn die Hesse-Matrix Hij=2fθiθjH_{ij} = \frac{\partial^2 f}{\partial \theta_i \partial \theta_j} signifikante außerdiagonale Elemente aufweist — berücksichtigt OAT nicht die Kreuzableitungen 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j} für iji \neq j.

Bei gekoppelten Parametern (Schwellen derselben Richtung über mehrere Zeitrahmen) — sind Wechselwirkungen die Regel, nicht die Ausnahme. Die Eintrittsschwelle auf dem höheren Zeitrahmen bestimmt, welche Signale den mittleren erreichen, und die Schwelle auf dem mittleren bestimmt die Filterwirksamkeit auf dem unteren. Bei additiven Parametern (unterschiedliche Richtungen, unabhängige Filter) sind Kreuzableitungen nahe null — und OAT funktioniert gut.

Bayessche Optimierung: Intelligente Suche

Bayesian optimization: surrogate model of the objective function

Die Idee

Statt blinder Aufzählung oder gieriger Suche baut die Bayessche Optimierung ein Surrogatmodell der Zielfunktion und wählt bei jedem Schritt den Punkt aus, an dem die erwartete Verbesserung maximal ist.

Algorithmus:

  1. Mehrere zufällige Punkte wählen, die Zielfunktion auswerten
  2. Ein Surrogatmodell aufbauen (approximiert f(θ)f(\theta) anhand beobachteter Punkte)
  3. Den Punkt mit maximaler erwarteter Verbesserung finden (Acquisition-Funktion)
  4. Die Zielfunktion an diesem Punkt auswerten
  5. Das Surrogatmodell aktualisieren
  6. Schritte 3-5 wiederholen

Der entscheidende Unterschied zu OAT: Bayessche Optimierung betrachtet alle Parameter gleichzeitig und kann diagonale Grate im Parameterraum erkunden.

TPE (Tree-structured Parzen Estimator)

TPE sampler: modeling good and bad parameter distributions

TPE ist der Standard-Sampler in Optuna. Statt f(θ)f(\theta) direkt zu modellieren, modelliert TPE zwei Verteilungen:

  • l(θ)l(\theta) — Verteilung der Parameter, bei denen die Zielfunktion besser als der Schwellenwert yy^* ist
  • g(θ)g(\theta) — Verteilung der Parameter, bei denen die Zielfunktion schlechter als der Schwellenwert yy^* ist

Die Acquisition-Funktion von TPE — das Verhältnis:

EI(θ)l(θ)g(θ)\text{EI}(\theta) \propto \frac{l(\theta)}{g(\theta)}

TPE wählt Punkte aus, bei denen l(θ)l(\theta) groß ist (Parameter ähnlich zu "guten") und g(θ)g(\theta) klein ist (Parameter unähnlich zu "schlechten").

Warum TPE für Backtests geeignet ist:

  • Bewältigt bedingte Abhängigkeiten zwischen Parametern
  • Erfordert keine Stetigkeit der Zielfunktion
  • Effizient bei moderaten Budgets (100-1000 Iterationen)
  • Unterstützt kategoriale und diskrete Parameter

Gaußprozess (GP)

Eine Alternative zu TPE — der Gaußprozess. GP modelliert f(θ)f(\theta) als multivariaten Normalprozess und liefert nicht nur eine Wertvorhersage, sondern auch die Unsicherheit an jedem Punkt.

f(θ)GP(m(θ),  k(θ,θ))f(\theta) \sim \mathcal{GP}\bigl(m(\theta),\; k(\theta, \theta')\bigr)

wobei m(θ)m(\theta) der Mittelwert ist und k(θ,θ)k(\theta, \theta') die Kovarianzfunktion (Kernel).

GP funktioniert gut, wenn:

  • es wenige Parameter gibt (bis 10-15)
  • die Zielfunktion glatt ist
  • jeder Durchlauf teuer ist (Minuten, Stunden)

Bei Backtests mit vorberechnetem Parquet-Cache, bei denen ein einzelner Durchlauf ~1 Sekunde dauert, wird üblicherweise TPE bevorzugt: Es baut das Modell schneller auf und skaliert besser auf 500+ Iterationen.

Praktische Integration mit Optuna

Optuna optimization framework: iterative parameter search

Vollständiges funktionierendes Beispiel

import optuna
from optuna.samplers import TPESampler
import numpy as np


def run_backtest(htf_pre, mtf_pre, ltf_pre, **params) -> dict:
    """
    Runs a backtest with given parameters.
    Returns a dict with metrics: pnl, max_dd, n_trades, trading_time, sharpe.
    Uses precomputed Parquet cache — ~1 second per run.
    """
    pass


def objective(trial: optuna.Trial) -> float:
    """Objective function for Optuna."""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),

        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),

        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),

        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),

        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    return -result["pnl_at_max_lev"]


study = optuna.create_study(
    sampler=TPESampler(seed=42),
    study_name="strategy_optimization",
    direction="minimize",
)

study.optimize(objective, n_trials=500, show_progress_bar=True)

print(f"Best PnL: {-study.best_value:.2f}%")
print(f"Best params: {study.best_params}")
print(f"Total trials: {len(study.trials)}")

Bei ~1 Sekunde pro Backtest (mit vorberechnetem Cache):

T500=500×1s8 MinutenT_{500} = 500 \times 1\text{s} \approx 8 \text{ Minuten}

Acht Minuten gegenüber 8.950 Jahren erschöpfender Suche. Und TPE findet in 500 Iterationen Kombinationen, die OAT in 96 übersieht, weil es den Parameterraum gleichzeitig statt eine Achse nach der anderen erkundet.

Speichern und Fortsetzen eines Studys

import optuna

study = optuna.create_study(
    storage="sqlite:///optuna_study.db",
    study_name="strategy_v2",
    sampler=TPESampler(seed=42),
    direction="minimize",
    load_if_exists=True,  # continue if study already exists
)

study.optimize(objective, n_trials=300)


study.optimize(objective, n_trials=200)

Hinzufügen von Nebenbedingungen

Nicht alle Parameterkombinationen sind gültig. Zum Beispiel sollte die Ausstiegsschwelle die Eintrittsschwelle nicht überschreiten:

def objective_with_constraints(trial: optuna.Trial) -> float:
    htf_entry = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    htf_exit  = trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005)

    if htf_exit > htf_entry:
        raise optuna.TrialPruned()

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)
    return -result["pnl_at_max_lev"]

Sampler-Vergleich

Sampler convergence comparison over iterations

Optuna unterstützt mehrere Sampler. Jeder hat seine eigenen Stärken.

TPESampler (Standard)

sampler = optuna.samplers.TPESampler(
    n_startup_trials=20,  # random trials before modeling begins
    seed=42,
)
  • Prinzip: Tree-structured Parzen Estimator
  • Stärken: gut für gemischte Parametertypen, skaliert auf 1000+ Iterationen
  • Schwächen: kann bei starken Parameter-Wechselwirkungen weniger effizient sein
  • Wann verwenden: standardmäßig, wenn kein Grund besteht, einen anderen zu wählen

CmaEsSampler

sampler = optuna.samplers.CmaEsSampler(seed=42)
  • Prinzip: Covariance Matrix Adaptation Evolution Strategy — ein evolutionärer Algorithmus, der die Kovarianzmatrix anpasst
  • Stärken: hervorragend im Auffinden von Wechselwirkungen zwischen kontinuierlichen Parametern, berücksichtigt Korrelationen
  • Schwächen: unterstützt keine kategorialen Parameter, benötigt mehr Iterationen zur Initialisierung
  • Wann verwenden: wenn alle Parameter kontinuierlich sind und Sie starke Wechselwirkungen vermuten

GPSampler

sampler = optuna.samplers.GPSampler(seed=42)
  • Prinzip: Gaußprozess mit Acquisition-Funktion
  • Stärken: beste Stichprobeneffizienz (weniger Iterationen für gutes Ergebnis), liefert Unsicherheitsschätzungen
  • Schwächen: O(n3)O(n^3) in der Iterationsanzahl — langsam bei n>200n > 200
  • Wann verwenden: wenn ein einzelner Backtest teuer ist (Minuten) und das Budget auf 100-200 Iterationen begrenzt ist

RandomSampler (Baseline)

sampler = optuna.samplers.RandomSampler(seed=42)
  • Prinzip: gleichverteiltes zufälliges Sampling
  • Stärken: bleibt nicht in lokalen Optima stecken, vollständige Raumabdeckung
  • Schwächen: nutzt vorherige Ergebnisse nicht
  • Wann verwenden: als Baseline zum Vergleich oder für explorative Analyse

QMCSampler

sampler = optuna.samplers.QMCSampler(seed=42)
  • Prinzip: Quasi-Monte-Carlo (Sobol-/Halton-Sequenzen) — füllt den Raum gleichmäßiger als ein Zufalls-Sampler
  • Stärken: bessere Raumabdeckung als RandomSampler, Reproduzierbarkeit
  • Schwächen: passt sich nicht an Ergebnisse an
  • Wann verwenden: für die ersten 50-100 Iterationen, bevor auf TPE gewechselt wird

Zusammenfassende Tabelle

Sampler Typ Wechselwirkungen Kategorial Bestes Budget
TPE Bayessch Teilweise Ja 100-1000
CmaEs Evolutionär Ja Nein 200-2000
GP Bayessch Ja Begrenzt 50-200
Random Zufällig Nein Ja Beliebig (Baseline)
QMC Quasi-zufällig Nein Nein 50-500

Praktischer Benchmark

import optuna
import time

def benchmark_sampler(sampler, n_trials=300):
    """Compare samplers on the same task."""
    study = optuna.create_study(sampler=sampler, direction="minimize")

    start = time.time()
    study.optimize(objective, n_trials=n_trials, show_progress_bar=False)
    elapsed = time.time() - start

    return {
        "best_value": -study.best_value,
        "elapsed_sec": elapsed,
        "best_trial": study.best_trial.number,
    }

samplers = {
    "TPE":    optuna.samplers.TPESampler(seed=42),
    "CmaEs":  optuna.samplers.CmaEsSampler(seed=42),
    "GP":     optuna.samplers.GPSampler(seed=42),
    "Random": optuna.samplers.RandomSampler(seed=42),
    "QMC":    optuna.samplers.QMCSampler(seed=42),
}

for name, sampler in samplers.items():
    result = benchmark_sampler(sampler, n_trials=300)
    print(f"{name:8s}: best PnL={result['best_value']:.2f}%, "
          f"found at trial #{result['best_trial']}, "
          f"time={result['elapsed_sec']:.1f}s")

Typische Ergebnisse für eine Strategie mit 12 Parametern:

Sampler Bester PnL Gefunden bei Iteration Sampler-Overhead
TPE ~51% ~180 Niedrig
CmaEs ~49% ~250 Mittel
GP ~48% ~90 Hoch bei n>200n > 200
Random ~42% ~270 Minimal
QMC ~43% ~200 Minimal

TPE und CmaEs übertreffen die Zufallssuche konsistent um 15-20% im finalen PnL. GP findet gute Ergebnisse früher, stößt aber bei einer großen Anzahl von Iterationen an eine Rechenobergrenze.

Multi-Objective-Optimierung: PnL vs. MaxDD

Pareto front: tradeoff between PnL and maximum drawdown

Warum ein einzelnes Kriterium nicht ausreicht

Die Maximierung des PnL ohne Drawdown-Beschränkungen ist ein Weg ins Verderben. Eine Strategie mit PnL +80% und MaxDD -30% ist aufgrund der Asymmetrie von Verlust und Gewinn deutlich riskanter als eine Strategie mit PnL +50% und MaxDD -5%.

Das Optimierungsproblem ist tatsächlich multikriteriell:

maxθ  PnL(θ)unter der BedingungMaxDD(θ)min\max_{\theta} \; \text{PnL}(\theta) \quad \text{unter der Bedingung} \quad \text{MaxDD}(\theta) \to \min

Diese Ziele stehen im Konflikt: aggressive Parameter erhöhen sowohl PnL als auch Drawdown. Die Lösung ist kein einzelner Punkt, sondern eine Pareto-Front: eine Menge von Lösungen, bei denen man eine Metrik nicht verbessern kann, ohne die andere zu verschlechtern.

NSGA-II / NSGA-III in Optuna

import optuna

def multi_objective(trial: optuna.Trial) -> tuple[float, float]:
    """Multi-objective function: (PnL, MaxDD)."""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),
        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),
        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),
        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),
        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    pnl = result["pnl"]          # maximize
    max_dd = result["max_dd"]    # minimize (already a negative number)

    return pnl, max_dd  # Optuna: both directions are set in create_study


study = optuna.create_study(
    directions=["maximize", "minimize"],
    sampler=optuna.samplers.NSGAIIISampler(seed=42),
    study_name="multi_objective_strategy",
)

study.optimize(multi_objective, n_trials=500)

pareto_trials = study.best_trials
print(f"Pareto front: {len(pareto_trials)} solutions")

for t in pareto_trials[:5]:
    print(f"  PnL={t.values[0]:.2f}%, MaxDD={t.values[1]:.2f}%")

Auswahl eines Punkts auf der Pareto-Front

Die Pareto-Front liefert mehrere Lösungen. Wie wählt man eine aus?

def select_from_pareto(
    pareto_trials: list,
    max_dd_limit: float = -5.0,
    min_pnl: float = 20.0,
) -> list:
    """
    Filter the Pareto front by constraints.

    max_dd_limit: maximum acceptable drawdown (e.g., -5%)
    min_pnl: minimum acceptable PnL (%)
    """
    filtered = []
    for trial in pareto_trials:
        pnl, max_dd = trial.values
        if max_dd >= max_dd_limit and pnl >= min_pnl:
            max_lev = min(50 / abs(max_dd), 100) if max_dd != 0 else 100
            pnl_at_max_lev = pnl * max_lev
            filtered.append({
                "trial": trial,
                "pnl": pnl,
                "max_dd": max_dd,
                "max_lev": max_lev,
                "pnl_at_max_lev": pnl_at_max_lev,
            })

    filtered.sort(key=lambda x: x["pnl_at_max_lev"], reverse=True)
    return filtered

Hinweis: Bei der Berechnung des PnL bei maximalem Hebel müssen Funding Rates berücksichtigt werden, sonst verwandelt sich theoretisch hoher Hebel auf dem realen Markt in einen Verlust. Außerdem ist der finale PnL eine Einzelpunktschätzung, und zur Beurteilung der Ergebnisstabilität benötigt man ein Monte-Carlo-Bootstrap.

Beispiel: Drei Strategien auf der Pareto-Front

Strategie PnL MaxDD MaxLev PnL@MaxLev Handelszeit
Strategie A ~55% ~0.9% ~55x ~3025% ~15%
Strategie B ~25% ~0.75% ~66x ~1650% ~5%
Strategie C ~300% ~17% ~3x ~900% ~45%

Strategie C mit einem beeindruckenden PnL von +300% erweist sich aufgrund des hohen Drawdowns als die unattraktivste nach PnL@MaxLev. Strategie A führt beim gehebelten Nettoertrag, aber unter Berücksichtigung des PnL pro aktiver Zeit könnte Strategie B vorzuziehen sein — 95% der freien Zeit können mit anderen Strategien gefüllt werden.

Konturdiagramme und Parameter-Wichtigkeit

Contour plots: visualizing parameter interactions and plateaus

Landschaftsvisualisierung

Nach der Optimierung — Visualisierung. Optuna bietet integrierte Werkzeuge:

import optuna.visualization as vis

fig_contour = vis.plot_contour(
    study,
    params=["htf_entry_sell", "mtf_entry_sell"],
)
fig_contour.show()

fig_importance = vis.plot_param_importances(study)
fig_importance.show()

fig_history = vis.plot_optimization_history(study)
fig_history.show()

fig_parallel = vis.plot_parallel_coordinate(
    study,
    params=["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell"],
)
fig_parallel.show()

fig_slice = vis.plot_slice(study)
fig_slice.show()

Konturdiagramm: Wechselwirkungen lesen

Ein Konturdiagramm baut einen zweidimensionalen Querschnitt der Zielfunktion für ein Parameterpaar auf. Wenn die Isolinien parallel zu einer der Achsen sind — wechselwirken die Parameter nicht, und OAT hätte dasselbe Optimum gefunden. Wenn die Isolinien diagonal sind — besteht eine Wechselwirkung, und OAT wird sie übersehen.

key_params = ["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell",
              "htf_entry_buy",  "mtf_entry_buy",  "ltf_entry_buy"]

for i, p1 in enumerate(key_params):
    for p2 in key_params[i+1:]:
        fig = vis.plot_contour(study, params=[p1, p2])
        fig.write_image(f"contour_{p1}_vs_{p2}.png")

Wenn ein Konturdiagramm ein Plateau zeigt — einen Bereich, in dem sich die Zielfunktion nur wenig ändert — ist das ein gutes Zeichen. Ein Plateau bedeutet, dass das Ergebnis robust gegenüber kleinen Parameterabweichungen ist. Mehr zur Plateau-Analyse und ihrem Zusammenhang mit Overfitting — im kommenden Artikel Plateau-Analyse.

Parameter-Wichtigkeit

importance = optuna.importance.get_param_importances(study)
for param, imp in importance.items():
    print(f"{param:20s}: {imp:.4f}")

Typische Ausgabe:

htf_entry_sell      : 0.2841
mtf_entry_sell      : 0.2103
ltf_entry_sell      : 0.1567
trail_pct           : 0.1204
htf_entry_buy       : 0.0892
...

Parameter mit einer Wichtigkeit < 0,01 können auf ihrem Standardwert fixiert werden — das reduziert die Dimensionalität des Problems und beschleunigt die Optimierung. Aber Vorsicht: geringe Wichtigkeit kann auch bedeuten, dass der Parameter nur in Wechselwirkung mit anderen wichtig ist. Über Konturdiagramme verifizieren.

Vorberechneter Cache: Warum 1 Sekunde pro Backtest alles ändert

Precomputed Parquet cache: accelerating backtests from hours to seconds

Die Geschwindigkeit eines einzelnen Backtests bestimmt, welche Optimierungsmethode Sie sich leisten können.

Backtest-Zeit 96 OAT 500 TPE 2000 CmaEs
60 Sekunden 1,6 Stunden 8,3 Stunden 33 Stunden
10 Sekunden 16 Minuten 83 Minuten 5,5 Stunden
1 Sekunde 1,5 Minuten 8 Minuten 33 Minuten
0,1 Sekunden 10 Sekunden 50 Sekunden 3,3 Minuten

Bei 60 Sekunden pro Backtest dauern 500 TPE-Iterationen 8 Stunden. Noch erträglich, aber das Iterieren (Ändern der Zielfunktion, Neustart) ist teuer. Bei 1 Sekunde — 8 Minuten, und Sie können täglich Dutzende Experimente durchführen.

Genau deshalb ist die Vorberechnung in den Parquet-Cache nicht nur eine Geschwindigkeitsoptimierung, sondern eine Erweiterung des Raums verfügbarer Methoden. Ohne Cache sind Sie auf OAT oder 100 GP-Iterationen beschränkt. Mit Cache — können Sie sich 2000 CmaEs-Iterationen oder ein vollständiges Multi-Objective-NSGA-III leisten.

import pyarrow.parquet as pq
import time

t0 = time.time()
htf_pre = pq.read_table("cache/htf_indicators.parquet").to_pandas()
mtf_pre = pq.read_table("cache/mtf_indicators.parquet").to_pandas()
ltf_pre = pq.read_table("cache/ltf_indicators.parquet").to_pandas()
print(f"Cache loaded in {time.time() - t0:.2f}s")  # ~0.3s

t1 = time.time()
result = run_backtest(htf_pre, mtf_pre, ltf_pre, htf_entry_sell=0.02, ...)
print(f"Backtest in {time.time() - t1:.2f}s")  # ~1.0s

Praktische Empfehlungen

Hybrid approach: combining OAT and Bayesian optimization

Wann OAT verwenden

OAT ist in folgenden Fällen gerechtfertigt:

  1. Explorative Analyse. Sie erkunden gerade erst eine Strategie und wollen verstehen, welche Parameter das Ergebnis überhaupt beeinflussen. 96 Durchläufe in 1,5 Minuten — ein exzellenter Ausgangspunkt.

  2. Additive Parameter. Bei Parametern, die auf nicht überlappenden Teilmengen von Trades wirken (Sell- vs. Buy-Richtungen, unterschiedliche Instrumente), liefert OAT schneller ein korrektes Ergebnis.

  3. Sehr teurer Backtest. Wenn ein einzelner Durchlauf 10+ Minuten dauert und nicht beschleunigt werden kann, ist OAT mit 96 Durchläufen (16 Stunden) 500 TPE-Iterationen (3,5 Tage) vorzuziehen.

Wann Optuna verwenden

Optuna ist in den meisten Fällen vorzuziehen:

  1. Mehr als 3 Parameter. Wechselwirkungen sind praktisch garantiert — OAT wird das Optimum übersehen.

  2. Multi-Timeframe-Strategien. Schwellen über verschiedene Zeitrahmen sind fast immer miteinander verbunden.

  3. Finale Optimierung. Wenn die Strategie das Monte-Carlo-Bootstrap durchlaufen hat und Sie von ihrer Robustheit überzeugt sind — Optuna wird die besten Parameter finden.

  4. Multi-Objective-Probleme. PnL vs. MaxDD vs. Handelszeit — OAT kann dieses Problem grundsätzlich nicht lösen.

Hybrider Ansatz: OAT für Additiv + Optuna für Gekoppelt

Sie müssen nicht zwischen OAT und Optuna wählen — es ist besser, sie zu kombinieren:

  1. Parameter klassifizieren. In additiv (unabhängig) und gekoppelt (interaktiv) unterteilen. Beispiel für 12 Trennungsparameter:

    • Additiv: htf_entry_sell <-> htf_entry_buy, mtf_entry_sell <-> mtf_entry_buy, ltf_entry_sell <-> ltf_entry_buy (Sell/Buy — unterschiedliche Richtungen, wirken auf nicht überlappende Trades)
    • Gekoppelte Gruppe Sell: htf_entry_sell, mtf_entry_sell, ltf_entry_sell (Filterkette: HTF -> MTF -> LTF für Sell-Signale)
    • Gekoppelte Gruppe Buy: htf_entry_buy, mtf_entry_buy, ltf_entry_buy
  2. OAT für Additiv. Sell- und Buy-Gruppen unabhängig optimieren. Wenn Sell-Parameter Buy-Trades nicht beeinflussen — liefert OAT in Minuten ein korrektes Ergebnis.

  3. Optuna für Gekoppelt. Innerhalb jeder Gruppe (Sell: 6 Parameter Entry+Exit) TPE verwenden. 6 Parameter statt 12 — das Budget wird halbiert.

sell_params = oat_sweep(sell_param_grid, run_backtest, initial_params)

def objective_sell(trial):
    params = sell_params.copy()
    params["htf_entry_sell"] = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    params["mtf_entry_sell"] = trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005)
    params["ltf_entry_sell"] = trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005)
    params["htf_exit_sell"] = trial.suggest_float("htf_exit_sell", 0.0, 0.02, step=0.001)
    params["mtf_exit_sell"] = trial.suggest_float("mtf_exit_sell", 0.0, 0.02, step=0.001)
    params["ltf_exit_sell"] = trial.suggest_float("ltf_exit_sell", 0.0, 0.02, step=0.001)
    return -run_backtest(**params)["effective_score"]

study = optuna.create_study(sampler=optuna.samplers.TPESampler())
study.optimize(objective_sell, n_trials=300)  # 6 parameters → 300 is enough

Vollständige Optimierungs-Pipeline

1. Precompute Parquet cache (once)
2. Classify parameters: additive vs coupled
3. OAT for additive (~50 runs, ~1 min) → fix
4. Optuna TPE for coupled groups (300 iterations x 2 groups, ~10 min)
5. Optuna NSGA-III for meta-parameters (500 iterations, ~8 min) → Pareto front
6. Contour plots → visualize interactions
7. Monte Carlo bootstrap of best points → confidence intervals
8. Walk-Forward → out-of-sample validation

Schritt 8 — Walk-Forward-Optimierung — ist entscheidend für den Schutz vor Overfitting. Mehr dazu im kommenden Artikel Walk-Forward.

Fallstricke der Optimierung

Overfitting. Je mehr Parameter und je präziser die Optimierung — desto höher das Risiko, dass die Strategie an historische Daten angepasst wird. 500 Optuna-Iterationen mit 12 Parametern finden eine Kombination, die auf dem Trainingsset perfekt funktioniert, aber auf neuen Daten nutzlos ist.

Schutz:

  • Daten in Train/Test aufteilen (70/30)
  • Monte-Carlo-Bootstrap zur Beurteilung der Stabilität verwenden
  • Durch Walk-Forward validieren
  • Lösungen auf Plateaus bevorzugen (mehr dazu in Plateau-Analyse)

Problem multipler Vergleiche. Wenn Sie 500 Kombinationen testen, wächst die Wahrscheinlichkeit, zufällig ein "gutes" Ergebnis zu finden. Bonferroni-Korrektur oder FDR-Kontrolle (False Discovery Rate) helfen, aber der einfachere Ansatz ist Out-of-Sample-Validierung.

Unzureichendes Budget. TPE mit 50 Iterationen für 12 Parameter ist zu wenig. Die ersten 20 Iterationen sind zufällig (Startup), was nur 30 fürs Modellieren übrig lässt. Mindestbudget: 10×K=12010 \times K = 120 Iterationen für 12 Parameter, empfohlen: 3050×K30\text{--}50 \times K.

Freqtrade: Wie es in einem Produktions-Framework funktioniert

Freqtrade: automated trading framework with Optuna integration

Freqtrade — eines der populären Algotrading-Frameworks — nutzt Optuna unter der Haube über das Hyperopt-Modul. Seine Erfahrung bestätigt unsere Empfehlungen:

  • Sampler: TPE (Standard), GP, CmaEs, NSGA-II, QMC — alle über Konfiguration verfügbar
  • Loss-Funktionen: 12 eingebaute Loss-Funktionen, einschließlich ShortTradeDurHyperOptLoss, SharpeHyperOptLoss, MaxDrawDownHyperOptLoss
  • Multi-Objective: Unterstützung für NSGA-II und NSGA-III zur gleichzeitigen Optimierung mehrerer Metriken
  • Custom-Sampler: Möglichkeit, jeden Optuna-kompatiblen Sampler einzubinden

Eine Schlüsselerkenntnis aus dem Freqtrade-Ökosystem: eingebaute Loss-Funktionen decken typische Szenarien ab, aber für ernsthafte Optimierung braucht man eine eigene Objective-Funktion, die die Besonderheiten Ihrer Strategie berücksichtigt — aktive Zeit, Funding-Kosten, adaptives Drill-Down für präzise Fill-Simulation.

Fazit

Complete optimization pipeline: from data to validated parameters

Coordinate Descent (OAT) ist eine schnelle und intuitive Methode. Für 12 Parameter benötigt es nur 96 Durchläufe und ist in anderthalb Minuten fertig. Aber es ist blind für Parameter-Wechselwirkungen — und in Multi-Timeframe-Strategien sind Wechselwirkungen fast immer vorhanden.

Bayessche Optimierung über Optuna (TPE, GP, CmaEs) erkundet den Parameterraum als Ganzes. 500 Iterationen in 8 Minuten — mit vorberechnetem Parquet-Cache — finden Kombinationen, die für OAT unsichtbar sind.

Multi-Objective-Optimierung (NSGA-III) verwandelt das Problem "PnL maximieren" in das Problem "eine Pareto-Front von PnL vs. MaxDD aufbauen" — und liefert eine Menge von Lösungen mit unterschiedlichen Risiko-Ertrags-Kompromissen.

Aber Optimierung ist nur ein Teil der Pipeline. Die gefundenen Parameter müssen durch Monte-Carlo-Bootstrap validiert, um Funding Rates korrigiert, unter Berücksichtigung der aktiven Zeit neu berechnet und durch Walk-Forward-Validierung geführt werden. Mehr dazu in den kommenden Artikeln der Serie.


Nützliche Links

  1. Optuna: A Next-generation Hyperparameter Optimization Framework (Akiba et al., 2019)
  2. Algorithms for Hyper-Parameter Optimization (Bergstra et al., 2011) — the original TPE paper
  3. Optuna Documentation — Samplers
  4. Optuna Visualization Module
  5. Hansen, N. — The CMA Evolution Strategy: A Tutorial
  6. Deb, K. et al. — NSGA-II: A Fast and Elitist Multiobjective Genetic Algorithm (2002)
  7. Snoek, J. et al. — Practical Bayesian Optimization of Machine Learning Algorithms (2012)
  8. Freqtrade Documentation — Hyperopt
  9. Marcos Lopez de Prado — Advances in Financial Machine Learning, Chapter 12
  10. Bergstra, J. & Bengio, Y. — Random Search for Hyper-Parameter Optimization (2012)

Zitierung

@article{soloviov2026optuna,
  author = {Soloviov, Eugen},
  title = {Coordinate Descent vs Bayesian Optimization: Which Finds Better Parameters},
  year = {2026},
  url = {https://marketmaker.cc/en/blog/post/optuna-vs-coordinate-descent},
  description = {Why exhaustive search is impossible for 12+ parameters, how coordinate descent misses interactions, and how Optuna with a TPE sampler finds in 500 iterations what OAT cannot find in 96.}
}
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Dem Markt einen Schritt voraus

Abonniere unseren Newsletter für exklusive KI-Trading-Einblicke, Marktanalysen und Plattform-Updates.

Wir respektieren deine Privatsphäre. Jederzeit abbestellbar.