← Volver a los artículos
March 11, 2026
5 min de lectura

Descenso por Coordenadas vs. Optimización Bayesiana: Cuál Encuentra Mejores Parámetros

Descenso por Coordenadas vs. Optimización Bayesiana: Cuál Encuentra Mejores Parámetros
#algotrading
#backtest
#optimization
#Optuna
#TPE
#Bayesian optimization
#coordinate descent
#hyperparameters

Este es el quinto artículo de la serie "Backtests Sin Ilusiones". En artículos anteriores tratamos la asimetría entre pérdida y ganancia, el bootstrap de Monte Carlo, el impacto de las funding rates y la caché Parquet para backtests más rápidos. Ahora hablemos del proceso de encontrar los parámetros óptimos de una estrategia — una tarea donde la intuición falla con más frecuencia.

Tienes una estrategia con 12 parámetros. Cada parámetro toma ~9 valores. Quieres encontrar la combinación que maximice el PnL con un drawdown limitado. ¿Cómo lo haces?

Si tu respuesta es "recorro todas las combinaciones" — tienes un problema. Si tu respuesta es "cambio un parámetro a la vez" — tienes otro problema distinto. Este artículo trata sobre qué problemas acechan detrás de cada enfoque y cómo resolverlos.

Por qué la búsqueda exhaustiva es imposible

The curse of dimensionality: exponential growth of the search space

La maldición de la dimensionalidad

La búsqueda exhaustiva (grid search) prueba todas las combinaciones de valores para cada parámetro. Para dos parámetros con 9 valores, son 92=819^2 = 81 ejecuciones — perfectamente factible. Para tres: 93=7299^3 = 729 — tolerable.

Pero para una estrategia real con 12 parámetros:

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

Doscientos ochenta y dos mil millones de ejecuciones. Incluso si un solo backtest tarda 1 segundo (lo cual ya es optimista), la búsqueda exhaustiva tardaría:

T=282×1093600×24×3658,950 an˜osT = \frac{282 \times 10^{9}}{3600 \times 24 \times 365} \approx 8{,}950 \text{ años}

Esto es crecimiento exponencial: cada nuevo parámetro multiplica el espacio de búsqueda por 9. Añade un decimotercer parámetro — y en lugar de 9.000 años necesitas 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

Incluso con precomputación

En el artículo sobre la caché Parquet mostramos cómo precomputar timeframes e indicadores acelera un solo backtest hasta ~1 segundo. Pero incluso a 0,1 segundos por ejecución, la búsqueda exhaustiva de 12 parámetros requeriría 895 años. La precomputación ayuda, pero no resuelve el problema fundamental del crecimiento exponencial.

Necesitamos métodos que exploren el espacio de parámetros de forma más inteligente que la búsqueda exhaustiva.

Descenso por coordenadas y OAT: Rápido pero ciego

Parameter space exploration: OAT vs Bayesian optimization

Dos variantes de la misma idea

Hay dos enfoques relacionados — ambos optimizan un parámetro a la vez, pero difieren en el número de pasadas:

Barrido OAT (One-at-a-Time) — una única pasada por todos los parámetros. Se recorren los valores del primer parámetro, se fija el mejor, se pasa al segundo — y así sucesivamente. Una sola vez. Rápido y barato.

Descenso por coordenadas — multipasada. Después de optimizar el último parámetro, se vuelve al primero y se comprueba si el óptimo ha cambiado (ya que el contexto cambió — otros valores de parámetros ahora son distintos). Se repiten rondas hasta la convergencia. Más costoso, pero más preciso — cada ronda puede refinar la solución.

En la práctica, para backtests se usa más a menudo OAT: una única pasada por 12 parámetros — 96 ejecuciones. El descenso por coordenadas con 3-5 rondas — 300-500 ejecuciones, lo cual ya es comparable a Optuna, pero sin sus ventajas.

Para 12 parámetros con ~8 valores cada uno:

NOAT=K×N=12×8=96 ejecucionesN_{OAT} = K \times N = 12 \times 8 = 96 \text{ ejecuciones}

Compáralo con 282×109282 \times 10^9 para grid search. OAT es lineal: O(KN)O(K \cdot N) en lugar de O(NK)O(N^K). Esta es tanto su principal ventaja como su principal problema.

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

