Cada número da nota, e de onde ele veio
Método suficiente para conferir a aritmética na mão, ou para reconstruir um estudo equivalente a partir da mesma fonte licenciada.
Dados e procedência
A fonte é o Algoseek US Equity Trades and Quotes em resolução de nanossegundos, extraído do feed SIP consolidado (CTS, CQS, UTDF, UQDF) coletado no Equinix NY2 e NY4. Cada símbolo-dia é um CSV comprimido com o schema Date, Timestamp, EventType, Ticker, Price, Quantity, Exchange, Conditions, em que Timestamp é HH:MM:SS.nnnnnnnnn e Conditions é uma máscara de bits de condição de venda de 32 bits em hexadecimal.
Os pregões são enumerados a partir do manifesto diário em .index/YYYYMMDD.csv.gz e não por listagem do bucket, que é negada neste produto. Um único GET pequeno devolve bucket, prefix, file, compressed_bytes, uncompressed_bytes para o dia inteiro, em que a coluna 3 é o tamanho comprimido e a coluna 4 o descomprimido. Ler essa ordem ao contrário foi o que produziu uma estimativa inicial de 300 GB/dia para um produto que tem 27 GB/dia.
| Bloco | Pregões | Intervalo | Tamanho comprimido |
|---|---|---|---|
| 2019 | 60 | 20190201 a 20190429 | 12.4 GB |
| 2022 | 60 | 20220201 a 20220427 | 44.9 GB |
| 2024 | 60 | 20240201 a 20240426 | 29.3 GB |
Escopo exato baixado: 900 símbolo-dias em AAPL, SPY, JPM, XOM e F, 86,6 GB comprimidos. Os arquivos são baixados como GETs de gzip puro e não via S3 Select, que devolve CSV descomprimido e praticamente dobrou os bytes em um arquivo de teste.
Duas armadilhas no feed, ambas verificadas empiricamente
Errar qualquer uma das duas corrompe silenciosamente toda feature de volume e de fluxo de ordens, e nenhuma delas é óbvia na especificação do fornecedor.
O tape é a união de TRADE e TRADE NB, e os dois são disjuntos. A especificação descreve TRADE NB como um negócio no NBBO ou dentro dele e TRADE como todo o resto, o que deixa em aberto se o segundo reemite o primeiro, então a questão foi resolvida por medição.
A sobreposição exata por tupla de timestamp, preço, quantidade e venue dá 0. Um teste de correspondência aproximada, perguntando se cada impressão TRADE NB tem uma impressão TRADE de mesmo preço, quantidade e venue dentro de 1 ms, devolve taxa de acerto de 0,0000, contra 0,0000 para um controle cujos timestamps de TRADE são deslocados 5 segundos para frente.
Estruturalmente os dois também são disjuntos: de 95% a 98% das impressões TRADE carregam a flag de odd lot no bit 31, contra 0,00% das impressões TRADE NB, e o próprio produto só de trades da Algoseek contém exatamente essa união, 83.244 linhas contra as 83.243 da união para JPM em 2019-03-15. Usar apenas uma metade corta o tape ao meio, e tratá-las como duplicatas também.
Restatements oficiais de abertura e fechamento precisam ser descartados. O bit 24 (tOfficialClose) e o bit 26 (tOfficialOpen) marcam um market center reafirmando o preço do leilão, não uma nova execução. Para F em 2024-03-15 a impressão de fechamento da NYSE de 36.626.227 ações aparece cinco vezes, uma como a execução do leilão e depois de novo às 16:01, 16:10, 18:30 e 19:00, de modo que somar a união ingenuamente dá a F 238 milhões de ações naquele dia, enquanto descartar os bits 24 e 26 dá 88 milhões, que é o que F de fato negociou.
Uma terceira correção é menor mas vale registrar: o briefing original pedia excluir FINRA, CRF e outros venues fora de bolsa, só que CRF não existe como rótulo neste feed. Todos os 19 identificadores de market center do SIP foram enumerados ao longo dos três anos, e a Algoseek consolida todo reporte fora de bolsa sob FINRA (código SIP D), então excluir FINRA é a forma operacional dessa instrução.
Abaixo está o filtro completo, aplicado de forma idêntica aos dois datasets para que as barras OHLC por trás dos braços A e B sejam bit a bit iguais.
tape = pl.concat([trade, trade_nb]) # disjoint halves, verified above
tape = tape.filter(
(pl.col("cond") & (1 << 24) == 0) # tOfficialClose restatement
& (pl.col("cond") & (1 << 26) == 0) # tOfficialOpen restatement
& (~pl.col("event").is_in(["TRADE CANCELLED", "TRADE NB CANCELLED"]))
& (~pl.col("Exchange").is_in(["FINRA", "UNKNOWN", "INVALID"]))
& pl.col("ts").is_between(RTH_OPEN, RTH_CLOSE)
& (pl.col("Price") > 0) & (pl.col("Quantity") > 0)
)Reconstrução do NBBO
A melhor oferta de compra e venda nacional vem das linhas QUOTE BID NB e QUOTE ASK NB, preenchidas para frente por lado, com as linhas que compartilham timestamp colapsadas antes ao seu último estado. Esse colapso importa porque um lado pode imprimir antes do outro, então preencher a sequência crua para frente fabrica books travados ou cruzados que nunca existiram, a uma taxa perto de 5% das atualizações.
nb = (
quotes.sort("ts")
.group_by("ts", maintain_order=True).last() # collapse before filling, not after
.with_columns([pl.col("bid").forward_fill(), pl.col("ask").forward_fill()])
)As estatísticas ponderadas por tempo são calculadas de forma exata inserindo uma linha sintética de estado em cada fronteira de minuto, de modo que cada intervalo de peso cai inteiramente dentro de uma barra.
Conjuntos de features
As 53 features do braço B cobrem geometria da barra (amplitude, corpo, sombras, close location value e gap), os estimadores de volatilidade Parkinson, Garman-Klass e Rogers-Satchell, desvios e cruzamentos de médias móveis em 5, 15, 60 e 390 barras, momentum e taxa de variação até 1.950 barras, volatilidade e assimetria móveis, RSI em 14 e 60, MACD com sinal e histograma, ATR, %B e largura de Bollinger, %K e %D do Estocástico, Williams %R, CCI, posição Donchian, TRIX, índice de Ulcer, frequência de novos extremos, uma razão de volatilidade curta sobre longa e retornos defasados.
Volume está deliberadamente ausente porque não é uma quantidade OHLC, o que exclui on-balance volume, money flow e indicadores baseados em VWAP do braço B por construção.
O braço A acrescenta 151 features do tape em três famílias. As colunas do lado do negócio trazem contagem de trades, volume, volume financeiro, tamanho médio, mediano e máximo de trade, desvio do VWAP em relação ao fechamento, volume de compra e de venda sob classificação Lee-Ready contra o NBBO vigente, volume e fluxo financeiro assinados, desequilíbrio de contagem, as frações executadas em cada lado da cota ou além dele e estritamente dentro dela, spread efetivo simples e ponderado por volume, variância realizada e variação bipower a partir de preços negociados, iliquidez de Amihud, spread implícito de Roll, lambda de Kyle estimado dentro do minuto, média e dispersão da duração entre trades, participação de odd lots e de blocos, contagem de venues, índice Herfindahl de venues, participação do venue principal e um markout de um segundo.
As colunas do lado da cota trazem o spread cotado ponderado por tempo em bps com mínimo, máximo e dispersão, profundidade ponderada por tempo de cada lado, desequilíbrio de tamanho, microprice e seu desvio em relação ao ponto médio, abertura, máxima, mínima e fechamento do ponto médio, variância realizada do ponto médio, a fração de tempo travado ou cruzado, contagens de atualização do NBBO por lado, razão cota-negócio e o desequilíbrio de fluxo de ordens de Cont acumulado ao longo de estados consecutivos do NBBO. Doze desses sinais entram ainda nas defasagens 1, 2, 3 e 5 e como médias móveis de 5, 15 e 60 barras, dando ao braço A a mesma profundidade de histórico que os indicadores móveis do braço B já têm.
Juntar uma ação de US$ 11 com um ETF de US$ 520 cujas taxas de mensagem diferem em duas ordens de grandeza significa que colunas de nível não podem entrar cruas. Níveis de preço viram desvios em pontos-base do fechamento da barra e colunas de contagem e tamanho viram z-scores móveis em janela de cinco dias, com fluxos normalizados pelo turnover móvel, e toda janela móvel que termina em t usa apenas informação disponível em t.
# levels -> bps from close; counts/sizes -> trailing z over 5 sessions (1,950 bars)
df = df.with_columns([
((pl.col("q_mid_close") / pl.col("close") - 1) * 1e4).alias("a_q_mid_close_bps"),
((pl.col("t_n") - pl.col("t_n").rolling_mean(1950)) /
pl.col("t_n").rolling_std(1950)).alias("a_t_n_z"),
])Modelos
Três implementações de gradient boosting, LightGBM, XGBoost e CatBoost, todas com profundidade 4, 15 folhas, mínimo de 500 amostras por folha, taxa de aprendizado 0,02, 300 árvores, subamostragem de colunas 0,5 e penalidade L2 de 10, com SEED = 20260731 em todos os lugares.
Retornos de um minuto em ações carregam razão sinal-ruído da ordem de 1e-3, então capacidade tabular comum memoriza a janela de treino: um teste rápido com árvores de 63 folhas devolveu R-quadrado out-of-sample de -0,60, enquanto a configuração regularizada devolveu -0,046 com previsões não degeneradas. Esses valores foram fixados antes da rodada completa a partir das propriedades conhecidas do alvo, nunca do desempenho de qualquer braço, e depois aplicados de forma idêntica a cada braço, horizonte, bloco e fold, de modo que não há busca escondida a deflacionar.
Mecânica do walk-forward
Cada símbolo-bloco é dividido em cinco segmentos contíguos, gerando quatro folds expansivos, com o treino sempre precedendo o teste no tempo. Alvos sobrepostos são a forma padrão de vazar em um estudo de horizonte, já que em h = 390 uma linha de treino perto da fronteira tem alvo se estendendo fundo na janela de teste, então todo fold descarta as últimas h barras da sua faixa de treino e um fold é pulado quando a purga deixa menos de 2.000 linhas de treino, o que remove a maioria dos folds em um mês.
O indicador mais longo do braço B precisa de 1.950 barras de histórico enquanto o do braço A precisa de 60, então sem um aquecimento comum os dois painéis diferiam em quantas linhas trazem valores ausentes, o que é uma diferença nos dados e não na informação. As primeiras 1.950 barras de cada símbolo-bloco são portanto descartadas em todos os braços.
for fold in expanding_folds(block, n_segments=5):
train = block[: fold.train_end - HORIZON] # purge h bars of overlap
if len(train) < 2000:
continue # skipped, and logged as skipped
test = block[fold.train_end : fold.test_end]
model = fit(ARM_FEATURES[arm], train, params=FROZEN, seed=SEED)
preds[fold] = model.predict(test[ARM_FEATURES[arm]])Esse laço produziu 459 ajustes ao longo de 3 blocos x 5 horizontes x até 4 folds x 3 braços x 3 modelos.
Tamanho amostral honesto e inferência
O R-quadrado out-of-sample é medido contra uma previsão zero e não contra a média do período de teste, já que esta última é uma constante com look-ahead. Ao lado dele ficam o rank IC de Spearman, a acurácia direcional e as quebras por ticker, com o destaque sendo a diferença pareada A menos B dentro de cada célula de bloco, horizonte, fold e modelo, o que remove os efeitos de período e de fold que dominam os níveis.
Alvos sobrepostos de h barras fornecem cerca de n/h observações independentes, e cinco tickers correlacionados não fornecem cinco séries independentes. A correlação média par a par medida em retornos de uma hora é 0,32, com AAPL contra SPY em 0,85, o que pelo argumento padrão de variância dá 2,19 séries efetivas.
rho = mean_pairwise_corr(returns_1h) # 0.32 measured, not assumed n_series_eff = n_sym / (1 + (n_sym - 1) * rho) # 5 -> 2.19 n_eff = (n_rows / horizon) * (n_series_eff / n_sym)
Os intervalos de confiança vêm de um bootstrap que reamostra fatias contíguas de tempo carregando os cinco tickers juntos. Um intervalo construído reamostrando linhas saiu 1,7 vez estreito demais, com largura de 95% de 0,184 onde o bootstrap conjunto por fatias de tempo dá 0,310.
A nula empírica
Limiares fixos foram substituídos inteiramente por uma nula empírica, que não exige nenhuma suposição sobre tamanho amostral efetivo. O mesmo fold é reexecutado contra um alvo rotacionado circularmente dentro de cada símbolo-bloco, destruindo a correspondência entre features e alvo enquanto preserva cada distribuição marginal, a autocorrelação do alvo e a correlação entre tickers, após o que um resultado real é julgado pelo seu percentil contra essa dispersão.
k = rng.integers(1, len(y)) y_null = np.roll(y, k) # circular: no rows are dropped
Isso rodou 480 reajustes, 20 rotações para cada bloco, horizonte e braço. A rotação é a única reetiquetagem que preserva estrutura quando as observações não podem ser trocadas livremente, e é isso que faz da distribuição resultante a nula correta para este desenho.
A verificação econômica
Detectabilidade estatística não é operacionalidade, então em um minuto o sinal da previsão é operado e reportado bruto, depois líquido do spread efetivo que o tape de fato imprimiu naquele minuto mais uma provisão de taxas, com o custo medido a partir dos dados e não assumido.
pos = np.sign(pred) turnover = np.abs(np.diff(pos, prepend=0)) / 2 gross_bps = pos * fwd_ret_bps cost_bps = turnover * (eff_spread_bps + FEE_BPS) # FEE_BPS = 0.5 net_bps = gross_bps - cost_bps breakeven = gross_bps.mean() / turnover.mean() # what the signal can afford to pay
Minutos sem impressões em bolsa, cerca de 0,01% das barras, herdam o fechamento anterior e são marcados com contagem zero de trades, então seu spread efetivo é nulo e é preenchido com a mediana da amostra apenas nesta análise.
Testes de vazamento e o que eles pegaram
Três testes precisavam passar antes que qualquer comparação fosse reportada, e o pipeline sai com código diferente de zero em caso de falha.
| Teste | O que faz | Resultado |
|---|---|---|
| Causalidade | Reconstruir um símbolo-dia a partir de um tape truncado às 12:00 e comparar cada barra anterior com a construção do dia inteiro | Passou: 149 barras, 63 colunas numéricas, 0 divergências |
| Vazamento plantado | Adicionar o próprio alvo como feature ao braço B | Passou: R-quadrado vai de -0,097 a +0,609, IC a 0,993 |
| Nula de rotação | Rotacionar circularmente os alvos dentro de cada símbolo-bloco, 480 reajustes | Passou: o IC real de 1 minuto fica cerca de 12 dp acima da média da nula |
Os três testes passaram em dados reais antes de qualquer comparação da nota ser produzida.
Dois bugs nos próprios testes ficam registrados aqui porque a diferença entre um teste que passou e um teste que não podia falhar é todo o valor de um teste. O primeiro estava escrito como if python3 leak_tests.py 2>&1 | tee -a "$LOG"; then, que testa o código de saída do tee e não o do script, e foi corrigido com set -o pipefail e ${PIPESTATUS[0]}.
O segundo era a nula por deslocamento, que usava shift(-k) e portanto anulava as últimas k linhas de cada grupo, e como o teste avalia o último fold seu conjunto de teste ficava vazio e a função devolvia silenciosamente nada. Corrigi-la exigiu uma rotação circular mais fill_nan(None), porque o polars trata NaN e null como coisas distintas e drop_nulls passa direto por NaNs que entraram pela rotação.
Essa nula funcionando expôs então um erro de inferência, não de pipeline. Sua primeira rodada marcou R-quadrado = +0,042 em um alvo que não pode conter sinal, porque o tamanho amostral efetivo estava sendo calculado como linhas/h, tratando cinco tickers como cinco réplicas independentes, o que superestimava todo n_eff em cerca de 2,4x e produzia intervalos de bootstrap 1,7x estreitos demais, ambos corrigidos como descrito acima.
Ambiente
python 3.10.12 polars 1.40.1 numpy 2.2.6 scipy 1.15.3 pandas 2.3.3 lightgbm 4.6.0 xgboost 3.2.0 catboost 1.2.10 scikit-learn 1.7.2 shap 0.49.1 pyarrow 24.0.0 boto3 1.43.9 matplotlib 3.10.9
Todo modelo é semeado e os sorteios de bootstrap e de rotação usam numpy.random.default_rng(SEED) com SEED = 20260731, então uma nova execução sobre os mesmos dados brutos reproduz as tabelas exatamente. O custo total da rodada foi de US$ 0, já que as leituras no S3 são cobradas da Algoseek, e o tempo total de parede foi de cerca de 90 minutos.
Os gráficos da nota são desenhados a partir das tabelas de resultado publicadas, que são as mesmas tabelas de onde vêm os números do texto.
Pedindo o pipeline completo
Esta página é suficiente para conferir na mão cada número da nota, ou para reconstruir um estudo equivalente a partir da mesma fonte licenciada. Os scripts de produção (o dimensionador, o downloader, a construção de features de tick para barra, o montador do painel, o executor do walk-forward, a nula, a análise e as figuras) não estão anexados, então escreva para daniel@daru.finance e eu os envio.

