← Kembali ke artikel
March 17, 2026
5 menit baca

Drill-Down Adaptif: Backtest dengan Granularitas Bervariasi dari Menit hingga Trade Mentah

Drill-Down Adaptif: Backtest dengan Granularitas Bervariasi dari Menit hingga Trade Mentah
#algotrading
#backtest
#parquet
#optimasi
#granularitas
#drill-down
#resolusi adaptif

Candle menit adalah granularitas standar untuk backtest. Namun di dalam satu candle menit, harga bisa bergerak berbeda-beda: kadang sebesar 0,01%, di lain waktu sebesar 2%. Ketika stop-loss dan take-profit sama-sama jatuh dalam rentang [low, high] dari satu candle menit, backtest tidak tahu mana yang terpicu lebih dulu. Inilah masalah ambiguitas eksekusi (fill ambiguity).

Solusi naif adalah beralih ke data tingkat detik untuk seluruh backtest. Namun selama dua tahun, itu berarti ~63 juta bar detik alih-alih ~1 juta bar menit. Penyimpanan meningkat 60x, kecepatan turun secara proporsional.

Drill-down adaptif menyelesaikan masalah ini: gunakan granularitas halus hanya di tempat yang benar-benar membutuhkannya.

Ambiguitas eksekusi: SL dan TP sama-sama jatuh dalam rentang satu candle

Masalah: Ambiguitas Eksekusi pada Candle Besar

Perhatikan situasi spesifik. Strategi membuka posisi long pada 3000 USDT. Stop-loss: 2970 (-1%). Take-profit: 3060 (+2%).

Candle menit pada 14:37:

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

Baik SL (2970) maupun TP (3060) jatuh dalam rentang [2965, 3065]. Mana yang terpicu lebih dulu?

Kemungkinan hasil:

  • Harga turun lebih dulu -> SL terpicu -> rugi -1%
  • Harga naik lebih dulu -> TP terpicu -> untung +2%

Selisih dalam satu trade: 3 poin persentase. Dengan leverage 10x — 30%. Untuk backtest dengan ratusan trade, penyelesaian ambiguitas eksekusi yang keliru secara sistematis mendistorsi hasil.

Bagaimana Framework Menanganinya Secara Default

Sebagian besar mesin backtest menggunakan salah satu dari dua heuristik:

  1. Optimistis: TP terpicu lebih dulu -> hasil membengkak (overstated)
  2. Pesimistis: SL terpicu lebih dulu -> hasil menyusut (understated)

Kedua pendekatan ini hanya tebakan. Data nyata tersedia pada tingkat detik atau bahkan milidetik, dan tidak ada alasan untuk menebak ketika Anda bisa melihat.

Drill-Down: Strategi Empat Tingkat

Piramida resolusi drill-down adaptif empat tingkat

Ide drill-down: mulai pada tingkat menit dan "menggali turun" ke tingkat yang lebih rendah hanya ketika ada ambiguitas — entah karena pergerakan harga maupun lonjakan volume.

Level 1: 1m (candle menit)
  -> Jika SL atau TP jelas berada di luar rentang [low, high] — selesaikan di tempat
  -> Jika keduanya berada dalam rentang — drill down

Level 2: 1s (candle detik)
  -> Muat 60 bar detik untuk menit ini
  -> Telusuri detik demi detik: mana yang terpicu lebih dulu?
  -> Jika bar detik ambigu, ATAU price_move >= min_pct, ATAU volume >= median_1s * vol_mult — drill down

Level 3: 100ms (candle milidetik)
  -> Muat hingga 10 bar 100ms untuk detik ini
  -> Telusuri 100ms demi 100ms
  -> Jika bar 100ms ambigu, ATAU price_move >= min_pct, ATAU volume >= median_100ms * vol_mult — drill down

Level 4: Trade mentah
  -> Muat trade individual untuk bucket 100ms ini
  -> Selesaikan eksekusi pada tingkat trade-demi-trade — presisi maksimum yang mungkin

Kapan Drill-Down Tidak Diperlukan

Dalam 95% kasus, drill-down tidak diperlukan. Skenario tipikal:

SL tidak ambigu: high candle tidak mencapai TP, low menembus SL -> SL terpicu, tidak perlu drill-down.

TP tidak ambigu: low tidak mencapai SL, high menembus TP -> TP terpicu, tidak perlu drill-down.

Tidak ada yang terpicu: kedua level berada di luar rentang -> posisi tetap terbuka.

