← Retour aux articles
March 17, 2026
5 min de lecture

Drill-down adaptatif : backtest à granularité variable, de la minute aux transactions brutes

Drill-down adaptatif : backtest à granularité variable, de la minute aux transactions brutes
#algotrading
#backtest
#parquet
#optimization
#granularity
#drill-down
#adaptive resolution
Part 5 of 10 · Collection
High-Performance Backtest Engines

Les bougies d'une minute constituent la granularité standard des backtests. Mais au sein d'une seule bougie d'une minute, le prix peut évoluer de façon très différente : parfois de 0,01 %, parfois de 2 %. Lorsque le stop-loss et le take-profit tombent tous deux dans la plage [low, high] d'une même bougie d'une minute, le backtest ne sait pas lequel s'est déclenché en premier. C'est le problème de l'ambiguïté d'exécution (fill ambiguity).

La solution naïve consiste à passer à des données à la seconde pour tout le backtest. Mais sur deux ans, cela représente ~63 millions de barres à la seconde au lieu de ~1 million de barres à la minute. Le stockage est multiplié par 60, la vitesse chute proportionnellement.

Le drill-down adaptatif résout ce problème : utiliser une granularité fine uniquement là où elle est réellement nécessaire.

Ambiguïté d'exécution : SL et TP tombent tous deux dans la plage d'une même bougie

Le problème : l'ambiguïté d'exécution sur les grandes bougies

Considérons une situation concrète. La stratégie a ouvert une position longue à 3000 USDT. Stop-loss : 2970 (-1 %). Take-profit : 3060 (+2 %).

La bougie d'une minute à 14:37 :

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

Le SL (2970) et le TP (3060) tombent tous deux dans la plage [2965, 3065]. Lequel s'est déclenché en premier ?

Issues possibles :

  • Le prix a d'abord baissé -> SL déclenché -> perte de -1 %
  • Le prix a d'abord monté -> TP déclenché -> gain de +2 %

L'écart sur une seule transaction : 3 points de pourcentage. Avec un levier de 10x, 30 %. Pour un backtest comportant des centaines de transactions, une résolution incorrecte de l'ambiguïté d'exécution fausse systématiquement les résultats.

Comment les frameworks gèrent cela par défaut

La plupart des moteurs de backtest utilisent l'une de deux heuristiques :

  1. Optimiste : le TP se déclenche en premier -> résultats gonflés
  2. Pessimiste : le SL se déclenche en premier -> résultats minorés

Les deux approches relèvent de la devinette. Les données réelles sont disponibles à la seconde, voire à la milliseconde, et il n'y a aucune raison de deviner quand on peut regarder.

Drill-down : une stratégie à quatre niveaux

Pyramide de résolution du drill-down adaptatif à quatre niveaux

L'idée du drill-down : commencer au niveau de la minute et « descendre » à un niveau inférieur uniquement en cas d'ambiguïté, que ce soit à cause d'un mouvement de prix ou de pics 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

Quand le drill-down n'est pas nécessaire

Dans 95 % des cas, le drill-down n'est pas requis. Scénarios typiques :

SL sans ambiguïté : le high de la bougie n'atteint pas le TP, le low franchit le SL -> SL déclenché, pas de drill-down nécessaire.

TP sans ambiguïté : le low n'atteint pas le SL, le high franchit le TP -> TP déclenché, pas de drill-down nécessaire.

Aucun déclenché : les deux niveaux sont hors de la plage -> la position reste ouverte.

Détection de gap : l'open de la bougie suivante saute par-dessus le SL ou le TP -> exécution au prix d'ouverture, pas de drill-down.

Le drill-down n'est nécessaire que pour ~5 % des barres, lorsque les deux niveaux tombent dans la plage d'une même bougie.

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)

Performance

Mode Temps par vérification d'exécution Quand utilisé
1m (sans drill-down) ~0ms ~95 % des cas
Drill-down 1s ~5ms (premier accès au mois) ~5 % des cas
Drill-down 100ms ~1ms <0,5 % des cas
Drill-down transactions brutes ~0,5ms <0,1 % des cas

