← Volver a los artículos
March 17, 2026
5 min de lectura

Drill-down adaptativo: backtest con granularidad variable, de minutos a operaciones individuales

Drill-down adaptativo: backtest con granularidad variable, de minutos a operaciones individuales
#algotrading
#backtest
#parquet
#optimization
#granularity
#drill-down
#adaptive resolution
Part 5 of 10 · Collection
High-Performance Backtest Engines

Las velas de un minuto son la granularidad estándar para los backtests. Pero dentro de una sola vela de un minuto, el precio puede moverse de forma muy distinta: a veces un 0,01 %, otras un 2 %. Cuando tanto el stop-loss como el take-profit caen dentro del rango [low, high] de una misma vela de un minuto, el backtest no sabe cuál se activó primero. Este es el problema de la ambigüedad de ejecución (fill ambiguity).

La solución ingenua es pasar a datos de nivel de segundo para todo el backtest. Pero en dos años, eso supone ~63 millones de barras de segundo en lugar de ~1 millón de barras de minuto. El almacenamiento se multiplica por 60 y la velocidad cae en proporción.

El drill-down adaptativo resuelve este problema: usar granularidad fina solo donde realmente se necesita.

Ambigüedad de ejecución: tanto SL como TP caen dentro del rango de una misma vela

El problema: ambigüedad de ejecución en velas grandes

Consideremos una situación concreta. La estrategia abrió un largo a 3000 USDT. Stop-loss: 2970 (-1 %). Take-profit: 3060 (+2 %).

La vela de un minuto a las 14:37:

  • Open: 3010
  • High: 3065
  • Low: 2965
  • Close: 3050

Tanto SL (2970) como TP (3060) caen dentro del rango [2965, 3065]. ¿Cuál se activó primero?

Resultados posibles:

  • El precio bajó primero -> se activó el SL -> pérdida del -1 %
  • El precio subió primero -> se activó el TP -> ganancia del +2 %

La diferencia en una sola operación: 3 puntos porcentuales. Con apalancamiento 10x, un 30 %. En un backtest con cientos de operaciones, una resolución incorrecta de la ambigüedad de ejecución distorsiona los resultados de forma sistemática.

Cómo lo manejan los frameworks por defecto

La mayoría de los motores de backtest usan una de dos heurísticas:

  1. Optimista: el TP se activa primero -> resultados inflados
  2. Pesimista: el SL se activa primero -> resultados desinflados

Ambos enfoques son adivinanzas. Los datos reales están disponibles a nivel de segundo o incluso de milisegundo, y no hay motivo para adivinar cuando se puede mirar.

Drill-down: estrategia de cuatro niveles

Pirámide de resolución de drill-down adaptativo de cuatro niveles

La idea del drill-down: empezar a nivel de minuto y "profundizar" a un nivel inferior solo cuando hay ambigüedad, ya sea por movimiento del precio o por picos de volumen.

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

Cuándo no hace falta el drill-down

En el 95 % de los casos, el drill-down no es necesario. Escenarios típicos:

SL inequívoco: el high de la vela no llega al TP, el low atraviesa el SL -> se activó el SL, no hace falta drill-down.

TP inequívoco: el low no llega al SL, el high atraviesa el TP -> se activó el TP, no hace falta drill-down.

Ninguno se activó: ambos niveles están fuera del rango -> la posición permanece abierta.

Detección de gap: el open de la vela siguiente salta a través del SL o del TP -> ejecución al precio de apertura, sin drill-down.

El drill-down solo hace falta en ~5 % de las barras, cuando ambos niveles caen dentro del rango de una misma vela.

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)

Rendimiento

Modo Tiempo por comprobación de ejecución Cuándo se usa
1m (sin drill-down) ~0ms ~95 % de los casos
Drill-down 1s ~5ms (primer acceso al mes) ~5 % de los casos
Drill-down 100ms ~1ms <0,5 % de los casos
Drill-down operaciones individuales ~0,5ms <0,1 % de los casos

En un backtest de 2 años con ~400 operaciones, el drill-down se invoca para unas 20 velas. Sobrecarga total: menos de 1 segundo para todo el backtest.