Deteksi gap: open candle berikutnya melompati SL atau TP -> eksekusi pada harga open, tanpa drill-down.

Drill-down hanya diperlukan untuk ~5% bar — ketika kedua level jatuh dalam rentang satu candle.

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)

Performa

Mode Waktu per pemeriksaan eksekusi Kapan digunakan
1m (tanpa drill-down) ~0ms ~95% kasus
Drill-down 1s ~5ms (akses pertama ke bulan) ~5% kasus
Drill-down 100ms ~1ms <0,5% kasus
Drill-down trade mentah ~0,5ms <0,1% kasus

Selama backtest 2 tahun dengan ~400 trade, drill-down dipanggil untuk sekitar 20 candle. Total overhead — kurang dari 1 detik untuk seluruh backtest.

Penyimpanan Data Adaptif

Drill-down membutuhkan data detik dan milidetik. Namun menyimpan semuanya pada granularitas maksimum tidak praktis:

Granularitas Bar selama 2 tahun Ukuran Parquet
1m ~1,05 jt ~15 MB
1s ~63 jt ~550 MB/bulan
100ms ~630 jt ~5 GB/bulan

Arsip 1s lengkap selama 2 tahun sekitar 13 GB. 100ms — lebih dari 100 GB. Menyimpan semuanya memang mungkin tetapi boros, mengingat drill-down hanya menggunakan kurang dari 1% data ini.

Deteksi Detik Panas (Hot-Second)

Deteksi detik panas dan penghematan penyimpanan adaptif

Pengamatan kunci: detik di mana harga bergerak signifikan hanyalah sebagian kecil. Jika harga berubah kurang dari 0,1% dalam satu detik — tidak ada gunanya menyimpan rincian 100ms untuk detik tersebut.

Deteksi detik panas: saat mengunduh dan memproses data, kita menganalisis setiap detik dan menghasilkan candle 100ms hanya untuk detik "panas" — yaitu detik di mana pergerakan harga melampaui ambang batas.

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

Penghematan Penyimpanan

Sebagai contoh — ETHUSDT selama satu bulan tipikal:

Pendekatan Ukuran Granularitas
1m saja ~1 MB 1 menit
Semua 1s ~550 MB 1 detik
Semua 100ms ~5 GB 100 ms
Adaptif ~600 MB 1s + 100ms hanya untuk detik panas

Dengan ambang batas min_price_change_pct = 1.0%, detik panas mencakup kurang dari 1% dari seluruh detik. Data 100ms untuk detik tersebut menambah ~50 MB pada 550 MB data detik — overhead yang dapat diabaikan.

Jika data detik juga disimpan secara adaptif (hanya ketika pergerakan dalam satu menit melampaui 0,1%), volumenya dapat dikurangi 3-5x lagi.

Hierarki penyimpanan Parquet adaptif: file menit, detik, milidetik panas, dan trade

Struktur Penyimpanan 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)

Setiap file mencakup satu bulan data. Data detik, milidetik, dan trade dimuat secara lazy — hanya ketika drill-down memintanya. File stats.json berisi volume median terkomputasi sebelumnya yang digunakan untuk pemicu drill-down berbasis volume.

Optimasi Parquet untuk Data Keuangan

Data keuangan memiliki karakteristik spesifik: timestamp tumbuh secara monoton, harga berubah dengan mulus, volume bervariasi secara signifikan. Pengaturan optimal:

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

Mengapa pengaturan ini:

  • DELTA_BINARY_PACKED untuk timestamp: timestamp berurutan berbeda dengan nilai tetap (60 untuk 1m, 1 untuk 1s). Pengkodean delta memampatkannya hampir menjadi nol.
  • BYTE_STREAM_SPLIT untuk float: memecah byte float32 menjadi beberapa aliran (semua byte pertama bersama, semua byte kedua bersama, dan seterusnya). Untuk harga yang berubah mulus, ini mencapai kompresi 2-3x lebih baik dibanding pengkodean standar.
  • ZSTD level 9: kompresi bagus dengan kecepatan dekompresi yang dapat diterima.
  • float32 alih-alih float64: cukup untuk harga dan volume, menghemat 50% memori.

Pemuatan Lazy dengan Caching

Drill-down meminta data detik untuk menit tertentu. Memuat file parquet untuk setiap permintaan itu lambat. Solusinya — pemuatan lazy dengan cache LRU per bulan.

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

Menerapkan Drill-Down ke Backtesting

