Drill-Down Adaptativo: Backtest com Granularidade Variável de Minutos a Trades Brutos
Candles de minuto são a granularidade padrão para backtests. Mas dentro de um único candle de um minuto, o preço pode se mover de forma diferente: às vezes 0,01%, outras vezes 2%. Quando tanto o stop-loss quanto o take-profit caem dentro do intervalo [low, high] de um único candle de minuto, o backtest não sabe qual foi acionado primeiro. Este é o problema da ambiguidade de execução (fill ambiguity).
A solução ingênua é mudar para dados de nível de segundo em todo o backtest. Mas em dois anos, isso são ~63 milhões de barras de segundo em vez de ~1 milhão de barras de minuto. O armazenamento aumenta 60x, a velocidade cai proporcionalmente.
O drill-down adaptativo resolve esse problema: usar granularidade fina apenas onde realmente é necessário.

O Problema: Ambiguidade de Execução em Candles Grandes
Considere uma situação específica. A estratégia abriu uma posição long a 3000 USDT. Stop-loss: 2970 (-1%). Take-profit: 3060 (+2%).
O candle de minuto às 14:37:
- Abertura: 3010
- Máxima: 3065
- Mínima: 2965
- Fechamento: 3050
Tanto o SL (2970) quanto o TP (3060) caem dentro do intervalo [2965, 3065]. Qual foi acionado primeiro?
Resultados possíveis:
- O preço caiu primeiro -> SL acionado -> perda de -1%
- O preço subiu primeiro -> TP acionado -> lucro de +2%
A diferença em uma única operação: 3 pontos percentuais. Com alavancagem de 10x — 30%. Para um backtest com centenas de operações, a resolução incorreta da ambiguidade de execução distorce sistematicamente os resultados.
Como os Frameworks Lidam com Isso por Padrão
A maioria dos mecanismos de backtest usa uma de duas heurísticas:
- Otimista: o TP é acionado primeiro -> resultados inflados
- Pessimista: o SL é acionado primeiro -> resultados subestimados
Ambas as abordagens são adivinhação. Dados reais estão disponíveis em nível de segundo ou até milissegundo, e não há razão para adivinhar quando se pode observar.
Drill-Down: Estratégia de Quatro Níveis

