Coordinate Descent vs Bayesiaanse Optimalisatie: Wie Vindt Betere Parameters
Dit is het vijfde artikel in de serie "Backtests zonder illusies". In eerdere artikelen behandelden we de asymmetrie tussen verlies en winst, Monte Carlo bootstrap, de invloed van funding rates, en Parquet cache voor snellere backtests. Laten we het nu hebben over het proces van het vinden van optimale strategieparameters — een taak waarbij intuïtie het vaakst faalt.
Je hebt een strategie met 12 parameters. Elke parameter neemt ~9 waarden aan. Je wilt de combinatie vinden die de PnL maximaliseert bij een beperkte drawdown. Hoe pak je dat aan?
Als je antwoord is "ik loop alle combinaties na" — heb je een probleem. Als je antwoord is "ik verander één parameter tegelijk" — heb je een ander probleem. Dit artikel gaat over welke problemen achter elke aanpak schuilgaan en hoe je ze oplost.
Waarom uitputtend zoeken onmogelijk is

De vloek van de dimensionaliteit
Uitputtend zoeken (grid search) test elke combinatie van waarden voor elke parameter. Voor twee parameters met 9 waarden is dat runs — volkomen haalbaar. Voor drie: — nog te doen.
Maar voor een echte strategie met 12 parameters:
Tweehonderdtweeëntachtig miljard runs. Zelfs als een enkele backtest 1 seconde duurt (wat al optimistisch is), zou uitputtend zoeken duren:
Dit is exponentiële groei: elke nieuwe parameter vermenigvuldigt de zoekruimte met 9. Voeg een 13e parameter toe — en in plaats van 9.000 jaar heb je er 80.000 nodig.
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
Zelfs met vooraf berekenen
In het artikel over de Parquet cache lieten we zien hoe het vooraf berekenen van timeframes en indicatoren een enkele backtest versnelt tot ~1 seconde. Maar zelfs bij 0,1 seconde per run zou uitputtend zoeken over 12 parameters 895 jaar vergen. Vooraf berekenen helpt, maar lost het fundamentele probleem van exponentiële groei niet op.
We hebben methoden nodig die de parameterruimte slimmer verkennen dan uitputtend zoeken.
Coordinate descent en OAT: snel maar blind

