Drill-down adaptativo: backtest con granularidad variable, de minutos a operaciones individuales
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.

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:
- Optimista: el TP se activa primero -> resultados inflados
- 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

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

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.

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.

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

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:
- 1m — pasada base para el 95 % de las barras
- 1s — drill-down durante ambigüedad de ejecución o picos de volumen
- 100ms — drill-down para segundos calientes con movimiento extremo o volumen anómalo
- Operaciones individuales — drill-down para buckets de 100ms calientes, resolviendo las ejecuciones a nivel de operación individual
Cuatro niveles de almacenamiento:
- Todo 1m — archivo completo, ~15 MB para 2 años
- Todo 1s — archivo completo o adaptativo, ~550 MB/mes
- Solo 100ms calientes — <1 % de los segundos, ~50 MB/mes
- 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
- Apache Parquet — formato de almacenamiento de datos
- Apache Arrow — codificación BYTE_STREAM_SPLIT
- Zstandard — algoritmo de compresión
- Lopez de Prado — Advances in Financial Machine Learning
- 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.}
}
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.