Adaptieve Drill-Down: Backtesten met Variabele Granulariteit van Minuten tot Ruwe Trades
Minutenkaarsen zijn de standaardgranulariteit voor backtests. Maar binnen één minuutkaars kan de prijs zich anders gedragen: soms met 0,01%, dan weer met 2%. Wanneer zowel stop-loss als take-profit binnen de [low, high]-range van één minuutkaars vallen, weet de backtest niet welke het eerst is geraakt. Dit is het fill-ambiguïteitsprobleem.
De naïeve oplossing is om over te schakelen naar seconde-data voor de hele backtest. Maar over twee jaar zijn dat ~63 miljoen secondebars in plaats van ~1 miljoen minuutbars. De opslag neemt 60x toe, de snelheid daalt evenredig.
Adaptieve drill-down lost dit probleem op: gebruik fijne granulariteit alleen waar het echt nodig is.

Het Probleem: Fill-Ambiguïteit op Grote Candles
Bekijk een specifieke situatie. De strategie opende een long op 3000 USDT. Stop-loss: 2970 (-1%). Take-profit: 3060 (+2%).
De minuutkaars om 14:37:
- Open: 3010
- High: 3065
- Low: 2965
- Close: 3050
Zowel SL (2970) als TP (3060) vallen binnen de range [2965, 3065]. Welke is het eerst geraakt?
Mogelijke uitkomsten:
- Prijs daalde eerst -> SL geraakt -> verlies van -1%
- Prijs steeg eerst -> TP geraakt -> winst van +2%
Het verschil in één trade: 3 procentpunten. Met 10x leverage — 30%. Voor een backtest met honderden trades vervormt een verkeerde oplossing van de fill-ambiguïteit systematisch de resultaten.
Hoe Frameworks Dit Standaard Afhandelen
De meeste backtest-engines gebruiken een van twee heuristieken:
- Optimistisch: TP triggert eerst -> opgeblazen resultaten
- Pessimistisch: SL triggert eerst -> gedempte resultaten
Beide benaderingen zijn gokwerk. Echte data is beschikbaar op seconde- of zelfs milliseconde-niveau, en er is geen reden om te gokken als je kunt kijken.
Drill-Down: Vier-Niveau Strategie

Het idee achter drill-down: begin op minuutniveau en "boor omlaag" naar een lager niveau alleen wanneer er ambiguïteit is — hetzij door prijsbeweging, hetzij door volumepieken.
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
Wanneer Drill-Down Niet Nodig Is
In 95% van de gevallen is drill-down niet vereist. Typische scenario's:
Ondubbelzinnige SL: candle high bereikt TP niet, low doorbreekt SL -> SL geraakt, geen drill-down nodig.
Ondubbelzinnige TP: low bereikt SL niet, high doorbreekt TP -> TP geraakt, geen drill-down nodig.
Geen van beide geraakt: beide niveaus liggen buiten de range -> positie blijft open.
Gap-detectie: de open van de volgende candle springt door SL of TP heen -> uitvoering tegen openprijs, geen drill-down.
Drill-down is alleen nodig voor ~5% van de bars — wanneer beide niveaus binnen de range van één candle vallen.
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)
Prestaties
| Modus | Tijd per fill-check | Wanneer gebruikt |
|---|---|---|
| 1m (geen drill-down) | ~0ms | ~95% van de gevallen |
| 1s drill-down | ~5ms (eerste toegang tot maand) | ~5% van de gevallen |
| 100ms drill-down | ~1ms | <0,5% van de gevallen |
| Ruwe trades drill-down | ~0,5ms | <0,1% van de gevallen |
Over een backtest van 2 jaar met ~400 trades wordt drill-down ongeveer 20 keer aangeroepen. Totale overhead — minder dan 1 seconde voor de hele backtest.
Adaptieve Data-opslag
Drill-down vereist seconde- en milliseconde-data. Maar alles op maximale granulariteit opslaan is onpraktisch:
| Granulariteit | Bars over 2 jaar | Parquet-grootte |
|---|---|---|
| 1m | ~1,05M | ~15 MB |
| 1s | ~63M | ~550 MB/maand |
| 100ms | ~630M | ~5 GB/maand |
Een compleet 1s-archief over 2 jaar is ongeveer 13 GB. 100ms — meer dan 100 GB. Alles opslaan is mogelijk maar verspilling, gezien drill-down minder dan 1% van deze data gebruikt.
Hot-Second Detectie