Almacenamiento adaptativo de datos

El drill-down requiere datos de segundo y de milisegundo. Pero almacenar todo con la máxima granularidad es poco práctico:

Granularidad Barras en 2 años Tamaño Parquet
1m ~1,05M ~15 MB
1s ~63M ~550 MB/mes
100ms ~630M ~5 GB/mes

Un archivo completo de 1s en 2 años ronda los 13 GB. 100ms, más de 100 GB. Almacenar todo es posible pero derrochador, teniendo en cuenta que el drill-down usa menos del 1 % de estos datos.

Detección de segundos calientes

Detección de segundos calientes y ahorro de almacenamiento adaptativo

La observación clave: los segundos en los que el precio se mueve de forma significativa representan una pequeña fracción. Si el precio cambió menos del 0,1 % en un segundo, no tiene sentido almacenar el desglose de 100ms para ese segundo.

Detección de segundos calientes: al descargar y procesar los datos, analizamos cada segundo y generamos velas de 100ms solo para los segundos "calientes", aquellos en los que el movimiento del precio superó el umbral.

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

Ahorro de almacenamiento

Por ejemplo, ETHUSDT en un mes típico:

Enfoque Tamaño Granularidad
Solo 1m ~1 MB 1 minuto
Todo 1s ~550 MB 1 segundo
Todo 100ms ~5 GB 100 ms
Adaptativo ~600 MB 1s + 100ms solo para segundos calientes

Con un umbral de min_price_change_pct = 1.0%, los segundos calientes representan menos del 1 % de todos los segundos. Los datos de 100ms para ellos añaden ~50 MB a los 550 MB de datos de segundo, una sobrecarga insignificante.

Si los datos de segundo también se almacenan de forma adaptativa (solo cuando el movimiento dentro de un minuto supera el 0,1 %), el volumen puede reducirse otro factor de 3 a 5.

Jerarquía de almacenamiento Parquet adaptativa: archivos de minuto, segundo, milisegundos calientes y operaciones

Estructura de almacenamiento 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 archivo cubre un mes de datos. Los datos de segundo, milisegundo y operaciones se cargan de forma perezosa (lazy), solo cuando el drill-down los solicita. El archivo stats.json contiene los volúmenes medianos precalculados que se usan para los disparadores de drill-down basados en volumen.

Optimización de Parquet para datos financieros

Los datos financieros tienen características específicas: las marcas de tiempo crecen de forma monótona, los precios cambian con suavidad, los volúmenes varían considerablemente. Ajustes óptimos:

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 qué estos ajustes:

  • DELTA_BINARY_PACKED para marcas de tiempo: las marcas de tiempo consecutivas difieren en un valor fijo (60 para 1m, 1 para 1s). La codificación delta las comprime casi a cero.
  • BYTE_STREAM_SPLIT para float: divide los bytes de float32 en flujos (todos los primeros bytes juntos, todos los segundos bytes juntos, etc.). Para precios que cambian con suavidad, logra una compresión de 2 a 3 veces mejor que la codificación estándar.
  • ZSTD nivel 9: buena compresión con una velocidad de descompresión aceptable.
  • float32 en lugar de float64: suficiente para precios y volúmenes, ahorra un 50 % de memoria.

Carga perezosa con caché

El drill-down solicita datos de segundo para un minuto concreto. Cargar un archivo parquet para cada solicitud es lento. La solución: carga perezosa con una caché LRU por mes.

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

Aplicar el drill-down al backtesting

Integración en el bucle del 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

Relación con la caché de estado móvil (rolling state cache)

El drill-down complementa la caché parquet agregada; resuelven problemas distintos:

Caché de estado móvil Drill-down adaptativo
Propósito Valores correctos de indicadores HTF Orden preciso de ejecución de SL/TP
Opera sobre Cada vela de 1m Solo durante la ambigüedad de ejecución (~5 %)
Datos Precalculados, almacenados de forma permanente Cargados de forma perezosa, caché de los últimos meses
Afecta a Señales de entrada/salida Precio y hora de ejecución

