O espaço de parâmetros de dois eixos: por que a maior parte do seu sweep deveria ser quase gratuita
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

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

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

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

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:
- 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.
- Indexar um cache apenas sobre o eixo caro. Memoizar a construção de features pelos seus parâmetros de indicador (como faz o
SignalCachecom(period, hma_length)). Testes adjacentes que partilham uma combinação de indicador reutilizam então os mesmos arrays de graça. - 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.
- 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.
- 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
- 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.
- 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. - 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.
- 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.
- 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.
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.