← Voltar aos artigos
July 3, 2026
5 min read

O espaço de parâmetros de dois eixos: por que a maior parte do seu sweep deveria ser quase gratuita

O espaço de parâmetros de dois eixos: por que a maior parte do seu sweep deveria ser quase gratuita
#algotrading
#backtest
#busca de parâmetros
#otimização
#cache
#maldição da dimensionalidade
Part 4 of 10 · Collection
High-Performance Backtest Engines

Parte da série "Backtests sem ilusões".

A maldição da dimensionalidade costuma ser apresentada como um aviso: cada parâmetro adicionado multiplica o espaço de busca, tornando inviável percorrer uma estratégia de 18 dimensões. Esse enquadramento assume silenciosamente que todas as dimensões custam o mesmo para avaliar. Não é o caso. No motor dual/triplo-timeframe aqui descrito, dois terços dos parâmetros são quase gratuitos de percorrer, e um terço carrega essencialmente toda a conta computacional. Uma vez identificada essa divisão, "o espaço de busca é grande demais" deixa de ser a queixa correta. A pergunta certa é: quais dimensões realmente têm custo?

A dimensão que não precisava ser recalculada

O benchmark de busca de parâmetros aqui descrito executa uma estratégia de momentum multi-timeframe: uma média móvel de Hull e a sua variante triplamente suavizada (HMA3) em dois ou três timeframes, com um filtro de separação direcional que decide quando um cruzamento é "limpo" o suficiente para agir. O espaço de busca completo da variante de três timeframes tem dezoito dimensões — o período e o comprimento da HMA por timeframe, mais um conjunto de limiares de separação de entrada e saída em cada timeframe.

A forma ingénua de percorrer isto é um único laço: escolher um vetor de parâmetros, construir cada indicador do zero, executar a simulação, pontuá-la e repetir. Foi assim que se começou. Era lento, e era lento por um motivo que se revelou embaraçoso assim que identificado: para a esmagadora maioria dos testes adjacentes, estavam a ser recalculados indicadores que não tinham mudado.

Basta deslocar o limiar de separação de entrada de 0,03 para 0,035 e executar novamente. A HMA sobre um ano de velas de 1 minuto é idêntica, bit a bit, ao teste anterior — o limiar não aparece em nenhum lugar da sua definição. Ainda assim, o laço ingénuo reconstrói-a na mesma, ao longo de mais de meio milhão de velas, em três timeframes, todas as vezes. Quase todo o orçamento computacional era gasto a re-derivar quantidades invariantes ao parâmetro que, de facto, estava a variar.

Essa observação é o artigo inteiro. No momento em que se percebe que alguns parâmetros alteram os indicadores e outros alteram apenas a regra de decisão aplicada sobre indicadores fixos, o espaço de parâmetros deixa de ser uma nuvem plana de dimensões e passa a ser duas camadas aninhadas com preços radicalmente diferentes.

Dois eixos, não um

Dois eixos de um espaço de parâmetros a preços diferentes: uma grade esparsa e cara de indicadores cujas células desbloqueiam, cada uma, uma folha densa e quase gratuita de configurações de limiares

Os parâmetros dividem-se de acordo com aquilo que a sua reavaliação obriga a recalcular:

  • O eixo caro — parâmetros de indicadores. O período do timeframe e o comprimento da HMA. Ao alterar qualquer um dos dois, é preciso reconstruir o indicador em toda a série de preços: reamostrar o timeframe superior, executar a ponderação de Hull, recalcular a série de cruzamentos e a separação em cada cruzamento. Trata-se de uma passagem O(n) por vela de histórico, paga na íntegra em cada combinação distinta. Na estratégia de três timeframes este eixo tem seis parâmetros: (period, hma_length) para cada um dos timeframes alto, médio e baixo.

  • O eixo barato — limiares de decisão. Os filtros de separação direcional que decidem, com base nas séries de cruzamento e separação já calculadas, se deve entrar-se ou sair-se. Ao alterar um limiar, nenhum indicador se move. Executa-se apenas uma única passagem O(n) sobre os sinais pré-calculados, verificando os filtros e registando as execuções. Na estratégia de três timeframes este eixo tem doze parâmetros (quatro limiares de separação — entrada-compra, entrada-venda, saída-compra, saída-venda — em cada um dos três timeframes). A variante de dois timeframes tem oito (quatro limiares em cada um dos dois timeframes).