Sur un backtest de 2 ans avec ~400 transactions, le drill-down est invoqué pour une vingtaine de bougies environ. Surcoût total : moins d'une seconde pour tout le backtest.

Stockage adaptatif des données

Le drill-down nécessite des données à la seconde et à la milliseconde. Mais tout stocker à la granularité maximale est peu réaliste :

Granularité Barres sur 2 ans Taille Parquet
1m ~1,05M ~15 MB
1s ~63M ~550 MB/mois
100ms ~630M ~5 GB/mois

Une archive 1s complète sur 2 ans représente environ 13 GB. 100ms, plus de 100 GB. Tout stocker est possible mais gaspilleur, sachant que le drill-down utilise moins de 1 % de ces données.

Détection des secondes chaudes

Détection des secondes chaudes et économies de stockage adaptatif

L'observation clé : les secondes durant lesquelles le prix bouge de façon significative représentent une petite fraction. Si le prix a changé de moins de 0,1 % en une seconde, il est inutile de stocker le détail à 100ms pour cette seconde.

Détection des secondes chaudes : lors du téléchargement et du traitement des données, nous analysons chaque seconde et générons des bougies de 100ms uniquement pour les secondes « chaudes », celles où le mouvement de prix a dépassé le seuil.

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

Économies de stockage

Par exemple, ETHUSDT sur un mois typique :

Approche Taille Granularité
1m uniquement ~1 MB 1 minute
Tout 1s ~550 MB 1 seconde
Tout 100ms ~5 GB 100 ms
Adaptatif ~600 MB 1s + 100ms uniquement pour les secondes chaudes

Avec un seuil de min_price_change_pct = 1.0%, les secondes chaudes représentent moins de 1 % de toutes les secondes. Les données 100ms correspondantes ajoutent ~50 MB aux 550 MB de données à la seconde, un surcoût négligeable.

Si les données à la seconde sont elles aussi stockées de manière adaptative (uniquement lorsque le mouvement dans une minute dépasse 0,1 %), le volume peut être réduit d'un facteur supplémentaire de 3 à 5.

Hiérarchie de stockage Parquet adaptative : fichiers de minute, de seconde, de millisecondes chaudes et de transactions

Structure de stockage 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)

Chaque fichier couvre un mois de données. Les données à la seconde, à la milliseconde et les transactions sont chargées de manière paresseuse (lazy), uniquement lorsque le drill-down les demande. Le fichier stats.json contient les volumes médians précalculés utilisés pour les déclencheurs de drill-down basés sur le volume.

Optimisation de Parquet pour les données financières

Les données financières ont des caractéristiques spécifiques : les horodatages croissent de manière monotone, les prix évoluent en douceur, les volumes varient fortement. Réglages optimaux :

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,
    )

Pourquoi ces réglages :

  • DELTA_BINARY_PACKED pour les horodatages : des horodatages consécutifs diffèrent d'une valeur fixe (60 pour 1m, 1 pour 1s). L'encodage delta les compresse presque à zéro.
  • BYTE_STREAM_SPLIT pour les float : sépare les octets d'un float32 en flux (tous les premiers octets ensemble, tous les deuxièmes octets ensemble, etc.). Pour des prix qui évoluent en douceur, cela permet une compression 2 à 3 fois meilleure que l'encodage standard.
  • ZSTD niveau 9 : bonne compression avec une vitesse de décompression acceptable.
  • float32 au lieu de float64 : suffisant pour les prix et les volumes, économise 50 % de mémoire.

Chargement paresseux avec mise en cache

Le drill-down demande les données à la seconde pour une minute précise. Charger un fichier parquet à chaque requête est lent. La solution : un chargement paresseux avec un cache LRU par mois.

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

Appliquer le drill-down au backtesting

Intégration dans la boucle du 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

Relation avec le rolling state cache

Le drill-down complète le cache parquet agrégé : ils résolvent des problèmes différents.

