← Мақалаларға оралу
March 17, 2026
5 мин оқу

Адаптивті Drill-Down: Минуттан бастап шикі мәмілелерге дейінгі айнымалы дәлдіктегі бэктест

Адаптивті Drill-Down: Минуттан бастап шикі мәмілелерге дейінгі айнымалы дәлдіктегі бэктест
#algotrading
#backtest
#parquet
#optimization
#granularity
#drill-down
#adaptive resolution
Part 5 of 10 · Collection
High-Performance Backtest Engines

Минуттық свечалар бэктесттер үшін стандартты дәлдік болып табылады. Бірақ бір минуттық свеча ішінде баға әртүрлі қозғалуы мүмкін: кейде 0,01%-ға, ал кейде 2%-ға. Стоп-лосс пен тейк-профит екеуі де бір минуттық свечаның [low, high] диапазонына сәйкес келгенде, бэктест қайсысы бірінші іске қосылғанын білмейді. Бұл — толтыру белгісіздігі (fill ambiguity) мәселесі.

Қарапайым шешім — бүкіл бэктест үшін секундтық деректерге көшу. Бірақ екі жыл ішінде бұл ~1 миллион минуттық бар орнына ~63 миллион секундтық бар дегенді білдіреді. Сақтау орны 60 есе өседі, ал жылдамдық сол пропорцияда төмендейді.

Адаптивті drill-down бұл мәселені шешеді: нәзік дәлдікті тек шынымен қажет жерде ғана қолданыңыз.

Fill ambiguity: both SL and TP fall within a single candle range

Мәселе: Үлкен свечалардағы толтыру белгісіздігі

Нақты жағдайды қарастырайық. Стратегия 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 пайыздық пункт. 10x левередж кезінде — 30%. Жүздеген мәмілесі бар бэктест үшін толтыру белгісіздігін қате шешу нәтижелерді жүйелі түрде бұрмалайды.

Фреймворктер мұны әдепкі бойынша қалай өңдейді

Бэктест қозғалтқыштарының көпшілігі екі эвристиканың бірін қолданады:

  1. Оптимистік: алдымен TP іске қосылады -> нәтижелер асыра көрсетіледі
  2. Пессимистік: алдымен SL іске қосылады -> нәтижелер төмендетіледі

Екі тәсіл де болжам жасау болып табылады. Нақты деректер секундтық немесе тіпті миллисекундтық деңгейде қолжетімді, ал көруге болатын кезде болжам жасаудың қажеті жоқ.

Drill-Down: Төрт деңгейлі стратегия

Adaptive four-level drill-down resolution pyramid

Drill-down идеясы: минуттық деңгейден бастап, белгісіздік болғанда ғана — баға қозғалысына немесе көлем секірулеріне байланысты — төменгі деңгейге "бұрғылап түсу".

Level 1: 1m (minute candles)
  -> If SL or TP is unambiguously outside the [low, high] range — resolve on the spot
  -> If both are within the range — drill down

Level 2: 1s (second candles)
  -> Load 60 second bars for this minute
  -> Walk through second by second: which triggered first?
  -> If a second bar is ambiguous, OR price_move >= min_pct, OR volume >= median_1s * vol_mult — drill down

Level 3: 100ms (millisecond candles)
  -> Load up to 10 bars of 100ms for this second
  -> Walk through 100ms by 100ms
  -> If a 100ms bar is ambiguous, OR price_move >= min_pct, OR volume >= median_100ms * vol_mult — drill down

Level 4: Raw trades
  -> Load individual trades for this 100ms bucket
  -> Resolve the fill at trade-by-trade level — maximum possible precision

Drill-Down қажет болмайтын жағдайлар

95% жағдайда drill-down қажет емес. Типтік сценарийлер:

Бір мәнді SL: свеча high-і TP-ге жетпейді, low SL-ды бұзады -> SL іске қосылды, drill-down қажет емес.

Бір мәнді TP: low SL-ге жетпейді, high TP-ні бұзады -> TP іске қосылды, drill-down қажет емес.

Ешқайсысы іске қосылмады: екі деңгей де диапазоннан тыс -> позиция ашық қалады.

Гэп анықтау: келесі свечаның ашылуы SL немесе TP арқылы секіреді -> ашылу бағасы бойынша орындалу, drill-down жоқ.

Drill-down тек ~5% бар үшін ғана қажет — екі деңгей де бір свечаның диапазонына сәйкес келгенде.

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)

