← Voltar aos artigos
June 26, 2026
5 min read

A Escada de Velocidade do Backtest: 298x em uma CPU de Laptop, PnL Idêntico Até o Último Trade

A Escada de Velocidade do Backtest: 298x em uma CPU de Laptop, PnL Idêntico Até o Último Trade
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization
Part 1 of 10 · Collection
High-Performance Backtest Engines

Parte da série "Backtests Sem Ilusões".

📄 Este artigo cresceu até virar um paper de pesquisa. Um kernel de backtest dependente de caminho é implementado de cinco formas — do pandas ingênuo até um kernel numba paralelo — com cada degrau verificado para produzir o mesmo PnL por combinação, de modo que a única coisa que muda é a velocidade. Leia o paper online (versão interativa + PDF) em speed-ladder.marketmaker.cc, código e dados em github.com/suenot/backtest-speed-ladder.

Setenta segundos. É esse o tempo que a implementação de referência ingênua leva para varrer 80 combinações de parâmetros de uma estratégia de média móvel sobre 150.000 barras: pandas rolling().apply() para os indicadores, um loop Python simples para os trades. É o perfil que uma fração enorme do código de pesquisa real usa na prática, porque é o perfil que naturalmente surge quando se escreve a estratégia da forma mais óbvia.

O mesmo sweep, no mesmo laptop, produzindo o mesmo PnL para cada combinação até o último trade: 0,23 segundos.

A diferença entre esses dois números — um medido 298x — é o assunto deste artigo. Nem um ponto percentual dela veio de hardware novo. Nenhuma GPU foi envolvida (nenhuma sequer está disponível nesta máquina no sentido CUDA). Cada degrau da escada é a mesma estratégia, os mesmos dados, as mesmas taxas, a mesma contagem de trades, verificada por um gate de equivalência que reprova o benchmark inteiro se os resultados por combinação de qualquer implementação divergirem. O que mudou foi apenas como o trabalho é expresso: o que roda no interpretador, o que roda compilado, e o que roda em paralelo. E como uma baseline deliberadamente lenta pode lisonjear qualquer número de manchete, mais um dado antes de começar: mesmo contra uma implementação numpy vetorizada competente — o código que um bom programador numpy entregaria — o motor final ainda é cerca de 13x mais rápido.

Quando uma busca de parâmetros é lenta, o reflexo é buscar hardware maior — uma GPU, um cluster, um orçamento de nuvem. A realidade medida deste experimento aponta para algo bem menos glamouroso: o gargalo era o motor (um loop interno interpretado fazendo chamadas Python por janela) e a orquestração (rodando combinações independentes de forma serial em um único núcleo). Ambos são corrigíveis em uma tarde, na máquina que você já tem, sem nenhuma mudança nos resultados.

Aqui está a escada inteira de uma vez. Tudo abaixo é a anatomia de cada degrau.

Degrau Implementação Tempo de parede Speedup Combos/s
M0 pandas: rolling.apply + loop Python por barra 69.92 s 1.0x 1.1
M1 numpy: WMA por janela deslizante + trades vetorizados 3.07 s 22.7x 26.0
M2 numba: WMA @njit + loop de eventos @njit 1.98 s 35.3x 40.4
M3 numba prange: threads entre combos 0.32 s 217.6x 248.9
M4 pool de processos + numba: processos entre combos 0.23 s 297.9x 340.9

Apple M2 Max (12 núcleos), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) fixado em uma única thread para que os degraus single-thread sejam genuinamente single-core. 150.000 barras × 80 combos, melhor de 3 tempos de parede, aquecimento do JIT excluído. Todos os degraus — incluindo a baseline pandas — cronometrados por completo e verificados para produzir PnL e contagens de trades idênticas por combo nos 80 combos.

Um kernel, cinco implementações

Five rungs of one staircase: the same backtest kernel climbing from a 70-second pandas baseline to a 0.23-second parallel numba run, each step verified to produce identical PnL

Para que uma comparação de velocidade tenha algum significado, a coisa sendo computada precisa estar exatamente fixada, e cada implementação precisa ser comprovada como computando-a. Então o experimento fixa um único kernel de estratégia e o mantém constante nos cinco degraus.