Rolling state cache Drill-down adaptatif
Objectif Valeurs correctes des indicateurs HTF Ordre d'exécution précis SL/TP
Opère sur Chaque bougie 1m Uniquement en cas d'ambiguïté d'exécution (~5 %)
Données Précalculées, stockées de façon permanente Chargées de façon paresseuse, cache des derniers mois
Affecte Les signaux d'entrée/sortie Le prix et l'heure d'exécution

Les deux approches éliminent des erreurs invisibles au niveau de la bougie journalière mais critiques pour un backtesting réaliste.

Synthèse : comparaison des approches de simulation d'exécution

Approche Précision Vitesse Stockage
Heuristique OHLC (optimiste/pessimiste) Faible Instantanée 1m uniquement
Backtest complet 1s Élevée Lente (x60) ~550 MB/mois
Backtest complet 100ms Très élevée Très lente (x600) ~5 GB/mois
Backtest complet en transactions brutes Maximale Extrêmement lente ~50 GB/mois
Drill-down adaptatif (4 niveaux) Maximale ~Instantanée 1m + 1s + 100ms chaudes + transactions chaudes

Le drill-down offre la précision d'un backtest complet en 1s à la vitesse d'un backtest en 1m. L'observation clé : une granularité élevée n'est pas nécessaire partout, seulement aux points de décision.

Pics de volume déclenchant le drill-down vers des niveaux de granularité plus fins

Drill-down basé sur le volume

Le drill-down initial ne se déclenche que sur le mouvement de prix, lorsque la plage [low, high] d'une bougie est suffisamment large pour créer une ambiguïté d'exécution. Mais le prix n'est pas le seul signal indiquant qu'il s'est passé quelque chose d'intéressant au sein d'une barre.

Les pics de volume sont un déclencheur tout aussi important. Une seconde où le volume atteint 500 fois la médiane correspond typiquement à un gros ordre au marché, à une cascade de liquidations ou à un flash crash. Même si le corps de la bougie paraît petit, la trajectoire réelle du prix au sein de cette seconde a pu être violente, touchant des extrêmes que la représentation OHLC dissimule.

La condition de drill-down est désormais de type OU : soit un mouvement de prix significatif, SOIT un pic de volume anormal déclenche la descente vers une granularité plus fine.

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

Cela capture des scénarios invisibles à une détection basée uniquement sur le prix : une barre avec open=3000, close=3001 mais un volume 50 000 fois supérieur à la norme a pu toucher brièvement 2950 et 3050 en quelques millisecondes. Sans drill-down basé sur le volume, le backtest n'examinerait jamais cette seconde de plus près.

Transactions brutes : le quatrième niveau

La hiérarchie initiale à trois niveaux (1m -> 1s -> 100ms) laisse encore une lacune : au sein d'un même bucket de 100ms, plusieurs transactions peuvent s'exécuter à des prix différents. Pour un bucket avec high=3060 et low=2965, nous ignorons toujours la séquence exacte.

La solution : descendre jusqu'aux transactions brutes comme quatrième et dernier niveau.

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)

Au niveau des transactions brutes, il n'y a aucune ambiguïté : chaque transaction possède un prix et un horodatage exacts. L'exécution est résolue de manière définitive :

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

Le niveau des transactions brutes est invoqué extrêmement rarement, moins de 0,1 % de toutes les barres, mais lorsqu'il l'est, il fournit une vérité terrain (ground truth) qu'aucune approximation basée sur les bougies ne peut égaler.

Seuils distincts par transition

Les différentes transitions de résolution ont des caractéristiques différentes. Un mouvement de prix de 0,1 % au sein d'une seconde est significatif ; les mêmes 0,1 % au sein d'un bucket de 100ms sont extrêmes. De même, les distributions de volume diffèrent à chaque échelle de temps.

Chaque transition de niveau possède désormais ses propres paramètres min_pct et vol_mult :

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

Cela permet d'affiner indépendamment la sensibilité de chaque transition. En pratique, la transition de 100ms vers les transactions peut utiliser un seuil plus strict, car le coût de chargement des transactions brutes pour un seul bucket de 100ms est minime.