Twee varianten van hetzelfde idee
Er zijn twee verwante aanpakken — beide optimaliseren één parameter tegelijk, maar verschillen in het aantal doorlopen.
OAT (One-at-a-Time) sweep — één enkele doorloop door alle parameters. Ga door de waarden van de eerste parameter, fixeer de beste, ga naar de tweede — enzovoort. Eenmalig. Snel en goedkoop.
Coordinate Descent — meerdere doorlopen. Na het optimaliseren van de laatste parameter keer je terug naar de eerste en controleer je of het optimum is veranderd (omdat de context is veranderd — andere parameterwaarden zijn nu anders). Rondes worden herhaald tot convergentie. Duurder, maar preciezer — elke ronde kan de oplossing verfijnen.
In de praktijk wordt voor backtests vaker OAT gebruikt: één enkele doorloop door 12 parameters — 96 runs. Coordinate descent met 3-5 rondes — 300-500 runs, wat al vergelijkbaar is met Optuna, maar zonder de voordelen ervan.
Voor 12 parameters met elk ~8 waarden:
Vergelijk dat met voor grid search. OAT is lineair: in plaats van . Dit is zowel zijn belangrijkste voordeel als zijn belangrijkste probleem.
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
Welke metriek moet je kiezen voor optimalisatie? In plaats van de ruwe PnL of PnL@MaxLev wordt aanbevolen de effective score — PnL per actieve tijd te gebruiken, geëxtrapoleerd naar een jaar. Deze metriek houdt rekening met de tijd in positie en maakt een correcte vergelijking mogelijk van strategieën met verschillende handelsfrequenties.
De blinde vlek: parameterinteracties
OAT gaat ervan uit dat het effect van elke parameter additief is — dat wil zeggen, de optimale waarde van de ene parameter hangt niet af van de waarden van de andere. Deze aanname klopt voor sommige parameters, maar gaat mank bij gekoppelde parameters.
Additieve versus gekoppelde parameters
Voordat je gaat optimaliseren — is het nuttig om parameters te classificeren:
Additief (onafhankelijk) — de optimale waarde van de ene hangt niet af van de andere. Ze kunnen goedkoop één voor één worden geoptimaliseerd:
htf_entry_sellenhtf_entry_buy— instapdrempels voor verschillende richtingen (sell/buy) op hetzelfde timeframe. De sell-drempel filtert shortsignalen, de buy-drempel — longs. Ze werken op niet-overlappende deelverzamelingen van trades.tp_targetenbe_trigger— take-profit en breakeven, als ze geen tegenstrijdige exitvoorwaarden creëren.
Gekoppeld (interactief) — de optimale waarde van de ene hangt af van de andere. Gezamenlijke optimalisatie is nodig:
htf_entry_sellenmtf_entry_sell— drempels voor dezelfde richting (sell) op verschillende timeframes. HTF bepaalt welke signalen MTF bereiken, en de MTF-drempel bepaalt de filtereffectiviteit. Het HTF-optimum verschuift wanneer MTF verandert.ltf_entry_sell,mtf_entry_sell,htf_entry_sell— de hele drempelketen voor één richting.partial_fracentp_target— de grootte van de gedeeltelijke sluiting hangt af van het TP-niveau.
Praktische aanpak: optimaliseer eerst goedkoop de additieve parameters via OAT. Optimaliseer daarna de gekoppelde groepen via Optuna. Dit verlaagt het budget: in plaats van 12 parameters in Optuna sturen we slechts 6-8 gekoppelde, terwijl de rest al vastligt.
Voorbeeld: hoe OAT een interactie mist
Beschouw twee gekoppelde drempels:
htf_entry_sell— drempel op het hogere timeframe (sell-richting)mtf_entry_sell— drempel op het middelste timeframe (sell-richting)
OAT fixeert mtf_entry_sell = 0.01 (beginwaarde) en doorloopt htf_entry_sell. Vindt de beste waarde: htf_entry_sell = 0.02. Fixeert die en gaat naar de volgende parameter — komt nooit terug.
Dit is wat OAT miste:
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% |
De combinatie (0.03, 0.02) levert een PnL van +51% op, maar OAT zal die nooit overwegen omdat bij een vaste mtf_entry_sell = 0.01 de waarde htf_entry_sell = 0.03 slechts +35% oplevert. OAT is "vastgelopen" in het lokale optimum (0.02, 0.01) en kan het globale optimum (0.03, 0.02) niet zien.
Dit is een klassiek probleem: als het landschap van de doelfunctie diagonale richels bevat (wanneer het optimum van de ene parameter verschuift terwijl een andere verandert), mist OAT die.
Het probleem formaliseren
Laat de doelfunctie zijn (PnL). OAT vindt een punt waar:
Maar dit is een noodzakelijke, geen voldoende voorwaarde voor een globaal optimum. Als de Hessiaanse matrix significante niet-diagonale elementen heeft — houdt OAT geen rekening met de kruisafgeleiden wanneer .
Voor gekoppelde parameters (drempels van dezelfde richting over meerdere timeframes) — zijn interacties de regel, niet de uitzondering. De instapdrempel op het hogere timeframe bepaalt welke signalen het middelste bereiken, en de drempel op het middelste bepaalt de filtereffectiviteit op het lagere. Voor additieve parameters (verschillende richtingen, onafhankelijke filters) liggen kruisafgeleiden dicht bij nul — en OAT werkt goed.
Bayesiaanse optimalisatie: slim zoeken

Het idee
In plaats van blind opsommen of gulzig zoeken, bouwt Bayesiaanse optimalisatie een surrogaatmodel van de doelfunctie en kiest bij elke stap het punt waar de verwachte verbetering maximaal is.
Algoritme:
- Kies enkele willekeurige punten, evalueer de doelfunctie
- Bouw een surrogaatmodel (benadert op basis van geobserveerde punten)
- Vind het punt met maximale verwachte verbetering (acquisitiefunctie)
- Evalueer de doelfunctie op dat punt
- Werk het surrogaatmodel bij
- Herhaal stappen 3-5
Het belangrijkste verschil met OAT: Bayesiaanse optimalisatie beschouwt alle parameters tegelijk en kan diagonale richels in de parameterruimte verkennen.
TPE (Tree-structured Parzen Estimator)