O kernel é um cruzamento HMA/HMA3 — um sistema stop-and-reverse sobre duas médias móveis estilo Hull. O bloco de construção é a média móvel ponderada:

WMAp(x)i=j=1pjxip+jj=1pj\mathrm{WMA}_p(x)_i = \frac{\sum_{j=1}^{p} j \cdot x_{i-p+j}}{\sum_{j=1}^{p} j}

A Hull Moving Average compõe três delas para reduzir o atraso:

HMAn(x)=WMAn(2WMAn/2(x)WMAn(x))\mathrm{HMA}_n(x) = \mathrm{WMA}_{\lfloor\sqrt{n}\rceil}\Big(2\,\mathrm{WMA}_{\lfloor n/2\rceil}(x) - \mathrm{WMA}_{n}(x)\Big)

e a HMA3 é uma irmã mais suave, construída a partir de WMAs em aproximadamente n/6n/6, n/4n/4 e n/2n/2, suavizada mais uma vez. Por combinação de parâmetros isso são sete passagens de WMA sobre seis comprimentos de janela distintos — uma pilha de indicadores real, não um brinquedo.

A regra de trading é deliberada e proveitosamente stateful: a direção é comprada quando a HMA está abaixo da HMA3 e vendida caso contrário; abre-se uma posição na primeira direção definida; em cada cruzamento, fecha-se a posição, contabiliza-se o PnL menos uma taxa de round-trip de 0,09%, e reverte-se. A posição carrega entre barras — o que você faz na barra ii depende do estado acumulado desde o último cruzamento. Essa dependência de caminho é o cerne do experimento: é a propriedade que faz backtests diferentes de pipelines genéricos de dataframe, e (como vamos medir) complica a questão da GPU — embora não, como se verá, da forma que o folclore diz.

O resto da configuração, para que você possa julgar os números:

  • Dados: 150.000 barras de movimento browniano geométrico sintético, com seed fixa (seed=42). O desempenho aqui é limitado pelo tamanho do array e pelos comprimentos de janela, não por qual caminho de preço você alimenta — e uma série sintética torna todo o experimento determinístico e reproduzível por qualquer pessoa.
  • Grid: 80 comprimentos distintos de HMA espalhados sobre [6,200][6, 200] — de modo que o sweep contém tanto combos baratos de janela curta quanto combos caros de janela longa, como um grid real.
  • Cronometragem: wall-clock, melhor de 3 por degrau, com a compilação JIT aquecida fora do cronômetro e os workers do pool aquecidos antes do início da contagem. Cada degrau — incluindo a baseline pandas — é cronometrado por completo nos 80 combos. O BLAS (Accelerate da Apple) é fixado em uma única thread, para que os degraus single-thread sejam genuinamente single-core: o degrau numpy não está discretamente multithreading seus matvecs por trás da comparação.
  • Gate de equivalência: após a cronometragem, o vetor (PnL, contagem de trades) por combo de cada degrau é comparado contra a referência — as contagens de trades precisam bater exatamente, o PnL até uma tolerância absoluta de 10610^{-6} pontos percentuais. A execução registrada reporta all_ok: true para todos os degraus, incluindo a baseline pandas, nos 80 combos. Se esse gate falhar, não há benchmark — há apenas cinco programas computando cinco coisas diferentes em cinco velocidades diferentes, que é como muitas alegações de "nosso motor é 100x mais rápido" discretamente funcionam.

Um número do bloco de equivalência merece um momento de honestidade: a fingerprint do primeiro combo é um PnL de −5165,58 pontos percentuais em 57.029 trades. Isso não é um resultado de estratégia do qual se envergonhar — é o comprimento de HMA mais curto (6) virando a quase todo tremor de um passeio aleatório e pagando 0,09% a cada vez, exatamente como deveria. É uma fingerprint de correção, não um backtest negociável. Não leia alfa nisso; leia determinismo nisso — cinco implementações chegando aos mesmos 57.029 trades e ao mesmo PnL até seis casas decimais é o que "idêntico" significa aqui.

Com isso estabelecido, cada speedup abaixo é velocidade pura. Nada foi aproximado embora.

