← Voltar aos artigos
March 17, 2026
5 min read

Drill-Down Adaptativo: Backtest com Granularidade Variável de Minutos a Trades Brutos

Drill-Down Adaptativo: Backtest com Granularidade Variável de Minutos a Trades Brutos
#algotrading
#backtest
#parquet
#optimization
#granularity
#drill-down
#adaptive resolution
Part 5 of 10 · Collection
High-Performance Backtest Engines

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.

Ambiguidade de execução: tanto SL quanto TP caem dentro do intervalo de um único candle

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:

  1. Otimista: o TP é acionado primeiro -> resultados inflados
  2. 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

Pirâmide de resolução adaptativa de drill-down em 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)

Detecção de segundos quentes e economia de armazenamento adaptativo

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.

Hierarquia de armazenamento Parquet adaptativa: arquivos de minuto, segundo, milissegundo quente e trade

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.

Picos de volume acionando drill-down para níveis de granularidade mais finos

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

Fluxo de dados multi-exchange: Binance e Bybit convergindo em camadas de granularidade unificadas

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:

  1. 1m — passagem base para 95% das barras
  2. 1s — drill-down durante ambiguidade de execução ou picos de volume
  3. 100ms — drill-down para segundos quentes com movimento extremo ou volume anômalo
  4. Trades brutos — drill-down para buckets de 100ms quentes, resolvendo execuções no nível de trade individual

Quatro níveis de armazenamento:

  1. Todos os 1m — arquivo completo, ~15 MB para 2 anos
  2. Todos os 1s — arquivo completo ou adaptativo, ~550 MB/mês
  3. Apenas 100ms quente — <1% dos segundos, ~50 MB/mês
  4. 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

  1. Apache Parquet — formato de armazenamento de dados
  2. Apache Arrow — codificação BYTE_STREAM_SPLIT
  3. Zstandard — algoritmo de compressão
  4. Lopez de Prado — Advances in Financial Machine Learning
  5. 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.}
}
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.