TPE is de standaard sampler in Optuna. In plaats van direct te modelleren, modelleert TPE twee verdelingen:
- — verdeling van parameters waar de doelfunctie beter is dan drempelwaarde
- — verdeling van parameters waar de doelfunctie slechter is dan drempelwaarde
De acquisitiefunctie van TPE — de verhouding:
TPE selecteert punten waar groot is (parameters vergelijkbaar met "goede") en klein is (parameters niet vergelijkbaar met "slechte").
Waarom TPE geschikt is voor backtests:
- Behandelt voorwaardelijke afhankelijkheden tussen parameters
- Vereist geen continuïteit van de doelfunctie
- Efficiënt bij gematigde budgetten (100-1000 iteraties)
- Ondersteunt categorische en discrete parameters
Gaussian Process (GP)
Een alternatief voor TPE — Gaussian Process. GP modelleert als een multivariaat normaal proces en levert niet alleen een waardevoorspelling, maar ook onzekerheid op elk punt.
waarbij het gemiddelde is, de covariantiefunctie (kernel).
GP werkt goed wanneer:
- er weinig parameters zijn (tot 10-15)
- de doelfunctie glad is
- elke run duur is (minuten, uren)
Voor backtests met een vooraf berekende Parquet cache, waarbij een enkele run ~1 seconde duurt, heeft TPE meestal de voorkeur: het bouwt het model sneller op en schaalt beter naar 500+ iteraties.
Praktische integratie met Optuna

Volledig werkend voorbeeld
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)}")
Bij ~1 seconde per backtest (met vooraf berekende cache):
Acht minuten tegenover 8.950 jaar uitputtend zoeken. En TPE vindt in 500 iteraties combinaties die OAT mist in 96, omdat het de parameterruimte gelijktijdig verkent in plaats van één as per keer.
Een study opslaan en hervatten
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)
Beperkingen toevoegen
Niet alle parametercombinaties zijn geldig. Bijvoorbeeld, de exit-drempel mag de instapdrempel niet overschrijden:
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"]
Vergelijking van samplers

Optuna ondersteunt verschillende samplers. Elk heeft zijn eigen sterke punten.
TPESampler (standaard)
sampler = optuna.samplers.TPESampler(
n_startup_trials=20, # random trials before modeling begins
seed=42,
)
- Principe: Tree-structured Parzen Estimator
- Sterke punten: goed voor gemengde parametertypes, schaalt naar 1000+ iteraties
- Zwakke punten: kan minder efficiënt zijn bij sterke parameterinteracties
- Wanneer gebruiken: standaard, als er geen reden is om een andere te kiezen
CmaEsSampler
sampler = optuna.samplers.CmaEsSampler(seed=42)
- Principe: Covariance Matrix Adaptation Evolution Strategy — een evolutionair algoritme dat de covariantiematrix aanpast
- Sterke punten: uitstekend in het vinden van interacties tussen continue parameters, houdt rekening met correlaties
- Zwakke punten: ondersteunt geen categorische parameters, vereist meer iteraties voor initialisatie
- Wanneer gebruiken: als alle parameters continu zijn en je sterke interacties vermoedt
GPSampler
sampler = optuna.samplers.GPSampler(seed=42)
- Principe: Gaussian Process met acquisitiefunctie
- Sterke punten: beste steekproefefficiëntie (minder iteraties voor een goed resultaat), levert onzekerheidsschattingen
- Zwakke punten: in aantal iteraties — traag wanneer
- Wanneer gebruiken: als een enkele backtest duur is (minuten) en het budget beperkt is tot 100-200 iteraties
RandomSampler (baseline)
sampler = optuna.samplers.RandomSampler(seed=42)
- Principe: uniforme willekeurige steekproef
- Sterke punten: raakt niet vast in lokale optima, volledige dekking van de ruimte
- Zwakke punten: gebruikt geen eerdere resultaten
- Wanneer gebruiken: als baseline voor vergelijking, of voor verkennende analyse
QMCSampler
sampler = optuna.samplers.QMCSampler(seed=42)
- Principe: Quasi-Monte Carlo (Sobol/Halton-reeksen) — vult de ruimte gelijkmatiger dan een willekeurige sampler
- Sterke punten: betere ruimtedekking dan RandomSampler, reproduceerbaarheid
- Zwakke punten: past zich niet aan resultaten aan
- Wanneer gebruiken: voor de eerste 50-100 iteraties voordat je overschakelt naar TPE
Samenvattende tabel
| Sampler | Type | Interacties | Categorisch | Beste budget |
|---|---|---|---|---|
| TPE | Bayesiaans | Gedeeltelijk | Ja | 100-1000 |
| CmaEs | Evolutionair | Ja | Nee | 200-2000 |
| GP | Bayesiaans | Ja | Beperkt | 50-200 |
| Random | Willekeurig | Nee | Ja | Elk (baseline) |
| QMC | Quasi-willekeurig | Nee | Nee | 50-500 |
Praktische 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 resultaten voor een strategie met 12 parameters:
| Sampler | Beste PnL | Gevonden bij iteratie | Sampler-overhead |
|---|---|---|---|
| TPE | ~51% | ~180 | Laag |
| CmaEs | ~49% | ~250 | Gemiddeld |
| GP | ~48% | ~90 | Hoog bij |
| Random | ~42% | ~270 | Minimaal |
| QMC | ~43% | ~200 | Minimaal |
TPE en CmaEs presteren consistent 15-20% beter dan willekeurig zoeken in eindresultaat-PnL. GP vindt eerder goede resultaten, maar stuit bij een groot aantal iteraties op een rekenplafond.
Multi-objective optimalisatie: PnL vs MaxDD