Degrau M0: o perfil pandas ingênuo — 69,9 s

Anatomy of the naive pandas baseline: a rolling.apply window spawning a Python lambda call for every one of 150,000 bars while the interpreter loop crawls beneath it

A baseline não é um espantalho. É o código que você obtém ao escrever uma WMA da forma sugerida pela documentação do pandas e o loop de eventos da forma que a descrição da estratégia sugere:

def pd_wma(s: pd.Series, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    w /= w.sum()
    return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()

def run_pandas_one(close, length):
    h, h3 = pd_hma(close, length), pd_hma3(close, length)  # 7 rolling.apply WMAs
    total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
    for i in range(len(close)):                            # Python bar loop
        if np.isnan(h[i]) or np.isnan(h3[i]):
            continue
        d = 1 if h[i] < h3[i] else -1
        if prev_dir == 0:
            prev_dir, pos, entry = d, d, close[i]
            continue
        if d != prev_dir:                                  # cross: close + reverse
            pnl = ((close[i] - entry) if pos == 1
                   else (entry - close[i])) / entry * 100 - FEE
            total += pnl
            ntr += 1
            pos, entry, prev_dir = d, close[i], d
    return total, ntr

Por que isso é lento? Não porque o pandas seja "ruim" — mas por causa de onde a iteração vive. rolling(period).apply(lambda ...) é um loop em nível Python vestido de fantasia vetorizada. Para cada uma das 150.000 barras, o pandas materializa uma janela, cruza a fronteira C/Python, invoca um callable Python, e empacota o resultado. Mesmo com raw=True (que ao menos entrega à lambda um ndarray puro em vez de uma Series), o overhead de interpretador por chamada supera em muito as dezenas-a-centenas de FLOPs que a janela realmente precisa. Multiplique por sete passagens de WMA por combo, e a pilha de indicadores sozinha já são milhões de idas e voltas ao interpretador. Depois o loop de barras roda outras 150.000 iterações interpretadas por combo, cada uma fazendo indexação com verificação de limites em escalares numpy, empacotando floats, e despachando dinamicamente sobre tipos que o interpretador redescobre a cada vez.

O resultado: 69,92 s para o sweep, cerca de 0,87 s por combo, um throughput de 1,1 combos por segundo. Em um grid de 80 combos você dá de ombros e espera um minuto. O problema é que ninguém roda grids de 80 combos por muito tempo — e esse custo escala linearmente para sempre. Vamos voltar a isso.

Degrau M1: numpy — pare de chamar Python em um loop — 3,07 s, 22,7x

O primeiro degrau elimina ambos os loops interpretados de uma vez, e vale a pena separar os dois truques porque eles têm generalidade muito diferente.

O lado do indicador é o fácil, totalmente geral. Uma média móvel ponderada sobre todas as janelas é apenas um produto matriz-vetor contra uma view com stride do input — sem cópias, uma única chamada BLAS:

def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    win = np.lib.stride_tricks.sliding_window_view(x, period)  # zero-copy view
    out = np.full(len(x), np.nan)
    out[period - 1:] = win @ w / w.sum()                       # one matvec
    return out

sliding_window_view constrói uma view (n − p + 1, p) da mesma memória, e win @ w computa o produto escalar de cada janela em código compilado. O milhão de invocações de lambda vira uma chamada de biblioteca.

O lado do trade é o interessante, porque o loop de eventos é stateful — e ainda assim, para este kernel, ele se vetoriza. A percepção é que a posição em qualquer barra depende apenas do sinal de HMA − HMA3, não de nenhum resultado de trade. O estado nunca realimenta as decisões. Então o loop inteiro colapsa em "encontre as viradas de sinal, colete os preços nesses índices":

d = np.where(h[idx] < h3[idx], 1, -1)             # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1       # bars where it crosses
cross = idx[np.concatenate(([0], flips))]         # entry/exit indices
side  = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
               (entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)

3,07 s, um speedup de 22,7x, 26,0 combos por segundo — em um único núcleo, com o BLAS fixado em uma única thread. Este degrau merece um rótulo: é a baseline competente, a implementação que um bom programador numpy entregaria, e a régua justa para tudo acima dele. Mas duas ressalvas honestas acompanham este degrau.

Primeiro, essa vetorização é uma reescrita analítica específica da estratégia, não uma transformação mecânica. Ela existe porque o kernel é stop-and-reverse sem stops, sem saídas com trailing, sem dimensionamento de posição que dependa do PnL corrente. Adicione um stop-loss — a funcionalidade mais comum imaginável — e a saída na barra ii muda qual entrada existe na barra j>ij > i, o estado realimenta o caminho, e a forma fechada evapora. A maioria dos kernels de produção vive do lado errado dessa linha.

Segundo, este é o degrau onde a correção vai morrer. A contabilidade de índices de virada (+1 aqui, [:-1] ali, a semeadura da primeira direção) é exatamente o tipo de código que produz bugs de execução off-by-one — a mesma espécie de bug que nossa taxonomia de look-ahead mostrou ser capaz de fabricar um Sharpe de 15 a partir de ruído. O gate de equivalência não é uma formalidade neste degrau; é a única razão para confiar nele. Reescritas vetorizadas engenhosas sem uma checagem de equivalência contra uma implementação de referência simples são como motores se afastam sutilmente da estratégia que alegam testar.

Degrau M2: numba — compile o loop que você realmente quer escrever — 1,98 s, 35,3x

A Python event loop passing through the numba JIT compiler and emerging as tight machine code: the same branchy bar-by-bar logic, compiled instead of interpreted

O degrau M2 adota a filosofia oposta: em vez de contorcer o algoritmo para caber em primitivas vetorizadas, escreva os loops ingênuos — e compile-os. O Numba (Lam, Pitrou & Seibert, 2015) compila via JIT um subconjunto numérico de Python através do LLVM para código de máquina:

@njit(cache=True)
def nb_wma(x, period):
    n = x.shape[0]
    out = np.full(n, np.nan)
    wsum = period * (period + 1) / 2.0
    for i in range(period - 1, n):        # the "slow" loop, now machine code
        s = 0.0
        for j in range(period):
            s += x[i - period + 1 + j] * (j + 1)
        out[i] = s / wsum
    return out

@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
    h  = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
    a  = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
    h3 = nb_wma(a, pi)

O loop de eventos dentro de nb_sweep é textualmente o loop do M0. Branches, continue, estado carregado em locais — tudo isso. Sob @njit essas variáveis locais vivem em registradores, os branches são instruções de salto reais, e o custo por iteração cai de microssegundos de despacho de interpretador para nanossegundos.

1,98 s — 35,3x sobre o pandas, mas apenas cerca de 1,6x sobre o numpy (derivado: 3,07/1,98). Esse passo modesto é instrutivo por si só: os loops internos do numpy já estavam compilados, então o ganho do numba na matemática de features se limita a pular a materialização de janelas e arrays intermediários. A parte transformadora está em outro lugar:

  1. O loop de eventos agora é grátis — e "grátis" é medido, não retórico. O M1 gastou sua engenhosidade tornando a lógica de trade vetorizável. O M2 torna essa engenhosidade desnecessária — o loop ingênuo, auditável, fácil de modificar roda em velocidade de máquina. Cronometrar o estágio de features separadamente do loop de trade dentro deste kernel compilado atribui 99,3% do seu tempo à matemática de features WMA e apenas 0,7% ao loop de eventos stateful. Você pode adicionar um stop-loss amanhã sem precisar de um projeto de pesquisa — e guarde essa divisão; ela redecide o argumento da GPU abaixo.
  2. Ele destrava os próximos dois degraus. Um kernel compilado, que libera o GIL, com alocação leve é a unidade de trabalho de que a orquestração paralela precisa. Você não consegue paralelizar produtivamente o M0 — doze cópias de algo lento continuam lentas, só mais aquecidas.

Uma nota metodológica: o numba compila na primeira chamada, e essa compilação (centenas de milissegundos) não deve estar dentro do cronômetro — o harness aquece o JIT em uma fatia de 500 barras antes de medir, e cache=True persiste os kernels compilados entre execuções do processo. Benchmarks que "esquecem" esse detalhe produzem números de numba injustamente ruins (com a compilação fria incluída) ou irreprodutíveis.

Degrau M3: prange — o paralelismo que você já tinha — 0,32 s, 217,6x

Eighty independent parameter combos fanned out across twelve CPU cores: performance and efficiency cores pulling unequal window lengths in parallel

Aqui está a observação que torna a busca massiva de parâmetros especial: os 80 combos são completamente independentes. Sem estado compartilhado, sem ordenação, sem comunicação. Isso é trabalho embaraçosamente paralelo que os degraus M0–M2 rodavam em um único núcleo entre doze, por puro hábito.

O numba torna a correção quase sintática — troque o range do loop de combos por prange:

@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
    N = params.shape[0]
    totals = np.empty(N, dtype=np.float64)
    ntrs = np.empty(N, dtype=np.int64)
    for k in prange(N):                    # threads across combos
        t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
                          params[k, 3], params[k, 4], params[k, 5], fee)
        totals[k] = t
        ntrs[k] = ntr
    return totals, ntrs