Ambos enfoques eliminan errores invisibles a nivel de vela diaria pero críticos para un backtesting realista.

Resumen: comparación de enfoques de simulación de ejecución

Enfoque Precisión Velocidad Almacenamiento
Heurística OHLC (optimista/pesimista) Baja Instantánea Solo 1m
Backtest completo 1s Alta Lenta (x60) ~550 MB/mes
Backtest completo 100ms Muy alta Muy lenta (x600) ~5 GB/mes
Backtest completo de operaciones individuales Máxima Extremadamente lenta ~50 GB/mes
Drill-down adaptativo (4 niveles) Máxima ~Instantánea 1m + 1s + 100ms calientes + operaciones calientes

El drill-down proporciona la precisión de un backtest completo de 1s a la velocidad de un backtest de 1m. La observación clave: la granularidad alta no hace falta en todas partes, solo en los puntos de decisión.

Picos de volumen que disparan el drill-down a niveles de granularidad más finos

Drill-down basado en volumen

El drill-down original se dispara solo por el movimiento del precio, cuando el rango [low, high] de una vela es lo bastante amplio como para crear ambigüedad de ejecución. Pero el precio no es la única señal de que ha ocurrido algo interesante dentro de una barra.

Los picos de volumen son un disparador igual de importante. Un segundo en el que el volumen es 500 veces la mediana suele corresponder a una gran orden de mercado, una cascada de liquidaciones o un flash crash. Aunque el cuerpo de la vela parezca pequeño, la trayectoria real del precio dentro de ese segundo puede haber sido violenta, tocando extremos que la representación OHLC oculta.

La condición de drill-down ahora es basada en OR: o un movimiento de precio significativo O un pico de volumen anómalo dispara el descenso a una granularidad más 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

Esto captura escenarios invisibles para la detección basada solo en precio: una barra con open=3000, close=3001 pero un volumen 50.000 veces la norma puede haber tocado brevemente 2950 y 3050 en cuestión de milisegundos. Sin drill-down basado en volumen, el backtest nunca examinaría ese segundo más de cerca.

Operaciones individuales: el cuarto nivel

La jerarquía original de tres niveles (1m -> 1s -> 100ms) sigue dejando un hueco: dentro de un mismo bucket de 100ms pueden ejecutarse varias operaciones a precios distintos. Para un bucket con high=3060 y low=2965 seguimos sin conocer la secuencia exacta.

La solución: profundizar hasta las operaciones individuales como cuarto y último nivel.

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)

A nivel de operaciones individuales no hay ambigüedad: cada operación tiene un precio y una marca de tiempo exactos. La ejecución se resuelve de forma definitiva:

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

El nivel de operaciones individuales se invoca muy raramente, menos del 0,1 % de todas las barras, pero cuando lo hace proporciona una verdad de referencia (ground truth) que ninguna aproximación basada en velas puede igualar.

Umbrales separados por transición

Distintas transiciones de resolución tienen características distintas. Un movimiento de precio del 0,1 % dentro de un segundo es significativo; ese mismo 0,1 % dentro de un bucket de 100ms es extremo. Del mismo modo, las distribuciones de volumen difieren en cada escala temporal.

Cada transición de nivel tiene ahora sus propios parámetros min_pct y vol_mult:

1s → 100ms:   --min-pct-1s 0.1   --vol-mult-1s 500
100ms → trades: --min-pct-100ms 0.1 --vol-mult-100ms 500

Esto permite ajustar de forma independiente la sensibilidad de cada transición. En la práctica, la transición de 100ms a operaciones puede usar un umbral más estricto, porque el coste de cargar las operaciones individuales de un solo bucket de 100ms es 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

Estadísticas de mediana persistentes

El drill-down basado en volumen requiere conocer el volumen mediano en cada escala temporal. Calcular las medianas al vuelo para cada backtest anularía las ventajas de rendimiento. La solución: precalcular las medianas una vez y cachearlas.

Para cada símbolo, los volúmenes medianos con granularidad de 1s y 100ms se calculan a partir de datos históricos y se almacenan en un archivo stats.json:

{
  "ETHUSDT": {
    "median_volume_1s": 12.5,
    "median_volume_100ms": 1.8
  },
  "BTCUSDT": {
    "median_volume_1s": 0.45,
    "median_volume_100ms": 0.06
  }
}

Las estadísticas se calculan una vez por símbolo cuando los datos se descargan por primera vez y se reutilizan en todos los backtests posteriores. Si los datos se actualizan (nuevos meses descargados), las estadísticas se recalculan de forma incremental.

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

Flujo de datos multi-exchange: Binance y Bybit convergiendo en capas de granularidad unificadas

Soporte multi-exchange: Bybit

No todos los símbolos están disponibles en Binance. Para activos como XAUTUSDT (oro), los datos deben provenir de otros exchanges. El sistema de drill-down ahora soporta Bybit como fuente de datos alternativa.

Para los símbolos de Bybit, todos los niveles de velas (1m, 1s, 100ms) y las operaciones individuales se construyen a partir del flujo de operaciones sin procesar de Bybit. El proceso es el mismo (las operaciones sin procesar se agregan en velas a cada escala temporal), pero la fuente de datos es distinta.

data/{SYMBOL}/
├── source.json              # {"exchange": "bybit"} or {"exchange": "binance"}
├── klines_1m/
│   └── ...
├── klines_1s/
│   └── ...
├── klines_100ms_hot/
│   └── ...
└── trades_hot/              # Raw trades for hot 100ms buckets
    └── ...

El cargador de datos comprueba source.json y usa el pipeline de descarga adecuado. Desde la perspectiva del motor de backtest, el formato de los datos es idéntico independientemente del exchange de origen: la lógica de drill-down es agnóstica al exchange.

Esto es especialmente importante para estrategias cross-exchange o para símbolos que solo cotizan en ciertos mercados.

Conclusión

El drill-down adaptativo es la aplicación de un principio sencillo: gastar recursos de cómputo y almacenamiento de forma proporcional a la importancia de los datos.

Cuatro niveles de granularidad:

  1. 1m — pasada base para el 95 % de las barras
  2. 1s — drill-down durante ambigüedad de ejecución o picos de volumen
  3. 100ms — drill-down para segundos calientes con movimiento extremo o volumen anómalo
  4. Operaciones individuales — drill-down para buckets de 100ms calientes, resolviendo las ejecuciones a nivel de operación individual

Cuatro niveles de almacenamiento:

  1. Todo 1m — archivo completo, ~15 MB para 2 años
  2. Todo 1s — archivo completo o adaptativo, ~550 MB/mes
  3. Solo 100ms calientes — <1 % de los segundos, ~50 MB/mes
  4. Solo operaciones calientes — operaciones sin procesar para los buckets de 100ms más extremos

Dos disparadores de drill-down (lógica OR):

  • Basado en precio: el rango de precios de la barra supera min_pct
  • Basado en volumen: el volumen de la barra supera median * vol_mult

El resultado: un backtest con la precisión de un simulador de ticks a la velocidad de un backtest por minuto. Un almacenamiento que crece de forma lineal, no exponencial. Y soporte para múltiples exchanges (Binance y Bybit) con una lógica de drill-down agnóstica al exchange.

Para saber más sobre la caché precalculada para estrategias multi-timeframe, consulta el artículo Caché Parquet agregada. Sobre el impacto de las funding rates en los resultados con apalancamiento alto: Funding rates kill your leverage.


Enlaces útiles

  1. Apache Parquet — formato de almacenamiento de datos
  2. Apache Arrow — codificación BYTE_STREAM_SPLIT
  3. Zstandard — algoritmo de compresión
  4. Lopez de Prado — Advances in Financial Machine Learning
  5. Binance — Historical Market Data

Cita

@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

Mantente a la vanguardia

Suscríbete a nuestro boletín para recibir información exclusiva sobre trading con IA, análisis de mercado y actualizaciones de la plataforma.

Respetamos tu privacidad. Puedes darte de baja en cualquier momento.