Integrasi ke dalam loop 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

Hubungan dengan Rolling State Cache

Drill-down melengkapi cache parquet teragregasi — keduanya menyelesaikan masalah yang berbeda:

Rolling state cache Drill-down adaptif
Tujuan Nilai indikator HTF yang benar Urutan eksekusi SL/TP yang presisi
Beroperasi pada Setiap candle 1m Hanya saat ambiguitas eksekusi (~5%)
Data Terkomputasi sebelumnya, disimpan permanen Dimuat lazy, cache bulan-bulan terbaru
Memengaruhi Sinyal masuk/keluar Harga dan waktu eksekusi

Kedua pendekatan menghilangkan kesalahan yang tak terlihat pada tingkat candle harian tetapi krusial untuk backtest yang realistis.

Ringkasan: Perbandingan Pendekatan Simulasi Eksekusi

Pendekatan Akurasi Kecepatan Penyimpanan
Heuristik OHLC (optimis/pesimis) Rendah Instan 1m saja
Backtest 1s penuh Tinggi Lambat (x60) ~550 MB/bulan
Backtest 100ms penuh Sangat tinggi Sangat lambat (x600) ~5 GB/bulan
Backtest trade mentah penuh Maksimum Sangat lambat sekali ~50 GB/bulan
Drill-down adaptif (4 tingkat) Maksimum ~Instan 1m + 1s + 100ms panas + trade panas

Drill-down memberikan akurasi backtest 1s penuh pada kecepatan backtest 1m. Pengamatan kunci: granularitas tinggi tidak dibutuhkan di mana-mana — hanya pada titik keputusan.

Lonjakan volume yang memicu drill-down ke tingkat granularitas lebih halus

Drill-Down Berbasis Volume

Drill-down asli hanya terpicu pada pergerakan harga — ketika rentang [low, high] sebuah candle cukup lebar untuk menciptakan ambiguitas eksekusi. Namun harga bukan satu-satunya sinyal bahwa sesuatu yang menarik terjadi dalam satu bar.

Lonjakan volume adalah pemicu yang sama pentingnya. Sebuah detik di mana volumenya 500x median biasanya berkaitan dengan order pasar besar, kaskade likuidasi, atau flash crash. Bahkan jika badan candle tampak kecil, jalur harga aktual dalam detik tersebut mungkin liar — menyentuh titik ekstrem yang disembunyikan oleh representasi OHLC.

Kondisi drill-down kini bersifat berbasis OR: entah pergerakan harga signifikan ATAU lonjakan volume anomali memicu penurunan ke granularitas lebih halus.

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

Ini menangkap skenario yang tak terlihat oleh deteksi berbasis harga saja: sebuah bar dengan open=3000, close=3001 tetapi volume 50.000x normal mungkin sempat menyentuh 2950 dan 3050 dalam hitungan milidetik. Tanpa drill-down berbasis volume, backtest tidak akan pernah memeriksa detik ini lebih dekat.

Trade Mentah: Tingkat Keempat

Hierarki tiga tingkat asli (1m -> 1s -> 100ms) masih menyisakan celah: dalam satu bucket 100ms, beberapa trade bisa dieksekusi pada harga berbeda. Untuk bucket dengan high=3060 dan low=2965, kita masih tidak tahu urutan persisnya.

Solusinya: drill down ke trade mentah sebagai tingkat keempat dan terakhir.

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)

Pada tingkat trade mentah, tidak ada ambiguitas — setiap trade memiliki harga dan timestamp yang tepat. Eksekusi diselesaikan secara definitif:

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

Tingkat trade mentah dipanggil sangat jarang — kurang dari 0,1% dari seluruh bar — tetapi ketika dipanggil, ia memberikan ground truth yang tidak dapat ditandingi oleh aproksimasi berbasis candle mana pun.

Ambang Batas Terpisah per Transisi

Transisi resolusi yang berbeda memiliki karakteristik berbeda. Pergerakan harga 0,1% dalam satu detik itu signifikan; 0,1% yang sama dalam satu bucket 100ms itu ekstrem. Demikian pula, distribusi volume berbeda pada setiap skala waktu.

Setiap transisi tingkat kini memiliki parameter min_pct dan vol_mult sendiri:

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

Ini memungkinkan penyetelan halus sensitivitas setiap transisi secara independen. Dalam praktiknya, transisi 100ms-ke-trade dapat menggunakan ambang batas yang lebih ketat karena biaya memuat trade mentah untuk satu bucket 100ms sangat minimal.