Waarom één criterium niet genoeg is
Het maximaliseren van PnL zonder drawdown-beperkingen is een weg naar rampspoed. Een strategie met PnL +80% en MaxDD -30% is, vanwege de asymmetrie tussen verlies en winst, aanzienlijk riskanter dan een strategie met PnL +50% en MaxDD -5%.
Het optimalisatieprobleem is in feite multi-objective:
Deze doelen staan op gespannen voet: agressieve parameters verhogen zowel PnL als drawdown. De oplossing is geen enkel punt, maar een Pareto-front: een verzameling oplossingen waarbij je de ene metriek niet kunt verbeteren zonder de andere te verslechteren.
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}%")
Een punt op het Pareto-front kiezen
Het Pareto-front biedt meerdere oplossingen. Hoe kies je er één?
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
Let op: bij het berekenen van PnL bij maximale hefboom moet je rekening houden met funding rates, anders verandert een theoretisch hoge hefboom op de echte markt in een verlies. Bovendien is de uiteindelijke PnL een enkele-puntschatting, en om de stabiliteit van het resultaat te beoordelen heb je Monte Carlo bootstrap nodig.
Voorbeeld: drie strategieën op het Pareto-front
| Strategie | PnL | MaxDD | MaxLev | PnL@MaxLev | Handelstijd |
|---|---|---|---|---|---|
| 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, met een indrukwekkende PnL van +300%, blijkt vanwege de hoge drawdown het minst aantrekkelijk volgens PnL@MaxLev. Strategie A is koploper in netto gehefboomd rendement, maar rekening houdend met PnL per actieve tijd kan strategie B te verkiezen zijn — 95% van de vrije tijd kan worden opgevuld met andere strategieën.
Contourplots en parameterbelang

Landschapsvisualisatie
Na de optimalisatie — visualisatie. Optuna biedt ingebouwde tools:
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()
Contourplot: interacties aflezen
Een contourplot bouwt een tweedimensionale doorsnede van de doelfunctie voor een paar parameters. Als de isolijnen parallel aan een van de assen lopen — wisselwerken de parameters niet, en OAT zou hetzelfde optimum hebben gevonden. Als de isolijnen diagonaal lopen — is er een interactie, en OAT zal die missen.
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")
Als een contourplot een plateau toont — een gebied waar de doelfunctie weinig verandert — is dat een goed teken. Een plateau betekent dat het resultaat robuust is tegen kleine parameterafwijkingen. Meer over plateauanalyse en het verband met overfitting — in het aankomende artikel Plateauanalyse.
Parameterbelang
importance = optuna.importance.get_param_importances(study)
for param, imp in importance.items():
print(f"{param:20s}: {imp:.4f}")
Typische output:
htf_entry_sell : 0.2841
mtf_entry_sell : 0.2103
ltf_entry_sell : 0.1567
trail_pct : 0.1204
htf_entry_buy : 0.0892
...
Parameters met een belang < 0,01 kunnen worden vastgezet op hun standaardwaarde — dit verlaagt de dimensionaliteit van het probleem en versnelt de optimalisatie. Maar wees voorzichtig: laag belang kan ook betekenen dat de parameter alleen belangrijk is in interactie met andere. Verifieer via contourplots.
Vooraf berekende cache: waarom 1 seconde per backtest alles verandert