Assim, a forma real do espaço é: 6 caros + 12 baratos = 18 para três timeframes, e 4 caros + 8 baratos = 12 para dois timeframes. Dois terços da dimensionalidade vivem no eixo barato. E os dois eixos não diferem apenas em quantidade — diferem em custo unitário por mais de três ordens de grandeza, e é sobre esse número que trata o resto deste artigo.

A estrutura é estritamente aninhada. Cada ponto do eixo caro — um (period, hma_length) concreto por timeframe — define um conjunto de arrays de sinais fixos. Sobre essa base fixa assenta uma folha inteira do eixo barato: milhares de vetores de limiares, cada um uma passagem rápida sobre os mesmos arrays. A base paga-se uma única vez e amortiza-se ao longo de toda a folha.

Por que os indicadores são invariantes aos limiares

O cache só funciona devido a uma propriedade matemática, e não a um detalhe de implementação afortunado, e vale a pena enunciá-la com precisão, pois é o pressuposto que sustenta tudo o resto. Os arrays de indicadores são função exclusivamente de (period, hma_length). Os limiares de separação não aparecem em lugar nenhum da sua definição.

Concretamente, para cada timeframe o motor pré-calcula quatro arrays alinhados sobre o índice base: a HMA hma, a sua versão triplamente suavizada hma3, a série de cruzamentos cross (+1 compra / −1 venda / 0), e a percentagem de separation em cada cruzamento. Cada um destes deriva dos preços e dos dois parâmetros do indicador. O limiar — o número contra o qual se compara separation — é aplicado mais tarde, no momento da decisão. Nunca toca nos arrays.

Isto não é um rearranjo trivialmente seguro. É seguro apenas porque houve cuidado com quando a informação fica disponível, uma preocupação tratada no trabalho sobre viés de look-ahead noutra parte desta série. O indicador do timeframe superior na vela base i é calculado a partir de velas superiores já fechadas mais uma vela em formação cujo fecho corrente é igual ao fecho base atual close[i] — um valor conhecido na vela i. Nada do futuro se infiltra. Assim, o sinal pré-calculado na vela i é exatamente o que um bot em produção teria visto na vela i, e permanece válido independentemente do limiar testado posteriormente. Fazer cache de um sinal com fugas apenas guardaria a fuga; fazer cache de um sinal causal guarda algo com que é realmente possível operar.

A invariância proporciona uma fatorização. A avaliação de um vetor de parâmetros completo θ = (indicator_params, threshold_params) escreve-se como:

signals   = build_indicators(indicator_params)      # EXPENSIVE, depends only on indicator_params
score     = simulate(signals, threshold_params)     # CHEAP, reuses signals across all thresholds

build_indicators não lê threshold_params. Esse único facto é o que legitima o cache: mantendo indicator_params fixo e variando threshold_params livremente, signals é uma constante calculada uma única vez.

A arquitetura: calcular uma vez, percorrer muitas

Um cache de sinais indexado pelos parâmetros de indicadores, a alimentar uma passagem rápida em lote: uma única construção de indicador expande-se numa ampla fila de avaliações de limiares, partilhando os mesmos arrays em cache

O motor (scripts/engine_multitf.py, commit bfc8aaa no nosso backtester) implementa a fatorização com duas peças cujos nomes dizem exatamente o que fazem.

SignalCache — o eixo caro, memoizado. É um dicionário indexado por (period_bars, hma_length). Ao pedir-lhe os sinais de um timeframe, devolve o TFSignals em cache se essa combinação de indicador já tiver sido construída antes; caso contrário, constrói-a uma vez e armazena-a. Como a chave é apenas os parâmetros do indicador, cada configuração de limiar que partilha uma combinação de indicador — e num sweep denso de limiares isso são milhares delas — atinge a mesma entrada de cache. Os timeframes superiores beneficiam especialmente disto: uma grade grosseira de, digamos, quatro períodos candidatos vezes um punhado de comprimentos de HMA é um número pequeno de construções caras distintas, cada uma reutilizada em toda a folha de limiares empilhada sobre ela.

