Koordinatenabstieg vs. Bayessche Optimierung: Wer findet die besseren Parameter
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

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 Durchläufe — vollkommen machbar. Bei drei: — noch erträglich.
Aber bei einer echten Strategie mit 12 Parametern:
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:
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

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:
Vergleichen Sie das mit für die Grid Search. OAT ist linear: statt . 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_sellundhtf_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_targetundbe_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_sellundmtf_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_fracundtp_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 die Zielfunktion (PnL). OAT findet einen Punkt, an dem gilt:
Das ist jedoch eine notwendige, keine hinreichende Bedingung für ein globales Optimum. Wenn die Hesse-Matrix signifikante außerdiagonale Elemente aufweist — berücksichtigt OAT nicht die Kreuzableitungen für .
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

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:
- Mehrere zufällige Punkte wählen, die Zielfunktion auswerten
- Ein Surrogatmodell aufbauen (approximiert anhand beobachteter Punkte)
- Den Punkt mit maximaler erwarteter Verbesserung finden (Acquisition-Funktion)
- Die Zielfunktion an diesem Punkt auswerten
- Das Surrogatmodell aktualisieren
- 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 ist der Standard-Sampler in Optuna. Statt direkt zu modellieren, modelliert TPE zwei Verteilungen:
- — Verteilung der Parameter, bei denen die Zielfunktion besser als der Schwellenwert ist
- — Verteilung der Parameter, bei denen die Zielfunktion schlechter als der Schwellenwert ist
Die Acquisition-Funktion von TPE — das Verhältnis:
TPE wählt Punkte aus, bei denen groß ist (Parameter ähnlich zu "guten") und 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 als multivariaten Normalprozess und liefert nicht nur eine Wertvorhersage, sondern auch die Unsicherheit an jedem Punkt.
wobei der Mittelwert ist und 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

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):
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

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: in der Iterationsanzahl — langsam bei
- 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 |
| 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

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:
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

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

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

Wann OAT verwenden
OAT ist in folgenden Fällen gerechtfertigt:
-
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.
-
Additive Parameter. Bei Parametern, die auf nicht überlappenden Teilmengen von Trades wirken (Sell- vs. Buy-Richtungen, unterschiedliche Instrumente), liefert OAT schneller ein korrektes Ergebnis.
-
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:
-
Mehr als 3 Parameter. Wechselwirkungen sind praktisch garantiert — OAT wird das Optimum übersehen.
-
Multi-Timeframe-Strategien. Schwellen über verschiedene Zeitrahmen sind fast immer miteinander verbunden.
-
Finale Optimierung. Wenn die Strategie das Monte-Carlo-Bootstrap durchlaufen hat und Sie von ihrer Robustheit überzeugt sind — Optuna wird die besten Parameter finden.
-
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:
-
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
- Additiv:
-
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.
-
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: Iterationen für 12 Parameter, empfohlen: .
Freqtrade: Wie es in einem Produktions-Framework funktioniert

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

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
- Optuna: A Next-generation Hyperparameter Optimization Framework (Akiba et al., 2019)
- Algorithms for Hyper-Parameter Optimization (Bergstra et al., 2011) — the original TPE paper
- Optuna Documentation — Samplers
- Optuna Visualization Module
- Hansen, N. — The CMA Evolution Strategy: A Tutorial
- Deb, K. et al. — NSGA-II: A Fast and Elitist Multiobjective Genetic Algorithm (2002)
- Snoek, J. et al. — Practical Bayesian Optimization of Machine Learning Algorithms (2012)
- Freqtrade Documentation — Hyperopt
- Marcos Lopez de Prado — Advances in Financial Machine Learning, Chapter 12
- 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.}
}
Authors
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.