← Voltar aos artigos
March 11, 2026
5 min read

Descida por Coordenadas vs. Otimização Bayesiana: Quem Encontra Melhores Parâmetros

Descida por Coordenadas vs. Otimização Bayesiana: Quem Encontra Melhores Parâmetros
#algotrading
#backtest
#optimization
#Optuna
#TPE
#Bayesian optimization
#coordinate descent
#hyperparameters

Este é o quinto artigo da série "Backtests Sem Ilusões". Nos artigos anteriores, abordamos a assimetria entre perda e lucro, o bootstrap de Monte Carlo, o impacto das funding rates e o cache Parquet para backtests mais rápidos. Agora vamos falar sobre o processo de encontrar os parâmetros ótimos de uma estratégia — uma tarefa em que a intuição falha com mais frequência.

Você tem uma estratégia com 12 parâmetros. Cada parâmetro assume ~9 valores. Você quer encontrar a combinação que maximize o PnL com um drawdown limitado. Como fazer isso?

Se sua resposta é "percorro todas as combinações" — você tem um problema. Se sua resposta é "mudo um parâmetro de cada vez" — você tem outro problema. Este artigo trata dos problemas escondidos atrás de cada abordagem e de como resolvê-los.

Por que a busca exaustiva é impossível

The curse of dimensionality: exponential growth of the search space

A maldição da dimensionalidade

A busca exaustiva (grid search) testa cada combinação de valores para cada parâmetro. Para dois parâmetros com 9 valores, isso são 92=819^2 = 81 execuções — perfeitamente viável. Para três: 93=7299^3 = 729 — tolerável.

Mas para uma estratégia real com 12 parâmetros:

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

Duzentos e oitenta e dois bilhões de execuções. Mesmo que um único backtest leve 1 segundo (o que já é otimista), a busca exaustiva levaria:

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

Isso é crescimento exponencial: cada novo parâmetro multiplica o espaço de busca por 9. Adicione um 13º parâmetro — e em vez de 9.000 anos você precisará de 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

Mesmo com pré-computação

No artigo sobre o cache Parquet mostramos como pré-computar timeframes e indicadores acelera um único backtest para ~1 segundo. Mas mesmo a 0,1 segundo por execução, a busca exaustiva de 12 parâmetros exigiria 895 anos. A pré-computação ajuda, mas não resolve o problema fundamental do crescimento exponencial.

Precisamos de métodos que explorem o espaço de parâmetros de forma mais inteligente do que a busca exaustiva.

Descida por coordenadas e OAT: rápido, mas cego

Parameter space exploration: OAT vs Bayesian optimization

Duas variantes da mesma ideia

Existem duas abordagens relacionadas — ambas otimizam um parâmetro de cada vez, mas diferem no número de passagens:

Varredura OAT (One-at-a-Time) — uma única passagem por todos os parâmetros. Percorre-se os valores do primeiro parâmetro, fixa-se o melhor, passa-se para o segundo — e assim por diante. Uma única vez. Rápido e barato.

Coordinate Descent — multi-passagem. Após otimizar o último parâmetro, retorna-se ao primeiro e verifica-se se o ótimo mudou (já que o contexto mudou — outros valores de parâmetros agora são diferentes). As rodadas se repetem até a convergência. Mais caro, mas mais preciso — cada rodada pode refinar a solução.

Na prática, para backtests, o OAT é mais usado: uma única passagem por 12 parâmetros — 96 execuções. O Coordinate Descent com 3-5 rodadas — 300-500 execuções, o que já é comparável ao Optuna, mas sem suas vantagens.

Para 12 parâmetros com ~8 valores cada:

NOAT=K×N=12×8=96 execuc¸o˜esN_{OAT} = K \times N = 12 \times 8 = 96 \text{ execuções}

Compare com 282×109282 \times 10^9 para grid search. O OAT é linear: O(KN)O(K \cdot N) em vez de O(NK)O(N^K). Essa é tanto sua principal vantagem quanto seu 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