¿Qué métrica elegir para la optimización? En lugar del PnL bruto o del PnL@MaxLev, se recomienda usar el effective score — PnL por tiempo activo extrapolado a un año. Esta métrica tiene en cuenta el tiempo en posición y permite una comparación correcta entre estrategias con distinta frecuencia de trading.

El punto ciego: interacciones entre parámetros

OAT asume que el efecto de cada parámetro es aditivo — es decir, el valor óptimo de un parámetro no depende de los valores de los demás. Este supuesto se cumple para algunos parámetros, pero se rompe para los acoplados.

Parámetros aditivos vs. acoplados

Antes de optimizar — es útil clasificar los parámetros:

Aditivos (independientes) — el valor óptimo de uno no depende del otro. Pueden optimizarse uno a uno de forma barata:

  • htf_entry_sell y htf_entry_buy — umbrales de entrada para direcciones distintas (venta/compra) en el mismo timeframe. El umbral de venta filtra señales cortas, el de compra — largas. Operan sobre subconjuntos de operaciones que no se solapan.
  • tp_target y be_trigger — take-profit y breakeven, si no crean condiciones de salida contradictorias.

Acoplados (interactivos) — el valor óptimo de uno depende del otro. Se necesita optimización conjunta:

  • htf_entry_sell y mtf_entry_sell — umbrales para la misma dirección (venta) en distintos timeframes. HTF determina qué señales llegan a MTF, y el umbral de MTF determina la eficacia del filtrado. El óptimo de HTF se desplaza cuando cambia MTF.
  • ltf_entry_sell, mtf_entry_sell, htf_entry_sell — toda la cadena de umbrales para una dirección.
  • partial_frac y tp_target — el tamaño del cierre parcial depende del nivel de TP.

Enfoque práctico: primero optimizar de forma barata los parámetros aditivos vía OAT. Luego optimizar los grupos acoplados vía Optuna. Esto reduce el presupuesto: en lugar de 12 parámetros en Optuna, enviamos solo 6-8 acoplados, mientras el resto ya está fijado.

Ejemplo: cómo OAT pasa por alto una interacción

Consideremos dos umbrales acoplados:

  • htf_entry_sell — umbral en el timeframe superior (dirección venta)
  • mtf_entry_sell — umbral en el timeframe medio (dirección venta)

OAT fija mtf_entry_sell = 0.01 (valor inicial) y recorre htf_entry_sell. Encuentra el mejor valor: htf_entry_sell = 0.02. Lo fija y pasa al siguiente parámetro — nunca vuelve.

Esto es lo que OAT pasó por alto:

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 combinación (0.03, 0.02) produce un PnL de +51%, pero OAT nunca la considerará porque, con mtf_entry_sell = 0.01 fijo, el valor htf_entry_sell = 0.03 solo produce +35%. OAT se quedó "atascado" en el óptimo local (0.02, 0.01) y no puede ver el óptimo global (0.03, 0.02).

Este es un problema clásico: si el paisaje de la función objetivo contiene crestas diagonales (cuando el óptimo de un parámetro se desplaza a medida que cambia otro), OAT las pasa por alto.

Formalizando el problema

Sea f(θ1,θ2,,θK)f(\theta_1, \theta_2, \ldots, \theta_K) la función objetivo (PnL). OAT encuentra un punto donde:

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

Pero esta es una condición necesaria, no suficiente, para un óptimo global. Si la matriz Hessiana Hij=2fθiθjH_{ij} = \frac{\partial^2 f}{\partial \theta_i \partial \theta_j} tiene elementos fuera de la diagonal significativos — OAT no tiene en cuenta las derivadas cruzadas 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j} cuando iji \neq j.

Para parámetros acoplados (umbrales de la misma dirección en múltiples timeframes) — las interacciones son la regla, no la excepción. El umbral de entrada en el timeframe superior determina qué señales llegan al medio, y el umbral del medio determina la eficacia del filtrado en el inferior. Para parámetros aditivos (direcciones distintas, filtros independientes) las derivadas cruzadas son cercanas a cero — y OAT funciona bien.

Optimización bayesiana: búsqueda inteligente

Bayesian optimization: surrogate model of the objective function

La idea

