← К списку статей
March 17, 2026
5 мин. чтения

Adaptive drill-down: бэктест с переменной гранулярностью от минут до сырых сделок

Adaptive drill-down: бэктест с переменной гранулярностью от минут до сырых сделок
#алготрейдинг
#бэктест
#parquet
#оптимизация
#гранулярность
#drill-down
#адаптивная резолюция

Минутные свечи — стандартная гранулярность для бэктестов. Но внутри одной минутной свечи цена может двигаться по-разному: иногда на 0.01%, а иногда на 2%. Когда стоп-лосс и тейк-профит оба попадают в диапазон [low, high] одной минутной свечи — бэктест не знает, что сработало первым. Это проблема fill ambiguity.

Наивное решение — перейти на секундные данные для всего бэктеста. Но за два года это ~63 миллиона секундных баров вместо ~1 миллиона минутных. Хранилище увеличивается в 60 раз, скорость падает пропорционально.

Адаптивный drill-down решает эту проблему: использовать мелкую гранулярность только там, где она действительно нужна.

Fill ambiguity: и SL, и TP попадают в диапазон одной свечи

Проблема: fill ambiguity на крупных свечах

Рассмотрим конкретную ситуацию. Стратегия открыла лонг по 3000 USDT. Стоп-лосс: 2970 (-1%). Тейк-профит: 3060 (+2%).

Минутная свеча в 14:37:

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

И SL (2970), и TP (3060) попали в диапазон [2965, 3065]. Что сработало первым?

Возможные исходы:

  • Цена сначала пошла вниз → сработал SL → убыток -1%
  • Цена сначала пошла вверх → сработал TP → прибыль +2%

Разница в одной сделке: 3 процентных пункта. При leverage 10× — 30%. Для бэктеста с сотнями сделок неправильное разрешение fill ambiguity систематически искажает результаты.

Как фреймворки решают это по умолчанию

Большинство бэктест-движков используют одну из двух эвристик:

  1. Оптимистичная: TP срабатывает первым → завышенные результаты
  2. Пессимистичная: SL срабатывает первым → заниженные результаты

Оба подхода — гадание. Реальные данные доступны на секундном или даже миллисекундном уровне, и нет причины гадать, когда можно посмотреть.

Drill-down: четырёхуровневая стратегия

Adaptive four-level drill-down resolution pyramid

Идея drill-down: начинаем на минутном уровне и «проваливаемся» на уровень ниже только при неоднозначности — по цене или по объёму.

Уровень 1: 1m (минутные свечи)
   Если SL или TP однозначно вне диапазона [low, high]  решаем на месте
   Если оба внутри диапазона  drill down 

Уровень 2: 1s (секундные свечи)
   Загружаем 60 секундных баров для этой минуты
   Проходим посекундно: кто сработал первым?
   Если секундный бар неоднозначен, ИЛИ price_move >= min_pct, ИЛИ volume >= median_1s * vol_mult  drill down 

Уровень 3: 100ms (миллисекундные свечи)
   Загружаем до 10 баров по 100ms для этой секунды
   Проходим по 100ms: кто сработал первым?
   Если 100ms бар неоднозначен, ИЛИ price_move >= min_pct, ИЛИ volume >= median_100ms * vol_mult  drill down 

Уровень 4: Сырые сделки (raw trades)
   Загружаем отдельные сделки для этого 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% баров — когда оба уровня попадают в диапазон одной свечи.