Qual métrica escolher para a otimização? Em vez do PnL bruto ou do PnL@MaxLev, recomenda-se usar o effective score — PnL por tempo ativo extrapolado para um ano. Essa métrica leva em conta o tempo em posição e permite uma comparação correta entre estratégias com frequências de negociação diferentes.

O ponto cego: interações entre parâmetros

O OAT assume que o efeito de cada parâmetro é aditivo — ou seja, o valor ótimo de um parâmetro não depende dos valores dos outros. Essa suposição vale para alguns parâmetros, mas falha para os acoplados.

Parâmetros aditivos vs. acoplados

Antes de otimizar — é útil classificar os parâmetros:

Aditivos (independentes) — o valor ótimo de um não depende do outro. Podem ser otimizados um a um a baixo custo:

  • htf_entry_sell e htf_entry_buy — limiares de entrada para direções diferentes (venda/compra) no mesmo timeframe. O limiar de venda filtra sinais de short, o de compra — de long. Operam em subconjuntos de trades que não se sobrepõem.
  • tp_target e be_trigger — take-profit e breakeven, se não criarem condições de saída conflitantes.

Acoplados (interativos) — o valor ótimo de um depende do outro. É necessária otimização conjunta:

  • htf_entry_sell e mtf_entry_sell — limiares para a mesma direção (venda) em timeframes diferentes. O HTF determina quais sinais chegam ao MTF, e o limiar do MTF determina a eficácia da filtragem. O ótimo do HTF muda quando o MTF muda.
  • ltf_entry_sell, mtf_entry_sell, htf_entry_sell — toda a cadeia de limiares para uma direção.
  • partial_frac e tp_target — o tamanho do fechamento parcial depende do nível de TP.

Abordagem prática: primeiro otimize os parâmetros aditivos a baixo custo via OAT. Depois otimize os grupos acoplados via Optuna. Isso reduz o orçamento: em vez de 12 parâmetros no Optuna, enviamos apenas 6-8 acoplados, enquanto o restante já está fixo.

Exemplo: como o OAT deixa passar uma interação

Considere dois limiares acoplados:

  • htf_entry_sell — limiar no timeframe superior (direção venda)
  • mtf_entry_sell — limiar no timeframe médio (direção venda)

O OAT fixa mtf_entry_sell = 0.01 (valor inicial) e percorre htf_entry_sell. Encontra o melhor valor: htf_entry_sell = 0.02. Fixa-o e passa para o próximo parâmetro — nunca retorna.

Eis o que o OAT deixou passar:

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%

A combinação (0.03, 0.02) produz um PnL de +51%, mas o OAT nunca a considerará porque, com mtf_entry_sell = 0.01 fixo, o valor htf_entry_sell = 0.03 produz apenas +35%. O OAT ficou "preso" no ótimo local (0.02, 0.01) e não consegue ver o ótimo global (0.03, 0.02).

Esse é um problema clássico: se a paisagem da função objetivo contém cristas diagonais (quando o ótimo de um parâmetro se desloca conforme outro muda), o OAT as deixa passar.

Formalizando o problema

Seja f(θ1,θ2,,θK)f(\theta_1, \theta_2, \ldots, \theta_K) a função objetivo (PnL). O OAT encontra um ponto onde:

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

Mas essa é uma condição necessária, não suficiente, para um ótimo global. Se a matriz Hessiana Hij=2fθiθjH_{ij} = \frac{\partial^2 f}{\partial \theta_i \partial \theta_j} tiver elementos fora da diagonal significativos — o OAT não leva em conta as derivadas cruzadas 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j} quando iji \neq j.

Para parâmetros acoplados (limiares da mesma direção em vários timeframes) — as interações são a regra, não a exceção. O limiar de entrada no timeframe superior determina quais sinais chegam ao médio, e o limiar do médio determina a eficácia da filtragem no inferior. Para parâmetros aditivos (direções diferentes, filtros independentes) as derivadas cruzadas são próximas de zero — e o OAT funciona bem.

Otimização bayesiana: busca inteligente

Bayesian optimization: surrogate model of the objective function

A ideia