Өнімділік

Режим Бір толтыру тексеруіне уақыт Қашан қолданылады
1m (drill-down жоқ) ~0мс жағдайлардың ~95%
1s drill-down ~5мс (айға алғашқы қатынау) жағдайлардың ~5%
100ms drill-down ~1мс жағдайлардың <0,5%
Шикі мәмілелер drill-down ~0,5мс жағдайлардың <0,1%

~400 мәмілесі бар 2 жылдық бэктестте drill-down шамамен 20 свечада шақырылады. Жалпы жүктеме — бүкіл бэктест үшін 1 секундтан аз.

Адаптивті деректерді сақтау

Drill-down секундтық және миллисекундтық деректерді қажет етеді. Бірақ бәрін максималды дәлдікпен сақтау практикалық емес:

Дәлдік 2 жылдағы барлар Parquet өлшемі
1m ~1,05М ~15 МБ
1s ~63М ~550 МБ/ай
100ms ~630М ~5 ГБ/ай

2 жылдағы толық 1s мұрағаты шамамен 13 ГБ құрайды. 100ms — 100 ГБ-тан асады. Бәрін сақтау мүмкін, бірақ ысырапшыл, себебі drill-down осы деректердің 1%-нан азын пайдаланады.

Hot-Second анықтау

Hot-second detection and adaptive storage savings

Негізгі бақылау: баға айтарлықтай қозғалатын секундтар кіші бөлікті құрайды. Егер баға секунд ішінде 0,1%-дан аз өзгерсе — сол секунд үшін 100ms бөлшектеуін сақтаудың мәні жоқ.

Hot-second анықтау: деректерді жүктеп алу және өңдеу кезінде біз әрбір секундты талдаймыз және 100ms свечаларын тек "hot" секундтар үшін ғана — яғни баға қозғалысы шектен асқан секундтар үшін ғана — жасаймыз.

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

Сақтау орнын үнемдеу

Мысалы — ETHUSDT типтік ай ішінде:

Тәсіл Өлшемі Дәлдік
Тек 1m ~1 МБ 1 минут
Барлық 1s ~550 МБ 1 секунд
Барлық 100ms ~5 ГБ 100 мс
Адаптивті ~600 МБ 1s + 100ms тек hot секундтар үшін

min_price_change_pct = 1.0% шегі кезінде hot секундтар барлық секундтардың 1%-нан азын құрайды. Олар үшін 100ms деректері 550 МБ секундтық деректерге ~50 МБ қосады — елеусіз жүктеме.

Егер секундтық деректер де адаптивті түрде сақталса (тек минут ішіндегі қозғалыс 0,1%-дан асқанда), көлемді тағы 3-5 есе азайтуға болады.

Adaptive Parquet storage hierarchy: minute, second, hot millisecond and trade files

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)

Әрбір файл деректердің бір айын қамтиды. Секундтық, миллисекундтық және мәміле деректері жалқау (lazy) жүктеледі — тек drill-down сұраған кезде ғана. stats.json файлында көлемге негізделген drill-down триггерлері үшін пайдаланылатын алдын ала есептелген медианалық көлемдер бар.

Қаржылық деректер үшін Parquet оптимизациясы

Қаржылық деректердің өзіндік ерекшеліктері бар: timestamp-тар монотонды түрде өседі, бағалар тегіс өзгереді, көлемдер айтарлықтай өзгереді. Оңтайлы баптаулар:

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

Осы баптаулар неге қолданылады:

  • Timestamp-тар үшін DELTA_BINARY_PACKED: кезекті timestamp-тар тұрақты мәнге ерекшеленеді (1m үшін 60, 1s үшін 1). Дельта кодтау оларды нөлге жақын дәрежеде сығады.
  • Float үшін BYTE_STREAM_SPLIT: float32 байттарын ағындарға бөледі (барлық бірінші байттар бірге, барлық екінші байттар бірге, т.с.с.). Тегіс өзгеретін бағалар үшін бұл стандартты кодтаудан 2-3 есе жақсы сығуға қол жеткізеді.
  • ZSTD деңгей 9: қолайлы декомпрессия жылдамдығымен жақсы сығу.
  • float64 орнына float32: бағалар мен көлемдер үшін жеткілікті, жадты 50%-ға үнемдейді.

Кэштеумен жалқау жүктеу

Drill-down белгілі бір минут үшін секундтық деректерді сұрайды. Әрбір сұраныс үшін parquet файлын жүктеу баяу. Шешім — айға негізделген LRU кэшпен жалқау жүктеу.

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

