← Kembali ke artikel
March 17, 2026
Bacaan 5 minit

Adaptive Drill-Down: Backtest dengan Granulariti Pemboleh ubah dari Minit hingga Dagangan Mentah

Adaptive Drill-Down: Backtest dengan Granulariti Pemboleh ubah dari Minit hingga Dagangan Mentah
#algotrading
#backtest
#parquet
#pengoptimuman
#granulariti
#drill-down
#resolusi adaptif

Lilin minit adalah granulariti standard untuk backtest. Tetapi dalam satu lilin minit, harga boleh bergerak secara berbeza: kadangkala sebanyak 0.01%, kadangkala 2%. Apabila kedua-dua stop-loss dan take-profit jatuh dalam julat [low, high] satu lilin minit, backtest tidak tahu mana yang dicetuskan dahulu. Ini adalah masalah ketidakjelasan pengisian (fill ambiguity).

Penyelesaian naif ialah beralih ke data peringkat saat untuk keseluruhan backtest. Tetapi dalam tempoh dua tahun, itu kira-kira 63 juta bar saat berbanding ~1 juta bar minit. Storan meningkat 60x, kelajuan menurun secara berkadar.

Adaptive drill-down menyelesaikan masalah ini: gunakan granulariti halus hanya di mana ia benar-benar diperlukan.

Ketidakjelasan pengisian: kedua-dua SL dan TP jatuh dalam julat satu lilin

Masalah: Ketidakjelasan Pengisian pada Lilin Besar

Pertimbangkan situasi khusus. Strategi membuka posisi panjang pada 3000 USDT. Stop-loss: 2970 (-1%). Take-profit: 3060 (+2%).

Lilin minit pada 14:37:

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

Kedua-dua SL (2970) dan TP (3060) jatuh dalam julat [2965, 3065]. Mana yang dicetuskan dahulu?

Kemungkinan hasil:

  • Harga turun dahulu -> SL dicetuskan -> kerugian -1%
  • Harga naik dahulu -> TP dicetuskan -> keuntungan +2%

Perbezaan dalam satu dagangan: 3 mata peratusan. Dengan leverage 10x — 30%. Untuk backtest dengan ratusan dagangan, penyelesaian ketidakjelasan pengisian yang tidak tepat secara sistematik mengherotkan keputusan.

Cara Rangka Kerja Mengendalikan Ini secara Lalai

Kebanyakan enjin backtest menggunakan salah satu daripada dua heuristik:

  1. Optimistik: TP dicetuskan dahulu -> keputusan yang melambung
  2. Pesimistik: SL dicetuskan dahulu -> keputusan yang merendah

Kedua-dua pendekatan ini adalah tekaan. Data sebenar tersedia pada peringkat saat atau bahkan milisaat, dan tidak ada sebab untuk meneka apabila anda boleh melihat.

Drill-Down: Strategi Empat Peringkat

Piramid resolusi drill-down adaptif empat peringkat

Idea drill-down: mulakan pada peringkat minit dan "drill down" ke peringkat lebih rendah hanya apabila terdapat kekaburan — sama ada disebabkan pergerakan harga atau lonjakan volum.

Peringkat 1: 1m (lilin minit)
  -> Jika SL atau TP berada jelas di luar julat [low, high] — selesaikan di tempat
  -> Jika kedua-dua berada dalam julat — drill down

Peringkat 2: 1s (lilin saat)
  -> Muatkan 60 bar saat untuk minit ini
  -> Lalui saat demi saat: mana yang dicetuskan dahulu?
  -> Jika bar saat tidak jelas, ATAU price_move >= min_pct, ATAU volume >= median_1s * vol_mult — drill down

Peringkat 3: 100ms (lilin milisaat)
  -> Muatkan sehingga 10 bar 100ms untuk saat ini
  -> Lalui 100ms demi 100ms
  -> Jika bar 100ms tidak jelas, ATAU price_move >= min_pct, ATAU volume >= median_100ms * vol_mult — drill down

Peringkat 4: Dagangan mentah
  -> Muatkan dagangan individu untuk baldi 100ms ini
  -> Selesaikan pengisian pada peringkat dagangan demi dagangan — ketepatan maksimum yang mungkin

Bila Drill-Down Tidak Diperlukan

Dalam 95% kes, drill-down tidak diperlukan. Senario tipikal:

SL tidak jelas: high lilin tidak mencapai TP, low menembus SL -> SL dicetuskan, tiada drill-down diperlukan.