Em vez de enumeração cega ou busca gulosa, a otimização bayesiana constrói um modelo substituto da função objetivo e, em cada passo, seleciona o ponto onde a melhoria esperada é máxima.

Algoritmo:

  1. Escolher vários pontos aleatórios, avaliar a função objetivo
  2. Construir um modelo substituto (aproxima f(θ)f(\theta) a partir dos pontos observados)
  3. Encontrar o ponto com melhoria esperada máxima (função de aquisição)
  4. Avaliar a função objetivo nesse ponto
  5. Atualizar o modelo substituto
  6. Repetir os passos 3-5

A diferença chave em relação ao OAT: a otimização bayesiana considera todos os parâmetros simultaneamente e pode explorar cristas diagonais no espaço de parâmetros.

TPE (Tree-structured Parzen Estimator)

TPE sampler: modeling good and bad parameter distributions

O TPE é o sampler padrão no Optuna. Em vez de modelar f(θ)f(\theta) diretamente, o TPE modela duas distribuições:

  • l(θ)l(\theta) — distribuição dos parâmetros em que a função objetivo é melhor que o limiar yy^*
  • g(θ)g(\theta) — distribuição dos parâmetros em que a função objetivo é pior que o limiar yy^*

A função de aquisição do TPE — a razão:

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

O TPE seleciona pontos onde l(θ)l(\theta) é grande (parâmetros semelhantes aos "bons") e g(θ)g(\theta) é pequeno (parâmetros não semelhantes aos "ruins").

Por que o TPE é adequado para backtests:

  • Lida com dependências condicionais entre parâmetros
  • Não requer continuidade da função objetivo
  • Eficiente com orçamentos moderados (100-1000 iterações)
  • Suporta parâmetros categóricos e discretos

Processo Gaussiano (GP)

Uma alternativa ao TPE — o Processo Gaussiano. O GP modela f(θ)f(\theta) como um processo normal multivariado e fornece não apenas uma previsão do valor, mas também a incerteza em cada ponto.

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

onde m(θ)m(\theta) é a média, k(θ,θ)k(\theta, \theta') é a função de covariância (kernel).

O GP funciona bem quando:

  • há poucos parâmetros (até 10-15)
  • a função objetivo é suave
  • cada execução é cara (minutos, horas)

Para backtests com cache Parquet pré-computado, em que uma única execução leva ~1 segundo, geralmente prefere-se o TPE: ele constrói o modelo mais rápido e escala melhor para 500+ iterações.

Integração prática com o Optuna

Optuna optimization framework: iterative parameter search

Exemplo 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 (com cache pré-computado):

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

Oito minutos contra 8.950 anos de busca exaustiva. E o TPE, em 500 iterações, encontra combinações que o OAT deixa passar em 96, porque explora o espaço de parâmetros simultaneamente, em vez de um eixo de cada vez.

Salvando e retomando um estudo

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)

Adicionando restrições

Nem todas as combinações de parâmetros são válidas. Por exemplo, o limiar de saída não deve exceder o limiar 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"]

Comparação de samplers

Sampler convergence comparison over iterations

O Optuna suporta vários samplers. Cada um tem seus próprios pontos fortes.

TPESampler (padrão)

sampler = optuna.samplers.TPESampler(
    n_startup_trials=20,  # random trials before modeling begins
    seed=42,
)
  • Princípio: Tree-structured Parzen Estimator
  • Pontos fortes: bom para tipos de parâmetros mistos, escala para 1000+ iterações
  • Pontos fracos: pode ser menos eficiente com interações fortes entre parâmetros
  • Quando usar: por padrão, se não houver motivo para escolher outro

CmaEsSampler

sampler = optuna.samplers.CmaEsSampler(seed=42)
  • Princípio: Covariance Matrix Adaptation Evolution Strategy — um algoritmo evolutivo que adapta a matriz de covariância
  • Pontos fortes: excelente em encontrar interações entre parâmetros contínuos, leva em conta correlações
  • Pontos fracos: não suporta parâmetros categóricos, requer mais iterações para inicialização
  • Quando usar: se todos os parâmetros forem contínuos e você suspeitar de interações fortes

