Adaptive Drill-Down: Backtest dengan Granulariti Pemboleh ubah dari Minit hingga Dagangan Mentah
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.

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:
- Optimistik: TP dicetuskan dahulu -> keputusan yang melambung
- 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

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)

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.

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.

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

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:
- 1m — laluan asas untuk 95% bar
- 1s — drill-down semasa kekaburan pengisian atau lonjakan volum
- 100ms — drill-down untuk saat panas dengan pergerakan ekstrem atau volum anomali
- Dagangan mentah — drill-down untuk baldi 100ms panas, menyelesaikan pengisian pada peringkat dagangan individu
Empat peringkat storan:
- Semua 1m — arkib lengkap, ~15 MB untuk 2 tahun
- Semua 1s — arkib lengkap atau adaptif, ~550 MB/bulan
- 100ms panas sahaja — <1% saat, ~50 MB/bulan
- 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
- Apache Parquet — format storan data
- Apache Arrow — pengekodan BYTE_STREAM_SPLIT
- Zstandard — algoritma pemampatan
- Lopez de Prado — Advances in Financial Machine Learning
- 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.}
}
Pengarang
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.