Como nb_sweep é compilada em modo nopython, ela não segura o GIL, e a camada de threading do numba distribui as iterações pelos 12 núcleos. O array close de somente leitura é compartilhado por todas as threads a custo zero.

0,32 s — 217,6x sobre o pandas, 248,9 combos por segundo. O passo sobre o M2 single-thread é de cerca de 6,2x em 12 núcleos (derivado: 1,98/0,32), e a diferença em relação ao "ideal 12x" vale ser explicada honestamente em vez de escondida: os 12 núcleos do M2 Max são 8 de performance + 4 de eficiência, então o teto nominal nunca foi 12x; os 80 combos têm custos extremamente desiguais (uma HMA de comprimento 6 é muito mais barata que uma de comprimento 200), então as threads terminam de forma desigual; e cada chamada de kernel aloca seus arrays intermediários de um alocador compartilhado. Speedups paralelos em máquinas reais têm essa cara. Quem cita um Nx limpo em N núcleos para tarefas heterogêneas está medindo algo sintético.

Degrau M4: um pool de processos pelo último terço — 0,23 s, 297,9x

O degrau final substitui threads por processos — o mesmo kernel compilado, orquestrado por um ProcessPoolExecutor:

with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
                         initargs=(close,)) as ex:          # ship data ONCE
    list(ex.map(_warmup_worker, range(12 * 3)))             # JIT-warm every worker
    results = list(ex.map(_run_one_combo, grid, chunksize=1))