class AdaptiveFillSimulator:
    """
    Четырёхуровневый drill-down для определения fill order.
    """
    def __init__(self, data_loader):
        self.loader = data_loader
        self.cache_1s = {}  # Кэш секундных данных по месяцам

    def check_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 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):
        """Уровень 2: посекундный проход."""
        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):
        """Пессимистичное предположение: 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% случаев
Raw trades drill-down ~0.5ms <0.1% случаев

За 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

Hot-second detection and adaptive storage savings

Ключевое наблюдение: секунды, в которых цена значительно двигается, составляют малую долю. Если за секунду цена изменилась менее чем на 0.1% — нет смысла хранить 100ms breakdown для этой секунды.

Hot-second detection: при скачивании и обработке данных анализируем каждую секунду и генерируем 100ms свечи только для «горячих» секунд — тех, где ценовое движение превысило пороговое значение.

def process_trades_adaptive(
    trades: pd.DataFrame,
    min_price_change_pct: float = 1.0,
) -> tuple[pd.DataFrame, pd.DataFrame]:
    """
    Обрабатывает сырые trades в адаптивную структуру:
    - 1s свечи для всех секунд
    - 100ms свечи только для "горячих" секунд

    Args:
        trades: DataFrame с колонками [timestamp, price, quantity]
        min_price_change_pct: порог для drill-down в 100ms

    Returns:
        (df_1s, df_100ms_hot) — секундные свечи и 100ms для горячих секунд
    """
    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

Экономия хранилища

Для примера — ETHUSDT за типичный месяц:

Подход Размер Гранулярность
Только 1m ~1 MB 1 минута
Все 1s ~550 MB 1 секунда
Все 100ms ~5 GB 100 мс
Адаптивный ~600 MB 1s + 100ms только для горячих

При пороге min_price_change_pct = 1.0% горячие секунды составляют менее 1% от всех секунд. 100ms данные для них добавляют ~50 MB к 550 MB секундных данных — пренебрежимая надбавка.

Если при этом хранить секундные данные тоже адаптивно (только когда движение внутри минуты > 0.1%), объём можно сократить ещё в 3–5 раз.

Адаптивная иерархия Parquet-хранилища: минутные, секундные, горячие миллисекундные и сделочные файлы

Структура parquet-хранилища

data/{SYMBOL}/
├── source.json                # Источник данных: {"exchange": "binance"} или {"exchange": "bybit"}
├── stats.json                 # Предвычисленные медианы объёмов: {"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 (только горячие секунды)
   └── ...
├── trades_hot/
   ├── 2024-01.parquet       # Сырые сделки для горячих 100ms бакетов
   └── ...
└── states_1m.parquet          # Precomputed rolling state cache (~112 MB)

Каждый файл — месяц данных. Секундные, миллисекундные данные и сырые сделки загружаются lazily — только когда drill-down их запрашивает. Файл stats.json содержит предвычисленные медианы объёмов для volume-based триггеров drill-down.

Оптимизация parquet для финансовых данных

Финансовые данные имеют специфику: timestamps монотонно растут, цены меняются плавно, объёмы сильно варьируются. Оптимальные настройки:

import pyarrow as pa
import pyarrow.parquet as pq

schema = pa.schema([
    pa.field("timestamp", pa.int32()),    # Секунды с epoch — int32 достаточно
    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 → delta-сжатие
    "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,
    )

Почему эти настройки:

  • 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

class AdaptiveDataLoader:
    """
    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] = {}

    def load_1s_for_minute(self, minute_ts: pd.Timestamp) -> pd.DataFrame | None:
        """Загрузить 1s данные для конкретной минуты."""
        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:
        """Загрузить 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:
            return None

    def _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')

            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 для бэктеста

Интеграция в бэктест-цикл:

def backtest_with_adaptive_fill(
    states: pd.DataFrame,
    strategy_params: dict,
    data_loader: AdaptiveDataLoader,
) -> list:
    """
    Бэктест с адаптивным drill-down для 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

Связь с rolling state cache

Drill-down дополняет агрегированный parquet-кэш — они решают разные задачи:

Rolling state cache Adaptive drill-down
Задача Корректные значения индикаторов HTF Точный порядок исполнения SL/TP
Работает на Каждой 1m свече Только при fill ambiguity (~5%)
Данные Precomputed, хранятся постоянно Lazy loaded, кэш последних месяцев
Влияет на Сигналы входа/выхода Цену и время исполнения

Оба подхода устраняют ошибки, невидимые на уровне дневных свечей, но критичные для реалистичного бэктеста.