@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

Statistiques de médiane persistantes

Le drill-down basé sur le volume nécessite de connaître le volume médian à chaque échelle de temps. Calculer les médianes à la volée pour chaque backtest annulerait les gains de performance. La solution : précalculer les médianes une fois et les mettre en cache.

Pour chaque symbole, les volumes médians à granularité 1s et 100ms sont calculés à partir des données historiques et stockés dans un fichier stats.json :

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

Les statistiques sont calculées une fois par symbole lors du premier téléchargement des données et réutilisées pour tous les backtests suivants. Si les données sont mises à jour (nouveaux mois téléchargés), les statistiques sont recalculées de manière incrémentale.

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

Flux de données multi-exchange : Binance et Bybit convergeant vers des couches de granularité unifiées

Prise en charge multi-exchange : Bybit

Tous les symboles ne sont pas disponibles sur Binance. Pour des actifs comme XAUTUSDT (or), les données doivent provenir d'autres exchanges. Le système de drill-down prend désormais en charge Bybit comme source de données alternative.

Pour les symboles Bybit, tous les niveaux de bougies (1m, 1s, 100ms) et les transactions brutes sont construits à partir du flux de transactions brutes de Bybit. Le processus est le même — les transactions brutes sont agrégées en bougies à chaque échelle de temps — mais la source de données diffère.

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

Le chargeur de données vérifie source.json et utilise le pipeline de téléchargement approprié. Du point de vue du moteur de backtest, le format des données est identique quel que soit l'exchange source : la logique de drill-down est agnostique à l'exchange.

C'est particulièrement important pour les stratégies cross-exchange ou pour les symboles négociés exclusivement sur certaines places.

Conclusion

Le drill-down adaptatif est l'application d'un principe simple : dépenser les ressources de calcul et de stockage proportionnellement à l'importance des données.

Quatre niveaux de granularité :

  1. 1m — passe de base pour 95 % des barres
  2. 1s — drill-down en cas d'ambiguïté d'exécution ou de pics de volume
  3. 100ms — drill-down pour les secondes chaudes à mouvement extrême ou volume anormal
  4. Transactions brutes — drill-down pour les buckets de 100ms chauds, résolvant les exécutions au niveau de la transaction individuelle

Quatre niveaux de stockage :

  1. Tout 1m — archive complète, ~15 MB pour 2 ans
  2. Tout 1s — archive complète ou adaptative, ~550 MB/mois
  3. 100ms chaudes uniquement — <1 % des secondes, ~50 MB/mois
  4. Transactions chaudes uniquement — transactions brutes pour les buckets de 100ms les plus extrêmes

Deux déclencheurs de drill-down (logique OU) :

  • Basé sur le prix : la plage de prix de la barre dépasse min_pct
  • Basé sur le volume : le volume de la barre dépasse median * vol_mult

Le résultat : un backtest doté de la précision d'un simulateur de ticks à la vitesse d'un backtest à la minute. Un stockage qui croît de façon linéaire, et non exponentielle. Et la prise en charge de plusieurs exchanges — Binance et Bybit — avec une logique de drill-down agnostique à l'exchange.

Pour en savoir plus sur le cache précalculé pour les stratégies multi-timeframe, voir l'article Cache Parquet agrégé. Sur l'impact des funding rates sur les résultats à fort levier — Funding rates kill your leverage.


Liens utiles

  1. Apache Parquet — format de stockage de données
  2. Apache Arrow — encodage BYTE_STREAM_SPLIT
  3. Zstandard — algorithme de compression
  4. Lopez de Prado — Advances in Financial Machine Learning
  5. Binance — Historical Market Data

Citation

@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

Gardez une longueur d'avance sur le marché

Abonnez-vous à notre newsletter pour des insights exclusifs sur le trading IA, des analyses de marché et des mises à jour de la plateforme.

Nous respectons votre vie privée. Désabonnement possible à tout moment.