De snelheid van een enkele backtest bepaalt welke optimalisatiemethode je je kunt veroorloven.
| Backtest-tijd | 96 OAT | 500 TPE | 2000 CmaEs |
|---|---|---|---|
| 60 seconden | 1,6 uur | 8,3 uur | 33 uur |
| 10 seconden | 16 minuten | 83 minuten | 5,5 uur |
| 1 seconde | 1,5 minuut | 8 minuten | 33 minuten |
| 0,1 seconden | 10 seconden | 50 seconden | 3,3 minuten |
Bij 60 seconden per backtest duren 500 TPE-iteraties 8 uur. Nog te doen, maar itereren (de doelfunctie veranderen, opnieuw opstarten) wordt duur. Bij 1 seconde — 8 minuten, en je kunt tientallen experimenten per dag uitvoeren.
Dit is precies waarom vooraf berekenen naar Parquet cache niet alleen een snelheidsoptimalisatie is, maar een uitbreiding van de ruimte van beschikbare methoden. Zonder cache ben je beperkt tot OAT of 100 GP-iteraties. Met cache — kun je je 2000 CmaEs-iteraties veroorloven of een volledige multi-objective NSGA-III.
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 aanbevelingen

Wanneer OAT gebruiken
OAT is gerechtvaardigd in de volgende gevallen:
-
Verkennende analyse. Je begint net een strategie te verkennen en wilt begrijpen welke parameters het resultaat überhaupt beïnvloeden. 96 runs in 1,5 minuut — een uitstekend startpunt.
-
Additieve parameters. Voor parameters die werken op niet-overlappende deelverzamelingen van trades (sell- vs buy-richtingen, verschillende instrumenten), geeft OAT sneller een correct resultaat.
-
Zeer dure backtest. Als een enkele run 10+ minuten duurt en niet versneld kan worden, heeft OAT met 96 runs (16 uur) de voorkeur boven 500 TPE-iteraties (3,5 dagen).
Wanneer Optuna gebruiken
Optuna heeft in de meeste gevallen de voorkeur:
-
Meer dan 3 parameters. Interacties zijn vrijwel gegarandeerd — OAT zal het optimum missen.
-
Multi-timeframe strategieën. Drempels over verschillende timeframes zijn bijna altijd onderling verbonden.
-
Uiteindelijke optimalisatie. Wanneer de strategie de Monte Carlo bootstrap heeft doorstaan en je vertrouwen hebt in de robuustheid ervan — zal Optuna de beste parameters vinden.
-
Multi-objective problemen. PnL vs MaxDD vs handelstijd — OAT kan dit probleem principieel niet oplossen.
Hybride aanpak: OAT voor additief + Optuna voor gekoppeld
Je hoeft niet te kiezen tussen OAT en Optuna — het is beter om ze te combineren:
-
Classificeer parameters. Verdeel ze in additief (onafhankelijk) en gekoppeld (interactief). Voorbeeld voor 12 scheidingsparameters:
- Additief:
htf_entry_sell<->htf_entry_buy,mtf_entry_sell<->mtf_entry_buy,ltf_entry_sell<->ltf_entry_buy(sell/buy — verschillende richtingen, werken op niet-overlappende trades) - Gekoppelde groep sell:
htf_entry_sell,mtf_entry_sell,ltf_entry_sell(filterketen: HTF -> MTF -> LTF voor sell-signalen) - Gekoppelde groep buy:
htf_entry_buy,mtf_entry_buy,ltf_entry_buy
- Additief:
-
OAT voor additief. Optimaliseer sell- en buy-groepen onafhankelijk. Als sell-parameters geen invloed hebben op buy-trades — geeft OAT binnen enkele minuten een correct resultaat.
-
Optuna voor gekoppeld. Gebruik binnen elke groep (sell: 6 parameters entry+exit) TPE. 6 parameters in plaats van 12 — het budget wordt gehalveerd.
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
Volledige optimalisatiepijplijn
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
Stap 8 — walk-forward optimalisatie — is van cruciaal belang voor bescherming tegen overfitting. Meer hierover in het aankomende artikel Walk-Forward.
Valkuilen bij optimalisatie
Overfitting. Hoe meer parameters en hoe preciezer de optimalisatie — hoe hoger het risico dat de strategie wordt aangepast aan historische data. 500 Optuna-iteraties met 12 parameters vinden een combinatie die perfect werkt op de trainingsset, maar nutteloos is op nieuwe data.
Bescherming:
- Splits data in train/test (70/30)
- Gebruik Monte Carlo bootstrap om de stabiliteit te beoordelen
- Valideer via walk-forward
- Geef de voorkeur aan oplossingen op plateaus (meer hierover in Plateauanalyse)
Probleem van meervoudige vergelijkingen. Als je 500 combinaties test, neemt de kans toe dat je toevallig een "goed" resultaat vindt. Bonferroni-correctie of FDR-controle (False Discovery Rate) helpen, maar de eenvoudigere aanpak is out-of-sample validatie.
Onvoldoende budget. TPE met 50 iteraties voor 12 parameters is te weinig. De eerste 20 iteraties zijn willekeurig (startup), waardoor er slechts 30 overblijven voor modellering. Minimaal budget: iteraties voor 12 parameters, aanbevolen: .
Freqtrade: hoe het werkt in een productieframework