Итого: сравнение подходов к fill simulation

Подход Точность Скорость Хранилище
OHLC эвристика (оптимист/пессимист) Низкая Мгновенно Только 1m
Полный 1s бэктест Высокая Медленно (×60) ~550 MB/мес
Полный 100ms бэктест Очень высокая Очень медленно (×600) ~5 GB/мес
Полный raw trades бэктест Максимальная Крайне медленно ~50 GB/мес
Adaptive drill-down (4 уровня) Максимальная ~Мгновенно 1m + 1s + 100ms hot + trades hot

Drill-down даёт точность полного 1s бэктеста при скорости 1m бэктеста. Ключевое наблюдение: высокая гранулярность нужна не везде — только в точках принятия решений.

Всплески объёма запускают drill-down на более мелкие уровни гранулярности

Volume-based drill-down

Оригинальный drill-down срабатывает только по движению цены — когда диапазон [low, high] свечи достаточно широк для fill ambiguity. Но цена — не единственный сигнал того, что внутри бара произошло что-то важное.

Всплески объёма — не менее важный триггер. Секунда, в которой объём в 500 раз превышает медиану, обычно соответствует крупному рыночному ордеру, каскаду ликвидаций или flash crash. Даже если тело свечи выглядит маленьким, реальная траектория цены внутри этой секунды могла быть экстремальной — касаясь значений, которые OHLC-представление скрывает.

Условие drill-down теперь на основе ИЛИ: значительное ценовое движение ИЛИ аномальный всплеск объёма запускают спуск к более мелкой гранулярности.

def is_hot(bar, median_volume, min_pct=0.1, vol_mult=500):
    """
    Определяет, требует ли бар drill-down на следующий уровень.
    Два независимых триггера (логика ИЛИ):
      - цена двинулась >= min_pct внутри бара
      - объём превысил median * vol_mult
    """
    price_move = (bar['high'] - bar['low']) / bar['open'] * 100
    return price_move >= min_pct or bar['volume'] >= median_volume * vol_mult

Это ловит сценарии, невидимые для детекции только по цене: бар с open=3000, close=3001, но объёмом в 50 000 раз выше нормы, мог за миллисекунды коснуться 2950 и 3050. Без volume-based drill-down бэктест никогда не стал бы рассматривать эту секунду подробнее.

Сырые сделки: четвёртый уровень

Оригинальная трёхуровневая иерархия (1m → 1s → 100ms) оставляет пробел: внутри одного 100ms бакета может исполниться несколько сделок по разным ценам. Для бакета с high=3060 и low=2965 мы по-прежнему не знаем точную последовательность.

Решение: drill-down до сырых сделок как четвёртый и финальный уровень.

1m свечи (базовый)
  └─> 1s свечи      (когда price_move >= min_pct ИЛИ volume >= median_1s * vol_mult)
      └─> 100ms свечи    (когда обнаружена горячая секунда)
          └─> Сырые сделки   (когда 100ms показывает price_move >= min_pct ИЛИ volume >= median_100ms * vol_mult)

На уровне сырых сделок нет неоднозначности — каждая сделка имеет точную цену и timestamp. Fill разрешается окончательно:

def resolve_from_trades(trades, sl_price, tp_price, side):
    """
    Проходим по отдельным сделкам в хронологическом порядке.
    Первая сделка, пересекающая SL или TP, определяет 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

Уровень сырых сделок вызывается крайне редко — менее 0.1% от всех баров — но когда вызывается, он даёт ground truth, недоступную никакой свечной аппроксимации.

Раздельные пороги для каждого перехода

Разные переходы между разрешениями имеют разные характеристики. Движение цены на 0.1% в пределах секунды — значимое; те же 0.1% в пределах 100ms бакета — экстремальные. Аналогично, распределения объёмов различаются на каждом таймскейле.

Каждый переход теперь имеет свои параметры min_pct и vol_mult:

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

Это позволяет тонко настраивать чувствительность каждого перехода независимо. На практике переход 100ms→trades может использовать более жёсткий порог, так как стоимость загрузки сырых сделок для одного 100ms бакета минимальна.

@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

Персистентные медианные статистики

Volume-based drill-down требует знания медианного объёма на каждом таймскейле. Вычислять медианы на лету для каждого бэктеста — это нивелирует преимущества в производительности. Решение: предвычислить медианы один раз и закэшировать.

Для каждого символа медианные объёмы на гранулярности 1s и 100ms вычисляются из исторических данных и сохраняются в файл stats.json:

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

Статистики вычисляются один раз для каждого символа при первой загрузке данных и переиспользуются во всех последующих бэктестах. При обновлении данных (загрузка новых месяцев) статистики пересчитываются инкрементально.

def compute_median_stats(symbol, data_dir):
    """Вычислить и закэшировать медианные статистики объёмов для символа."""
    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

Мультибиржевой поток данных: Binance и Bybit объединяются в единые уровни гранулярности

Мультибиржевая поддержка: Bybit

Не все символы доступны на Binance. Для активов вроде XAUTUSDT (золото) данные необходимо получать с других бирж. Система drill-down теперь поддерживает Bybit как альтернативный источник данных.

Для символов Bybit все уровни свечей (1m, 1s, 100ms) и сырые сделки строятся из потока сырых сделок Bybit. Процесс тот же — сырые сделки агрегируются в свечи на каждом таймскейле — но источник данных другой.

data/{SYMBOL}/
├── source.json              # {"exchange": "bybit"} или {"exchange": "binance"}
├── klines_1m/
│   └── ...
├── klines_1s/
│   └── ...
├── klines_100ms_hot/
│   └── ...
└── trades_hot/              # Сырые сделки для горячих 100ms бакетов
    └── ...

Data loader проверяет source.json и использует соответствующий pipeline загрузки. С точки зрения бэктест-движка формат данных идентичен вне зависимости от биржи-источника — логика drill-down не зависит от биржи.

Это особенно важно для кросс-биржевых стратегий или символов, которые торгуются исключительно на определённых площадках.

Заключение

Адаптивный drill-down — это применение простого принципа: тратить вычислительные ресурсы и место пропорционально важности данных.

Четыре уровня гранулярности:

  1. 1m — базовый проход для 95% баров
  2. 1s — drill-down при fill ambiguity или всплесках объёма
  3. 100ms — drill-down для горячих секунд с экстремальным движением или аномальным объёмом
  4. Сырые сделки — drill-down для горячих 100ms бакетов, разрешение fill на уровне каждой сделки

Четыре уровня хранения:

  1. Все 1m — полный архив, ~15 MB за 2 года
  2. Все 1s — полный архив или адаптивный, ~550 MB/мес
  3. Только горячие 100ms — <1% секунд, ~50 MB/мес
  4. Только горячие trades — сырые сделки для самых экстремальных 100ms бакетов

Два триггера drill-down (логика ИЛИ):

  • По цене: диапазон бара превышает min_pct
  • По объёму: объём бара превышает median * vol_mult

Результат: бэктест с точностью тикового симулятора при скорости минутного. Хранилище, которое растёт линейно, а не экспоненциально. И поддержка нескольких бирж — Binance и Bybit — с биржа-агностичной логикой drill-down.

Подробнее о precomputed cache для мультитаймфрейм-стратегий — в статье Агрегированный Parquet-кэш. О влиянии funding rates на результаты при высоком leverage — Funding rates убивают ваш leverage.


Полезные ссылки

  1. Apache Parquet — формат хранения данных
  2. Apache Arrow — BYTE_STREAM_SPLIT encoding
  3. Zstandard — алгоритм сжатия
  4. López de Prado — Advances in Financial Machine Learning
  5. Binance — Historical Market Data

Цитирование

@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 и сырым сделкам только там, где цена двигалась значительно или объём аномально вырос.}
}
Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

MarketMaker.cc Team

Количественные исследования и стратегии

Обсудить в Telegram
Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.