De sleutelobservatie: seconden waarin de prijs significant beweegt vormen een klein deel. Als de prijs binnen een seconde met minder dan 0,1% veranderde — heeft het geen zin om de 100ms-uitsplitsing voor die seconde op te slaan.
Hot-second detectie: bij het downloaden en verwerken van data analyseren we elke seconde en genereren we 100ms-kaarsen alleen voor "hot" seconden — die waarbij de prijsbeweging de drempel overschreed.
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
Opslagbesparingen
Bijvoorbeeld — ETHUSDT over een typische maand:
| Aanpak | Grootte | Granulariteit |
|---|---|---|
| Alleen 1m | ~1 MB | 1 minuut |
| Alle 1s | ~550 MB | 1 seconde |
| Alle 100ms | ~5 GB | 100 ms |
| Adaptief | ~600 MB | 1s + 100ms alleen voor hot seconden |
Met een drempel van min_price_change_pct = 1.0% vormen hot seconden minder dan 1% van alle seconden. 100ms-data hiervoor voegt ~50 MB toe aan de 550 MB secondedata — een verwaarloosbare overhead.
Als secondedata ook adaptief wordt opgeslagen (alleen wanneer beweging binnen een minuut 0,1% overschrijdt), kan het volume nog eens 3-5x worden verminderd.

Parquet-opslagstructuur
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)
Elk bestand beslaat één maand data. Seconde-, milliseconde- en tradedata wordt lui geladen — alleen wanneer drill-down erom vraagt. Het bestand stats.json bevat vooraf berekende mediaanvolumes die worden gebruikt voor volume-gebaseerde drill-down triggers.
Parquet-optimalisatie voor Financiële Data
Financiële data heeft specifieke kenmerken: timestamps groeien monotoon, prijzen veranderen soepel, volumes variëren significant. Optimale instellingen:
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,
)
Waarom deze instellingen:
- DELTA_BINARY_PACKED voor timestamps: opeenvolgende timestamps verschillen met een vaste waarde (60 voor 1m, 1 voor 1s). Delta-encoding comprimeert ze tot bijna nul.
- BYTE_STREAM_SPLIT voor float: splitst float32-bytes in streams (alle eerste bytes samen, alle tweede bytes samen, enz.). Voor soepel veranderende prijzen levert dit 2-3x betere compressie op dan standaard encoding.
- ZSTD level 9: goede compressie met acceptabele decompressiesnelheid.
- float32 in plaats van float64: voldoende voor prijzen en volumes, bespaart 50% geheugen.
Lui Laden met Caching
Drill-down vraagt seconde-data op voor een specifieke minuut. Een parquet-bestand voor elke aanvraag laden is traag. De oplossing — lui laden met een LRU-cache per maand.
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 Toepassen op Backtesting
Integratie in de backtest-loop:
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
Relatie met Rolling State Cache
Drill-down vult de geaggregeerde parquet-cache aan — ze lossen verschillende problemen op:
| Rolling state cache | Adaptieve drill-down | |
|---|---|---|
| Doel | Correcte HTF-indicatorwaarden | Nauwkeurige SL/TP-uitvoeringsvolgorde |
| Werkt op | Elke 1m-candle | Alleen tijdens fill-ambiguïteit (~5%) |
| Data | Vooraf berekend, permanent opgeslagen | Lui geladen, cache van recente maanden |
| Beïnvloedt | Entry/exit-signalen | Uitvoeringsprijs en -tijd |
Beide benaderingen elimineren fouten die onzichtbaar zijn op dagcandle-niveau maar kritiek voor realistisch backtesten.
Samenvatting: Vergelijking van Fill-Simulatiebenaderingen
| Aanpak | Nauwkeurigheid | Snelheid | Opslag |
|---|---|---|---|
| OHLC-heuristiek (optimist/pessimist) | Laag | Direct | Alleen 1m |
| Volledige 1s-backtest | Hoog | Traag (x60) | ~550 MB/maand |
| Volledige 100ms-backtest | Zeer hoog | Zeer traag (x600) | ~5 GB/maand |
| Volledige ruwe-trades-backtest | Maximaal | Extreem traag | ~50 GB/maand |
| Adaptieve drill-down (4 niveaus) | Maximaal | ~Direct | 1m + 1s + 100ms hot + trades hot |
Drill-down biedt de nauwkeurigheid van een volledige 1s-backtest met de snelheid van een 1m-backtest. De sleutelobservatie: hoge granulariteit is niet overal nodig — alleen op beslismomenten.