0,23 s — 297,9x sobre o pandas, 340,9 combos por segundo. Leia esse throughput novamente: este laptop agora está rodando aproximadamente 340 backtests completos de 150.000 barras por segundo, cada um computando sete médias móveis ponderadas e simulando dezenas de milhares de trades stateful.

A vantagem sobre o prange é real mas modesta — cerca de 1,4x (derivado: 0,32/0,23) — e a mecânica plausível é escalonamento e isolamento de memória: com chunksize=1 o pool distribui os combos um de cada vez, então a mistura desigual de janelas baratas e caras se balanceia dinamicamente entre os núcleos assimétricos, e cada processo worker tem seu próprio alocador, contornando a contenção nos temporários por combo. Reportamos isso como mecânica consistente com a medição, não como fatos separadamente comprovados.

Processos não são gratuitos, e o harness paga seus custos honestamente fora do cronômetro, onde são custos únicos (inicialização dos workers, envio de close para cada worker via o initializer, aquecimento do JIT por worker) — porque em uma busca real esses custos se amortizam ao longo de milhares de combos, não oitenta. A orientação geral honesta: prange é mais simples e geralmente suficiente; um pool de processos vence quando as tarefas são volumosas, o grid é grande, ou seu trabalho por combo segura o GIL em algum lugar que o numba não alcança.

E com isso, a escada se fatora em um resumo limpo. De M0 a M2 — o motor: 35,3x em um único núcleo, ao mover a iteração para fora do interpretador. De M2 a M4 — a orquestração: mais 8,4x (derivado: 1,98/0,23), ao usar os núcleos que já estavam lá. Multiplicado: 298x. Sem hardware novo, resultados idênticos. E medido a partir da baseline competente M1 em vez da ingênua, o motor final ainda fica cerca de 13x mais alto (derivado: 3,07/0,23) — a escada não é um artefato de escolher um ponto de partida lento.