sweep_separations — o eixo barato, em lote. Recebe os arrays de sinais em cache e uma matriz de vetores de limiares (sps, de forma [m, 12]) e passa todos por um único kernel compilado. Cada linha é uma passagem O(n): percorre as velas, aplica os filtros, regista execuções sem fugas em open[i+1], e totaliza o PnL e o tempo em posição. Nenhum indicador é reconstruído dentro deste laço — lê cross e separation diretamente do cache. A simulação interna é compilada via JIT (Numba), pelo que, uma vez aquecida, o custo por configuração é dominado pela única varredura linear sobre as velas, e não pela sobrecarga do Python.

As duas peças compõem-se na busca aninhada natural: o laço externo percorre o eixo caro (cada iteração constrói e guarda em cache uma combinação de indicador), e o laço interno expande um lote amplo ao longo do eixo barato sobre esses arrays em cache. No código, a forma é exatamente essa — um pequeno laço caro a envolver um amplo lote barato:

cache = SignalCache(base_close, base_ts)           # keyed by (period, hma_length)

for htf_p, htf_h, mtf_p, mtf_h, ltf_p, ltf_h in indicator_grid:   # EXPENSIVE axis (coarse)
    htf = cache.get(htf_p, htf_h)                  # built once, then a cache hit forever
    mtf = cache.get(mtf_p, mtf_h)
    ltf = cache.get(ltf_p, ltf_h)

    sps = sample_thresholds(m=4000)                # CHEAP axis: [m, 12] threshold vectors
    pnl, n_trades, bars_in_pos = sweep_separations(  # one compiled batch, no indicator work
        base_close, base_open, htf, ltf, sps, mtf=mtf)

O construtor caro é acionado o menor número de vezes possível, tantas quantas a grade de indicadores permitir, e deixa-se que o sweep barato faça o volume — milhares de linhas de sps contra arrays que nunca se movem. É essa a otimização inteira — sem aproximações, sem perda de fidelidade, apenas a recusa de recalcular o que não mudou.

O que o "gratuito" realmente custa: os números

Uma comparação em barras do custo por configuração: uma coluna alta para o recálculo por configuração ao lado de uma fatia fina para a passagem de limiar em cache, anotada com a diferença de aproximadamente 1.600x

Os dois eixos foram medidos sobre a carga de trabalho de demonstração: um ano completo de velas de 1 minuto de ETHUSDT (~527 mil velas), três timeframes, com os indicadores pré-aquecidos e a compilação JIT fora da medição de tempo.

No eixo barato, sweep_separations sustenta ~5.600 configurações de limiar por segundo. Trata-se de uma simulação completa — filtros, execuções, PnL, exposição — para cada configuração, ao longo de meio milhão de velas, a cerca de 180 microssegundos por configuração. A razão para essa velocidade é que não é realizado nenhum trabalho de indicadores: cada configuração lê os mesmos arrays em cache de cross e separation.

Agora, o preço da alternativa. Construir uma vez o conjunto de indicadores de três timeframes leva da ordem de algumas centenas de milissegundos (~0,3 s) — reamostragem de três timeframes, ponderação de Hull, extração de cruzamento e separação ao longo de todo o ano. Se os indicadores fossem recalculados dentro do laço de configurações — o desenho ingénuo de laço único —, cada uma dessas 5.600 configurações por segundo pagaria, em vez disso, a construção completa do indicador. O custo por configuração dispara de ~180 microssegundos para ~0,3 segundos:

Custo por configuração Configs/s
Eixo barato (sinais em cache) ~180 µs ~5.600
Recalcular indicadores por configuração ~0,3 s ~3,4

A proporção é de ~1.600x. Percorrer o eixo barato sobre sinais em cache é cerca de três ordens de grandeza mais barato do que o desenho ingénuo que reconstrói os indicadores para cada vetor de limiares. Em termos concretos: um lote de alguns milhares de configurações de limiar que termina em menos de um segundo pelo caminho em cache levaria a maior parte de uma hora se cada configuração reconstruísse os seus indicadores. Mesmos resultados, mesma fidelidade, sem atalhos na matemática — a única diferença é que um deles recalcula um invariante e o outro não.

Isto não é uma microotimização aplicada no final. Muda quais buscas são viáveis. A 5.600 cfg/s, o eixo de limiares é denso o suficiente para ser explorado devidamente — é possível permitir-se uma grade fina ou uma amostra longa aleatória/QMC por combinação de indicador — enquanto o eixo caro permanece um punhado deliberadamente grosseiro de construções. O orçamento computacional flui para onde os parâmetros realmente custam algo.

A maldição da dimensionalidade, reavaliada