@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

Statistik Median Persisten

Drill-down berbasis volume membutuhkan pengetahuan tentang volume median pada setiap skala waktu. Menghitung median secara on-the-fly untuk setiap backtest akan meniadakan manfaat performa. Solusinya: hitung median sekali dan cache.

Untuk setiap simbol, volume median pada granularitas 1s dan 100ms dihitung dari data historis dan disimpan dalam file stats.json:

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

Statistik dihitung sekali per simbol ketika data pertama kali diunduh dan digunakan kembali di seluruh backtest berikutnya. Jika data diperbarui (bulan baru diunduh), statistik dihitung ulang secara inkremental.

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

Aliran data multi-exchange: Binance dan Bybit menyatu ke dalam lapisan granularitas terpadu

Dukungan Multi-Exchange: Bybit

Tidak semua simbol tersedia di Binance. Untuk aset seperti XAUTUSDT (emas), data harus berasal dari exchange lain. Sistem drill-down kini mendukung Bybit sebagai sumber data alternatif.

Untuk simbol Bybit, semua tingkat candle (1m, 1s, 100ms) dan trade mentah dibangun dari aliran trade mentah Bybit. Prosesnya sama — trade mentah diagregasi menjadi candle pada setiap skala waktu — tetapi sumber datanya berbeda.

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

Data loader memeriksa source.json dan menggunakan pipeline unduhan yang sesuai. Dari sudut pandang mesin backtest, format data identik terlepas dari exchange sumbernya — logika drill-down bersifat agnostik terhadap exchange.

Hal ini sangat penting untuk strategi lintas-exchange atau simbol yang diperdagangkan secara eksklusif di venue tertentu.

Kesimpulan

Drill-down adaptif adalah penerapan prinsip sederhana: alokasikan sumber daya komputasi dan penyimpanan secara proporsional terhadap pentingnya data.

Empat tingkat granularitas:

  1. 1m — lintasan dasar untuk 95% bar
  2. 1s — drill-down saat ambiguitas eksekusi atau lonjakan volume
  3. 100ms — drill-down untuk detik panas dengan pergerakan ekstrem atau volume anomali
  4. Trade mentah — drill-down untuk bucket 100ms panas, menyelesaikan eksekusi pada tingkat trade individual

Empat tingkat penyimpanan:

  1. Semua 1m — arsip lengkap, ~15 MB untuk 2 tahun
  2. Semua 1s — arsip lengkap atau adaptif, ~550 MB/bulan
  3. Hanya 100ms panas — <1% detik, ~50 MB/bulan
  4. Hanya trade panas — trade mentah untuk bucket 100ms paling ekstrem

Dua pemicu drill-down (logika OR):

  • Berbasis harga: rentang harga bar melampaui min_pct
  • Berbasis volume: volume bar melampaui median * vol_mult

Hasilnya: backtest dengan akurasi simulator tick pada kecepatan tingkat menit. Penyimpanan yang tumbuh secara linear, bukan eksponensial. Dan dukungan untuk banyak exchange — Binance dan Bybit — dengan logika drill-down yang agnostik terhadap exchange.

Untuk lebih lanjut tentang cache terkomputasi untuk strategi multi-timeframe, lihat artikel Cache Parquet Teragregasi. Tentang dampak funding rate pada hasil dengan leverage tinggi — Funding rate membunuh leverage Anda.


Tautan Bermanfaat

  1. Apache Parquet — format penyimpanan data
  2. Apache Arrow — pengkodean BYTE_STREAM_SPLIT
  3. Zstandard — algoritma kompresi
  4. Lopez de Prado — Advances in Financial Machine Learning
  5. Binance — Historical Market Data

Sitasi

@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 = {Bagaimana granularitas data adaptif mempercepat backtest dan menghemat penyimpanan: drill-down dari 1m ke 1s, 100ms, dan trade mentah hanya di tempat harga bergerak signifikan atau volume melonjak.}
}
Penafian: Informasi yang disediakan dalam artikel ini hanya untuk tujuan edukasi dan informasi serta tidak merupakan nasihat keuangan, investasi, atau trading. Trading mata uang kripto mengandung risiko kerugian yang signifikan.

Penulis

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

Selangkah Lebih Maju dari Pasar

Berlangganan newsletter kami untuk wawasan AI trading eksklusif, analisis pasar, dan pembaruan platform.

Kami menghormati privasi Anda. Berhenti berlangganan kapan saja.