TP tidak jelas: low tidak mencapai SL, high menembus TP -> TP dicetuskan, tiada drill-down diperlukan.

Tiada yang dicetuskan: kedua-dua peringkat berada di luar julat -> kedudukan kekal terbuka.

Pengesanan gap: open lilin seterusnya melompat melalui SL atau TP -> pelaksanaan pada harga open, tiada drill-down.

Drill-down hanya diperlukan untuk ~5% bar — apabila kedua-dua peringkat jatuh dalam julat satu lilin.

class AdaptiveFillSimulator:
    """
    Drill-down empat peringkat untuk menentukan urutan pengisian.
    """
    def __init__(self, data_loader):
        self.loader = data_loader
        self.cache_1s = {}  # Cache data saat mengikut bulan

    def check_fill(self, timestamp, candle_1m, sl_price, tp_price, side):
        """
        Memeriksa sama ada SL atau TP dicetuskan pada lilin minit yang diberikan.

        Mengembalikan: ('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):
        """Peringkat 2: laluan saat demi saat."""
        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):
        """Andaian pesimistik: SL untuk panjang, TP untuk pendek."""
        if side == 'long':
            return ('sl', sl_price)
        else:
            return ('sl', sl_price)

Prestasi

Mod Masa setiap semakan pengisian Bila digunakan
1m (tiada drill-down) ~0ms ~95% kes
Drill-down 1s ~5ms (akses pertama ke bulan) ~5% kes
Drill-down 100ms ~1ms <0.5% kes
Drill-down dagangan mentah ~0.5ms <0.1% kes

Dalam backtest 2 tahun dengan ~400 dagangan, drill-down dipanggil untuk kira-kira 20 lilin. Jumlah overhed — kurang daripada 1 saat untuk keseluruhan backtest.

Storan Data Adaptif

Drill-down memerlukan data saat dan milisaat. Tetapi menyimpan semua pada granulariti maksimum adalah tidak praktikal:

Granulariti Bar dalam 2 tahun Saiz Parquet
1m ~1.05M ~15 MB
1s ~63M ~550 MB/bulan
100ms ~630M ~5 GB/bulan

Arkib 1s lengkap selama 2 tahun adalah kira-kira 13 GB. 100ms — lebih daripada 100 GB. Menyimpan semua adalah mungkin tetapi membazir, memandangkan drill-down menggunakan kurang daripada 1% data ini.

Pengesanan Saat Panas (Hot-Second)

Pengesanan saat panas dan penjimatan storan adaptif

Pemerhatian utama: saat di mana harga bergerak dengan ketara mewakili sebahagian kecil. Jika harga berubah kurang daripada 0.1% dalam satu saat — tidak ada gunanya menyimpan pecahan 100ms untuk saat tersebut.

Pengesanan saat panas: semasa memuat turun dan memproses data, kami menganalisis setiap saat dan menjana lilin 100ms hanya untuk saat "panas" — saat di mana pergerakan harga melebihi ambang.

def process_trades_adaptive(
    trades: pd.DataFrame,
    min_price_change_pct: float = 1.0,
) -> tuple[pd.DataFrame, pd.DataFrame]:
    """
    Memproses dagangan mentah ke dalam struktur adaptif:
    - Lilin 1s untuk semua saat
    - Lilin 100ms hanya untuk saat "panas"

    Args:
        trades: DataFrame dengan lajur [timestamp, price, quantity]
        min_price_change_pct: ambang untuk drill-down ke 100ms

    Returns:
        (df_1s, df_100ms_hot) — lilin saat dan 100ms untuk saat panas
    """
    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

Penjimatan Storan

Sebagai contoh — ETHUSDT dalam bulan tipikal:

Pendekatan Saiz Granulariti
1m sahaja ~1 MB 1 minit
Semua 1s ~550 MB 1 saat
Semua 100ms ~5 GB 100 ms
Adaptif ~600 MB 1s + 100ms hanya untuk saat panas

Dengan ambang min_price_change_pct = 1.0%, saat panas menyumbang kurang daripada 1% daripada semua saat. Data 100ms untuk mereka menambahkan ~50 MB kepada 550 MB data saat — overhed yang boleh diabaikan.

Jika data saat juga disimpan secara adaptif (hanya apabila pergerakan dalam minit melebihi 0.1%), volum boleh dikurangkan lagi sebanyak 3-5x.

Hierarki storan Parquet adaptif: minit, saat, milisaat panas dan fail dagangan

Struktur Storan Parquet

data/{SYMBOL}/
├── source.json                # Sumber pertukaran: {"exchange": "binance"} atau {"exchange": "bybit"}
├── stats.json                 # Median volum pra-dikira: {"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 (saat panas sahaja)
   └── ...
├── trades_hot/
   ├── 2024-01.parquet       # Dagangan mentah untuk baldi 100ms panas
   └── ...
└── states_1m.parquet          # Cache keadaan bergulir pra-dikira (~112 MB)

Setiap fail merangkumi satu bulan data. Data saat, milisaat, dan dagangan dimuatkan secara malas — hanya apabila drill-down memintanya. Fail stats.json mengandungi median volum pra-dikira yang digunakan untuk pencetus drill-down berasaskan volum.

Pengoptimuman Parquet untuk Data Kewangan

Data kewangan mempunyai ciri-ciri khusus: cap masa meningkat secara monoton, harga berubah dengan lancar, volum berbeza-beza dengan ketara. Tetapan optimum:

import pyarrow as pa
import pyarrow.parquet as pq

schema = pa.schema([
    pa.field("timestamp", pa.int32()),    # Saat dari epoch — int32 mencukupi
    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",   # Int monoton -> pemampatan delta
    "open":      "BYTE_STREAM_SPLIT",     # Float -> pemisahan aliran bait
    "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 tetapan ini:

  • DELTA_BINARY_PACKED untuk cap masa: cap masa berturutan berbeza dengan nilai tetap (60 untuk 1m, 1 untuk 1s). Pengekodan delta memampatkannya menjadi hampir sifar.
  • BYTE_STREAM_SPLIT untuk float: memisahkan bait float32 ke dalam aliran (semua bait pertama bersama, semua bait kedua bersama, dsb.). Untuk harga yang berubah dengan lancar, ini mencapai pemampatan 2-3x lebih baik daripada pengekodan standard.
  • ZSTD tahap 9: pemampatan baik dengan kelajuan penyahmampatan yang boleh diterima.
  • float32 berbanding float64: mencukupi untuk harga dan volum, menjimatkan 50% memori.

Pemuatan Malas dengan Caching

Drill-down meminta data saat untuk minit tertentu. Memuatkan fail parquet untuk setiap permintaan adalah perlahan. Penyelesaian — pemuatan malas dengan cache LRU mengikut bulan.

from functools import lru_cache
import pyarrow.parquet as pq
import pandas as pd

class AdaptiveDataLoader:
    """
    Pemuat malas dengan cache: memuatkan data saat mengikut bulan,
    menyimpan N bulan terakhir dalam memori.
    """
    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:
        """Muatkan data 1s untuk minit tertentu."""
        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:
        """Muatkan data 100ms untuk saat panas."""
        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):
        """Muatkan sebulan data 1s, buang data lama dari 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