En lugar de una enumeración ciega o una búsqueda voraz, la optimización bayesiana construye un modelo sustituto de la función objetivo y, en cada paso, selecciona el punto donde la mejora esperada es máxima.

Algoritmo:

  1. Elegir varios puntos aleatorios, evaluar la función objetivo
  2. Construir un modelo sustituto (aproxima f(θ)f(\theta) a partir de los puntos observados)
  3. Encontrar el punto con máxima mejora esperada (función de adquisición)
  4. Evaluar la función objetivo en ese punto
  5. Actualizar el modelo sustituto
  6. Repetir los pasos 3-5

La diferencia clave con OAT: la optimización bayesiana considera todos los parámetros simultáneamente y puede explorar crestas diagonales en el espacio de parámetros.

TPE (Tree-structured Parzen Estimator)

TPE sampler: modeling good and bad parameter distributions

TPE es el sampler por defecto en Optuna. En lugar de modelar f(θ)f(\theta) directamente, TPE modela dos distribuciones:

  • l(θ)l(\theta) — distribución de parámetros donde la función objetivo es mejor que el umbral yy^*
  • g(θ)g(\theta) — distribución de parámetros donde la función objetivo es peor que el umbral yy^*

La función de adquisición de TPE — el cociente:

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

TPE selecciona puntos donde l(θ)l(\theta) es grande (parámetros similares a los "buenos") y g(θ)g(\theta) es pequeño (parámetros no similares a los "malos").

Por qué TPE es adecuado para backtests:

  • Maneja dependencias condicionales entre parámetros
  • No requiere continuidad de la función objetivo
  • Eficiente con presupuestos moderados (100-1000 iteraciones)
  • Soporta parámetros categóricos y discretos

Proceso Gaussiano (GP)

Una alternativa a TPE — el Proceso Gaussiano. GP modela f(θ)f(\theta) como un proceso normal multivariante y proporciona no solo una predicción del valor, sino también la incertidumbre en cada punto.

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

donde m(θ)m(\theta) es la media, k(θ,θ)k(\theta, \theta') es la función de covarianza (kernel).

GP funciona bien cuando:

  • hay pocos parámetros (hasta 10-15)
  • la función objetivo es suave
  • cada ejecución es costosa (minutos, horas)

Para backtests con caché Parquet precomputada, donde una sola ejecución tarda ~1 segundo, generalmente se prefiere TPE: construye el modelo más rápido y escala mejor a 500+ iteraciones.

Integración práctica con Optuna

Optuna optimization framework: iterative parameter search

Ejemplo completo funcional

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)}")

A ~1 segundo por backtest (con caché precomputada):

T500=500×1s8 minutosT_{500} = 500 \times 1\text{s} \approx 8 \text{ minutos}

Ocho minutos frente a 8.950 años de búsqueda exhaustiva. Y TPE en 500 iteraciones encuentra combinaciones que OAT pasa por alto en 96, porque explora el espacio de parámetros simultáneamente en lugar de un eje a la vez.

Guardar y reanudar un estudio

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)

Añadir restricciones

No todas las combinaciones de parámetros son válidas. Por ejemplo, el umbral de salida no debería superar el umbral de entrada:

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"]

Comparación de samplers

Sampler convergence comparison over iterations

Optuna soporta varios samplers. Cada uno tiene sus propias fortalezas.

TPESampler (por defecto)

sampler = optuna.samplers.TPESampler(
    n_startup_trials=20,  # random trials before modeling begins
    seed=42,
)
  • Principio: Tree-structured Parzen Estimator
  • Fortalezas: bueno para tipos de parámetros mixtos, escala a 1000+ iteraciones
  • Debilidades: puede ser menos eficiente con interacciones fuertes entre parámetros
  • Cuándo usar: por defecto, si no hay razón para elegir otro

CmaEsSampler

sampler = optuna.samplers.CmaEsSampler(seed=42)
  • Principio: Covariance Matrix Adaptation Evolution Strategy — un algoritmo evolutivo que adapta la matriz de covarianza
  • Fortalezas: excelente para encontrar interacciones entre parámetros continuos, tiene en cuenta las correlaciones
  • Debilidades: no soporta parámetros categóricos, requiere más iteraciones para la inicialización
  • Cuándo usar: si todos los parámetros son continuos y sospechas interacciones fuertes