Por que não uma GPU — a versão honesta

A GPU sitting idle beside a saturated CPU: batchable moving-average math left on the CPU because a sweep of eighty combos and a quarter of a second is too narrow and too short to pay for the trip

"Simplesmente porte para uma GPU" é a resposta mais comum a um sweep de parâmetros lento, então este experimento mede os dois números dos quais essa conversa deveria partir — e nenhum sustenta a versão preguiçosa de nenhuma das respostas.

O modelo roofline (Williams, Waterman & Patterson, 2009) classifica um kernel por sua intensidade aritmética — FLOPs por byte movido. Para a pilha de features WMA neste sweep, contando 2p2p FLOPs por barra por janela de comprimento pp contra uma leitura de 8 bytes por barra, o sweep inteiro de 80 combos resulta em cerca de 6,2 GFLOP sobre 576 MB transferidos:

I=6.21×109 FLOP5.76×108 bytes10.78 FLOPbyteI = \frac{6.21 \times 10^9\ \text{FLOP}}{5.76 \times 10^8\ \text{bytes}} \approx 10.78\ \frac{\text{FLOP}}{\text{byte}}

(Essa é a contagem idealizada sobre as seis janelas WMA distintas por combo; contando as sete passagens como realmente executadas dá 11,07 FLOP/byte. Mesma conclusão de qualquer forma.)

Esse número importa por causa do que ele descarta: a alegação popular de que a matemática de backtest é "limitada por memória, então GPUs não podem ajudar" é falsa aqui. A ~10,8 FLOP/byte a matemática de features é decididamente do tipo compute-bound — bem além do ridge point onde hardware típico deixa de ser limitado por banda. Uma GPU absolutamente poderia agrupar 80 combos × 7 passagens WMA em um punhado de kernels grandes e mastigar a aritmética. Se a pilha de features fosse o problema inteiro, o caso da GPU seria respeitável.

O segundo número medido mata a outra resposta preguiçosa — a que nós mesmos teríamos buscado. Cronometrar o estágio de features separadamente do loop de trade dentro do kernel compilado dá uma divisão de 99,3% features, 0,7% loop de eventos. O argumento tentador — "backtests têm um loop de eventos stateful e ramificado, e isso é o que bloqueia a GPU" — está quantitativamente errado aqui: a CPU gasta essencialmente todo o seu tempo exatamente na parte que uma GPU poderia agrupar em batch. Reformule 80 combos × 7 passagens WMA como grandes convoluções em batch e você tem uma carga de trabalho de tensor perfeitamente razoável. Então a pergunta honesta não é se o trabalho poderia ir para uma GPU — a maior parte poderia. A pergunta é se a viagem compensa, e para este sweep não compensa, por duas razões específicas:

1. A largura explorável é de 80 combos — e uma GPU é uma máquina de largura. O único eixo honesto de paralelismo em um sweep de parâmetros é o próprio grid: dentro de um combo, o caminho de 150.000 barras é sequencial. Uma GPU quer dezenas de milhares de itens de trabalho independentes para preencher suas lanes e esconder latência; este sweep oferece oitenta. Doze núcleos de CPU já saturam essa largura — é literalmente o que os degraus M3–M4 mediram. Para as contagens de combos onde a largura de uma GPU sequer começaria a se engajar, a escada de CPU já está entregando centenas de backtests completos por segundo.

2. O trabalho inteiro dura 0,23 segundos. Na velocidade do M4 um combo custa cerca de 2,9 ms (derivado: 0,23 s / 80). Contra esse orçamento, latências de lançamento de kernel e pontos de sincronização de dispositivo não são erros de arredondamento amortizáveis — são uma fração material do trabalho. (Nesta máquina Apple de memória unificada, a transferência host-para-dispositivo é uma preocupação menor; em uma caixa CUDA com GPU discreta, ela também entra na conta.) A clássica vantagem da GPU amortiza overheads fixos sobre lotes enormes de trabalho; um sweep de menos de um segundo nunca produz um.