GPSampler

sampler = optuna.samplers.GPSampler(seed=42)
  • Princípio: Processo Gaussiano com função de aquisição
  • Pontos fortes: melhor eficiência amostral (menos iterações para um bom resultado), fornece estimativas de incerteza
  • Pontos fracos: O(n3)O(n^3) no número de iterações — lento quando n>200n > 200
  • Quando usar: se um único backtest for caro (minutos) e o orçamento estiver limitado a 100-200 iterações

RandomSampler (linha de base)

sampler = optuna.samplers.RandomSampler(seed=42)
  • Princípio: amostragem aleatória uniforme
  • Pontos fortes: não fica preso em ótimos locais, cobertura completa do espaço
  • Pontos fracos: não usa resultados anteriores
  • Quando usar: como linha de base para comparação, ou para análise exploratória

QMCSampler

sampler = optuna.samplers.QMCSampler(seed=42)
  • Princípio: Quasi-Monte Carlo (sequências de Sobol/Halton) — preenche o espaço de forma mais uniforme que um sampler aleatório
  • Pontos fortes: melhor cobertura do espaço que o RandomSampler, reprodutibilidade
  • Pontos fracos: não se adapta aos resultados
  • Quando usar: para as primeiras 50-100 iterações antes de mudar para o TPE

Tabela resumo

Sampler Tipo Interações Categórico Melhor orçamento
TPE Bayesiano Parcial Sim 100-1000
CmaEs Evolutivo Sim Não 200-2000
GP Bayesiano Sim Limitado 50-200
Random Aleatório Não Sim Qualquer (linha de base)
QMC Quase-aleatório Não Não 50-500

Benchmark prático

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 uma estratégia com 12 parâmetros:

Sampler Melhor PnL Encontrado na iteração Overhead do sampler
TPE ~51% ~180 Baixo
CmaEs ~49% ~250 Médio
GP ~48% ~90 Alto quando n>200n > 200
Random ~42% ~270 Mínimo
QMC ~43% ~200 Mínimo

O TPE e o CmaEs superam consistentemente a busca aleatória em 15-20% no PnL final. O GP encontra bons resultados mais cedo, mas atinge um teto computacional com um grande número de iterações.

Otimização multiobjetivo: PnL vs. MaxDD

Pareto front: tradeoff between PnL and maximum drawdown

Por que um único critério não é suficiente

Maximizar o PnL sem restrições de drawdown é um caminho para o desastre. Uma estratégia com PnL +80% e MaxDD -30% é, devido à assimetria entre perda e lucro, significativamente mais arriscada do que uma estratégia com PnL +50% e MaxDD -5%.

O problema de otimização é, na verdade, multiobjetivo:

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

Esses objetivos entram em conflito: parâmetros agressivos aumentam tanto o PnL quanto o drawdown. A solução não é um único ponto, mas uma frente de Pareto: um conjunto de soluções em que não se pode melhorar uma métrica sem piorar a outra.

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

Selecionando um ponto na frente de Pareto

A frente de Pareto oferece várias soluções. Como escolher uma?

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

Observação: ao calcular o PnL com alavancagem máxima, é preciso levar em conta as funding rates, caso contrário uma alavancagem teoricamente alta se transformará em prejuízo no mercado real. Além disso, o PnL final é uma estimativa de ponto único, e para avaliar a estabilidade do resultado é necessário o bootstrap de Monte Carlo.

Exemplo: três estratégias na frente de Pareto

Estratégia PnL MaxDD MaxLev PnL@MaxLev Tempo de negociação
Estratégia A ~55% ~0.9% ~55x ~3025% ~15%
Estratégia B ~25% ~0.75% ~66x ~1650% ~5%
Estratégia C ~300% ~17% ~3x ~900% ~45%

A estratégia C, com um PnL impressionante de +300%, acaba sendo a menos atraente segundo o PnL@MaxLev devido ao alto drawdown. A estratégia A lidera em retorno líquido alavancado, mas levando em conta o PnL por tempo ativo, a estratégia B pode ser preferível — 95% do tempo livre pode ser preenchido com outras estratégias.