Menggunakan Drill-Down untuk Backtesting

Integrasi ke dalam gelung backtest:

def backtest_with_adaptive_fill(
    states: pd.DataFrame,
    strategy_params: dict,
    data_loader: AdaptiveDataLoader,
) -> list:
    """
    Backtest dengan drill-down adaptif untuk simulasi pengisian.
    """
    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, atau 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 Cache Keadaan Bergulir

Drill-down melengkapi cache parquet teragregat — kedua-duanya menyelesaikan masalah yang berbeza:

Cache keadaan bergulir Drill-down adaptif
Tujuan Nilai penunjuk HTF yang betul Urutan pelaksanaan SL/TP yang tepat
Beroperasi pada Setiap lilin 1m Hanya semasa kekaburan pengisian (~5%)
Data Pra-dikira, disimpan secara kekal Dimuatkan secara malas, cache bulan terkini
Mempengaruhi Isyarat masuk/keluar Harga dan masa pelaksanaan

Kedua-dua pendekatan menghapuskan ralat yang tidak kelihatan pada peringkat lilin harian tetapi kritikal untuk backtesting yang realistik.

Ringkasan: Perbandingan Pendekatan Simulasi Pengisian

Pendekatan Ketepatan Kelajuan Storan
Heuristik OHLC (optimis/pesimis) Rendah Serta-merta 1m sahaja
Backtest 1s penuh Tinggi Perlahan (x60) ~550 MB/bulan
Backtest 100ms penuh Sangat tinggi Sangat perlahan (x600) ~5 GB/bulan
Backtest dagangan mentah penuh Maksimum Sangat perlahan ~50 GB/bulan
Drill-down adaptif (4 peringkat) Maksimum ~Serta-merta 1m + 1s + 100ms panas + dagangan panas

