Descente de Coordonnées vs Optimisation Bayésienne : Qui Trouve les Meilleurs Paramètres
Ceci est le cinquième article de la série "Backtests Sans Illusions". Dans les articles précédents, nous avons abordé l'asymétrie perte-profit, le bootstrap Monte Carlo, l'impact des funding rates, et le cache Parquet pour des backtests plus rapides. Parlons maintenant du processus de recherche des paramètres optimaux d'une stratégie — une tâche où l'intuition échoue le plus souvent.
Vous avez une stratégie avec 12 paramètres. Chaque paramètre prend ~9 valeurs. Vous voulez trouver la combinaison qui maximise le PnL avec un drawdown limité. Comment faites-vous ?
Si votre réponse est "je parcours toutes les combinaisons" — vous avez un problème. Si votre réponse est "je change un paramètre à la fois" — vous avez un autre problème. Cet article traite des problèmes qui se cachent derrière chaque approche et de la façon de les résoudre.
Pourquoi la recherche exhaustive est impossible

La malédiction de la dimensionnalité
La recherche exhaustive (grid search) teste chaque combinaison de valeurs pour chaque paramètre. Pour deux paramètres avec 9 valeurs, cela fait exécutions — parfaitement faisable. Pour trois : — tolérable.
Mais pour une stratégie réelle avec 12 paramètres :
Deux cent quatre-vingt-deux milliards d'exécutions. Même si un seul backtest prend 1 seconde (ce qui est déjà optimiste), la recherche exhaustive prendrait :
C'est une croissance exponentielle : chaque nouveau paramètre multiplie l'espace de recherche par 9. Ajoutez un 13e paramètre — et au lieu de 9 000 ans il en faut 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
Même avec précalcul
Dans l'article sur le cache Parquet, nous avons montré comment le précalcul des timeframes et des indicateurs accélère un backtest unique à ~1 seconde. Mais même à 0,1 seconde par exécution, la recherche exhaustive de 12 paramètres nécessiterait 895 ans. Le précalcul aide, mais ne résout pas le problème fondamental de la croissance exponentielle.
Nous avons besoin de méthodes qui explorent l'espace des paramètres plus intelligemment que la recherche exhaustive.
Descente de coordonnées et OAT : rapide mais aveugle