A ideia do drill-down: começar no nível de minuto e "descer" para um nível mais baixo apenas quando há ambiguidade — seja por movimento de preço ou picos de volume.
Level 1: 1m (minute candles)
-> If SL or TP is unambiguously outside the [low, high] range — resolve on the spot
-> If both are within the range — drill down
Level 2: 1s (second candles)
-> Load 60 second bars for this minute
-> Walk through second by second: which triggered first?
-> If a second bar is ambiguous, OR price_move >= min_pct, OR volume >= median_1s * vol_mult — drill down
Level 3: 100ms (millisecond candles)
-> Load up to 10 bars of 100ms for this second
-> Walk through 100ms by 100ms
-> If a 100ms bar is ambiguous, OR price_move >= min_pct, OR volume >= median_100ms * vol_mult — drill down
Level 4: Raw trades
-> Load individual trades for this 100ms bucket
-> Resolve the fill at trade-by-trade level — maximum possible precision
Quando o Drill-Down Não É Necessário
Em 95% dos casos, o drill-down não é necessário. Cenários típicos:
SL inequívoco: a máxima do candle não alcança o TP, a mínima rompe o SL -> SL acionado, sem necessidade de drill-down.
TP inequívoco: a mínima não alcança o SL, a máxima rompe o TP -> TP acionado, sem necessidade de drill-down.
Nenhum acionado: ambos os níveis estão fora do intervalo -> a posição permanece aberta.
Detecção de gap: a abertura do próximo candle salta através do SL ou TP -> execução no preço de abertura, sem drill-down.
O drill-down só é necessário para ~5% das barras — quando ambos os níveis caem dentro do intervalo de um único candle.
class AdaptiveFillSimulator:
"""
Four-level drill-down for determining fill order.
"""
def __init__(self, data_loader):
self.loader = data_loader
self.cache_1s = {} # Cache of second data by month
def check_fill(self, timestamp, candle_1m, sl_price, tp_price, side):
"""
Checks whether SL or TP triggered on the given minute candle.
Returns: ('sl', fill_price) | ('tp', fill_price) | None
"""
low, high = candle_1m['low'], candle_1m['high']
open_price = candle_1m['open']
if side == 'long':
if open_price <= sl_price:
return ('sl', open_price)
if open_price >= tp_price:
return ('tp', open_price)
else:
if open_price >= sl_price:
return ('sl', open_price)
if open_price <= tp_price:
return ('tp', open_price)
sl_hit = self._level_hit(sl_price, low, high, side, 'sl')
tp_hit = self._level_hit(tp_price, low, high, side, 'tp')
if sl_hit and not tp_hit:
return ('sl', sl_price)
if tp_hit and not sl_hit:
return ('tp', tp_price)
if not sl_hit and not tp_hit:
return None
return self._drill_down_1s(timestamp, sl_price, tp_price, side)
def _drill_down_1s(self, minute_ts, sl_price, tp_price, side):
"""Level 2: second-by-second pass."""
bars_1s = self.loader.load_1s_for_minute(minute_ts)
if bars_1s is None or len(bars_1s) == 0:
return self._pessimistic_fill(side, sl_price, tp_price)
for bar in bars_1s:
sl_hit = self._level_hit(sl_price, bar['low'], bar['high'], side, 'sl')
tp_hit = self._level_hit(tp_price, bar['low'], bar['high'], side, 'tp')
if sl_hit and not tp_hit:
return ('sl', sl_price)
if tp_hit and not sl_hit:
return ('tp', tp_price)
if sl_hit and tp_hit:
result = self._drill_down_100ms(bar['timestamp'], sl_price, tp_price, side)
if result:
return result
return self._pessimistic_fill(side, sl_price, tp_price)
def _pessimistic_fill(self, side, sl_price, tp_price):
"""Pessimistic assumption: SL for longs, TP for shorts."""
if side == 'long':
return ('sl', sl_price)
else:
return ('sl', sl_price)
Desempenho
| Modo | Tempo por verificação de execução | Quando é usado |
|---|---|---|
| 1m (sem drill-down) | ~0ms | ~95% dos casos |
| Drill-down de 1s | ~5ms (primeiro acesso ao mês) | ~5% dos casos |
| Drill-down de 100ms | ~1ms | <0,5% dos casos |
| Drill-down de trades brutos | ~0,5ms | <0,1% dos casos |
Em um backtest de 2 anos com ~400 operações, o drill-down é acionado para aproximadamente 20 candles. Overhead total — menos de 1 segundo para todo o backtest.
Armazenamento Adaptativo de Dados
O drill-down requer dados de segundo e milissegundo. Mas armazenar tudo na granularidade máxima é impraticável:
| Granularidade | Barras em 2 anos | Tamanho Parquet |
|---|---|---|
| 1m | ~1,05M | ~15 MB |
| 1s | ~63M | ~550 MB/mês |
| 100ms | ~630M | ~5 GB/mês |
Um arquivo completo de 1s ao longo de 2 anos é cerca de 13 GB. 100ms — mais de 100 GB. Armazenar tudo é possível, mas é um desperdício, considerando que o drill-down usa menos de 1% desses dados.
Detecção de Segundos Quentes (Hot-Second)