Drill-down memberikan ketepatan backtest 1s penuh pada kelajuan backtest 1m. Pemerhatian utama: granulariti tinggi tidak diperlukan di mana-mana — hanya pada titik keputusan.

Lonjakan volum mencetuskan drill-down ke peringkat granulariti yang lebih halus

Drill-Down Berasaskan Volum

Drill-down asal hanya mencetuskan pada pergerakan harga — apabila julat [low, high] sebuah lilin cukup lebar untuk mewujudkan kekaburan pengisian. Tetapi harga bukan satu-satunya isyarat bahawa sesuatu yang menarik berlaku dalam bar.

Lonjakan volum adalah pencetus yang sama pentingnya. Saat di mana volum 500x median biasanya sepadan dengan pesanan pasaran besar, lata likuidasi, atau flash crash. Walaupun badan lilin kelihatan kecil, laluan harga sebenar dalam saat tersebut mungkin liar — menyentuh had ekstrem yang tersembunyi oleh representasi OHLC.

Syarat drill-down kini berasaskan ATAU: sama ada pergerakan harga yang ketara ATAU lonjakan volum anomali mencetuskan penurunan ke granulariti yang lebih halus.

def is_hot(bar, median_volume, min_pct=0.1, vol_mult=500):
    """
    Menentukan sama ada bar memerlukan drill-down ke peringkat seterusnya.
    Dua pencetus bebas (logik ATAU):
      - harga bergerak >= min_pct dalam bar
      - volum melebihi 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 senario yang tidak kelihatan oleh pengesanan harga sahaja: bar dengan open=3000, close=3001 tetapi volum 50,000x norma mungkin telah secara singkat menyentuh 2950 dan 3050 dalam milisaat. Tanpa drill-down berasaskan volum, backtest tidak akan pernah memeriksa saat ini dengan lebih teliti.

Dagangan Mentah: Peringkat Keempat

Hierarki tiga peringkat asal (1m -> 1s -> 100ms) masih meninggalkan jurang: dalam satu baldi 100ms, pelbagai dagangan boleh dilaksanakan pada harga yang berbeza. Untuk baldi dengan high=3060 dan low=2965, kita masih tidak tahu urutan yang tepat.

Penyelesaian: drill down ke dagangan mentah sebagai peringkat keempat dan terakhir.

Lilin 1m (asas)
  └─> Lilin 1s    (apabila 1s menunjukkan price_move >= min_pct ATAU volume >= median_1s * vol_mult)
      └─> Lilin 100ms  (apabila saat panas dikesan)
          └─> Dagangan mentah     (apabila 100ms menunjukkan price_move >= min_pct ATAU volume >= median_100ms * vol_mult)

Pada peringkat dagangan mentah, tiada kekaburan — setiap dagangan mempunyai harga dan cap masa yang tepat. Pengisian diselesaikan secara muktamad:

def resolve_from_trades(trades, sl_price, tp_price, side):
    """
    Lalui dagangan individu dalam urutan kronologi.
    Dagangan pertama yang melepasi SL atau TP menentukan pengisian.
    """
    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

Peringkat dagangan mentah dipanggil sangat jarang — kurang daripada 0.1% daripada semua bar — tetapi apabila ia dipanggil, ia memberikan kebenaran asas yang tidak dapat ditandingi oleh sebarang penghampiran berasaskan lilin.

Ambang Berasingan untuk Setiap Peralihan

Peralihan resolusi yang berbeza mempunyai ciri-ciri yang berbeza. Pergerakan harga 0.1% dalam satu saat adalah ketara; 0.1% yang sama dalam baldi 100ms adalah ekstrem. Begitu juga, taburan volum berbeza pada setiap skala masa.

Setiap peralihan peringkat kini mempunyai parameter min_pct dan vol_mult tersendiri:

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

Ini membolehkan penalaan halus kepekaan setiap peralihan secara bebas. Dalam praktik, peralihan 100ms-ke-dagangan boleh menggunakan ambang yang lebih ketat kerana kos memuatkan dagangan mentah untuk satu baldi 100ms adalah 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 Berterusan

Drill-down berasaskan volum memerlukan mengetahui median volum pada setiap skala masa. Mengira median secara langsung untuk setiap backtest akan menafikan faedah prestasi. Penyelesaian: pra-kira median sekali dan cache mereka.

Untuk setiap simbol, median volum pada granulariti 1s dan 100ms dikira dari data sejarah dan disimpan dalam fail 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 dikira sekali per simbol apabila data pertama kali dimuat turun dan digunakan semula merentasi semua backtest seterusnya. Jika data dikemas kini (bulan baru dimuat turun), statistik dikira semula secara tambahan.

def compute_median_stats(symbol, data_dir):
    """Kira dan cache statistik median volum untuk simbol."""
    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 berbilang pertukaran: Binance dan Bybit menggabungkan ke dalam lapisan granulariti bersatu

Sokongan Berbilang Pertukaran: Bybit

Tidak semua simbol tersedia di Binance. Untuk aset seperti XAUTUSDT (emas), data mesti datang dari pertukaran lain. Sistem drill-down kini menyokong Bybit sebagai sumber data alternatif.

Untuk simbol Bybit, semua peringkat lilin (1m, 1s, 100ms) dan dagangan mentah dibina dari aliran dagangan mentah Bybit. Prosesnya sama — dagangan mentah diagregatkan ke dalam lilin pada setiap skala masa — tetapi sumber data berbeza.

data/{SYMBOL}/
├── source.json              # {"exchange": "bybit"} atau {"exchange": "binance"}
├── klines_1m/
│   └── ...
├── klines_1s/
│   └── ...
├── klines_100ms_hot/
│   └── ...
└── trades_hot/              # Dagangan mentah untuk baldi 100ms panas
    └── ...

Pemuat data memeriksa source.json dan menggunakan saluran muat turun yang sesuai. Dari perspektif enjin backtest, format data adalah sama tanpa mengira pertukaran sumber — logik drill-down adalah bebas pertukaran.

Ini amat penting untuk strategi merentasi pertukaran atau simbol yang berdagang secara eksklusif di tempat tertentu.

Kesimpulan

Drill-down adaptif adalah penerapan prinsip mudah: belanjakan sumber pengiraan dan storan secara berkadar dengan kepentingan data.

Empat peringkat granulariti:

  1. 1m — laluan asas untuk 95% bar
  2. 1s — drill-down semasa kekaburan pengisian atau lonjakan volum
  3. 100ms — drill-down untuk saat panas dengan pergerakan ekstrem atau volum anomali
  4. Dagangan mentah — drill-down untuk baldi 100ms panas, menyelesaikan pengisian pada peringkat dagangan individu

Empat peringkat storan:

  1. Semua 1m — arkib lengkap, ~15 MB untuk 2 tahun
  2. Semua 1s — arkib lengkap atau adaptif, ~550 MB/bulan
  3. 100ms panas sahaja — <1% saat, ~50 MB/bulan
  4. Dagangan panas sahaja — dagangan mentah untuk baldi 100ms paling ekstrem

Dua pencetus drill-down (logik ATAU):

  • Berasaskan harga: julat harga bar melebihi min_pct
  • Berasaskan volum: volum bar melebihi median * vol_mult

Hasilnya: backtest dengan ketepatan simulator tick pada kelajuan peringkat minit. Storan yang berkembang secara linear, bukan eksponen. Dan sokongan untuk berbilang pertukaran — Binance dan Bybit — dengan logik drill-down bebas pertukaran.

Untuk maklumat lanjut tentang cache pra-dikira untuk strategi berbilang jangka masa, lihat artikel Cache Parquet Teragregat. Tentang kesan kadar pembiayaan pada keputusan dengan leverage tinggi — Kadar pembiayaan membunuh leverage anda.


Pautan Berguna

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

Petikan

@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 granulariti data adaptif mempercepatkan backtest dan menjimatkan storan: drill-down dari 1m ke 1s, 100ms, dan dagangan mentah hanya di mana harga bergerak dengan ketara atau volum melonjak.}
}
Penafian: Maklumat yang disediakan dalam artikel ini adalah untuk tujuan pendidikan dan maklumat sahaja dan bukan merupakan nasihat kewangan, pelaburan, atau dagangan. Dagangan mata wang kripto melibatkan risiko kerugian yang ketara.

Pengarang

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

Kekal Mendahului Pasaran

Langgan surat berita kami untuk pandangan dagangan AI eksklusif, analisis pasaran, dan kemas kini platform.

Kami menghormati privasi anda. Berhenti melanggan pada bila-bila masa.