← लेखों की सूची पर वापस जाएँ
March 17, 2026
5 मिनट का पठन

एडैप्टिव ड्रिल-डाउन: मिनट से लेकर रॉ ट्रेड्स तक वेरिएबल ग्रैन्युलैरिटी के साथ बैकटेस्ट

एडैप्टिव ड्रिल-डाउन: मिनट से लेकर रॉ ट्रेड्स तक वेरिएबल ग्रैन्युलैरिटी के साथ बैकटेस्ट
#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 गुना बढ़ जाता है, और स्पीड उसी अनुपात में घट जाती है।

एडैप्टिव ड्रिल-डाउन इस समस्या को हल करता है: महीन ग्रैन्युलैरिटी का उपयोग केवल वहीं करें जहाँ वास्तव में इसकी आवश्यकता हो

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. आशावादी (Optimistic): पहले TP ट्रिगर होता है -> बढ़े-चढ़े परिणाम
  2. निराशावादी (Pessimistic): पहले SL ट्रिगर होता है -> कम आंके गए परिणाम

दोनों ही तरीके अनुमान लगाना हैं। असली डेटा सेकंड या यहाँ तक कि मिलीसेकंड स्तर पर उपलब्ध है, और जब देखा जा सकता है तो अनुमान लगाने की कोई ज़रूरत नहीं है।

ड्रिल-डाउन: चार-स्तरीय रणनीति

Adaptive four-level drill-down resolution pyramid

ड्रिल-डाउन का विचार: मिनट स्तर से शुरू करें और केवल तभी निचले स्तर पर "ड्रिल डाउन" करें जब अस्पष्टता हो — या तो कीमत की गतिविधि के कारण या वॉल्यूम स्पाइक्स के कारण।

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

जब ड्रिल-डाउन की आवश्यकता नहीं होती

95% मामलों में, ड्रिल-डाउन की आवश्यकता नहीं होती। सामान्य परिदृश्य:

स्पष्ट SL: कैंडल की high, TP तक नहीं पहुँचती, low, SL को तोड़ देती है -> SL ट्रिगर हुआ, ड्रिल-डाउन की ज़रूरत नहीं।

स्पष्ट TP: low, SL तक नहीं पहुँचती, high, TP को तोड़ देती है -> TP ट्रिगर हुआ, ड्रिल-डाउन की ज़रूरत नहीं।

कोई भी ट्रिगर नहीं हुआ: दोनों स्तर रेंज से बाहर हैं -> पोज़िशन खुली रहती है।

गैप डिटेक्शन: अगले कैंडल का open, SL या TP से होकर छलांग लगाता है -> open प्राइस पर निष्पादन, ड्रिल-डाउन नहीं।

ड्रिल-डाउन केवल ~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 (कोई ड्रिल-डाउन नहीं) ~0ms ~95% मामले
1s ड्रिल-डाउन ~5ms (महीने का पहला एक्सेस) ~5% मामले
100ms ड्रिल-डाउन ~1ms <0.5% मामले
रॉ ट्रेड्स ड्रिल-डाउन ~0.5ms <0.1% मामले

~400 ट्रेड्स वाले 2-वर्षीय बैकटेस्ट में, ड्रिल-डाउन लगभग 20 कैंडल्स के लिए इनवोक होता है। कुल ओवरहेड — पूरे बैकटेस्ट के लिए 1 सेकंड से भी कम।

एडैप्टिव डेटा स्टोरेज

ड्रिल-डाउन के लिए सेकंड और मिलीसेकंड डेटा की आवश्यकता होती है। लेकिन सब कुछ अधिकतम ग्रैन्युलैरिटी पर स्टोर करना अव्यावहारिक है:

ग्रैन्युलैरिटी 2 वर्षों में बार्स Parquet आकार
1m ~1.05M ~15 MB
1s ~63M ~550 MB/महीना
100ms ~630M ~5 GB/महीना

2 वर्षों का पूरा 1s आर्काइव लगभग 13 GB का है। 100ms — 100 GB से अधिक। सब कुछ स्टोर करना संभव है लेकिन अपव्ययी है, यह देखते हुए कि ड्रिल-डाउन इस डेटा का 1% से भी कम उपयोग करता है।

हॉट-सेकंड डिटेक्शन