Deux variantes de la même idée
Il existe deux approches connexes — toutes deux optimisent un paramètre à la fois, mais diffèrent par le nombre de passes :
Balayage OAT (One-at-a-Time) — une seule passe à travers tous les paramètres. On parcourt les valeurs du premier paramètre, on fixe le meilleur, on passe au second — et ainsi de suite. Une seule fois. Rapide et peu coûteux.
Descente de coordonnées — multi-passes. Après avoir optimisé le dernier paramètre, on revient au premier et on vérifie si l'optimum a changé (puisque le contexte a changé — les valeurs des autres paramètres sont désormais différentes). Les tours se répètent jusqu'à convergence. Plus coûteux, mais plus précis — chaque tour peut affiner la solution.
En pratique, pour les backtests, OAT est utilisé plus souvent : une seule passe à travers 12 paramètres — 96 exécutions. La descente de coordonnées avec 3-5 tours — 300-500 exécutions, ce qui est déjà comparable à Optuna, mais sans ses avantages.
Pour 12 paramètres avec ~8 valeurs chacun :
Comparez avec pour la grid search. OAT est linéaire : au lieu de . C'est à la fois son principal avantage et son principal problème.
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
Quelle métrique choisir pour l'optimisation ? Plutôt que le PnL brut ou le PnL@MaxLev, il est recommandé d'utiliser l'effective score — PnL par temps actif extrapolé à un an. Cette métrique tient compte du temps en position et permet une comparaison correcte de stratégies ayant des fréquences de trading différentes.
Le point aveugle : les interactions entre paramètres
OAT suppose que l'effet de chaque paramètre est additif — c'est-à-dire que la valeur optimale d'un paramètre ne dépend pas des valeurs des autres. Cette hypothèse tient pour certains paramètres, mais se rompt pour les paramètres couplés.
Paramètres additifs vs couplés
Avant d'optimiser — il est utile de classifier les paramètres :
Additifs (indépendants) — la valeur optimale de l'un ne dépend pas de l'autre. Ils peuvent être optimisés un par un à moindre coût :
htf_entry_sellethtf_entry_buy— seuils d'entrée pour des directions différentes (vente/achat) sur le même timeframe. Le seuil de vente filtre les signaux short, le seuil d'achat — les long. Ils opèrent sur des sous-ensembles de trades non chevauchants.tp_targetetbe_trigger— take-profit et breakeven, s'ils ne créent pas de conditions de sortie contradictoires.
Couplés (interactifs) — la valeur optimale de l'un dépend de l'autre. Une optimisation conjointe est nécessaire :
htf_entry_selletmtf_entry_sell— seuils pour la même direction (vente) sur des timeframes différents. HTF détermine quels signaux atteignent MTF, et le seuil MTF détermine l'efficacité du filtrage. L'optimum de HTF se déplace lorsque MTF change.ltf_entry_sell,mtf_entry_sell,htf_entry_sell— toute la chaîne de seuils pour une direction.partial_fracettp_target— la taille de la clôture partielle dépend du niveau de TP.
Approche pratique : optimiser d'abord les paramètres additifs à moindre coût via OAT. Puis optimiser les groupes couplés via Optuna. Cela réduit le budget : au lieu de 12 paramètres dans Optuna, on n'en envoie que 6-8 couplés, le reste étant déjà fixé.
Exemple : comment OAT rate une interaction
Considérons deux seuils couplés :
htf_entry_sell— seuil sur le timeframe supérieur (direction vente)mtf_entry_sell— seuil sur le timeframe intermédiaire (direction vente)
OAT fixe mtf_entry_sell = 0.01 (valeur initiale) et parcourt htf_entry_sell. Trouve la meilleure valeur : htf_entry_sell = 0.02. La fixe et passe au paramètre suivant — ne revient jamais en arrière.
Voici ce qu'OAT a raté :
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% |
La combinaison (0.03, 0.02) donne un PnL de +51%, mais OAT ne l'envisagera jamais car, avec mtf_entry_sell = 0.01 fixé, la valeur htf_entry_sell = 0.03 ne donne que +35%. OAT s'est "coincé" dans l'optimum local (0.02, 0.01) et ne peut pas voir l'optimum global (0.03, 0.02).
C'est un problème classique : si le paysage de la fonction objectif contient des crêtes diagonales (lorsque l'optimum d'un paramètre se déplace à mesure qu'un autre change), OAT les rate.
Formalisation du problème
Soit la fonction objectif (PnL). OAT trouve un point où :
Mais c'est une condition nécessaire, pas suffisante, pour un optimum global. Si la matrice hessienne possède des éléments hors diagonale significatifs — OAT ne tient pas compte des dérivées croisées lorsque .
Pour les paramètres couplés (seuils de la même direction sur plusieurs timeframes) — les interactions sont la règle, pas l'exception. Le seuil d'entrée sur le timeframe supérieur détermine quels signaux atteignent le timeframe intermédiaire, et le seuil de ce dernier détermine l'efficacité du filtrage sur le timeframe inférieur. Pour les paramètres additifs (directions différentes, filtres indépendants), les dérivées croisées sont proches de zéro — et OAT fonctionne bien.
Optimisation bayésienne : recherche intelligente

L'idée
Au lieu d'une énumération aveugle ou d'une recherche gloutonne, l'optimisation bayésienne construit un modèle de substitution de la fonction objectif et, à chaque étape, sélectionne le point où l'amélioration attendue est maximale.
Algorithme :
- Choisir plusieurs points aléatoires, évaluer la fonction objectif
- Construire un modèle de substitution (approxime à partir des points observés)
- Trouver le point avec l'amélioration attendue maximale (fonction d'acquisition)
- Évaluer la fonction objectif en ce point
- Mettre à jour le modèle de substitution
- Répéter les étapes 3-5
La différence clé avec OAT : l'optimisation bayésienne considère tous les paramètres simultanément et peut explorer les crêtes diagonales dans l'espace des paramètres.
TPE (Tree-structured Parzen Estimator)