GPSampler

sampler = optuna.samplers.GPSampler(seed=42)
  • Principio: Proceso Gaussiano con función de adquisición
  • Fortalezas: mejor eficiencia de muestreo (menos iteraciones para un buen resultado), proporciona estimaciones de incertidumbre
  • Debilidades: O(n3)O(n^3) en el número de iteraciones — lento cuando n>200n > 200
  • Cuándo usar: si un solo backtest es costoso (minutos) y el presupuesto está limitado a 100-200 iteraciones

RandomSampler (línea base)

sampler = optuna.samplers.RandomSampler(seed=42)
  • Principio: muestreo aleatorio uniforme
  • Fortalezas: no se atasca en óptimos locales, cobertura completa del espacio
  • Debilidades: no usa resultados previos
  • Cuándo usar: como línea base para comparación, o para análisis exploratorio

QMCSampler

sampler = optuna.samplers.QMCSampler(seed=42)
  • Principio: Quasi-Monte Carlo (secuencias Sobol/Halton) — llena el espacio de forma más uniforme que un sampler aleatorio
  • Fortalezas: mejor cobertura del espacio que RandomSampler, reproducibilidad
  • Debilidades: no se adapta a los resultados
  • Cuándo usar: para las primeras 50-100 iteraciones antes de cambiar a TPE

Tabla resumen

Sampler Tipo Interacciones Categórico Presupuesto óptimo
TPE Bayesiano Parcial 100-1000
CmaEs Evolutivo No 200-2000
GP Bayesiano Limitado 50-200
Random Aleatorio No Cualquiera (línea base)
QMC Cuasi-aleatorio No No 50-500

Benchmark práctico

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

Resultados típicos para una estrategia con 12 parámetros:

Sampler Mejor PnL Encontrado en la iteración Overhead del sampler
TPE ~51% ~180 Bajo
CmaEs ~49% ~250 Medio
GP ~48% ~90 Alto cuando n>200n > 200
Random ~42% ~270 Mínimo
QMC ~43% ~200 Mínimo

TPE y CmaEs superan de forma consistente a la búsqueda aleatoria en un 15-20% en PnL final. GP encuentra buenos resultados antes, pero alcanza un techo computacional con un gran número de iteraciones.

Optimización multiobjetivo: PnL vs. MaxDD

Pareto front: tradeoff between PnL and maximum drawdown

Por qué un solo criterio no es suficiente

Maximizar el PnL sin restricciones de drawdown es el camino al desastre. Una estrategia con PnL +80% y MaxDD -30% es, debido a la asimetría entre pérdida y ganancia, significativamente más arriesgada que una estrategia con PnL +50% y MaxDD -5%.

El problema de optimización es en realidad multiobjetivo:

maxθ  PnL(θ)sujeto aMaxDD(θ)min\max_{\theta} \; \text{PnL}(\theta) \quad \text{sujeto a} \quad \text{MaxDD}(\theta) \to \min

Estos objetivos entran en conflicto: los parámetros agresivos aumentan tanto el PnL como el drawdown. La solución no es un único punto, sino un frente de Pareto: un conjunto de soluciones donde no se puede mejorar una métrica sin empeorar la otra.

NSGA-II / NSGA-III en 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}%")

Seleccionar un punto en el frente de Pareto

El frente de Pareto ofrece varias soluciones. ¿Cómo elegir una?

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

Nota: al calcular el PnL con apalancamiento máximo, hay que tener en cuenta las funding rates, de lo contrario un apalancamiento teóricamente alto se convertirá en pérdida en el mercado real. Además, el PnL final es una estimación de un solo punto, y para evaluar la estabilidad del resultado se necesita el bootstrap de Monte Carlo.

Ejemplo: tres estrategias en el frente de Pareto

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

La estrategia C, con un impresionante PnL de +300%, resulta ser la menos atractiva según el PnL@MaxLev debido al alto drawdown. La estrategia A lidera en retorno neto apalancado, pero al tener en cuenta el PnL por tiempo activo, la estrategia B puede ser preferible — el 95% del tiempo libre puede llenarse con otras estrategias.

Gráficos de contorno e importancia de parámetros

Contour plots: visualizing parameter interactions and plateaus

Visualización del paisaje