Hot-second detection and adaptive storage savings

मुख्य अवलोकन: जिन सेकंड्स में कीमत उल्लेखनीय रूप से चलती है वे एक छोटा-सा हिस्सा होते हैं। यदि किसी सेकंड के भीतर कीमत 0.1% से कम बदली — तो उस सेकंड के लिए 100ms ब्रेकडाउन स्टोर करने का कोई फायदा नहीं है।

हॉट-सेकंड डिटेक्शन: डेटा डाउनलोड और प्रोसेस करते समय, हम प्रत्येक सेकंड का विश्लेषण करते हैं और केवल "हॉट" सेकंड्स के लिए 100ms कैंडल्स जनरेट करते हैं — यानी वे जहाँ कीमत की गतिविधि थ्रेशोल्ड से अधिक थी।

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 MB 1 मिनट
सभी 1s ~550 MB 1 सेकंड
सभी 100ms ~5 GB 100 ms
एडैप्टिव ~600 MB 1s + 100ms केवल हॉट सेकंड्स के लिए

min_price_change_pct = 1.0% के थ्रेशोल्ड के साथ, हॉट सेकंड्स सभी सेकंड्स का 1% से भी कम होते हैं। उनके लिए 100ms डेटा 550 MB सेकंड डेटा में ~50 MB जोड़ता है — एक नगण्य ओवरहेड।

यदि सेकंड डेटा भी एडैप्टिव रूप से स्टोर किया जाए (केवल तभी जब एक मिनट के भीतर गतिविधि 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)

प्रत्येक फ़ाइल डेटा के एक महीने को कवर करती है। सेकंड, मिलीसेकंड और ट्रेड डेटा को लेज़ी लोड किया जाता है — केवल तभी जब ड्रिल-डाउन इसका अनुरोध करे। stats.json फ़ाइल में पूर्व-गणना किए गए मीडियन वॉल्यूम होते हैं जो वॉल्यूम-आधारित ड्रिल-डाउन ट्रिगर्स के लिए उपयोग होते हैं।

फाइनेंशियल डेटा के लिए Parquet ऑप्टिमाइज़ेशन

फाइनेंशियल डेटा की विशिष्ट विशेषताएँ होती हैं: timestamps एकदिश (monotonically) रूप से बढ़ते हैं, कीमतें सहज रूप से बदलती हैं, वॉल्यूम काफी भिन्न होते हैं। इष्टतम सेटिंग्स:

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

ये सेटिंग्स क्यों:

  • Timestamps के लिए DELTA_BINARY_PACKED: लगातार timestamps एक निश्चित मान से भिन्न होते हैं (1m के लिए 60, 1s के लिए 1)। डेल्टा एन्कोडिंग उन्हें लगभग शून्य तक कंप्रेस कर देती है।
  • Float के लिए BYTE_STREAM_SPLIT: float32 बाइट्स को स्ट्रीम्स में विभाजित करता है (सभी पहले बाइट्स एक साथ, सभी दूसरे बाइट्स एक साथ, इत्यादि)। सहजता से बदलती कीमतों के लिए, यह मानक एन्कोडिंग से 2-3 गुना बेहतर कंप्रेशन हासिल करता है।
  • ZSTD लेवल 9: स्वीकार्य डीकंप्रेशन स्पीड के साथ अच्छा कंप्रेशन।
  • float64 के बजाय float32: कीमतों और वॉल्यूम के लिए पर्याप्त, 50% मेमोरी बचाता है।

कैशिंग के साथ लेज़ी लोडिंग

ड्रिल-डाउन किसी विशिष्ट मिनट के लिए सेकंड डेटा का अनुरोध करता है। प्रत्येक अनुरोध के लिए 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

बैकटेस्टिंग में ड्रिल-डाउन का प्रयोग

बैकटेस्ट लूप में इंटीग्रेशन:

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

रोलिंग स्टेट कैश के साथ संबंध

ड्रिल-डाउन एग्रीगेटेड parquet कैश का पूरक है — वे अलग-अलग समस्याओं को हल करते हैं:

रोलिंग स्टेट कैश एडैप्टिव ड्रिल-डाउन
उद्देश्य सही HTF इंडिकेटर मान सटीक SL/TP निष्पादन क्रम
संचालन प्रत्येक 1m कैंडल पर केवल फिल एम्बिग्युटी के दौरान (~5%)
डेटा पूर्व-गणना किया गया, स्थायी रूप से स्टोर लेज़ी लोडेड, हाल के महीनों का कैश
प्रभावित करता है एंट्री/एग्ज़िट सिग्नल्स निष्पादन मूल्य और समय

दोनों दृष्टिकोण उन त्रुटियों को समाप्त करते हैं जो दैनिक कैंडल स्तर पर अदृश्य होती हैं लेकिन यथार्थवादी बैकटेस्टिंग के लिए महत्वपूर्ण होती हैं।

सारांश: फिल सिमुलेशन दृष्टिकोणों की तुलना

दृष्टिकोण सटीकता स्पीड स्टोरेज
OHLC हेयुरिस्टिक (आशावादी/निराशावादी) कम तत्काल केवल 1m
पूर्ण 1s बैकटेस्ट अधिक धीमा (x60) ~550 MB/महीना
पूर्ण 100ms बैकटेस्ट बहुत अधिक बहुत धीमा (x600) ~5 GB/महीना
पूर्ण रॉ ट्रेड्स बैकटेस्ट अधिकतम अत्यंत धीमा ~50 GB/महीना
एडैप्टिव ड्रिल-डाउन (4-स्तरीय) अधिकतम ~तत्काल 1m + 1s + 100ms हॉट + ट्रेड्स हॉट

ड्रिल-डाउन 1m बैकटेस्ट की स्पीड पर पूर्ण 1s बैकटेस्ट की सटीकता प्रदान करता है। मुख्य अवलोकन: उच्च ग्रैन्युलैरिटी हर जगह आवश्यक नहीं है — केवल निर्णय बिंदुओं पर

Volume spikes triggering drill-down to finer granularity levels

वॉल्यूम-आधारित ड्रिल-डाउन

मूल ड्रिल-डाउन केवल कीमत की गतिविधि पर ट्रिगर होता है — जब किसी कैंडल की [low, high] रेंज फिल एम्बिग्युटी पैदा करने के लिए पर्याप्त चौड़ी हो। लेकिन कीमत ही एकमात्र संकेत नहीं है कि किसी बार के भीतर कुछ दिलचस्प हुआ।

वॉल्यूम स्पाइक्स समान रूप से महत्वपूर्ण ट्रिगर हैं। एक सेकंड जिसमें वॉल्यूम मीडियन का 500 गुना है, आमतौर पर एक बड़े मार्केट ऑर्डर, लिक्विडेशन कैस्केड, या फ्लैश क्रैश से मेल खाता है। भले ही कैंडल बॉडी छोटी दिखाई दे, उस सेकंड के भीतर वास्तविक प्राइस पाथ बेहद अस्थिर रहा हो सकता है — ऐसे चरम बिंदुओं को छूते हुए जिन्हें OHLC रिप्रेजेंटेशन छुपा देता है।

ड्रिल-डाउन शर्त अब OR-आधारित है: या तो एक उल्लेखनीय कीमत गतिविधि 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 को छू चुका हो सकता है। वॉल्यूम-आधारित ड्रिल-डाउन के बिना, बैकटेस्ट कभी इस सेकंड को बारीकी से नहीं देखेगा।

रॉ ट्रेड्स: चौथा स्तर

मूल तीन-स्तरीय पदानुक्रम (1m -> 1s -> 100ms) फिर भी एक अंतर छोड़ता है: एक ही 100ms बकेट के भीतर, कई ट्रेड्स अलग-अलग कीमतों पर निष्पादित हो सकते हैं। high=3060 और low=2965 वाले बकेट के लिए, हमें अभी भी सटीक क्रम नहीं पता चलता।

समाधान: चौथे और अंतिम स्तर के रूप में रॉ ट्रेड्स तक ड्रिल डाउन करना

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% से भी कम — लेकिन जब होता है, तो यह वह ग्राउंड ट्रुथ प्रदान करता है जिसकी बराबरी कोई भी कैंडल-आधारित अनुमान नहीं कर सकता।

प्रत्येक ट्रांज़िशन के लिए अलग थ्रेशोल्ड

अलग-अलग रेज़ोल्यूशन ट्रांज़िशन की अलग-अलग विशेषताएँ होती हैं। एक सेकंड के भीतर 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