E o loop de eventos? É a única parte que não faria batch — serial, ramificado, dependente de caminho, uma dependência carregada por loop de 150.000 barras que nenhum hardware pode paralelizar dentro de um combo, com exatamente os branches divergentes que as lanes SIMT odeiam. Um port para GPU deixaria isso na CPU ou rodaria uma lane por combo. Mas a 0,7% do kernel, é um termo de Amdahl pequeno demais para decidir qualquer coisa. É a parte que não iria; não é a razão para não ir. (Lembre do degrau M1 que para kernels sem realimentação o loop pode até ser analiticamente vetorizado — a reescrita que você perde no momento em que a estratégia ganha um stop.)

Uma nota de plataforma para completude: nesta máquina (Apple Silicon) o caminho de GPU seria MLX ou PyTorch-MPS, não CUDA — cupy e o ecossistema CUDA simplesmente não se aplicam — e qualquer um deles exigiria reescrever o caminho quente em um dialeto de tensor só para tentar o experimento. Isso é um custo real, sem, de acordo com a análise acima, nenhum retorno identificado para o formato deste sweep. A discussão sobre GPU aqui é analítica, fundamentada na intensidade aritmética medida e na divisão feature/loop medida, e a rotulamos como tal: nenhuma execução CUDA foi feita porque nenhuma era possível no hardware divulgado.

A frase-resumo que defenderíamos em uma revisão: quase todo esse trabalho poderia ir para uma GPU; este sweep é estreito e curto demais para a viagem compensar. E leia isso nas duas direções — não é um veredito de descarte. A reformulação em "big-matrix" em batch — recastando o sweep como grandes operações de tensor em milhares de combos de uma vez, ou um kernel genuinamente livre de realimentação que faz batch de ponta a ponta — é uma direção real e promissora que merece um estudo dedicado, não uma dispensa. A 80 combos e 0,23 segundos, ela simplesmente ainda não ganhou o bilhete. Se sua carga de trabalho tem essa largura, a aritmética muda, e você deveria refazer a conta, não nos citar.

Onde está o gargalo real: motor e orquestração

The real bottleneck revealed: an hourglass where the engine and the orchestration of thousands of parameter combos choke the flow, not the hardware underneath

Oitenta combos é um grid de demonstração. A busca de parâmetros real é onde esses fatores deixam de ser acadêmicos, porque grids crescem multiplicativamente: quatro parâmetros com dez valores cada são 10410^4 combos; adicione validação walk-forward com uma dúzia de folds e você está em 1,2×1051,2 \times 10^5 backtests completos antes mesmo de explorar qualquer coisa. Essa é a maldição da dimensionalidade, e é por isso que a estratégia de busca — Optuna, coordinate descent, Sobol — recebe tanta atenção: buscas mais inteligentes visitam menos pontos.

Mas a escada expõe a outra metade, menos discutida, da equação: o custo por ponto visitado. Extrapolando os throughputs medidos linearmente (os combos são independentes, então isso é aritmética, não modelagem):

Tamanho do grid No M0 (1,1 combos/s) No M4 (340,9 combos/s)
10.000 combos ~2,4 horas ~30 segundos
100.000 combos ~24 horas ~5 minutos

O mesmo experimento que é um job de batch noturno no motor ingênuo é uma consulta interativa no motor ajustado. Essa diferença se acumula de uma forma que tabelas de wall-clock subestimam: a 5 minutos por sweep você itera — reexecuta com um vazamento corrigido, adiciona um fold, amplia o grid, testa a ideia que surgiu no almoço. A 24 horas por sweep, você não faz isso. A velocidade do motor define o ritmo do loop de pesquisa, e o ritmo do loop de pesquisa é o produto de fato.

Há também uma leitura via lei de Amdahl para a escada inteira:

S=1(1p)+p/sS = \frac{1}{(1 - p) + p / s}