TPE est le sampler par défaut dans Optuna. Au lieu de modéliser directement, TPE modélise deux distributions :
- — distribution des paramètres où la fonction objectif est meilleure que le seuil
- — distribution des paramètres où la fonction objectif est pire que le seuil
La fonction d'acquisition de TPE — le rapport :
TPE sélectionne les points où est grand (paramètres similaires aux "bons") et est petit (paramètres non similaires aux "mauvais").
Pourquoi TPE convient aux backtests :
- Gère les dépendances conditionnelles entre paramètres
- Ne nécessite pas la continuité de la fonction objectif
- Efficace avec des budgets modérés (100-1000 itérations)
- Prend en charge les paramètres catégoriels et discrets
Processus gaussien (GP)
Une alternative à TPE — le processus gaussien. GP modélise comme un processus normal multivarié et fournit non seulement une prédiction de valeur, mais aussi l'incertitude en chaque point.
où est la moyenne, est la fonction de covariance (noyau).
GP fonctionne bien lorsque :
- il y a peu de paramètres (jusqu'à 10-15)
- la fonction objectif est lisse
- chaque exécution est coûteuse (minutes, heures)
Pour les backtests avec un cache Parquet précalculé, où une seule exécution prend ~1 seconde, TPE est généralement préféré : il construit le modèle plus rapidement et se met mieux à l'échelle pour 500+ itérations.
Intégration pratique avec Optuna

Exemple complet fonctionnel
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)}")
À ~1 seconde par backtest (avec cache précalculé) :
Huit minutes contre 8 950 ans de recherche exhaustive. Et TPE, en 500 itérations, trouve des combinaisons qu'OAT rate en 96, car il explore l'espace des paramètres simultanément plutôt qu'un axe à la fois.
Sauvegarder et reprendre une étude
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)
Ajout de contraintes
Toutes les combinaisons de paramètres ne sont pas valides. Par exemple, le seuil de sortie ne doit pas dépasser le seuil d'entrée :
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"]
Comparaison des samplers

Optuna prend en charge plusieurs samplers. Chacun a ses propres atouts.
TPESampler (par défaut)
sampler = optuna.samplers.TPESampler(
n_startup_trials=20, # random trials before modeling begins
seed=42,
)
- Principe : Tree-structured Parzen Estimator
- Atouts : bon pour les types de paramètres mixtes, s'étend à 1000+ itérations
- Faiblesses : peut être moins efficace avec de fortes interactions entre paramètres
- Quand l'utiliser : par défaut, s'il n'y a pas de raison d'en choisir un autre
CmaEsSampler
sampler = optuna.samplers.CmaEsSampler(seed=42)
- Principe : Covariance Matrix Adaptation Evolution Strategy — un algorithme évolutionnaire qui adapte la matrice de covariance
- Atouts : excellent pour trouver des interactions entre paramètres continus, tient compte des corrélations
- Faiblesses : ne prend pas en charge les paramètres catégoriels, nécessite plus d'itérations pour l'initialisation
- Quand l'utiliser : si tous les paramètres sont continus et que vous soupçonnez de fortes interactions
GPSampler
sampler = optuna.samplers.GPSampler(seed=42)
- Principe : Processus gaussien avec fonction d'acquisition
- Atouts : meilleure efficacité d'échantillonnage (moins d'itérations pour un bon résultat), fournit des estimations d'incertitude
- Faiblesses : en nombre d'itérations — lent lorsque
- Quand l'utiliser : si un seul backtest est coûteux (minutes) et que le budget est limité à 100-200 itérations
RandomSampler (référence)
sampler = optuna.samplers.RandomSampler(seed=42)
- Principe : échantillonnage aléatoire uniforme
- Atouts : ne reste pas bloqué dans des optima locaux, couverture complète de l'espace
- Faiblesses : n'utilise pas les résultats précédents
- Quand l'utiliser : comme référence pour comparaison, ou pour une analyse exploratoire
QMCSampler
sampler = optuna.samplers.QMCSampler(seed=42)
- Principe : Quasi-Monte Carlo (séquences de Sobol/Halton) — remplit l'espace de manière plus uniforme qu'un sampler aléatoire
- Atouts : meilleure couverture de l'espace que RandomSampler, reproductibilité
- Faiblesses : ne s'adapte pas aux résultats
- Quand l'utiliser : pour les 50-100 premières itérations avant de passer à TPE
Tableau récapitulatif
| Sampler | Type | Interactions | Catégoriel | Budget optimal |
|---|---|---|---|---|
| TPE | Bayésien | Partielles | Oui | 100-1000 |
| CmaEs | Évolutionnaire | Oui | Non | 200-2000 |
| GP | Bayésien | Oui | Limité | 50-200 |
| Random | Aléatoire | Non | Oui | Tout (référence) |
| QMC | Quasi-aléatoire | Non | Non | 50-500 |
Benchmark pratique
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")
Résultats typiques pour une stratégie avec 12 paramètres :
| Sampler | Meilleur PnL | Trouvé à l'itération | Overhead du sampler |
|---|---|---|---|
| TPE | ~51% | ~180 | Faible |
| CmaEs | ~49% | ~250 | Moyen |
| GP | ~48% | ~90 | Élevé quand |
| Random | ~42% | ~270 | Minimal |
| QMC | ~43% | ~200 | Minimal |
TPE et CmaEs surpassent systématiquement la recherche aléatoire de 15-20% en PnL final. GP trouve de bons résultats plus tôt, mais atteint un plafond de calcul avec un grand nombre d'itérations.
Optimisation multi-objectifs : PnL vs MaxDD