स्थायी मीडियन सांख्यिकी

वॉल्यूम-आधारित ड्रिल-डाउन के लिए प्रत्येक टाइमस्केल पर मीडियन वॉल्यूम जानना आवश्यक है। प्रत्येक बैकटेस्ट के लिए ऑन-द-फ्लाई मीडियन की गणना करना प्रदर्शन के लाभों को समाप्त कर देगा। समाधान: मीडियन को एक बार पूर्व-गणना करें और कैश करें

प्रत्येक सिंबल के लिए, 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 (गोल्ड) जैसी एसेट्स के लिए, डेटा को अन्य एक्सचेंजों से आना चाहिए। ड्रिल-डाउन सिस्टम अब वैकल्पिक डेटा स्रोत के रूप में 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 की जाँच करता है और उपयुक्त डाउनलोड पाइपलाइन का उपयोग करता है। बैकटेस्ट इंजन के दृष्टिकोण से, स्रोत एक्सचेंज चाहे जो भी हो, डेटा फ़ॉर्मेट समान है — ड्रिल-डाउन लॉजिक एक्सचेंज-अज्ञेयवादी (agnostic) है।

यह क्रॉस-एक्सचेंज स्ट्रैटेजी या ऐसे सिंबल्स के लिए विशेष रूप से महत्वपूर्ण है जो केवल कुछ प्लेटफ़ॉर्म्स पर ही ट्रेड होते हैं।

निष्कर्ष

एडैप्टिव ड्रिल-डाउन एक सरल सिद्धांत का अनुप्रयोग है: कंप्यूटेशनल संसाधनों और स्टोरेज को डेटा के महत्व के अनुपात में खर्च करें

चार ग्रैन्युलैरिटी स्तर:

  1. 1m — 95% बार्स के लिए बेस पास
  2. 1s — फिल एम्बिग्युटी या वॉल्यूम स्पाइक्स के दौरान ड्रिल-डाउन
  3. 100ms — चरम गतिविधि या असामान्य वॉल्यूम वाले हॉट सेकंड्स के लिए ड्रिल-डाउन
  4. रॉ ट्रेड्स — हॉट 100ms बकेट्स के लिए ड्रिल-डाउन, व्यक्तिगत ट्रेड स्तर पर फिल हल करना

चार स्टोरेज स्तर:

  1. सभी 1m — पूर्ण आर्काइव, 2 वर्षों के लिए ~15 MB
  2. सभी 1s — पूर्ण या एडैप्टिव आर्काइव, ~550 MB/महीना
  3. केवल हॉट 100ms — सेकंड्स का <1%, ~50 MB/महीना
  4. केवल हॉट ट्रेड्स — सबसे चरम 100ms बकेट्स के लिए रॉ ट्रेड्स

दो ड्रिल-डाउन ट्रिगर (OR लॉजिक):

  • कीमत-आधारित: बार की कीमत रेंज min_pct से अधिक है
  • वॉल्यूम-आधारित: बार का वॉल्यूम median * vol_mult से अधिक है

परिणाम: मिनट-स्तरीय स्पीड पर टिक सिम्युलेटर सटीकता वाला बैकटेस्ट। स्टोरेज जो एक्सपोनेंशियल नहीं बल्कि रैखिक रूप से बढ़ता है। और एक्सचेंज-अज्ञेयवादी ड्रिल-डाउन लॉजिक के साथ कई एक्सचेंजों — Binance और Bybit — का समर्थन।

मल्टी-टाइमफ्रेम स्ट्रैटेजी के लिए पूर्व-गणना किए गए कैश पर अधिक जानकारी के लिए, एग्रीगेटेड Parquet Cache लेख देखें। उच्च लीवरेज के साथ परिणामों पर फंडिंग रेट्स के प्रभाव पर — Funding rates आपके लीवरेज को खत्म कर देते हैं


उपयोगी लिंक्स

  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 ट्रेडिंग इनसाइट्स, मार्केट एनालिसिस और प्लेटफ़ॉर्म अपडेट के लिए हमारे न्यूज़लेटर को सब्सक्राइब करें।

हम आपकी गोपनीयता का सम्मान करते हैं। किसी भी समय अनसब्सक्राइब करें।