Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.
Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.
Минутные свечи — стандартная гранулярность для бэктестов. Но внутри одной минутной свечи цена может двигаться по-разному: иногда на 0.01%, а иногда на 2%. Когда стоп-лосс и тейк-профит оба попадают в диапазон [low, high] одной минутной свечи — бэктест не знает, что сработало первым. Это проблема fill ambiguity.
Наивное решение — перейти на секундные данные для всего бэктеста. Но за два года это ~63 миллиона секундных баров вместо ~1 миллиона минутных. Хранилище увеличивается в 60 раз, скорость падает пропорционально.
Адаптивный drill-down решает эту проблему: использовать мелкую гранулярность только там, где она действительно нужна.
И SL (2970), и TP (3060) попали в диапазон [2965, 3065]. Что сработало первым?
Возможные исходы:
Цена сначала пошла вниз → сработал SL → убыток -1%
Цена сначала пошла вверх → сработал TP → прибыль +2%
Разница в одной сделке: 3 процентных пункта. При leverage 10× — 30%. Для бэктеста с сотнями сделок неправильное разрешение fill ambiguity систематически искажает результаты.
Как фреймворки решают это по умолчанию
Большинство бэктест-движков используют одну из двух эвристик:
Оптимистичная: TP срабатывает первым → завышенные результаты
Пессимистичная: SL срабатывает первым → заниженные результаты
Оба подхода — гадание. Реальные данные доступны на секундном или даже миллисекундном уровне, и нет причины гадать, когда можно посмотреть.
Drill-down: трёхуровневая стратегия
Идея drill-down: начинаем на минутном уровне и «проваливаемся» на уровень ниже только при неоднозначности.
Уровень 1: 1m (минутные свечи)
→ Если SL или TP однозначно вне диапазона [low, high] — решаем на месте
→ Если оба внутри диапазона — drill down ↓
Уровень 2: 1s (секундные свечи)
→ Загружаем 60 секундных баров для этой минуты
→ Проходим посекундно: кто сработал первым?
→ Если секундный бар тоже неоднозначен — drill down ↓
Уровень 3: 100ms (миллисекундные свечи)
→ Загружаем до 10 баров по 100ms для этой секунды
→ Разрешаем fill на уровне ордербука
Когда drill-down не нужен
В 95% случаев drill-down не требуется. Типичные сценарии:
Однозначный SL: high свечи не достигает TP, low пробивает SL → SL сработал, drill-down не нужен.
Однозначный TP: low не достигает SL, high пробивает TP → TP сработал, drill-down не нужен.
Ни один не сработал: оба уровня вне диапазона → позиция остаётся открытой.
Gap detection: open следующей свечи прыгает через SL или TP → исполнение по цене открытия, без drill-down.
Drill-down нужен только в ~5% баров — когда оба уровня попадают в диапазон одной свечи.
classAdaptiveFillSimulator:
"""
Трёхуровневый drill-down для определения fill order.
"""def__init__(self, data_loader):
self.loader = data_loader
self.cache_1s = {} # Кэш секундных данных по месяцамdefcheck_fill(self, timestamp, candle_1m, sl_price, tp_price, side):
"""
Проверяет, сработал ли SL или TP на данной минутной свече.
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 andnot tp_hit:
return ('sl', sl_price)
if tp_hit andnot sl_hit:
return ('tp', tp_price)
ifnot sl_hit andnot tp_hit:
returnNonereturnself._drill_down_1s(timestamp, sl_price, tp_price, side)
def_drill_down_1s(self, minute_ts, sl_price, tp_price, side):
"""Уровень 2: посекундный проход."""
bars_1s = self.loader.load_1s_for_minute(minute_ts)
if bars_1s isNoneorlen(bars_1s) == 0:
returnself._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 andnot tp_hit:
return ('sl', sl_price)
if tp_hit andnot 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
returnself._pessimistic_fill(side, sl_price, tp_price)
def_pessimistic_fill(self, side, sl_price, tp_price):
"""Пессимистичное предположение: SL для лонгов, TP для шортов."""if side == 'long':
return ('sl', sl_price)
else:
return ('sl', sl_price)
Производительность
Режим
Время на 1 fill check
Когда используется
1m (без drill-down)
~0ms
~95% случаев
1s drill-down
~5ms (первый доступ к месяцу)
~5% случаев
100ms drill-down
~1ms
<0.5% случаев
За 2 года бэктеста с ~400 сделками drill-down вызывается примерно для 20 свечей. Общие накладные расходы — менее 1 секунды на весь бэктест.
Адаптивное хранение данных
Drill-down требует секундных и миллисекундных данных. Но хранить всё на максимальной гранулярности — непрактично:
Гранулярность
Баров за 2 года
Размер parquet
1m
~1.05M
~15 MB
1s
~63M
~550 MB/мес
100ms
~630M
~5 GB/мес
Полный архив 1s данных за 2 года — около 13 GB. 100ms — более 100 GB. Хранить всё — можно, но расточительно, учитывая что drill-down использует менее 1% этих данных.
Hot-second detection
Ключевое наблюдение: секунды, в которых цена значительно двигается, составляют малую долю. Если за секунду цена изменилась менее чем на 0.1% — нет смысла хранить 100ms breakdown для этой секунды.
Hot-second detection: при скачивании и обработке данных анализируем каждую секунду и генерируем 100ms свечи только для «горячих» секунд — тех, где ценовое движение превысило пороговое значение.
При пороге min_price_change_pct = 1.0% горячие секунды составляют менее 1% от всех секунд. 100ms данные для них добавляют ~50 MB к 550 MB секундных данных — пренебрежимая надбавка.
Если при этом хранить секундные данные тоже адаптивно (только когда движение внутри минуты > 0.1%), объём можно сократить ещё в 3–5 раз.
DELTA_BINARY_PACKED для timestamps: последовательные timestamps отличаются на фиксированное значение (60 для 1m, 1 для 1s). Delta-кодирование сжимает их почти до нуля.
BYTE_STREAM_SPLIT для float: разделяет байты float32 по потокам (все первые байты вместе, все вторые и т.д.). Для плавно меняющихся цен это даёт сжатие в 2–3 раза лучше, чем стандартное.
ZSTD level 9: хорошее сжатие при приемлемой скорости распаковки.
float32 вместо float64: достаточно для цен и объёмов, экономит 50% памяти.
Lazy loading с кэшированием
Drill-down запрашивает секундные данные для конкретной минуты. Загружать parquet-файл для каждого запроса — медленно. Решение — lazy loading с LRU-кэшем по месяцам.
from functools import lru_cache
import pyarrow.parquet as pq
import pandas as pd
classAdaptiveDataLoader:
"""
Lazy loader с кэшем: загружает секундные данные по месяцам,
хранит в памяти последние N месяцев.
"""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] = {}
defload_1s_for_minute(self, minute_ts: pd.Timestamp) -> pd.DataFrame | None:
"""Загрузить 1s данные для конкретной минуты."""
month_key = minute_ts.strftime("%Y-%m")
if month_key notinself._cache_1s:
self._load_month_1s(month_key)
if month_key notinself._cache_1s:
returnNone
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)]
defload_100ms_for_second(self, second_ts: pd.Timestamp) -> pd.DataFrame | None:
"""Загрузить 100ms данные для горячей секунды."""
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:
returnNonedef_load_month_1s(self, month_key: str):
"""Загрузить месяц 1s данных, вытеснить старый из кэша."""
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')
iflen(self._cache_1s) >= self.cache_months:
oldest = min(self._cache_1s.keys())
delself._cache_1s[oldest]
self._cache_1s[month_key] = df
except FileNotFoundError:
pass
Применение drill-down для бэктеста
Интеграция в бэктест-цикл:
defbacktest_with_adaptive_fill(
states: pd.DataFrame,
strategy_params: dict,
data_loader: AdaptiveDataLoader,
) -> list:
"""
Бэктест с адаптивным drill-down для fill simulation.
"""
fill_sim = AdaptiveFillSimulator(data_loader)
trades = []
position = Nonefor i inrange(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 isnotNone:
fill = fill_sim.check_fill(
ts, candle_1m,
position['sl'], position['tp'],
position['side'],
)
if fill isnotNone:
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 = Nonecontinue
signal = check_entry_signal(row, strategy_params)
if signal and position isNone:
position = {
'side': signal['side'],
'entry_price': row['close'],
'entry_time': ts,
'sl': signal['sl'],
'tp': signal['tp'],
}
return trades
Оба подхода устраняют ошибки, невидимые на уровне дневных свечей, но критичные для реалистичного бэктеста.
Итого: сравнение подходов к fill simulation
Подход
Точность
Скорость
Хранилище
OHLC эвристика (оптимист/пессимист)
Низкая
Мгновенно
Только 1m
Полный 1s бэктест
Высокая
Медленно (×60)
~550 MB/мес
Полный 100ms бэктест
Максимальная
Очень медленно (×600)
~5 GB/мес
Adaptive drill-down
Высокая
~Мгновенно
1m + 1s + 100ms hot
Drill-down даёт точность полного 1s бэктеста при скорости 1m бэктеста. Ключевое наблюдение: высокая гранулярность нужна не везде — только в точках принятия решений.
Заключение
Адаптивный drill-down — это применение простого принципа: тратить вычислительные ресурсы и место пропорционально важности данных.
Три уровня гранулярности:
1m — базовый проход для 95% баров
1s — drill-down при fill ambiguity
100ms — drill-down для горячих секунд с экстремальным движением
Три уровня хранения:
Все 1m — полный архив, ~15 MB за 2 года
Все 1s — полный архив или адаптивный, ~550 MB/мес
Только горячие 100ms — <1% секунд, ~50 MB/мес
Результат: бэктест с точностью тикового симулятора при скорости минутного. И хранилище, которое растёт линейно, а не экспоненциально с увеличением гранулярности.
@article{soloviov2026adaptivedrilldown,
author = {Soloviov, Eugen},
title = {Adaptive drill-down: бэктест с переменной гранулярностью от минут до миллисекунд},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/adaptive-resolution-drill-down-backtest},
description = {Как адаптивная гранулярность данных ускоряет бэктесты и экономит хранилище: drill-down от 1m к 1s и 100ms только там, где цена двигалась значительно.}
}