← Terug naar artikelen
March 11, 2026
5 min leestijd

Coordinate Descent vs Bayesiaanse Optimalisatie: Wie Vindt Betere Parameters

#algotrading
#backtest
#optimization
#Optuna
#TPE
#Bayesian optimization
#coordinate descent
#hyperparameters

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

The curse of dimensionality: exponential growth of the search space

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 92=819^2 = 81 runs — volkomen haalbaar. Voor drie: 93=7299^3 = 729 — nog te doen.

Maar voor een echte strategie met 12 parameters:

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

Tweehonderdtweeëntachtig miljard runs. Zelfs als een enkele backtest 1 seconde duurt (wat al optimistisch is), zou uitputtend zoeken duren:

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

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

Parameter space exploration: OAT vs Bayesian optimization

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:

NOAT=K×N=12×8=96 runsN_{OAT} = K \times N = 12 \times 8 = 96 \text{ runs}

Vergelijk dat met 282×109282 \times 10^9 voor grid search. OAT is lineair: O(KN)O(K \cdot N) in plaats van O(NK)O(N^K). 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_sell en htf_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_target en be_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_sell en mtf_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_frac en tp_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 f(θ1,θ2,,θK)f(\theta_1, \theta_2, \ldots, \theta_K) de doelfunctie zijn (PnL). OAT vindt een punt waar:

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

Maar dit is een noodzakelijke, geen voldoende voorwaarde voor een globaal optimum. Als de Hessiaanse matrix Hij=2fθiθjH_{ij} = \frac{\partial^2 f}{\partial \theta_i \partial \theta_j} significante niet-diagonale elementen heeft — houdt OAT geen rekening met de kruisafgeleiden 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j} wanneer iji \neq j.

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

Bayesian optimization: surrogate model of the objective function

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:

  1. Kies enkele willekeurige punten, evalueer de doelfunctie
  2. Bouw een surrogaatmodel (benadert f(θ)f(\theta) op basis van geobserveerde punten)
  3. Vind het punt met maximale verwachte verbetering (acquisitiefunctie)
  4. Evalueer de doelfunctie op dat punt
  5. Werk het surrogaatmodel bij
  6. 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 sampler: modeling good and bad parameter distributions

TPE is de standaard sampler in Optuna. In plaats van f(θ)f(\theta) direct te modelleren, modelleert TPE twee verdelingen:

  • l(θ)l(\theta) — verdeling van parameters waar de doelfunctie beter is dan drempelwaarde yy^*
  • g(θ)g(\theta) — verdeling van parameters waar de doelfunctie slechter is dan drempelwaarde yy^*

De acquisitiefunctie van TPE — de verhouding:

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

TPE selecteert punten waar l(θ)l(\theta) groot is (parameters vergelijkbaar met "goede") en g(θ)g(\theta) 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 f(θ)f(\theta) als een multivariaat normaal proces en levert niet alleen een waardevoorspelling, maar ook onzekerheid op elk punt.

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

waarbij m(θ)m(\theta) het gemiddelde is, k(θ,θ)k(\theta, \theta') 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

Optuna optimization framework: iterative parameter search

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

T500=500×1s8 minutenT_{500} = 500 \times 1\text{s} \approx 8 \text{ minuten}

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

Sampler convergence comparison over iterations

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: O(n3)O(n^3) in aantal iteraties — traag wanneer n>200n > 200
  • 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 n>200n > 200
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

Pareto front: tradeoff between PnL and maximum drawdown

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:

maxθ  PnL(θ)onder de voorwaardeMaxDD(θ)min\max_{\theta} \; \text{PnL}(\theta) \quad \text{onder de voorwaarde} \quad \text{MaxDD}(\theta) \to \min

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

Contour plots: visualizing parameter interactions and plateaus

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

Precomputed Parquet cache: accelerating backtests from hours to seconds

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

Hybrid approach: combining OAT and Bayesian optimization

Wanneer OAT gebruiken

OAT is gerechtvaardigd in de volgende gevallen:

  1. 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.

  2. Additieve parameters. Voor parameters die werken op niet-overlappende deelverzamelingen van trades (sell- vs buy-richtingen, verschillende instrumenten), geeft OAT sneller een correct resultaat.

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

  1. Meer dan 3 parameters. Interacties zijn vrijwel gegarandeerd — OAT zal het optimum missen.

  2. Multi-timeframe strategieën. Drempels over verschillende timeframes zijn bijna altijd onderling verbonden.

  3. 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.

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

  1. 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
  2. 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.

  3. 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: 10×K=12010 \times K = 120 iteraties voor 12 parameters, aanbevolen: 3050×K30\text{--}50 \times K.

Freqtrade: hoe het werkt in een productieframework

Freqtrade: automated trading framework with Optuna integration

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

Complete optimization pipeline: from data to validated parameters

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

  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)

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.}
}
Disclaimer: De informatie in dit artikel is uitsluitend bedoeld voor educatieve en informatieve doeleinden en vormt geen financieel, beleggings- of handelsadvies. Het handelen in cryptovaluta brengt een aanzienlijk risico op verlies met zich mee.

Auteurs

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

Blijf de markt voor

Abonneer je op onze nieuwsbrief voor exclusieve AI-handelsinzichten, marktanalyses en platformupdates.

We respecteren je privacy. Je kunt je op elk moment afmelden.