Freqtrade — een van de populaire algotrading-frameworks — gebruikt Optuna onder de motorkap via de Hyperopt-module. De ervaring ervan bevestigt onze aanbevelingen:
- Samplers: TPE (standaard), GP, CmaEs, NSGA-II, QMC — allemaal beschikbaar via configuratie
- Loss-functies: 12 ingebouwde loss-functies, waaronder ShortTradeDurHyperOptLoss, SharpeHyperOptLoss, MaxDrawDownHyperOptLoss
- Multi-objective: ondersteuning voor NSGA-II en NSGA-III voor gelijktijdige optimalisatie van meerdere metrieken
- Aangepaste samplers: mogelijkheid om elke Optuna-compatibele sampler aan te sluiten
Een belangrijke les uit het Freqtrade-ecosysteem: ingebouwde loss-functies dekken typische scenario's, maar voor serieuze optimalisatie heb je een aangepaste doelfunctie nodig die rekening houdt met de specifieke kenmerken van je strategie — actieve tijd, fundingkosten, adaptieve drill-down voor nauwkeurige fill-simulatie.
Conclusie

Coordinate descent (OAT) is een snelle en intuïtieve methode. Voor 12 parameters heeft het slechts 96 runs nodig en is het klaar in anderhalve minuut. Maar het is blind voor parameterinteracties — en bij multi-timeframe strategieën zijn interacties bijna altijd aanwezig.
Bayesiaanse optimalisatie via Optuna (TPE, GP, CmaEs) verkent de parameterruimte als geheel. 500 iteraties in 8 minuten — met een vooraf berekende Parquet cache — vinden combinaties die onzichtbaar zijn voor OAT.
Multi-objective optimalisatie (NSGA-III) verandert het probleem "maximaliseer PnL" in het probleem "bouw een Pareto-front van PnL vs MaxDD" — en levert een verzameling oplossingen met verschillende risico-rendementsafwegingen.
Maar optimalisatie is slechts een deel van de pijplijn. De gevonden parameters moeten worden gevalideerd via Monte Carlo bootstrap, gecorrigeerd voor funding rates, herberekend rekening houdend met actieve tijd, en doorlopen via walk-forward validatie. Meer hierover in de komende artikelen van de serie.
Nuttige 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)
Citatie
@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.}
}
Auteurs
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.