Pourquoi un seul critère ne suffit pas
Maximiser le PnL sans contraintes de drawdown est la voie vers la catastrophe. Une stratégie avec un PnL de +80% et un MaxDD de -30% est, en raison de l'asymétrie perte-profit, nettement plus risquée qu'une stratégie avec un PnL de +50% et un MaxDD de -5%.
Le problème d'optimisation est en réalité multi-objectifs :
Ces objectifs sont conflictuels : des paramètres agressifs augmentent à la fois le PnL et le drawdown. La solution n'est pas un point unique, mais un front de Pareto : un ensemble de solutions où l'on ne peut améliorer une métrique sans dégrader l'autre.
NSGA-II / NSGA-III dans 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}%")
Choisir un point sur le front de Pareto
Le front de Pareto offre plusieurs solutions. Comment en choisir une ?
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
Remarque : lors du calcul du PnL à effet de levier maximal, il faut tenir compte des funding rates, sinon un effet de levier théoriquement élevé se transformera en perte sur le marché réel. De plus, le PnL final est une estimation ponctuelle, et pour évaluer la stabilité du résultat, il faut le bootstrap Monte Carlo.
Exemple : trois stratégies sur le front de Pareto
| Stratégie | PnL | MaxDD | MaxLev | PnL@MaxLev | Temps de trading |
|---|---|---|---|---|---|
| Stratégie A | ~55% | ~0.9% | ~55x | ~3025% | ~15% |
| Stratégie B | ~25% | ~0.75% | ~66x | ~1650% | ~5% |
| Stratégie C | ~300% | ~17% | ~3x | ~900% | ~45% |
La stratégie C, avec un PnL impressionnant de +300%, se révèle la moins attrayante selon le PnL@MaxLev en raison d'un drawdown élevé. La stratégie A est en tête pour le rendement net avec effet de levier, mais en tenant compte du PnL par temps actif, la stratégie B peut être préférable — 95% du temps libre peut être occupé par d'autres stratégies.
Graphiques de contour et importance des paramètres