Después de la optimización — visualización. Optuna ofrece herramientas integradas:

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

Gráfico de contorno: leer interacciones

Un gráfico de contorno construye una sección transversal bidimensional de la función objetivo para un par de parámetros. Si las isolíneas son paralelas a uno de los ejes — los parámetros no interactúan, y OAT habría encontrado el mismo óptimo. Si las isolíneas son diagonales — hay interacción, y OAT la pasará por alto.

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 gráfico de contorno muestra una meseta — una región donde la función objetivo cambia poco — es una buena señal. Una meseta significa que el resultado es robusto ante pequeñas desviaciones de parámetros. Más sobre el análisis de mesetas y su relación con el overfitting — en el próximo artículo Análisis de mesetas.

Importancia de parámetros

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

Salida típica:

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

Los parámetros con importancia < 0,01 pueden fijarse en su valor por defecto — esto reduce la dimensionalidad del problema y acelera la optimización. Pero cuidado: la baja importancia también puede significar que el parámetro es importante solo en interacción con otros. Verificar mediante gráficos de contorno.

Caché precomputada: por qué 1 segundo por backtest lo cambia todo

Precomputed Parquet cache: accelerating backtests from hours to seconds

La velocidad de un solo backtest determina qué método de optimización puedes permitirte.

Tiempo de backtest 96 OAT 500 TPE 2000 CmaEs
60 segundos 1,6 horas 8,3 horas 33 horas
10 segundos 16 minutos 83 minutos 5,5 horas
1 segundo 1,5 minutos 8 minutos 33 minutos
0,1 segundos 10 segundos 50 segundos 3,3 minutos

A 60 segundos por backtest, 500 iteraciones de TPE tardan 8 horas. Todavía tolerable, pero iterar (cambiar la función objetivo, reiniciar) resulta costoso. A 1 segundo — 8 minutos, y puedes ejecutar docenas de experimentos al día.

Precisamente por esto la precomputación en caché Parquet no es solo una optimización de velocidad, sino una ampliación del espacio de métodos disponibles. Sin caché estás limitado a OAT o 100 iteraciones de GP. Con caché — puedes permitirte 2000 iteraciones de CmaEs o un NSGA-III multiobjetivo completo.

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

Recomendaciones prácticas

Hybrid approach: combining OAT and Bayesian optimization

Cuándo usar OAT

OAT está justificado en los siguientes casos:

  1. Análisis exploratorio. Recién empiezas a explorar una estrategia y quieres entender qué parámetros afectan al resultado. 96 ejecuciones en 1,5 minutos — un excelente punto de partida.

  2. Parámetros aditivos. Para parámetros que operan sobre subconjuntos de operaciones que no se solapan (direcciones venta vs. compra, instrumentos distintos), OAT dará un resultado correcto más rápido.

  3. Backtest muy costoso. Si una sola ejecución tarda 10+ minutos y no se puede acelerar, OAT con 96 ejecuciones (16 horas) es preferible a 500 iteraciones de TPE (3,5 días).

Cuándo usar Optuna

Optuna es preferible en la mayoría de los casos:

  1. Más de 3 parámetros. Las interacciones están prácticamente garantizadas — OAT pasará por alto el óptimo.

  2. Estrategias multi-timeframe. Los umbrales de distintos timeframes casi siempre están interconectados.

  3. Optimización final. Cuando la estrategia ha pasado el bootstrap de Monte Carlo y confías en su robustez — Optuna encontrará los mejores parámetros.

  4. Problemas multiobjetivo. PnL vs. MaxDD vs. tiempo de trading — OAT no puede resolver este problema en principio.

Enfoque híbrido: OAT para aditivos + Optuna para acoplados

No hay que elegir entre OAT y Optuna — es mejor combinarlos:

  1. Clasificar los parámetros. Dividirlos en aditivos (independientes) y acoplados (interactivos). Ejemplo para 12 parámetros de separación:

    • Aditivos: htf_entry_sell <-> htf_entry_buy, mtf_entry_sell <-> mtf_entry_buy, ltf_entry_sell <-> ltf_entry_buy (venta/compra — direcciones distintas, operan sobre operaciones no solapadas)
    • Grupo acoplado venta: htf_entry_sell, mtf_entry_sell, ltf_entry_sell (cadena de filtrado: HTF -> MTF -> LTF para señales de venta)
    • Grupo acoplado compra: htf_entry_buy, mtf_entry_buy, ltf_entry_buy
  2. OAT para aditivos. Optimizar los grupos de venta y compra de forma independiente. Si los parámetros de venta no afectan a las operaciones de compra — OAT dará un resultado correcto en minutos.

  3. Optuna para acoplados. Dentro de cada grupo (venta: 6 parámetros de entrada+salida) usar TPE. 6 parámetros en lugar de 12 — el presupuesto se reduce a la mitad.

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 de optimización completo

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