Acelerar qualquer estágio único pp por um fator ss é limitado por tudo mais que você deixou lento. A escada respeitou essa ordem: o ganho de 35,3x do motor atacou o termo que dominava (iteração interpretada, tanto na pilha de features quanto no loop), e o ganho de 8,4x da orquestração atacou o termo que dominava depois disso (onze núcleos ociosos). A divisão feature/loop é a mesma lição em miniatura — não poderíamos ter nomeado o real formato do argumento da GPU sem medir para onde o tempo realmente ia. Faça profiling, depois otimize — nessa ordem. A mesma lógica governa a camada de dados a montante do motor: nossos benchmarks Polars vs pandas encontraram o padrão idêntico (10–3500x em pipelines de rolling agrupado) para a metade de carga e transformação da pilha, e a mesma conclusão híbrida — motores colunares para o pipeline, um kernel compilado para a simulação dependente de caminho.

Duas notas de honestidade para fechar o ciclo sobre generalidade. Primeiro, este experimento é deliberadamente autocontido e sintético — dados com seed fixa, um kernel, uma máquina divulgada — para que qualquer um possa reproduzir o fenômeno deterministicamente; os números de wall-clock serão diferentes no seu hardware, mas a equivalência e a direção da escada não serão. Segundo, o fenômeno não é um artefato da configuração sintética: o benchmark do nosso motor HMA de produção (bench_param_sweep.py, rodado em dados reais de exchange com o modelo completo de taxas e fills de produção) mostra o mesmo formato de escada, com o caminho numba pousando aproximadamente 100–200x acima do perfil pandas ingênuo. O experimento autocontido existe para que você não precise confiar em nossos números de produção por fé.

Conclusões

  1. A escada é de 298x, e ela se fatora: 35,3x motor × 8,4x orquestração. Mover a iteração para fora do interpretador (pandas → numba) e distribuir combos independentes entre núcleos (um → doze) multiplicaram-se em um speedup próximo de três ordens de magnitude em um laptop inalterado. 69,92 s → 0,23 s; 1,1 → 340,9 combos/s. E não é um artefato de baseline lenta: contra a implementação numpy vetorizada competente, o motor final ainda é ~13x.
  2. Exija equivalência antes de admirar velocidade. Cada degrau aqui produz PnL e contagens de trades idênticas por combo, verificadas automaticamente nos 80 combos (tolerância absoluta de 10610^{-6} no PnL, exata nos trades). Um motor rápido que computa algo sutilmente diferente não é rápido — está errado em alto throughput, e reescritas vetorizadas são onde o erro costuma se infiltrar.
  3. @njit vence a vetorização engenhosa para lógica stateful. O degrau numpy precisou de uma forma fechada específica da estratégia que morre no momento em que você adiciona um stop-loss. O degrau numba compila o loop ingênuo e auditável — mesma classe de velocidade, nenhuma da fragilidade, e é a unidade que se paraleliza.
  4. A resposta da GPU é "não para este sweep" — por razões que você deveria conseguir nomear. A matemática de features é do tipo compute-bound (10,78 FLOP/byte) e é 99,3% do kernel compilado, então nem "backtests são limitados por memória" nem "o loop stateful domina" sobrevivem à medição. As razões honestas são largura e orçamento: 80 combos de paralelismo explorável que 12 núcleos de CPU já saturam, e um job total de 0,23 s que o overhead de lançamento e sincronização engoliria. A reformulação em big-matrix com batch em largura real continua sendo uma direção promissora, não refutada.
  5. Velocidade do motor é ritmo de pesquisa. No throughput do motor ingênuo, uma busca de 100.000 backtests é um dia; no throughput do topo da escada são cinco minutos. Antes de comprar hardware ou alugar um cluster, verifique se seu gargalo é sequer silício — o nosso era um lambda dentro de rolling.apply e onze núcleos ociosos.

O experimento completo — as cinco implementações, o harness de equivalência, o cálculo roofline, e cada número deste artigo regenerável a partir de um único script determinístico — está no paper complementar em speed-ladder.marketmaker.cc, com código e dados em github.com/suenot/backtest-speed-ladder.

O sweep que levava setenta segundos leva um quarto de um. Os mesmos trades, o mesmo PnL, o mesmo laptop. A GPU que você estava prestes a requisitar pode esperar; o loop de interpretador que você estava prestes a colocar em produção não pode.

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.