Visualisation du paysage
Après l'optimisation — la visualisation. Optuna propose des outils intégrés :
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()
Graphique de contour : lire les interactions
Un graphique de contour construit une coupe bidimensionnelle de la fonction objectif pour une paire de paramètres. Si les isolignes sont parallèles à l'un des axes — les paramètres n'interagissent pas, et OAT aurait trouvé le même optimum. Si les isolignes sont diagonales — il y a une interaction, et OAT la ratera.
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")
Si un graphique de contour montre un plateau — une région où la fonction objectif varie peu — c'est bon signe. Un plateau signifie que le résultat est robuste face à de petites déviations de paramètres. Plus sur l'analyse des plateaux et son lien avec l'overfitting — dans le prochain article Analyse des plateaux.
Importance des paramètres
importance = optuna.importance.get_param_importances(study)
for param, imp in importance.items():
print(f"{param:20s}: {imp:.4f}")
Sortie typique :
htf_entry_sell : 0.2841
mtf_entry_sell : 0.2103
ltf_entry_sell : 0.1567
trail_pct : 0.1204
htf_entry_buy : 0.0892
...
Les paramètres ayant une importance < 0,01 peuvent être fixés à leur valeur par défaut — cela réduit la dimensionnalité du problème et accélère l'optimisation. Mais attention : une faible importance peut aussi signifier que le paramètre n'est important qu'en interaction avec d'autres. À vérifier via les graphiques de contour.
Cache précalculé : pourquoi 1 seconde par backtest change tout

La vitesse d'un seul backtest détermine quelle méthode d'optimisation vous pouvez vous permettre.
| Temps de backtest | 96 OAT | 500 TPE | 2000 CmaEs |
|---|---|---|---|
| 60 secondes | 1,6 heure | 8,3 heures | 33 heures |
| 10 secondes | 16 minutes | 83 minutes | 5,5 heures |
| 1 seconde | 1,5 minute | 8 minutes | 33 minutes |
| 0,1 seconde | 10 secondes | 50 secondes | 3,3 minutes |
À 60 secondes par backtest, 500 itérations TPE prennent 8 heures. Encore tolérable, mais itérer (changer la fonction objectif, redémarrer) devient coûteux. À 1 seconde — 8 minutes, et vous pouvez lancer des dizaines d'expériences par jour.
C'est précisément pourquoi le précalcul dans le cache Parquet n'est pas seulement une optimisation de vitesse, mais une extension de l'espace des méthodes disponibles. Sans cache, vous êtes limité à OAT ou à 100 itérations GP. Avec cache — vous pouvez vous permettre 2000 itérations CmaEs ou un NSGA-III multi-objectifs complet.
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
Recommandations pratiques

Quand utiliser OAT
OAT est justifié dans les cas suivants :
-
Analyse exploratoire. Vous commencez tout juste à explorer une stratégie et voulez comprendre quels paramètres influencent le résultat. 96 exécutions en 1,5 minute — un excellent point de départ.
-
Paramètres additifs. Pour les paramètres qui opèrent sur des sous-ensembles de trades non chevauchants (directions vente vs achat, instruments différents), OAT donnera un résultat correct plus rapidement.
-
Backtest très coûteux. Si une seule exécution prend 10+ minutes et ne peut pas être accélérée, OAT avec 96 exécutions (16 heures) est préférable à 500 itérations TPE (3,5 jours).
Quand utiliser Optuna
Optuna est préférable dans la plupart des cas :
-
Plus de 3 paramètres. Les interactions sont pratiquement garanties — OAT ratera l'optimum.
-
Stratégies multi-timeframe. Les seuils sur différents timeframes sont presque toujours interconnectés.
-
Optimisation finale. Lorsque la stratégie a passé le bootstrap Monte Carlo et que vous êtes confiant dans sa robustesse — Optuna trouvera les meilleurs paramètres.
-
Problèmes multi-objectifs. PnL vs MaxDD vs temps de trading — OAT ne peut pas résoudre ce problème par principe.
Approche hybride : OAT pour l'additif + Optuna pour le couplé
Vous n'êtes pas obligé de choisir entre OAT et Optuna — il vaut mieux les combiner :
-
Classifier les paramètres. Diviser en additifs (indépendants) et couplés (interactifs). Exemple pour 12 paramètres de séparation :
- Additifs :
htf_entry_sell<->htf_entry_buy,mtf_entry_sell<->mtf_entry_buy,ltf_entry_sell<->ltf_entry_buy(vente/achat — directions différentes, opèrent sur des trades non chevauchants) - Groupe couplé vente :
htf_entry_sell,mtf_entry_sell,ltf_entry_sell(chaîne de filtrage : HTF -> MTF -> LTF pour les signaux de vente) - Groupe couplé achat :
htf_entry_buy,mtf_entry_buy,ltf_entry_buy
- Additifs :
-
OAT pour l'additif. Optimiser les groupes vente et achat indépendamment. Si les paramètres de vente n'affectent pas les trades d'achat — OAT donnera un résultat correct en quelques minutes.
-
Optuna pour le couplé. Au sein de chaque groupe (vente : 6 paramètres entrée+sortie), utiliser TPE. 6 paramètres au lieu de 12 — le budget est réduit de moitié.
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
Pipeline d'optimisation complet
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
L'étape 8 — l'optimisation walk-forward — est d'une importance critique pour la protection contre l'overfitting. Plus à ce sujet dans le prochain article Walk-Forward.
Pièges de l'optimisation
Overfitting. Plus il y a de paramètres et plus l'optimisation est précise — plus le risque d'ajuster la stratégie aux données historiques est élevé. 500 itérations Optuna avec 12 paramètres trouveront une combinaison qui fonctionne parfaitement sur l'ensemble d'entraînement, mais qui est inutile sur de nouvelles données.
Protection :
- Diviser les données en train/test (70/30)
- Utiliser le bootstrap Monte Carlo pour évaluer la stabilité
- Valider par walk-forward
- Préférer les solutions sur des plateaux (plus à ce sujet dans Analyse des plateaux)
Problème des comparaisons multiples. Si vous testez 500 combinaisons, la probabilité de trouver aléatoirement un "bon" résultat augmente. La correction de Bonferroni ou le contrôle du FDR (False Discovery Rate) aident, mais l'approche la plus simple est la validation out-of-sample.
Budget insuffisant. TPE avec 50 itérations pour 12 paramètres, c'est trop peu. Les 20 premières itérations sont aléatoires (startup), ne laissant que 30 pour la modélisation. Budget minimal : itérations pour 12 paramètres, recommandé : .
Freqtrade : comment ça fonctionne dans un framework de production

