← Zurück zu den Artikeln
March 17, 2026
5 min read

Adaptives Drill-Down: Backtest mit variabler Granularität von Minuten bis zu einzelnen Trades

Adaptives Drill-Down: Backtest mit variabler Granularität von Minuten bis zu einzelnen Trades
#algotrading
#backtest
#parquet
#optimization
#granularity
#drill-down
#adaptive resolution
Part 5 of 10 · Collection
High-Performance Backtest Engines

Minutenkerzen sind die Standardgranularität für Backtests. Aber innerhalb einer einzigen Minutenkerze kann sich der Preis ganz unterschiedlich bewegen: mal um 0,01 %, mal um 2 %. Wenn sowohl Stop-Loss als auch Take-Profit in den Bereich [low, high] einer einzelnen Minutenkerze fallen, weiß der Backtest nicht, welcher zuerst ausgelöst wurde. Das ist das Problem der Ausführungs-Mehrdeutigkeit (Fill Ambiguity).

Die naive Lösung besteht darin, für den gesamten Backtest auf Sekundendaten umzusteigen. Aber über zwei Jahre sind das ~63 Millionen Sekundenbalken statt ~1 Million Minutenbalken. Der Speicherbedarf steigt um das 60-Fache, die Geschwindigkeit sinkt proportional.

Adaptives Drill-Down löst dieses Problem: feine Granularität nur dort verwenden, wo sie tatsächlich benötigt wird.

Ausführungs-Mehrdeutigkeit: Sowohl SL als auch TP fallen in den Bereich einer einzelnen Kerze

Das Problem: Ausführungs-Mehrdeutigkeit bei großen Kerzen

Betrachten wir eine konkrete Situation. Die Strategie hat eine Long-Position bei 3000 USDT eröffnet. Stop-Loss: 2970 (-1 %). Take-Profit: 3060 (+2 %).

Die Minutenkerze um 14:37:

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

Sowohl SL (2970) als auch TP (3060) fallen in den Bereich [2965, 3065]. Welcher wurde zuerst ausgelöst?

Mögliche Ergebnisse:

  • Der Preis fiel zuerst -> SL ausgelöst -> Verlust von -1 %
  • Der Preis stieg zuerst -> TP ausgelöst -> Gewinn von +2 %

Der Unterschied bei einem einzigen Trade: 3 Prozentpunkte. Mit 10-fachem Hebel — 30 %. Bei einem Backtest mit Hunderten von Trades verzerrt eine falsche Auflösung der Ausführungs-Mehrdeutigkeit die Ergebnisse systematisch.

Wie Frameworks das standardmäßig handhaben

Die meisten Backtest-Engines verwenden eine von zwei Heuristiken:

  1. Optimistisch: TP löst zuerst aus -> geschönte Ergebnisse
  2. Pessimistisch: SL löst zuerst aus -> verschlechterte Ergebnisse

Beide Ansätze sind Rätselraten. Reale Daten stehen auf Sekunden- oder sogar Millisekundenebene zur Verfügung, und es gibt keinen Grund zu raten, wenn man nachschauen kann.

Drill-Down: Vierstufige Strategie

Adaptive vierstufige Drill-Down-Auflösungspyramide

Die Drill-Down-Idee: auf Minutenebene beginnen und nur dann auf eine niedrigere Ebene „hinabbohren", wenn es Mehrdeutigkeit gibt — sei es durch Preisbewegung oder durch Volumenausschläge.

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

Wann Drill-Down nicht nötig ist

In 95 % der Fälle ist Drill-Down nicht erforderlich. Typische Szenarien:

Eindeutiger SL: Das High der Kerze erreicht den TP nicht, das Low durchbricht den SL -> SL ausgelöst, kein Drill-Down nötig.

Eindeutiger TP: Das Low erreicht den SL nicht, das High durchbricht den TP -> TP ausgelöst, kein Drill-Down nötig.

Keiner ausgelöst: Beide Level liegen außerhalb des Bereichs -> Position bleibt offen.

Gap-Erkennung: Der Open der nächsten Kerze springt durch SL oder TP -> Ausführung zum Eröffnungspreis, kein Drill-Down.

Drill-Down ist nur für ~5 % der Balken nötig — wenn beide Level in den Bereich einer einzelnen Kerze fallen.

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

Modus Zeit pro Fill-Prüfung Wann verwendet
1m (kein Drill-Down) ~0ms ~95 % der Fälle
1s Drill-Down ~5ms (erster Zugriff auf den Monat) ~5 % der Fälle
100ms Drill-Down ~1ms <0,5 % der Fälle
Roh-Trades Drill-Down ~0,5ms <0,1 % der Fälle