El paso 8 — la optimización walk-forward — es crítico para la protección contra el overfitting. Más sobre esto en el próximo artículo Walk-Forward.

Trampas de la optimización

Overfitting. Cuantos más parámetros y más precisa la optimización — mayor el riesgo de sobreajustar la estrategia a los datos históricos. 500 iteraciones de Optuna con 12 parámetros encontrarán una combinación que funciona perfectamente en el conjunto de entrenamiento, pero es inútil con datos nuevos.

Protección:

Problema de comparaciones múltiples. Si pruebas 500 combinaciones, la probabilidad de encontrar aleatoriamente un "buen" resultado crece. La corrección de Bonferroni o el control de FDR (False Discovery Rate) ayudan, pero el enfoque más simple es la validación out-of-sample.

Presupuesto insuficiente. TPE con 50 iteraciones para 12 parámetros es demasiado poco. Las primeras 20 iteraciones son aleatorias (startup), dejando solo 30 para el modelado. Presupuesto mínimo: 10×K=12010 \times K = 120 iteraciones para 12 parámetros, recomendado: 3050×K30\text{--}50 \times K.

Freqtrade: cómo funciona en un framework de producción

Freqtrade: automated trading framework with Optuna integration

Freqtrade — uno de los frameworks de algotrading populares — usa Optuna internamente a través del módulo Hyperopt. Su experiencia confirma nuestras recomendaciones:

  • Samplers: TPE (por defecto), GP, CmaEs, NSGA-II, QMC — todos disponibles mediante configuración
  • Funciones de pérdida: 12 funciones de pérdida integradas, incluyendo ShortTradeDurHyperOptLoss, SharpeHyperOptLoss, MaxDrawDownHyperOptLoss
  • Multiobjetivo: soporte para NSGA-II y NSGA-III para optimización simultánea de múltiples métricas
  • Samplers personalizados: posibilidad de conectar cualquier sampler compatible con Optuna

Una lección clave del ecosistema Freqtrade: las funciones de pérdida integradas cubren escenarios típicos, pero para una optimización seria se necesita una función objetivo personalizada que tenga en cuenta las particularidades de tu estrategia — tiempo activo, costes de funding, drill-down adaptativo para simulación precisa de ejecuciones.

Conclusión

Complete optimization pipeline: from data to validated parameters

El descenso por coordenadas (OAT) es un método rápido e intuitivo. Para 12 parámetros solo requiere 96 ejecuciones y termina en minuto y medio. Pero es ciego a las interacciones entre parámetros — y en las estrategias multi-timeframe, las interacciones casi siempre están presentes.

La optimización bayesiana mediante Optuna (TPE, GP, CmaEs) explora el espacio de parámetros como un todo. 500 iteraciones en 8 minutos — con una caché Parquet precomputada — encuentran combinaciones invisibles para OAT.

La optimización multiobjetivo (NSGA-III) transforma el problema de "maximizar el PnL" en el problema de "construir un frente de Pareto de PnL vs. MaxDD" — y ofrece un conjunto de soluciones con distintos compromisos riesgo-retorno.

Pero la optimización es solo una parte del pipeline. Los parámetros encontrados deben validarse mediante bootstrap de Monte Carlo, corregirse por funding rates, recalcularse teniendo en cuenta el tiempo activo, y pasar por validación walk-forward. Más sobre ello en los próximos artículos de la serie.


Enlaces útiles

  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)

Cita

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

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

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

Newsletter

Mantente a la vanguardia

Suscríbete a nuestro boletín para recibir información exclusiva sobre trading con IA, análisis de mercado y actualizaciones de la plataforma.

Respetamos tu privacidad. Puedes darte de baja en cualquier momento.