Descida por Coordenadas vs. Otimização Bayesiana: Quem Encontra Melhores Parâmetros
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

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 execuções — perfeitamente viável. Para três: — tolerável.
Mas para uma estratégia real com 12 parâmetros:
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:
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

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:
Compare com para grid search. O OAT é linear: em vez de . 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_sellehtf_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_targetebe_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_sellemtf_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_fracetp_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 a função objetivo (PnL). O OAT encontra um ponto onde:
Mas essa é uma condição necessária, não suficiente, para um ótimo global. Se a matriz Hessiana tiver elementos fora da diagonal significativos — o OAT não leva em conta as derivadas cruzadas quando .
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

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:
- Escolher vários pontos aleatórios, avaliar a função objetivo
- Construir um modelo substituto (aproxima a partir dos pontos observados)
- Encontrar o ponto com melhoria esperada máxima (função de aquisição)
- Avaliar a função objetivo nesse ponto
- Atualizar o modelo substituto
- 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)

O TPE é o sampler padrão no Optuna. Em vez de modelar diretamente, o TPE modela duas distribuições:
- — distribuição dos parâmetros em que a função objetivo é melhor que o limiar
- — distribuição dos parâmetros em que a função objetivo é pior que o limiar
A função de aquisição do TPE — a razão:
O TPE seleciona pontos onde é grande (parâmetros semelhantes aos "bons") e é 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 como um processo normal multivariado e fornece não apenas uma previsão do valor, mas também a incerteza em cada ponto.
onde é a média, é 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

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

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: no número de iterações — lento quando
- 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 |
| 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

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

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

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

Quando usar o OAT
O OAT é justificado nos seguintes casos:
-
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.
-
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.
-
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:
-
Mais de 3 parâmetros. As interações são praticamente garantidas — o OAT vai deixar passar o ótimo.
-
Estratégias multi-timeframe. Os limiares em diferentes timeframes quase sempre estão interconectados.
-
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.
-
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:
-
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
- Aditivos:
-
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.
-
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:
- Divida os dados em treino/teste (70/30)
- Use o bootstrap de Monte Carlo para avaliar a estabilidade
- Valide via walk-forward
- Prefira soluções em platôs (mais sobre isso em Análise de platôs)
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: iterações para 12 parâmetros, recomendado: .
Freqtrade: como funciona em um framework de produção

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

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