Uma grade plana e uniforme de parâmetros reetiquetada como um mapa de preços de dois níveis: algumas células caras destacadas com traço grosso, o resto sombreado como quase gratuito

Voltando ao enquadramento com que este artigo abriu: a maldição da dimensionalidade diz que o espaço de busca cresce exponencialmente com o número de parâmetros, portanto mais dimensões é categoricamente pior. Isso é verdade quanto ao tamanho da grade. É enganador quanto ao custo de a cobrir, porque atribui o mesmo preço a todas as dimensões.

Ao separar os eixos, a contagem lê-se de outra forma. A estratégia de três timeframes tem 18 dimensões, mas apenas 6 delas são caras. As outras 12 são dimensões do eixo barato que expandem a grade sem expandir a conta computacional de forma significativa — é possível lançar-lhes milhares de configurações por tostões. A estratégia de dois timeframes tem 12 dimensões, com apenas 4 dimensões caras e 8 baratas. Em ambos os casos, a maior parte da "maldição" concentra-se no eixo que custa quase nada percorrer.

Assim, a forma honesta de raciocinar sobre o custo da busca não é "quantos parâmetros" mas sim "quantos parâmetros caros, e quão grosseira pode ser a sua grade." O eixo caro é onde o exponencial realmente dói, e é onde convém ter uma grade pequena e bem escolhida — alguns períodos candidatos, um intervalo modesto de comprimentos de HMA — possivelmente refinada de grosseira para fina, no espírito do drill-down de resolução adaptativa usado noutra parte. O eixo barato é onde é possível ser generoso, porque cada configuração de limiar adicional custa 180 microssegundos.

Esta reavaliação generaliza-se bem para além da estratégia de HMA aqui usada. O padrão — alguns parâmetros alteram as features, a maioria dos parâmetros altera a regra aplicada sobre features fixas — repete-se por toda a parte no trading sistemático. Comprimentos de indicadores, frequências de reamostragem e janelas de lookback são caros; limiares de entrada/saída, distâncias de stop, multiplicadores de dimensionamento de posição e filtros de confirmação são baratos. Sempre que um parâmetro apenas remodela a fronteira de decisão sobre sinais já calculados, ele pertence ao eixo barato, e é aí que deve ser percorrido. Ganhos de cache relacionados, da mesma família, aparecem no cache parquet multi-timeframe e na escada de velocidade do motor, mais abrangente.

Onde o almoço grátis tem conta

O eixo barato é barato em computação. Não é barato em estatística, e confundir as duas coisas é a forma de transformar um ganho de desempenho numa máquina de overfitting.

Cada configuração de limiar avaliada é um teste, e ao executar-se milhares de testes sobre um único dataset, o melhor deles parece bom em parte por sorte. Tornar esses testes 1.600 vezes mais baratos não faz a sorte desaparecer — torna-a mais fácil de acumular. Um sweep denso de limiares é precisamente a situação em que a inflação por testes múltiplos morde com mais força: muitos candidatos, um único histórico, e uma regra de seleção que reporta o máximo. O motor mais rápido do mundo entregará com prazer um vetor de limiares que se ajusta lindamente ao ruído da janela de teste e falha fora da amostra.

Por isso, a disciplina tem de escalar com a velocidade. No momento em que o custo computacional de um teste cai para perto de zero, a contabilidade estatística torna-se a restrição vinculante, e é preciso pagá-la explicitamente:

  • Deflacionar em função do número de testes. Pontuar o vencedor com base em quantas configurações foram experimentadas, não contra zero. O Sharpe Ratio deflacionado e a probabilidade de overfitting do backtest existem exatamente para isto — transformam "foram experimentados 4.000 vetores de limiares" num desconto sobre o edge reportado.
  • Validar fora da amostra, por fold. Um sweep barato ainda tem de ser executado dentro de uma divisão walk-forward honesta; um limiar que só vence dentro da amostra não vale nada, por mais rápido que tenha sido encontrado. O motor aqui descrito mantém as execuções sem fugas (open[i+1]) precisamente para que o eixo barato não possa comprar desempenho a espreitar o futuro.
  • Preferir patamares a picos. Como o eixo de limiares é denso e rápido, é possível mapear toda a sua superfície de resposta, não apenas o seu argmax. Uma região ampla de limiares que funcionam todos é um edge real; um único máximo pontiagudo é um artefacto de ajuste — a distinção entre patamar e pico, tornada acessível precisamente pela velocidade aqui descrita.