A observação-chave: os segundos em que o preço se move significativamente representam uma fração pequena. Se o preço mudou menos de 0,1% dentro de um segundo — não há sentido em armazenar o detalhamento de 100ms para esse segundo.
Detecção de segundos quentes: ao baixar e processar os dados, analisamos cada segundo e geramos candles de 100ms apenas para segundos "quentes" — aqueles em que o movimento de preço ultrapassou o limite.
def process_trades_adaptive(
trades: pd.DataFrame,
min_price_change_pct: float = 1.0,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Processes raw trades into an adaptive structure:
- 1s candles for all seconds
- 100ms candles only for "hot" seconds
Args:
trades: DataFrame with columns [timestamp, price, quantity]
min_price_change_pct: threshold for drill-down to 100ms
Returns:
(df_1s, df_100ms_hot) — second candles and 100ms for hot seconds
"""
trades['second'] = trades['timestamp'].dt.floor('1s')
df_1s = trades.groupby('second').agg(
open=('price', 'first'),
high=('price', 'max'),
low=('price', 'min'),
close=('price', 'last'),
volume=('quantity', 'sum'),
)
df_1s['price_change_pct'] = (df_1s['high'] - df_1s['low']) / df_1s['open'] * 100
hot_seconds = df_1s[df_1s['price_change_pct'] >= min_price_change_pct].index
hot_trades = trades[trades['second'].isin(hot_seconds)]
hot_trades['bucket_100ms'] = hot_trades['timestamp'].dt.floor('100ms')
df_100ms = hot_trades.groupby('bucket_100ms').agg(
open=('price', 'first'),
high=('price', 'max'),
low=('price', 'min'),
close=('price', 'last'),
volume=('quantity', 'sum'),
)
return df_1s, df_100ms
Economia de Armazenamento
Por exemplo — ETHUSDT ao longo de um mês típico:
| Abordagem | Tamanho | Granularidade |
|---|---|---|
| Apenas 1m | ~1 MB | 1 minuto |
| Todos 1s | ~550 MB | 1 segundo |
| Todos 100ms | ~5 GB | 100 ms |
| Adaptativo | ~600 MB | 1s + 100ms apenas para segundos quentes |
Com um limite de min_price_change_pct = 1.0%, os segundos quentes representam menos de 1% de todos os segundos. Os dados de 100ms para eles adicionam ~50 MB aos 550 MB de dados de segundo — um overhead desprezível.
Se os dados de segundo também forem armazenados de forma adaptativa (apenas quando o movimento dentro de um minuto ultrapassar 0,1%), o volume pode ser reduzido em mais 3-5x.

Estrutura de Armazenamento Parquet
data/{SYMBOL}/
├── source.json # Exchange source: {"exchange": "binance"} or {"exchange": "bybit"}
├── stats.json # Precomputed median volumes: {"median_volume_1s": ..., "median_volume_100ms": ...}
├── klines_1m/
│ ├── 2024-01.parquet # ~1 MB
│ ├── 2024-02.parquet
│ └── ...
├── klines_1s/
│ ├── 2024-01.parquet # ~550 MB
│ └── ...
├── klines_100ms_hot/
│ ├── 2024-01.parquet # ~50 MB (hot seconds only)
│ └── ...
├── trades_hot/
│ ├── 2024-01.parquet # Raw trades for hot 100ms buckets
│ └── ...
└── states_1m.parquet # Precomputed rolling state cache (~112 MB)
Cada arquivo cobre um mês de dados. Dados de segundo, milissegundo e trade são carregados de forma preguiçosa (lazy) — apenas quando o drill-down os solicita. O arquivo stats.json contém volumes medianos pré-calculados usados para os gatilhos de drill-down baseados em volume.
Otimização de Parquet para Dados Financeiros
Dados financeiros têm características específicas: os timestamps crescem monotonicamente, os preços mudam suavemente, os volumes variam significativamente. Configurações ótimas:
import pyarrow as pa
import pyarrow.parquet as pq
schema = pa.schema([
pa.field("timestamp", pa.int32()), # Seconds from epoch — int32 is sufficient
pa.field("open", pa.float32()),
pa.field("high", pa.float32()),
pa.field("low", pa.float32()),
pa.field("close", pa.float32()),
pa.field("volume", pa.float32()),
])
column_encodings = {
"timestamp": "DELTA_BINARY_PACKED", # Monotonic int -> delta compression
"open": "BYTE_STREAM_SPLIT", # Float -> byte-stream split
"high": "BYTE_STREAM_SPLIT",
"low": "BYTE_STREAM_SPLIT",
"close": "BYTE_STREAM_SPLIT",
"volume": "BYTE_STREAM_SPLIT",
}
def save_optimized_parquet(df, path):
table = pa.Table.from_pandas(df, schema=schema)
pq.write_table(
table, path,
compression="zstd",
compression_level=9,
use_dictionary=False,
write_statistics=False,
column_encoding=column_encodings,
)
Por que essas configurações:
- DELTA_BINARY_PACKED para timestamps: timestamps consecutivos diferem por um valor fixo (60 para 1m, 1 para 1s). A codificação delta os comprime a quase zero.
- BYTE_STREAM_SPLIT para float: divide os bytes de float32 em streams (todos os primeiros bytes juntos, todos os segundos bytes juntos etc.). Para preços que mudam suavemente, isso alcança compressão 2-3x melhor do que a codificação padrão.
- ZSTD nível 9: boa compressão com velocidade de descompressão aceitável.
- float32 em vez de float64: suficiente para preços e volumes, economiza 50% de memória.
Carregamento Preguiçoso (Lazy) com Cache
O drill-down solicita dados de segundo para um minuto específico. Carregar um arquivo parquet para cada solicitação é lento. A solução — carregamento preguiçoso com cache LRU por mês.
from functools import lru_cache
import pyarrow.parquet as pq
import pandas as pd
class AdaptiveDataLoader:
"""
Lazy loader with cache: loads second data by month,
keeps the last N months in memory.
"""
def __init__(self, symbol: str, data_dir: str = "data", cache_months: int = 2):
self.symbol = symbol
self.data_dir = data_dir
self.cache_months = cache_months
self._cache_1s: dict[str, pd.DataFrame] = {}
def load_1s_for_minute(self, minute_ts: pd.Timestamp) -> pd.DataFrame | None:
"""Load 1s data for a specific minute."""
month_key = minute_ts.strftime("%Y-%m")
if month_key not in self._cache_1s:
self._load_month_1s(month_key)
if month_key not in self._cache_1s:
return None
df = self._cache_1s[month_key]
minute_start = minute_ts.floor('1min')
minute_end = minute_start + pd.Timedelta(minutes=1)
return df[(df.index >= minute_start) & (df.index < minute_end)]
def load_100ms_for_second(self, second_ts: pd.Timestamp) -> pd.DataFrame | None:
"""Load 100ms data for a hot second."""
month_key = second_ts.strftime("%Y-%m")
path = f"{self.data_dir}/{self.symbol}/klines_100ms_hot/{month_key}.parquet"
try:
df = pd.read_parquet(path)
second_start = second_ts.floor('1s')
second_end = second_start + pd.Timedelta(seconds=1)
return df[(df.index >= second_start) & (df.index < second_end)]
except FileNotFoundError:
return None
def _load_month_1s(self, month_key: str):
"""Load a month of 1s data, evict old data from cache."""
path = f"{self.data_dir}/{self.symbol}/klines_1s/{month_key}.parquet"
try:
df = pd.read_parquet(path)
df.index = pd.to_datetime(df['timestamp'], unit='s')
if len(self._cache_1s) >= self.cache_months:
oldest = min(self._cache_1s.keys())
del self._cache_1s[oldest]
self._cache_1s[month_key] = df
except FileNotFoundError:
pass
Aplicando o Drill-Down ao Backtesting
Integração no loop de backtest:
def backtest_with_adaptive_fill(
states: pd.DataFrame,
strategy_params: dict,
data_loader: AdaptiveDataLoader,
) -> list:
"""
Backtest with adaptive drill-down for fill simulation.
"""
fill_sim = AdaptiveFillSimulator(data_loader)
trades = []
position = None
for i in range(len(states)):
row = states.iloc[i]
ts = states.index[i]
candle_1m = {
'open': row['open'], 'high': row['high'],
'low': row['low'], 'close': row['close'],
'timestamp': ts,
}
if position is not None:
fill = fill_sim.check_fill(
ts, candle_1m,
position['sl'], position['tp'],
position['side'],
)
if fill is not None:
fill_type, fill_price = fill
trades.append({
'entry_time': position['entry_time'],
'exit_time': ts,
'side': position['side'],
'entry_price': position['entry_price'],
'exit_price': fill_price,
'exit_type': fill_type,
'drill_down': fill_sim.last_drill_depth, # 0, 1, or 2
})
position = None
continue
signal = check_entry_signal(row, strategy_params)
if signal and position is None:
position = {
'side': signal['side'],
'entry_price': row['close'],
'entry_time': ts,
'sl': signal['sl'],
'tp': signal['tp'],
}
return trades
Relação com o Cache de Estado Rolling
O drill-down complementa o cache Parquet agregado — eles resolvem problemas diferentes:
| Cache de estado rolling | Drill-down adaptativo | |
|---|---|---|
| Propósito | Valores corretos de indicadores HTF | Ordem precisa de execução de SL/TP |
| Opera em | Cada candle de 1m | Apenas durante ambiguidade de execução (~5%) |
| Dados | Pré-calculados, armazenados permanentemente | Carregados sob demanda, cache de meses recentes |
| Afeta | Sinais de entrada/saída | Preço e horário de execução |
Ambas as abordagens eliminam erros invisíveis no nível de candle diário, mas críticos para um backtesting realista.
Resumo: Comparação de Abordagens de Simulação de Execução
| Abordagem | Precisão | Velocidade | Armazenamento |
|---|---|---|---|
| Heurística OHLC (otimista/pessimista) | Baixa | Instantânea | Apenas 1m |
| Backtest completo em 1s | Alta | Lenta (x60) | ~550 MB/mês |
| Backtest completo em 100ms | Muito alta | Muito lenta (x600) | ~5 GB/mês |
| Backtest completo em trades brutos | Máxima | Extremamente lenta | ~50 GB/mês |
| Drill-down adaptativo (4 níveis) | Máxima | ~Instantânea | 1m + 1s + 100ms quente + trades quentes |
O drill-down fornece a precisão de um backtest completo em 1s com a velocidade de um backtest em 1m. A observação-chave: alta granularidade não é necessária em todos os lugares — apenas em pontos de decisão.

Drill-Down Baseado em Volume
O drill-down original aciona apenas por movimento de preço — quando o intervalo [low, high] de um candle é amplo o suficiente para criar ambiguidade de execução. Mas o preço não é o único sinal de que algo interessante aconteceu dentro de uma barra.
Picos de volume são um gatilho igualmente importante. Um segundo em que o volume é 500x a mediana normalmente corresponde a uma grande ordem de mercado, uma cascata de liquidação ou um flash crash. Mesmo que o corpo do candle pareça pequeno, o caminho real do preço dentro desse segundo pode ter sido selvagem — tocando extremos que a representação OHLC esconde.
A condição de drill-down agora é baseada em OR: tanto um movimento de preço significativo QUANTO um pico de volume anômalo acionam a descida para granularidade mais fina.
def is_hot(bar, median_volume, min_pct=0.1, vol_mult=500):
"""
Determines if a bar warrants drill-down to the next level.
Two independent triggers (OR logic):
- price moved >= min_pct within the bar
- volume exceeded median * vol_mult
"""
price_move = (bar['high'] - bar['low']) / bar['open'] * 100
return price_move >= min_pct or bar['volume'] >= median_volume * vol_mult
Isso captura cenários invisíveis para a detecção baseada apenas em preço: uma barra com abertura=3000, fechamento=3001, mas volume 50.000x a norma, pode ter tocado brevemente 2950 e 3050 dentro de milissegundos. Sem o drill-down baseado em volume, o backtest nunca examinaria esse segundo mais de perto.
Trades Brutos: O Quarto Nível
A hierarquia original de três níveis (1m -> 1s -> 100ms) ainda deixa uma lacuna: dentro de um único bucket de 100ms, várias trades podem ser executadas em preços diferentes. Para um bucket com máxima=3060 e mínima=2965, ainda não sabemos a sequência exata.
A solução: descer até trades brutos (drill down to raw trades) como o quarto e último nível.
1m candles (base)
└─> 1s candles (when 1s shows price_move >= min_pct OR volume >= median_1s * vol_mult)
└─> 100ms candles (when hot second detected)
└─> Raw trades (when 100ms shows price_move >= min_pct OR volume >= median_100ms * vol_mult)
No nível de trades brutos, não há ambiguidade — cada trade tem um preço e timestamp exatos. A execução é resolvida definitivamente:
def resolve_from_trades(trades, sl_price, tp_price, side):
"""
Walk through individual trades in chronological order.
The first trade that crosses SL or TP determines the fill.
"""
for trade in trades:
price = trade['price']
if side == 'long':
if price <= sl_price:
return ('sl', price)
if price >= tp_price:
return ('tp', price)
else: # short
if price >= sl_price:
return ('sl', price)
if price <= tp_price:
return ('tp', price)
return None
O nível de trades brutos é acionado extremamente raramente — menos de 0,1% de todas as barras — mas quando é, fornece a verdade fundamental (ground truth) que nenhuma aproximação baseada em candle pode igualar.
Limites Separados por Transição
Diferentes transições de resolução têm características diferentes. Um movimento de preço de 0,1% dentro de um segundo é significativo; o mesmo 0,1% dentro de um bucket de 100ms é extremo. Da mesma forma, as distribuições de volume diferem em cada escala de tempo.
Cada transição de nível agora tem seus próprios parâmetros min_pct e vol_mult:
1s → 100ms: --min-pct-1s 0.1 --vol-mult-1s 500
100ms → trades: --min-pct-100ms 0.1 --vol-mult-100ms 500
Isso permite ajustar finamente a sensibilidade de cada transição de forma independente. Na prática, a transição de 100ms para trades pode usar um limite mais rígido porque o custo de carregar trades brutos para um único bucket de 100ms é mínimo.
@dataclass
class DrillDownConfig:
min_pct_1s: float = 0.1
vol_mult_1s: float = 500
min_pct_100ms: float = 0.1
vol_mult_100ms: float = 500
Estatísticas de Mediana Persistentes
O drill-down baseado em volume requer conhecer o volume mediano em cada escala de tempo. Calcular medianas em tempo real para cada backtest anularia os benefícios de desempenho. A solução: pré-calcular as medianas uma vez e armazená-las em cache.
Para cada símbolo, os volumes medianos nas granularidades de 1s e 100ms são calculados a partir de dados históricos e armazenados em um arquivo stats.json:
{
"ETHUSDT": {
"median_volume_1s": 12.5,
"median_volume_100ms": 1.8
},
"BTCUSDT": {
"median_volume_1s": 0.45,
"median_volume_100ms": 0.06
}
}
As estatísticas são calculadas uma vez por símbolo, quando os dados são baixados pela primeira vez, e reutilizadas em todos os backtests subsequentes. Se os dados forem atualizados (novos meses baixados), as estatísticas são recalculadas incrementalmente.
def compute_median_stats(symbol, data_dir):
"""Compute and cache median volume stats for a symbol."""
stats_path = f"{data_dir}/{symbol}/stats.json"
all_1s = load_all_months(f"{data_dir}/{symbol}/klines_1s/")
median_1s = all_1s['volume'].median()
all_100ms = load_all_months(f"{data_dir}/{symbol}/klines_100ms_hot/")
median_100ms = all_100ms['volume'].median()
stats = {
"median_volume_1s": float(median_1s),
"median_volume_100ms": float(median_100ms),
}
with open(stats_path, 'w') as f:
json.dump(stats, f, indent=2)
return stats

Suporte Multi-Exchange: Bybit
Nem todos os símbolos estão disponíveis na Binance. Para ativos como XAUTUSDT (ouro), os dados devem vir de outras exchanges. O sistema de drill-down agora suporta a Bybit como fonte alternativa de dados.
Para símbolos da Bybit, todos os níveis de candle (1m, 1s, 100ms) e trades brutos são construídos a partir do fluxo de trades brutos da Bybit. O processo é o mesmo — trades brutos são agregados em candles em cada escala de tempo — mas a fonte de dados é diferente.
data/{SYMBOL}/
├── source.json # {"exchange": "bybit"} or {"exchange": "binance"}
├── klines_1m/
│ └── ...
├── klines_1s/
│ └── ...
├── klines_100ms_hot/
│ └── ...
└── trades_hot/ # Raw trades for hot 100ms buckets
└── ...
O carregador de dados verifica source.json e usa o pipeline de download apropriado. Do ponto de vista do mecanismo de backtest, o formato dos dados é idêntico, independentemente da exchange de origem — a lógica de drill-down é agnóstica em relação à exchange.
Isso é particularmente importante para estratégias entre exchanges ou símbolos negociados exclusivamente em determinadas plataformas.
Conclusão
O drill-down adaptativo é a aplicação de um princípio simples: gastar recursos computacionais e armazenamento proporcionalmente à importância dos dados.
Quatro níveis de granularidade:
- 1m — passagem base para 95% das barras
- 1s — drill-down durante ambiguidade de execução ou picos de volume
- 100ms — drill-down para segundos quentes com movimento extremo ou volume anômalo
- Trades brutos — drill-down para buckets de 100ms quentes, resolvendo execuções no nível de trade individual
Quatro níveis de armazenamento:
- Todos os 1m — arquivo completo, ~15 MB para 2 anos
- Todos os 1s — arquivo completo ou adaptativo, ~550 MB/mês
- Apenas 100ms quente — <1% dos segundos, ~50 MB/mês
- Apenas trades quentes — trades brutos para os buckets de 100ms mais extremos
Dois gatilhos de drill-down (lógica OR):
- Baseado em preço: o intervalo de preço da barra excede
min_pct - Baseado em volume: o volume da barra excede
median * vol_mult
O resultado: um backtest com a precisão de um simulador de tick na velocidade de nível de minuto. Armazenamento que cresce linearmente, não exponencialmente. E suporte para múltiplas exchanges — Binance e Bybit — com lógica de drill-down agnóstica à exchange.
Para mais sobre cache pré-calculado para estratégias multi-timeframe, veja o artigo Cache Parquet Agregado. Sobre o impacto das taxas de financiamento nos resultados com alta alavancagem — As taxas de financiamento destroem sua alavancagem.
Links Úteis
- Apache Parquet — formato de armazenamento de dados
- Apache Arrow — codificação BYTE_STREAM_SPLIT
- Zstandard — algoritmo de compressão
- Lopez de Prado — Advances in Financial Machine Learning
- Binance — Dados Históricos de Mercado
Citação
@article{soloviov2026adaptivedrilldown,
author = {Soloviov, Eugen},
title = {Adaptive Drill-Down: Backtest with Variable Granularity from Minutes to Raw Trades},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/adaptive-resolution-drill-down-backtest},
description = {How adaptive data granularity speeds up backtests and saves storage: drill-down from 1m to 1s, 100ms, and raw trades only where price moved significantly or volume spiked.}
}
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.