Drill-Down-ты бэктестингке қолдану

Бэктест циклына интеграциялау:

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

Rolling State Cache-пен байланыс

Drill-down агрегатталған parquet кэшін толықтырады — олар әртүрлі мәселелерді шешеді:

Rolling state cache Адаптивті drill-down
Мақсаты Дұрыс HTF индикатор мәндері Дәл SL/TP орындалу реті
Қай жерде жұмыс істейді Әрбір 1m свечада Тек толтыру белгісіздігі кезінде (~5%)
Деректер Алдын ала есептелген, тұрақты сақталады Жалқау жүктелген, соңғы айлардың кэші
Неге әсер етеді Кіру/шығу сигналдары Орындалу бағасы мен уақыты

Екі тәсіл де күндізгі свеча деңгейінде көрінбейтін, бірақ шынайы бэктестинг үшін маңызды қателерді жояды.

Қорытынды: Толтыру симуляциясы тәсілдерін салыстыру

Тәсіл Дәлдік Жылдамдық Сақтау орны
OHLC эвристикасы (оптимист/пессимист) Төмен Лезде Тек 1m
Толық 1s бэктест Жоғары Баяу (x60) ~550 МБ/ай
Толық 100ms бэктест Өте жоғары Өте баяу (x600) ~5 ГБ/ай
Толық шикі мәмілелер бэктесті Максималды Шектен тыс баяу ~50 ГБ/ай
Адаптивті drill-down (4 деңгей) Максималды ~Лезде 1m + 1s + 100ms hot + мәмілелер hot

Drill-down 1m бэктестің жылдамдығымен толық 1s бэктестің дәлдігін қамтамасыз етеді. Негізгі бақылау: жоғары дәлдік барлық жерде қажет емес — тек шешім қабылдау нүктелерінде ғана.

Volume spikes triggering drill-down to finer granularity levels

Көлемге негізделген Drill-Down

Бастапқы drill-down тек баға қозғалысында ғана іске қосылады — свеча диапазоны [low, high] толтыру белгісіздігін тудыратындай кең болғанда. Бірақ баға бардың ішінде қызық бір нәрсе болғанының жалғыз белгісі емес.

Көлем секірулері де бірдей маңызды триггер болып табылады. Көлемі медианадан 500 есе жоғары секунд, әдетте, үлкен нарықтық бұйрыққа, ликвидация каскадына немесе флэш-крэшке сәйкес келеді. Свеча денесі кішкентай болып көрінсе де, сол секунд ішіндегі нақты баға жолы қатты құбылмалы болуы мүмкін — OHLC көрінісі жасыратын шеткі нүктелерге тиіп өткен болуы мүмкін.

Drill-down шарты енді OR-негізделген: не айтарлықтай баға қозғалысы, НЕМЕСЕ аномальды көлем секірісі нәзік дәлдікке түсуді іске қосады.

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

Бұл тек баға негізіндегі анықтауға көрінбейтін сценарийлерді ұстайды: open=3000, close=3001 бар, бірақ көлемі нормадан 50 000 есе жоғары бар миллисекундтар ішінде қысқаша 2950 мен 3050-ге тиіп өткен болуы мүмкін. Көлемге негізделген drill-down болмаса, бэктест бұл секундты ешқашан жақынырақ қарамайды.

Шикі мәмілелер: төртінші деңгей

Бастапқы үш деңгейлі иерархия (1m -> 1s -> 100ms) әлі де олқылық қалдырады: бір 100ms шелегінің ішінде бірнеше мәміле әртүрлі бағада орындалуы мүмкін. high=3060 және low=2965 бар шелек үшін біз әлі де нақты ретті білмейміз.

Шешім: төртінші және соңғы деңгей ретінде шикі мәмілелерге дейін drill-down жасау.

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)

Шикі мәмілелер деңгейінде белгісіздік жоқ — әрбір мәміленің нақты бағасы мен timestamp-і бар. Толтыру түпкілікті шешіледі:

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

Шикі мәмілелер деңгейі өте сирек шақырылады — барлық барлардың 0,1%-нан аз — бірақ шақырылған кезде, ешбір свечаға негізделген жуықтау тең келе алмайтын негізгі шындықты (ground truth) береді.

Әрбір өтпелі кезең үшін бөлек шектер