Freqtrade — l'un des frameworks d'algotrading populaires — utilise Optuna en interne via le module Hyperopt. Son expérience confirme nos recommandations :
- Samplers : TPE (par défaut), GP, CmaEs, NSGA-II, QMC — tous disponibles via configuration
- Fonctions de perte : 12 fonctions de perte intégrées, dont ShortTradeDurHyperOptLoss, SharpeHyperOptLoss, MaxDrawDownHyperOptLoss
- Multi-objectifs : prise en charge de NSGA-II et NSGA-III pour l'optimisation simultanée de plusieurs métriques
- Samplers personnalisés : possibilité de brancher tout sampler compatible avec Optuna
Une leçon clé de l'écosystème Freqtrade : les fonctions de perte intégrées couvrent les scénarios typiques, mais pour une optimisation sérieuse il faut une fonction objectif personnalisée qui tient compte des spécificités de votre stratégie — temps actif, coûts de funding, drill-down adaptatif pour une simulation précise des exécutions.
Conclusion

La descente de coordonnées (OAT) est une méthode rapide et intuitive. Pour 12 paramètres, elle ne nécessite que 96 exécutions et se termine en une minute et demie. Mais elle est aveugle aux interactions entre paramètres — et dans les stratégies multi-timeframe, les interactions sont presque toujours présentes.
L'optimisation bayésienne via Optuna (TPE, GP, CmaEs) explore l'espace des paramètres dans son ensemble. 500 itérations en 8 minutes — avec un cache Parquet précalculé — trouvent des combinaisons invisibles pour OAT.
L'optimisation multi-objectifs (NSGA-III) transforme le problème "maximiser le PnL" en problème "construire un front de Pareto de PnL vs MaxDD" — et fournit un ensemble de solutions avec différents compromis risque-rendement.
Mais l'optimisation n'est qu'une partie du pipeline. Les paramètres trouvés doivent être validés par le bootstrap Monte Carlo, corrigés pour les funding rates, recalculés en tenant compte du temps actif, et passés par une validation walk-forward. Plus à ce sujet dans les prochains articles de la série.
Liens utiles
- 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)
Citation
@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.