Gráficos de contorno e importância de parâmetros

Contour plots: visualizing parameter interactions and plateaus

Visualização da paisagem

Depois da otimização — visualização. O Optuna oferece ferramentas 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: lendo interações

Um gráfico de contorno constrói um corte bidimensional da função objetivo para um par de parâmetros. Se as isolinhas forem paralelas a um dos eixos — os parâmetros não interagem, e o OAT teria encontrado o mesmo ótimo. Se as isolinhas forem diagonais — há uma interação, e o OAT vai deixá-la passar.

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

Se um gráfico de contorno mostrar um platô — uma região onde a função objetivo muda pouco — isso é um bom sinal. Um platô significa que o resultado é robusto a pequenos desvios de parâmetros. Mais sobre a análise de platôs e sua relação com o overfitting — no próximo artigo Análise de platôs.

Importância dos parâmetros

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

Saída 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
...

Parâmetros com importância < 0,01 podem ser fixados em seu valor padrão — isso reduz a dimensionalidade do problema e acelera a otimização. Mas cuidado: baixa importância também pode significar que o parâmetro é importante apenas em interação com outros. Verifique por meio de gráficos de contorno.

Cache pré-computado: por que 1 segundo por backtest muda tudo

Precomputed Parquet cache: accelerating backtests from hours to seconds

A velocidade de um único backtest determina qual método de otimização você pode se dar ao luxo de usar.

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

A 60 segundos por backtest, 500 iterações do TPE levam 8 horas. Ainda tolerável, mas iterar (mudar a função objetivo, reiniciar) fica caro. A 1 segundo — 8 minutos, e você pode executar dezenas de experimentos por dia.

É exatamente por isso que a pré-computação em cache Parquet não é apenas uma otimização de velocidade, mas uma expansão do espaço de métodos disponíveis. Sem cache, você fica limitado ao OAT ou a 100 iterações de GP. Com cache — você pode se dar ao luxo de 2000 iterações de CmaEs ou um 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

Recomendações práticas

Hybrid approach: combining OAT and Bayesian optimization

Quando usar o OAT

O OAT é justificado nos seguintes casos:

  1. Análise exploratória. Você está apenas começando a explorar uma estratégia e quer entender quais parâmetros afetam o resultado. 96 execuções em 1,5 minuto — um excelente ponto de partida.

  2. Parâmetros aditivos. Para parâmetros que operam em subconjuntos de trades que não se sobrepõem (direções venda vs. compra, instrumentos diferentes), o OAT dará um resultado correto mais rápido.

  3. Backtest muito caro. Se uma única execução leva 10+ minutos e não pode ser acelerada, o OAT com 96 execuções (16 horas) é preferível a 500 iterações do TPE (3,5 dias).

Quando usar o Optuna

O Optuna é preferível na maioria dos casos:

  1. Mais de 3 parâmetros. As interações são praticamente garantidas — o OAT vai deixar passar o ótimo.

  2. Estratégias multi-timeframe. Os limiares em diferentes timeframes quase sempre estão interconectados.

  3. Otimização final. Quando a estratégia passou pelo bootstrap de Monte Carlo e você confia em sua robustez — o Optuna encontrará os melhores parâmetros.

  4. Problemas multiobjetivo. PnL vs. MaxDD vs. tempo de negociação — o OAT não pode resolver esse problema em princípio.

Abordagem híbrida: OAT para aditivos + Optuna para acoplados

Você não precisa escolher entre OAT e Optuna — é melhor combiná-los:

  1. Classifique os parâmetros. Divida-os em aditivos (independentes) e acoplados (interativos). Exemplo para 12 parâmetros de separação:

    • Aditivos: htf_entry_sell <-> htf_entry_buy, mtf_entry_sell <-> mtf_entry_buy, ltf_entry_sell <-> ltf_entry_buy (venda/compra — direções diferentes, operam em trades que não se sobrepõem)
    • Grupo acoplado venda: htf_entry_sell, mtf_entry_sell, ltf_entry_sell (cadeia de filtragem: HTF -> MTF -> LTF para sinais de venda)
    • Grupo acoplado compra: htf_entry_buy, mtf_entry_buy, ltf_entry_buy
  2. OAT para aditivos. Otimize os grupos de venda e compra de forma independente. Se os parâmetros de venda não afetam os trades de compra — o OAT dará um resultado correto em minutos.

  3. Optuna para acoplados. Dentro de cada grupo (venda: 6 parâmetros entrada+saída) use o TPE. 6 parâmetros em vez de 12 — o orçamento é reduzido pela metade.

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 otimização 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