Bei einem zweijährigen Backtest mit ~400 Trades wird Drill-Down für etwa 20 Kerzen aufgerufen. Gesamter Mehraufwand — weniger als 1 Sekunde für den gesamten Backtest.

Adaptive Datenspeicherung

Drill-Down erfordert Sekunden- und Millisekundendaten. Aber alles mit maximaler Granularität zu speichern ist unpraktisch:

Granularität Balken über 2 Jahre Parquet-Größe
1m ~1,05M ~15 MB
1s ~63M ~550 MB/Monat
100ms ~630M ~5 GB/Monat

Ein vollständiges 1s-Archiv über 2 Jahre umfasst etwa 13 GB. 100ms — über 100 GB. Alles zu speichern ist möglich, aber verschwenderisch, wenn man bedenkt, dass Drill-Down weniger als 1 % dieser Daten nutzt.

Hot-Second-Erkennung

Hot-Second-Erkennung und adaptive Speichereinsparungen

Die zentrale Beobachtung: Sekunden, in denen sich der Preis deutlich bewegt, machen einen kleinen Anteil aus. Wenn sich der Preis innerhalb einer Sekunde um weniger als 0,1 % geändert hat — dann lohnt es sich nicht, die 100ms-Aufschlüsselung für diese Sekunde zu speichern.

Hot-Second-Erkennung: Beim Herunterladen und Verarbeiten der Daten analysieren wir jede Sekunde und erzeugen 100ms-Kerzen nur für „heiße" Sekunden — jene, in denen die Preisbewegung den Schwellenwert überschritten hat.

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

Speichereinsparungen

Als Beispiel — ETHUSDT über einen typischen Monat:

Ansatz Größe Granularität
Nur 1m ~1 MB 1 Minute
Alle 1s ~550 MB 1 Sekunde
Alle 100ms ~5 GB 100 ms
Adaptiv ~600 MB 1s + 100ms nur für heiße Sekunden

Mit einem Schwellenwert von min_price_change_pct = 1.0% machen heiße Sekunden weniger als 1 % aller Sekunden aus. Die 100ms-Daten dafür fügen den 550 MB Sekundendaten ~50 MB hinzu — ein vernachlässigbarer Mehraufwand.

Wenn auch Sekundendaten adaptiv gespeichert werden (nur wenn die Bewegung innerhalb einer Minute 0,1 % überschreitet), lässt sich das Volumen um weitere das 3- bis 5-Fache reduzieren.

Adaptive Parquet-Speicherhierarchie: Minuten-, Sekunden-, heiße Millisekunden- und Trade-Dateien

Parquet-Speicherstruktur

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)

Jede Datei umfasst einen Monat an Daten. Sekunden-, Millisekunden- und Trade-Daten werden verzögert geladen (lazy) — nur wenn Drill-Down sie anfordert. Die Datei stats.json enthält vorberechnete Median-Volumina, die für volumenbasierte Drill-Down-Trigger verwendet werden.

Parquet-Optimierung für Finanzdaten

Finanzdaten haben spezifische Eigenschaften: Zeitstempel wachsen monoton, Preise ändern sich sanft, Volumina variieren erheblich. Optimale Einstellungen:

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

Warum diese Einstellungen:

  • DELTA_BINARY_PACKED für Zeitstempel: aufeinanderfolgende Zeitstempel unterscheiden sich um einen festen Wert (60 bei 1m, 1 bei 1s). Delta-Kodierung komprimiert sie nahezu auf null.
  • BYTE_STREAM_SPLIT für Float: teilt float32-Bytes in Streams auf (alle ersten Bytes zusammen, alle zweiten Bytes zusammen usw.). Bei sanft wechselnden Preisen erreicht das eine 2- bis 3-fach bessere Kompression als die Standardkodierung.
  • ZSTD Level 9: gute Kompression bei akzeptabler Dekompressionsgeschwindigkeit.
  • float32 statt float64: ausreichend für Preise und Volumina, spart 50 % Speicher.

Lazy Loading mit Caching

Drill-Down fordert Sekundendaten für eine bestimmte Minute an. Für jede Anfrage eine Parquet-Datei zu laden ist langsam. Die Lösung — Lazy Loading mit einem LRU-Cache nach Monat.

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

Drill-Down auf das Backtesting anwenden

Integration in die Backtest-Schleife:

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

Zusammenhang mit dem Rolling State Cache

Drill-Down ergänzt den aggregierten Parquet-Cache — sie lösen unterschiedliche Probleme:

Rolling State Cache Adaptives Drill-Down
Zweck Korrekte HTF-Indikatorwerte Präzise SL/TP-Ausführungsreihenfolge
Arbeitet auf Jeder 1m-Kerze Nur bei Ausführungs-Mehrdeutigkeit (~5 %)
Daten Vorberechnet, dauerhaft gespeichert Verzögert geladen, Cache der letzten Monate
Beeinflusst Ein-/Ausstiegssignale Ausführungspreis und -zeit

Beide Ansätze eliminieren Fehler, die auf Tageskerzenebene unsichtbar, aber für realistisches Backtesting entscheidend sind.

Zusammenfassung: Vergleich der Fill-Simulations-Ansätze

Ansatz Genauigkeit Geschwindigkeit Speicher
OHLC-Heuristik (Optimist/Pessimist) Niedrig Sofort Nur 1m
Vollständiger 1s-Backtest Hoch Langsam (x60) ~550 MB/Monat
Vollständiger 100ms-Backtest Sehr hoch Sehr langsam (x600) ~5 GB/Monat
Vollständiger Roh-Trades-Backtest Maximal Extrem langsam ~50 GB/Monat
Adaptives Drill-Down (4-stufig) Maximal ~Sofort 1m + 1s + 100ms heiß + Trades heiß

Drill-Down liefert die Genauigkeit eines vollständigen 1s-Backtests bei der Geschwindigkeit eines 1m-Backtests. Die zentrale Beobachtung: hohe Granularität wird nicht überall benötigt — nur an den Entscheidungspunkten.

Volumenausschläge lösen Drill-Down zu feineren Granularitätsebenen aus

Volumenbasiertes Drill-Down

Das ursprüngliche Drill-Down löst nur bei Preisbewegung aus — wenn der Bereich [low, high] einer Kerze breit genug ist, um Ausführungs-Mehrdeutigkeit zu erzeugen. Aber der Preis ist nicht das einzige Signal dafür, dass innerhalb eines Balkens etwas Interessantes passiert ist.

Volumenausschläge sind ein ebenso wichtiger Trigger. Eine Sekunde, in der das Volumen das 500-Fache des Medians beträgt, entspricht typischerweise einer großen Market-Order, einer Liquidationskaskade oder einem Flash Crash. Selbst wenn der Kerzenkörper klein erscheint, kann der tatsächliche Preisverlauf innerhalb dieser Sekunde wild gewesen sein — mit Extremwerten, die die OHLC-Darstellung verbirgt.

Die Drill-Down-Bedingung ist jetzt ODER-basiert: entweder eine signifikante Preisbewegung ODER ein anomaler Volumenausschlag löst den Abstieg zu feinerer Granularität aus.

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

Das erfasst Szenarien, die für eine rein preisbasierte Erkennung unsichtbar sind: Ein Balken mit open=3000, close=3001, aber einem Volumen vom 50.000-Fachen der Norm, kann innerhalb von Millisekunden kurz 2950 und 3050 berührt haben. Ohne volumenbasiertes Drill-Down würde der Backtest diese Sekunde niemals genauer untersuchen.

Roh-Trades: Die vierte Ebene

Die ursprüngliche dreistufige Hierarchie (1m -> 1s -> 100ms) hinterlässt weiterhin eine Lücke: Innerhalb eines einzelnen 100ms-Buckets können mehrere Trades zu unterschiedlichen Preisen ausgeführt werden. Bei einem Bucket mit high=3060 und low=2965 kennen wir die genaue Reihenfolge immer noch nicht.

Die Lösung: Drill-down bis zu den Roh-Trades als vierte und letzte Ebene.

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)

Auf der Ebene der Roh-Trades gibt es keine Mehrdeutigkeit — jeder Trade hat einen exakten Preis und Zeitstempel. Die Ausführung wird definitiv aufgelöst:

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

Die Roh-Trades-Ebene wird äußerst selten aufgerufen — weniger als 0,1 % aller Balken — aber wenn, dann liefert sie eine Grundwahrheit (Ground Truth), die keine kerzenbasierte Annäherung erreichen kann.

Separate Schwellenwerte pro Übergang

Verschiedene Auflösungsübergänge haben unterschiedliche Eigenschaften. Eine Preisbewegung von 0,1 % innerhalb einer Sekunde ist signifikant; dieselben 0,1 % innerhalb eines 100ms-Buckets sind extrem. Ebenso unterscheiden sich die Volumenverteilungen auf jeder Zeitskala.

Jeder Ebenenübergang hat jetzt seine eigenen Parameter min_pct und vol_mult:

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