Әртүрлі рұқсат ажыратымдылығы өтпелі кезеңдерінің әртүрлі сипаттамалары бар. Секунд ішіндегі 0,1% баға қозғалысы маңызды; 100ms шелегінің ішіндегі сол 0,1% шектен тыс. Дәл солай, көлем таралуы әрбір уақыт шкаласында әртүрлі болады.

Әрбір деңгей өтпелі кезеңінің енді өзінің 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-тен мәмілелерге өту қатаңырақ шекті қолдана алады, себебі бір 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

Тұрақты медиана статистикасы

Көлемге негізделген 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):
    """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

Multi-exchange data flow: Binance and Bybit converging into unified granularity layers

Көп-биржалық қолдау: Bybit

Барлық символдар Binance-та қолжетімді емес. XAUTUSDT (алтын) сияқты активтер үшін деректер басқа биржалардан келуі керек. Drill-down жүйесі енді балама деректер көзі ретінде Bybit-ті қолдайды.

Bybit символдары үшін барлық свеча деңгейлері (1m, 1s, 100ms) және шикі мәмілелер Bybit-тің шикі мәміле ағынынан құрылады. Процесс бірдей — шикі мәмілелер әрбір уақыт шкаласында свечаларға агрегатталады — бірақ деректер көзі басқаша.

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

Деректер жүктегіші source.json-ды тексеріп, тиісті жүктеу құбырын пайдаланады. Бэктест қозғалтқышының көзқарасы бойынша, деректер форматы қайнар көз биржасына қарамастан бірдей — drill-down логикасы биржаға тәуелсіз (exchange-agnostic).

Бұл әсіресе биржалар аралық стратегиялар немесе тек белгілі бір алаңдарда ғана сатылатын символдар үшін маңызды.

Қорытынды

Адаптивті drill-down — қарапайым қағиданың қолданылуы: есептеу ресурстары мен сақтау орнын деректердің маңыздылығына пропорционалды жұмсаңыз.

Төрт дәлдік деңгейі:

  1. 1m — барлардың 95%-ы үшін негізгі өту
  2. 1s — толтыру белгісіздігі немесе көлем секірулері кезіндегі drill-down
  3. 100ms — шектен тыс қозғалысы немесе аномальды көлемі бар hot секундтар үшін drill-down
  4. Шикі мәмілелер — hot 100ms шелектері үшін drill-down, жеке мәміле деңгейінде толтыруды шешу

Төрт сақтау деңгейі:

  1. Барлық 1m — толық мұрағат, 2 жылға ~15 МБ
  2. Барлық 1s — толық немесе адаптивті мұрағат, ~550 МБ/ай
  3. Тек hot 100ms — секундтардың <1%, ~50 МБ/ай
  4. Тек hot мәмілелер — ең шектен тыс 100ms шелектері үшін шикі мәмілелер

Екі drill-down триггері (OR логикасы):

  • Бағаға негізделген: бардың баға диапазоны min_pct-тен асады
  • Көлемге негізделген: бардың көлемі median * vol_mult-тен асады

Нәтиже: минут деңгейіндегі жылдамдықпен тик-симулятор дәлдігіндегі бэктест. Экспоненциалды емес, сызықты өсетін сақтау орны. Және биржаға тәуелсіз drill-down логикасымен бірнеше биржаларды — Binance және Bybit — қолдау.

Мульти-таймфрейм стратегиялар үшін алдын ала есептелген кэш туралы толығырақ Агрегатталған Parquet Cache мақаласын қараңыз. Жоғары левередж кезіндегі нәтижелерге фандинг ставкаларының әсері туралы — Фандинг ставкалары левереджіңізді өлтіреді.


Пайдалы сілтемелер

  1. Apache Parquet — деректерді сақтау форматы
  2. Apache Arrow — BYTE_STREAM_SPLIT кодтауы
  3. Zstandard — сығу алгоритмі
  4. Lopez de Prado — Advances in Financial Machine Learning
  5. Binance — тарихи нарық деректері

Дәйексөз

@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 = {How adaptive data granularity speeds up backtests and saves storage: drill-down from 1m to 1s, 100ms, and raw trades only where price moved significantly or volume spiked.}
}
blog.disclaimer

Authors

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

Нарықтан бір қадам алда болыңыз

AI сауда талдаулары, нарық аналитикасы және платформа жаңалықтары үшін біздің ақпараттық бюллетеньге жазылыңыз.

Біз сіздің жекелігіңізді құрметтейміз. Кез келген уақытта жазылымнан шығуға болады.