Volume-gebaseerde Drill-Down
De originele drill-down triggert alleen op prijsbeweging — wanneer de [low, high]-range van een candle breed genoeg is om fill-ambiguïteit te creëren. Maar prijs is niet het enige signaal dat er iets interessants gebeurde binnen een bar.
Volumepieken zijn een even belangrijke trigger. Een seconde waarin het volume 500x de mediaan is, komt doorgaans overeen met een grote marktorder, een liquidatiecascade of een flash crash. Zelfs als de candle-body klein lijkt, kan het werkelijke prijspad binnen die seconde wild zijn geweest — waarbij extremen zijn geraakt die de OHLC-representatie verbergt.
De drill-down-conditie is nu OR-gebaseerd: hetzij een significante prijsbeweging OF een abnormale volumepiek triggert de afdaling naar fijnere granulariteit.
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
Dit vangt scenario's op die onzichtbaar zijn voor prijs-alleen-detectie: een bar met open=3000, close=3001 maar volume 50.000x de norm kan kortstondig 2950 en 3050 hebben geraakt binnen milliseconden. Zonder volume-gebaseerde drill-down zou de backtest deze seconde nooit nader onderzoeken.
Ruwe Trades: Het Vierde Niveau
De originele drie-niveau hiërarchie (1m -> 1s -> 100ms) laat nog steeds een gat: binnen één 100ms-bucket kunnen meerdere trades tegen verschillende prijzen worden uitgevoerd. Voor een bucket met high=3060 en low=2965 weten we nog steeds niet de exacte volgorde.
De oplossing: drill down naar ruwe trades als het vierde en laatste 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)
Op het niveau van ruwe trades is er geen ambiguïteit — elke trade heeft een exacte prijs en timestamp. De fill wordt definitief opgelost:
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
Het niveau van ruwe trades wordt uiterst zelden aangeroepen — minder dan 0,1% van alle bars — maar wanneer dat wel gebeurt, levert het ground truth op die geen candle-gebaseerde benadering kan evenaren.
Aparte Drempels per Overgang
Verschillende resolutie-overgangen hebben verschillende kenmerken. Een prijsbeweging van 0,1% binnen een seconde is significant; dezelfde 0,1% binnen een 100ms-bucket is extreem. Evenzo verschillen de volumeverdelingen op elke tijdschaal.
Elke niveau-overgang heeft nu zijn eigen min_pct- en vol_mult-parameters:
1s → 100ms: --min-pct-1s 0.1 --vol-mult-1s 500
100ms → trades: --min-pct-100ms 0.1 --vol-mult-100ms 500
Dit maakt het mogelijk om de gevoeligheid van elke overgang onafhankelijk fijn af te stemmen. In de praktijk kan de overgang van 100ms naar trades een strakkere drempel gebruiken omdat de kosten van het laden van ruwe trades voor één 100ms-bucket minimaal zijn.
@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
Permanente Mediaanstatistieken
Volume-gebaseerde drill-down vereist kennis van het mediaanvolume op elke tijdschaal. Medianen on-the-fly berekenen voor elke backtest zou de prestatievoordelen tenietdoen. De oplossing: medianen één keer vooraf berekenen en cachen.
Voor elk symbool worden mediaanvolumes op 1s- en 100ms-granulariteit berekend uit historische data en opgeslagen in een stats.json-bestand:
{
"ETHUSDT": {
"median_volume_1s": 12.5,
"median_volume_100ms": 1.8
},
"BTCUSDT": {
"median_volume_1s": 0.45,
"median_volume_100ms": 0.06
}
}
De statistieken worden één keer per symbool berekend wanneer data voor het eerst wordt gedownload en hergebruikt in alle volgende backtests. Als de data wordt bijgewerkt (nieuwe maanden gedownload), worden de statistieken incrementeel herberekend.
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 Ondersteuning: Bybit
Niet alle symbolen zijn beschikbaar op Binance. Voor assets zoals XAUTUSDT (goud) moet data van andere exchanges komen. Het drill-down-systeem ondersteunt nu Bybit als alternatieve databron.
Voor Bybit-symbolen worden alle candle-niveaus (1m, 1s, 100ms) en ruwe trades opgebouwd uit Bybit's raw trade-stream. Het proces is hetzelfde — ruwe trades worden geaggregeerd tot candles op elke tijdschaal — maar de databron verschilt.
data/{SYMBOL}/
├── source.json # {"exchange": "bybit"} or {"exchange": "binance"}
├── klines_1m/
│ └── ...
├── klines_1s/
│ └── ...
├── klines_100ms_hot/
│ └── ...
└── trades_hot/ # Raw trades for hot 100ms buckets
└── ...
De data-loader controleert source.json en gebruikt de juiste downloadpijplijn. Vanuit het perspectief van de backtest-engine is het dataformaat identiek, ongeacht de brondata-exchange — de drill-down-logica is exchange-agnostisch.
Dit is bijzonder belangrijk voor cross-exchange strategieën of symbolen die exclusief op bepaalde platforms handelen.
Conclusie
Adaptieve drill-down is de toepassing van een eenvoudig principe: besteed rekencapaciteit en opslag proportioneel aan het belang van de data.
Vier granulariteitsniveaus:
- 1m — basispas voor 95% van de bars
- 1s — drill-down bij fill-ambiguïteit of volumepieken
- 100ms — drill-down voor hot seconden met extreme beweging of abnormaal volume
- Ruwe trades — drill-down voor hot 100ms-buckets, fills oplossen op individueel tradeniveau
Vier opslagniveaus:
- Alle 1m — compleet archief, ~15 MB voor 2 jaar
- Alle 1s — compleet of adaptief archief, ~550 MB/maand
- Alleen hot 100ms — <1% van de seconden, ~50 MB/maand
- Alleen hot trades — ruwe trades voor de meest extreme 100ms-buckets
Twee drill-down triggers (OR-logica):
- Prijs-gebaseerd: het prijsbereik van de bar overschrijdt
min_pct - Volume-gebaseerd: het volume van de bar overschrijdt
median * vol_mult
Het resultaat: een backtest met de nauwkeurigheid van een tick-simulator op minuutsnelheid. Opslag die lineair groeit, niet exponentieel. En ondersteuning voor meerdere exchanges — Binance en Bybit — met exchange-agnostische drill-down-logica.
Voor meer over vooraf berekende cache voor multi-timeframe-strategieën, zie het artikel Geaggregeerde Parquet Cache. Over de impact van funding rates op resultaten met hoge leverage — Funding rates verpesten je leverage.
Nuttige Links
- Apache Parquet — data-opslagformaat
- Apache Arrow — BYTE_STREAM_SPLIT encoding
- Zstandard — compressiealgoritme
- Lopez de Prado — Advances in Financial Machine Learning
- Binance — Historical Market Data
Citatie
@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.}
}
Auteurs
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.