Adaptives Drill-Down: Backtest mit variabler Granularität von Minuten bis zu einzelnen Trades
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.

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:
- Optimistisch: TP löst zuerst aus -> geschönte Ergebnisse
- 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

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

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.

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.

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-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:
- 1m — Basisdurchlauf für 95 % der Balken
- 1s — Drill-Down bei Ausführungs-Mehrdeutigkeit oder Volumenausschlägen
- 100ms — Drill-Down für heiße Sekunden mit extremer Bewegung oder anomalem Volumen
- Roh-Trades — Drill-Down für heiße 100ms-Buckets, Auflösung der Ausführungen auf Ebene einzelner Trades
Vier Speicherebenen:
- Alle 1m — vollständiges Archiv, ~15 MB für 2 Jahre
- Alle 1s — vollständiges oder adaptives Archiv, ~550 MB/Monat
- Nur heiße 100ms — <1 % der Sekunden, ~50 MB/Monat
- 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
- Apache Parquet — Datenspeicherformat
- Apache Arrow — BYTE_STREAM_SPLIT-Kodierung
- Zstandard — Kompressionsalgorithmus
- Lopez de Prado — Advances in Financial Machine Learning
- 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.}
}
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.