Das erlaubt eine unabhängige Feinabstimmung der Empfindlichkeit jedes Übergangs. In der Praxis kann der Übergang von 100ms zu Trades einen engeren Schwellenwert verwenden, da die Kosten für das Laden der Roh-Trades für einen einzelnen 100ms-Bucket minimal sind.

@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

Persistente Median-Statistiken

Volumenbasiertes Drill-Down erfordert die Kenntnis des Median-Volumens auf jeder Zeitskala. Die Mediane für jeden Backtest zur Laufzeit zu berechnen, würde die Performance-Vorteile zunichtemachen. Die Lösung: Mediane einmal vorberechnen und cachen.

Für jedes Symbol werden die Median-Volumina bei 1s- und 100ms-Granularität aus historischen Daten berechnet und in einer Datei stats.json gespeichert:

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

Die Statistiken werden einmal pro Symbol berechnet, wenn die Daten erstmals heruntergeladen werden, und über alle nachfolgenden Backtests hinweg wiederverwendet. Wenn die Daten aktualisiert werden (neue Monate heruntergeladen), werden die Statistiken inkrementell neu berechnet.

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

Multi-Exchange-Datenfluss: Binance und Bybit fließen in einheitliche Granularitätsebenen zusammen

Multi-Exchange-Unterstützung: Bybit

Nicht alle Symbole sind auf Binance verfügbar. Für Assets wie XAUTUSDT (Gold) müssen die Daten von anderen Börsen kommen. Das Drill-Down-System unterstützt jetzt Bybit als alternative Datenquelle.

Für Bybit-Symbole werden alle Kerzenebenen (1m, 1s, 100ms) und Roh-Trades aus dem Roh-Trade-Stream von Bybit aufgebaut. Der Prozess ist derselbe — Roh-Trades werden auf jeder Zeitskala zu Kerzen aggregiert — aber die Datenquelle ist eine andere.

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

Der Data Loader prüft source.json und verwendet die passende Download-Pipeline. Aus Sicht der Backtest-Engine ist das Datenformat unabhängig von der Quellbörse identisch — die Drill-Down-Logik ist börsenunabhängig.

Das ist besonders wichtig für Cross-Exchange-Strategien oder Symbole, die ausschließlich an bestimmten Handelsplätzen gehandelt werden.

Fazit

Adaptives Drill-Down ist die Anwendung eines einfachen Prinzips: Rechenressourcen und Speicher proportional zur Wichtigkeit der Daten einsetzen.

Vier Granularitätsebenen:

  1. 1m — Basisdurchlauf für 95 % der Balken
  2. 1s — Drill-Down bei Ausführungs-Mehrdeutigkeit oder Volumenausschlägen
  3. 100ms — Drill-Down für heiße Sekunden mit extremer Bewegung oder anomalem Volumen
  4. Roh-Trades — Drill-Down für heiße 100ms-Buckets, Auflösung der Ausführungen auf Ebene einzelner Trades

Vier Speicherebenen:

  1. Alle 1m — vollständiges Archiv, ~15 MB für 2 Jahre
  2. Alle 1s — vollständiges oder adaptives Archiv, ~550 MB/Monat
  3. Nur heiße 100ms — <1 % der Sekunden, ~50 MB/Monat
  4. Nur heiße Trades — Roh-Trades für die extremsten 100ms-Buckets

Zwei Drill-Down-Trigger (ODER-Logik):

  • Preisbasiert: Die Preisspanne des Balkens überschreitet min_pct
  • Volumenbasiert: Das Volumen des Balkens überschreitet median * vol_mult

Das Ergebnis: ein Backtest mit der Genauigkeit eines Tick-Simulators bei minütlicher Geschwindigkeit. Ein Speicher, der linear und nicht exponentiell wächst. Und Unterstützung für mehrere Börsen — Binance und Bybit — mit börsenunabhängiger Drill-Down-Logik.

Mehr über den vorberechneten Cache für Multi-Timeframe-Strategien im Artikel Aggregierter Parquet-Cache. Über den Einfluss von Funding Rates auf die Ergebnisse bei hohem Hebel — Funding rates kill your leverage.


Nützliche Links

  1. Apache Parquet — Datenspeicherformat
  2. Apache Arrow — BYTE_STREAM_SPLIT-Kodierung
  3. Zstandard — Kompressionsalgorithmus
  4. Lopez de Prado — Advances in Financial Machine Learning
  5. Binance — Historical Market Data

Zitation

@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

Dem Markt einen Schritt voraus

Abonniere unseren Newsletter für exklusive KI-Trading-Einblicke, Marktanalysen und Plattform-Updates.

Wir respektieren deine Privatsphäre. Jederzeit abbestellbar.