O passo 8 — a otimização walk-forward — é fundamental para a proteção contra overfitting. Mais sobre isso no próximo artigo Walk-Forward.

Armadilhas da otimização

Overfitting. Quanto mais parâmetros e mais precisa a otimização — maior o risco de ajustar a estratégia aos dados históricos. 500 iterações do Optuna com 12 parâmetros encontrarão uma combinação que funciona perfeitamente no conjunto de treino, mas é inútil em dados novos.

Proteção:

Problema de comparações múltiplas. Se você testar 500 combinações, a probabilidade de encontrar aleatoriamente um "bom" resultado aumenta. A correção de Bonferroni ou o controle de FDR (False Discovery Rate) ajudam, mas a abordagem mais simples é a validação out-of-sample.

Orçamento insuficiente. O TPE com 50 iterações para 12 parâmetros é muito pouco. As primeiras 20 iterações são aleatórias (startup), deixando apenas 30 para a modelagem. Orçamento mínimo: 10×K=12010 \times K = 120 iterações para 12 parâmetros, recomendado: 3050×K30\text{--}50 \times K.

Freqtrade: como funciona em um framework de produção

Freqtrade: automated trading framework with Optuna integration

O Freqtrade — um dos frameworks populares de algotrading — usa o Optuna internamente por meio do módulo Hyperopt. Sua experiência confirma nossas recomendações:

  • Samplers: TPE (padrão), GP, CmaEs, NSGA-II, QMC — todos disponíveis via configuração
  • Funções de perda: 12 funções de perda integradas, incluindo ShortTradeDurHyperOptLoss, SharpeHyperOptLoss, MaxDrawDownHyperOptLoss
  • Multiobjetivo: suporte para NSGA-II e NSGA-III para otimização simultânea de múltiplas métricas
  • Samplers personalizados: possibilidade de conectar qualquer sampler compatível com o Optuna

Uma lição fundamental do ecossistema Freqtrade: as funções de perda integradas cobrem cenários típicos, mas para uma otimização séria você precisa de uma função objetivo personalizada que leve em conta as particularidades da sua estratégia — tempo ativo, custos de funding, drill-down adaptativo para simulação precisa de execuções.

Conclusão

Complete optimization pipeline: from data to validated parameters

A descida por coordenadas (OAT) é um método rápido e intuitivo. Para 12 parâmetros, ele exige apenas 96 execuções e termina em um minuto e meio. Mas é cego para as interações entre parâmetros — e em estratégias multi-timeframe, as interações estão quase sempre presentes.

A otimização bayesiana via Optuna (TPE, GP, CmaEs) explora o espaço de parâmetros como um todo. 500 iterações em 8 minutos — com um cache Parquet pré-computado — encontram combinações invisíveis para o OAT.

A otimização multiobjetivo (NSGA-III) transforma o problema de "maximizar o PnL" no problema de "construir uma frente de Pareto de PnL vs. MaxDD" — e fornece um conjunto de soluções com diferentes compensações entre risco e retorno.

Mas a otimização é apenas parte do pipeline. Os parâmetros encontrados precisam ser validados por meio do bootstrap de Monte Carlo, corrigidos para as funding rates, recalculados levando em conta o tempo ativo, e submetidos à validação walk-forward. Mais sobre isso nos próximos artigos da série.


Links úteis

  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ção

@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

Fique à frente do mercado

Assine nossa newsletter para insights exclusivos sobre trading com IA, análises de mercado e atualizações da plataforma.

Respeitamos sua privacidade. Cancele a inscrição a qualquer momento.