A estrutura de dois eixos deve ser lida da forma correta: não compra mais confiança, compra mais testes ao mesmo custo de confiança por teste. Isso é genuinamente valioso — a cobertura densa do eixo barato é o que permite encontrar patamares e caracterizar a superfície — mas só se o livro de contas estatístico for mantido honesto. A velocidade remove a desculpa computacional para não se buscar exaustivamente; não remove a obrigação de descontar o que essa busca exaustiva encontra.

Como estruturar a sua própria busca

Para aplicar isto à própria estratégia, o trabalho é sobretudo de classificação — ordenar os parâmetros no eixo correto — seguido de um laço aninhado:

  1. Rotular cada parâmetro conforme aquilo que obriga a recalcular. Se alterá-lo altera um array de indicador/feature, é caro. Se apenas altera uma comparação, um limiar, ou uma regra de dimensionamento aplicada sobre arrays fixos, é barato. Em caso de dúvida, perguntar: este parâmetro aparece em algum lugar dentro da definição do indicador? Se não, é barato.
  2. Indexar um cache apenas sobre o eixo caro. Memoizar a construção de features pelos seus parâmetros de indicador (como faz o SignalCache com (period, hma_length)). Testes adjacentes que partilham uma combinação de indicador reutilizam então os mesmos arrays de graça.
  3. Processar o eixo barato em lote sobre as features em cache. Executar as configurações de limiar como um laço compilado e compacto sobre os sinais pré-calculados, e não como reavaliações completas. É daqui que vem o throughput — no caso aqui descrito, os ~5.600 cfg/s.
  4. Aninhar os laços: o caro por fora, o barato por dentro. Manter a grade cara pequena e deliberada (grosseira, ou de grosseira para fina); deixar que o sweep barato seja denso. Gastar o orçamento onde os parâmetros realmente custam algo.
  5. Orçamentar os testes contra o overfitting, não contra o relógio. Agora que o relógio já não é o limite, que o Sharpe deflacionado / PBO o seja. Decidir quantos testes do eixo barato é possível permitir estatisticamente, e validar o vencedor fora da amostra.

O ganho de engenharia e a salvaguarda estatística são duas faces da mesma ideia: separar os eixos permite buscar exaustivamente as dimensões baratas, e é exatamente por isso que depois é preciso descontar o quão exaustiva foi essa busca.

Conclusões principais

  1. Nem todas as dimensões custam o mesmo. Os parâmetros de uma estratégia dividem-se num eixo caro (indicadores — recalculados sobre toda a série) e num eixo barato (limiares — uma passagem O(n) sobre sinais pré-calculados). No motor aqui descrito, isso é 6 caros + 12 baratos para três timeframes, 4 + 8 para dois timeframes.
  2. Os indicadores são invariantes aos limiares, e essa invariância é toda a otimização. A construção de features depende apenas dos parâmetros do indicador, pelo que se constrói uma vez, guarda-se em cache por (period, hma_length), e reutiliza-se em cada configuração de limiar que partilha a combinação.
  3. O eixo barato funciona ~1.600 vezes mais barato. ~5.600 configurações de limiar por segundo (~180 µs cada) sobre sinais em cache, contra ~0,3 s por configuração se os indicadores forem reconstruídos todas as vezes. Mesma fidelidade — a única diferença é a recusa de recalcular um invariante.
  4. A maldição da dimensionalidade é, na verdade, uma maldição de dimensionalidade cara. A maior parte da contagem de parâmetros vive no eixo que é quase gratuito de percorrer. Manter grosseira a grade cara; ser generoso na barata.
  5. A velocidade desloca a restrição vinculante da computação para a estatística. Um sweep denso e rápido é um motor de testes múltiplos. Deflacionar em função do número de testes, validar por fold, e preferir patamares a picos — o almoço grátis é real, mas a conta estatística não é opcional.

O motor completo — SignalCache, sweep_separations, a simulação multi-TF sem fugas, e o teste de paridade que a ancora à semântica em produção da vela em formação — reside em scripts/engine_multitf.py (commit bfc8aaa) no nosso backtester. Da próxima vez que alguém disser que uma estratégia de 18 parâmetros é grande demais para buscar, vale perguntar quantos desses parâmetros realmente movem os indicadores. Normalmente é um terço deles, e o resto é quase gratuito.

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.