← กลับไปยังบทความ
March 17, 2026
อ่าน 5 นาที

Adaptive Drill-Down: แบ็คเทสต์ด้วยความละเอียดข้อมูลแบบยืดหยุ่น ตั้งแต่นาทีจนถึงการเทรดดิบ

Adaptive Drill-Down: แบ็คเทสต์ด้วยความละเอียดข้อมูลแบบยืดหยุ่น ตั้งแต่นาทีจนถึงการเทรดดิบ
#algotrading
#backtest
#parquet
#การปรับแต่ง
#ความละเอียด
#drill-down
#adaptive resolution

แท่งเทียนรายนาทีคือความละเอียดมาตรฐานสำหรับแบ็คเทสต์ แต่ภายในแท่งเทียนหนึ่งนาที ราคาอาจเคลื่อนไหวแตกต่างกัน: บางครั้งเพียง 0.01% บางครั้งถึง 2% เมื่อทั้ง stop-loss และ take-profit อยู่ในช่วง [low, high] ของแท่งเทียนเดียวกัน แบ็คเทสต์ไม่รู้ว่าอันไหนถูกเรียกก่อน นี่คือปัญหา fill ambiguity

วิธีแก้ที่ง่ายที่สุดคือเปลี่ยนมาใช้ข้อมูลระดับวินาทีสำหรับแบ็คเทสต์ทั้งหมด แต่ในช่วงสองปี นั่นหมายถึง ~63 ล้าน second bars แทนที่จะเป็น ~1 ล้าน minute bars พื้นที่จัดเก็บเพิ่มขึ้น 60 เท่า ความเร็วลดลงตามสัดส่วน

Adaptive drill-down แก้ปัญหานี้: ใช้ความละเอียดสูงเฉพาะในจุดที่จำเป็นจริงๆ

Fill ambiguity: ทั้ง SL และ TP อยู่ในช่วงของแท่งเทียนเดียว

ปัญหา: Fill Ambiguity บนแท่งเทียนขนาดใหญ่

ลองพิจารณาสถานการณ์เฉพาะ กลยุทธ์เปิด long ที่ 3000 USDT Stop-loss: 2970 (-1%) Take-profit: 3060 (+2%)

แท่งเทียนรายนาทีที่ 14:37:

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

ทั้ง SL (2970) และ TP (3060) อยู่ในช่วง [2965, 3065] อันไหนถูกเรียกก่อน?

ผลลัพธ์ที่เป็นไปได้:

  • ราคาลงก่อน -> SL ถูกเรียก -> ขาดทุน -1%
  • ราคาขึ้นก่อน -> TP ถูกเรียก -> กำไร +2%

ความแตกต่างในการเทรดครั้งเดียว: 3 เปอร์เซ็นต์พอยท์ ด้วย leverage 10x — 30% สำหรับแบ็คเทสต์ที่มีการเทรดหลายร้อยครั้ง การแก้ไข fill ambiguity ที่ไม่ถูกต้องจะบิดเบือนผลลัพธ์อย่างเป็นระบบ

วิธีที่ Framework จัดการโดยค่าเริ่มต้น

เครื่องมือแบ็คเทสต์ส่วนใหญ่ใช้ heuristic หนึ่งในสองแบบ:

  1. Optimistic: TP ถูกเรียกก่อน -> ผลลัพธ์สูงเกินจริง
  2. Pessimistic: SL ถูกเรียกก่อน -> ผลลัพธ์ต่ำเกินจริง

ทั้งสองวิธีคือการเดา ข้อมูลจริงมีอยู่ในระดับวินาทีหรือแม้แต่มิลลิวินาที และไม่มีเหตุผลที่จะเดาเมื่อสามารถตรวจสอบได้

Drill-Down: กลยุทธ์สี่ระดับ

พีระมิดความละเอียด adaptive four-level drill-down

แนวคิด drill-down: เริ่มต้นที่ระดับนาทีและ "เจาะลึก" ลงสู่ระดับล่างเฉพาะเมื่อมี ambiguity — ไม่ว่าจะเกิดจากการเคลื่อนไหวของราคาหรือปริมาณการซื้อขายที่พุ่งสูง

ระดับ 1: 1m (แท่งเทียนรายนาที)
  -> ถ้า SL หรือ TP อยู่นอกช่วง [low, high] อย่างชัดเจน — แก้ไขทันที
  -> ถ้าทั้งสองอยู่ในช่วง — drill down

ระดับ 2: 1s (แท่งเทียนรายวินาที)
  -> โหลด 60 second bars สำหรับนาทีนี้
  -> ตรวจสอบทีละวินาที: อันไหนถูกเรียกก่อน?
  -> ถ้า second bar ไม่ชัดเจน หรือ price_move >= min_pct หรือ volume >= median_1s * vol_mult — drill down

ระดับ 3: 100ms (แท่งเทียนมิลลิวินาที)
  -> โหลดสูงสุด 10 bars ของ 100ms สำหรับวินาทีนี้
  -> ตรวจสอบทีละ 100ms
  -> ถ้า 100ms bar ไม่ชัดเจน หรือ price_move >= min_pct หรือ volume >= median_100ms * vol_mult — drill down

ระดับ 4: Raw trades
  -> โหลดการเทรดแต่ละรายการสำหรับ 100ms bucket นี้
  -> แก้ไข fill ในระดับเทรดต่อเทรด — ความแม่นยำสูงสุดที่เป็นไปได้

เมื่อไม่ต้องการ Drill-Down

ใน 95% ของกรณี ไม่จำเป็นต้อง drill-down สถานการณ์ทั่วไป:

SL ที่ชัดเจน: high ของแท่งเทียนไม่ถึง TP แต่ low ทะลุ SL -> SL ถูกเรียก ไม่ต้อง drill-down

TP ที่ชัดเจน: low ไม่ถึง SL แต่ high ทะลุ TP -> TP ถูกเรียก ไม่ต้อง drill-down

ไม่ถูกเรียกทั้งคู่: ทั้งสองระดับอยู่นอกช่วง -> position ยังคงเปิดอยู่

Gap detection: open ของแท่งเทียนถัดไปกระโดดผ่าน SL หรือ TP -> execution ที่ราคา open ไม่ต้อง drill-down

Drill-down จำเป็นสำหรับแค่ ~5% ของ bars — เมื่อทั้งสองระดับอยู่ในช่วงของแท่งเทียนเดียว

class AdaptiveFillSimulator:
    """
    Four-level drill-down สำหรับการกำหนดลำดับ fill
    """
    def __init__(self, data_loader):
        self.loader = data_loader
        self.cache_1s = {}  # Cache ของข้อมูลวินาทีตามเดือน

    def check_fill(self, timestamp, candle_1m, sl_price, tp_price, side):
        """
        ตรวจสอบว่า SL หรือ TP ถูกเรียกบนแท่งเทียนรายนาทีที่กำหนด

        คืนค่า: ('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):
        """สมมติฐาน pessimistic: SL สำหรับ long, TP สำหรับ short"""
        if side == 'long':
            return ('sl', sl_price)
        else:
            return ('sl', sl_price)

ประสิทธิภาพ

โหมด เวลาต่อการตรวจสอบ fill เมื่อใช้
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 แท่งเทียน overhead รวมทั้งหมด — น้อยกว่า 1 วินาทีสำหรับแบ็คเทสต์ทั้งหมด

การจัดเก็บข้อมูลแบบ Adaptive

Drill-down ต้องการข้อมูลวินาทีและมิลลิวินาที แต่การจัดเก็บทุกอย่างในความละเอียดสูงสุดนั้นไม่ใช่เรื่องจริง:

ความละเอียด Bars ใน 2 ปี ขนาด Parquet
1m ~1.05M ~15 MB
1s ~63M ~550 MB/เดือน
100ms ~630M ~5 GB/เดือน

archive 1s ทั้งหมดใน 2 ปีประมาณ 13 GB 100ms — กว่า 100 GB การจัดเก็บทั้งหมดเป็นไปได้แต่สิ้นเปลือง เมื่อพิจารณาว่า drill-down ใช้ข้อมูลน้อยกว่า 1% ของทั้งหมด

Hot-Second Detection

Hot-second detection และการประหยัดพื้นที่จัดเก็บแบบ adaptive

ข้อสังเกตสำคัญ: วินาทีที่ราคาเคลื่อนไหวอย่างมีนัยสำคัญมีสัดส่วนน้อย ถ้าราคาเปลี่ยนแปลงน้อยกว่า 0.1% ในหนึ่งวินาที — ไม่มีประโยชน์ที่จะจัดเก็บรายละเอียด 100ms สำหรับวินาทีนั้น

Hot-second detection: เมื่อดาวน์โหลดและประมวลผลข้อมูล เราวิเคราะห์แต่ละวินาทีและสร้างแท่งเทียน 100ms เฉพาะสำหรับวินาที "hot" — วินาทีที่การเคลื่อนไหวของราคาเกินเกณฑ์

def process_trades_adaptive(
    trades: pd.DataFrame,
    min_price_change_pct: float = 1.0,
) -> tuple[pd.DataFrame, pd.DataFrame]:
    """
    ประมวลผลการเทรดดิบเป็นโครงสร้างแบบ adaptive:
    - แท่งเทียน 1s สำหรับทุกวินาที
    - แท่งเทียน 100ms เฉพาะสำหรับวินาที "hot"

    Args:
        trades: DataFrame ที่มีคอลัมน์ [timestamp, price, quantity]
        min_price_change_pct: เกณฑ์สำหรับ drill-down ลงสู่ 100ms

    Returns:
        (df_1s, df_100ms_hot) — แท่งเทียนวินาทีและ 100ms สำหรับวินาที hot
    """
    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
Adaptive ~600 MB 1s + 100ms เฉพาะวินาที hot

ด้วยเกณฑ์ min_price_change_pct = 1.0% วินาที hot คิดเป็นน้อยกว่า 1% ของวินาทีทั้งหมด ข้อมูล 100ms สำหรับวินาทีเหล่านั้นเพิ่มประมาณ 50 MB ลงใน 550 MB ของข้อมูลวินาที — overhead ที่เล็กน้อยมาก

ถ้าข้อมูลวินาทีถูกจัดเก็บแบบ adaptive ด้วย (เฉพาะเมื่อการเคลื่อนไหวภายในนาทีเกิน 0.1%) ปริมาณสามารถลดลงได้อีก 3-5 เท่า

โครงสร้างการจัดเก็บ Parquet แบบ Adaptive: ไฟล์นาที, วินาที, มิลลิวินาที hot และการเทรด

โครงสร้างการจัดเก็บ Parquet

data/{SYMBOL}/
├── source.json                # แหล่งข้อมูล Exchange: {"exchange": "binance"} หรือ {"exchange": "bybit"}
├── stats.json                 # 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)
   └── ...
├── trades_hot/
   ├── 2024-01.parquet       # Raw trades สำหรับ 100ms hot buckets
   └── ...
└── states_1m.parquet          # Rolling state cache ที่คำนวณล่วงหน้า (~112 MB)

แต่ละไฟล์ครอบคลุมข้อมูลหนึ่งเดือน ข้อมูลวินาที มิลลิวินาที และการเทรดถูกโหลดแบบ lazy — เฉพาะเมื่อ drill-down ร้องขอ ไฟล์ stats.json มี median volumes ที่คำนวณล่วงหน้าที่ใช้สำหรับ volume-based drill-down triggers

การปรับแต่ง Parquet สำหรับข้อมูลการเงิน

ข้อมูลการเงินมีลักษณะเฉพาะ: timestamps เพิ่มขึ้นแบบ monotonic ราคาเปลี่ยนแปลงอย่างราบเรียบ ปริมาณแตกต่างกันมาก การตั้งค่าที่เหมาะสม:

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

เหตุผลที่เลือกการตั้งค่าเหล่านี้:

  • DELTA_BINARY_PACKED สำหรับ timestamps: timestamps ต่อเนื่องกันต่างกันด้วยค่าคงที่ (60 สำหรับ 1m, 1 สำหรับ 1s) Delta encoding บีบอัดให้เกือบเป็นศูนย์
  • BYTE_STREAM_SPLIT สำหรับ float: แยก bytes ของ float32 เป็น streams (bytes แรกทั้งหมดรวมกัน, bytes ที่สองทั้งหมดรวมกัน ฯลฯ) สำหรับราคาที่เปลี่ยนแปลงอย่างราบเรียบ บรรลุการบีบอัด 2-3 เท่าดีกว่า standard encoding
  • ZSTD level 9: การบีบอัดดีด้วยความเร็ว decompression ที่ยอมรับได้
  • float32 แทน float64: เพียงพอสำหรับราคาและปริมาณ ประหยัดหน่วยความจำ 50%

Lazy Loading พร้อม Caching

Drill-down ร้องขอข้อมูลวินาทีสำหรับนาทีเฉพาะ การโหลดไฟล์ parquet สำหรับแต่ละคำขอช้า วิธีแก้ — lazy loading พร้อม LRU cache ตามเดือน

from functools import lru_cache
import pyarrow.parquet as pq
import pandas as pd

class AdaptiveDataLoader:
    """
    Lazy loader พร้อม cache: โหลดข้อมูลวินาทีตามเดือน
    เก็บ 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 สำหรับวินาที hot"""
        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 หนึ่งเดือน ลบข้อมูลเก่าออกจาก 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 ไปใช้ในการ Backtesting

การรวมเข้ากับ backtest loop:

def backtest_with_adaptive_fill(
    states: pd.DataFrame,
    strategy_params: dict,
    data_loader: AdaptiveDataLoader,
) -> list:
    """
    แบ็คเทสต์พร้อม adaptive drill-down สำหรับการจำลอง fill
    """
    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, หรือ 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 เสริม aggregated parquet cache — ทั้งสองแก้ปัญหาที่ต่างกัน:

Rolling state cache Adaptive drill-down
วัตถุประสงค์ ค่า HTF indicator ที่ถูกต้อง ลำดับการ execute SL/TP ที่แม่นยำ
ทำงานบน ทุกแท่งเทียน 1m เฉพาะระหว่าง fill ambiguity (~5%)
ข้อมูล คำนวณล่วงหน้า จัดเก็บถาวร โหลดแบบ lazy, cache ของเดือนล่าสุด
ส่งผลต่อ สัญญาณ entry/exit ราคาและเวลา execution

ทั้งสองวิธีขจัดข้อผิดพลาดที่มองไม่เห็นในระดับแท่งเทียนรายวัน แต่สำคัญมากสำหรับการ backtesting ที่สมจริง

สรุป: การเปรียบเทียบวิธีการจำลอง Fill

วิธีการ ความแม่นยำ ความเร็ว พื้นที่จัดเก็บ
OHLC heuristic (optimist/pessimist) ต่ำ ทันที 1m เท่านั้น
แบ็คเทสต์ 1s เต็มรูปแบบ สูง ช้า (x60) ~550 MB/เดือน
แบ็คเทสต์ 100ms เต็มรูปแบบ สูงมาก ช้ามาก (x600) ~5 GB/เดือน
แบ็คเทสต์ raw trades เต็มรูปแบบ สูงสุด ช้าสุดขีด ~50 GB/เดือน
Adaptive drill-down (4-level) สูงสุด ~ทันที 1m + 1s + 100ms hot + trades hot

Drill-down ให้ความแม่นยำเทียบเท่าแบ็คเทสต์ 1s เต็มรูปแบบด้วยความเร็วของแบ็คเทสต์ 1m ข้อสังเกตสำคัญ: ไม่จำเป็นต้องมีความละเอียดสูงทุกที่ — เฉพาะที่จุดตัดสินใจเท่านั้น

Volume spikes ที่กระตุ้น drill-down ลงสู่ระดับความละเอียดที่ละเอียดกว่า

Volume-Based Drill-Down

Drill-down เดิมกระตุ้นเฉพาะจากการเคลื่อนไหวของราคา — เมื่อช่วง [low, high] ของแท่งเทียนกว้างพอที่จะสร้าง fill ambiguity แต่ราคาไม่ใช่สัญญาณเดียวที่บ่งบอกว่ามีบางอย่างน่าสนใจเกิดขึ้นภายใน bar

Volume spikes เป็น trigger ที่สำคัญไม่แพ้กัน วินาทีที่ปริมาณมากกว่า median 500 เท่า มักสอดคล้องกับ market order ขนาดใหญ่, การ cascade ของ liquidation หรือ flash crash แม้ว่า candle body จะดูเล็กน้อย เส้นทางราคาจริงภายในวินาทีนั้นอาจผันผวนรุนแรง — แตะ extremes ที่การแสดงผล OHLC ซ่อนไว้

เงื่อนไข drill-down ตอนนี้เป็น OR-based: ทั้งการเคลื่อนไหวของราคาที่มีนัยสำคัญหรือ volume spike ที่ผิดปกติ กระตุ้นการลงสู่ความละเอียดที่ละเอียดกว่า

def is_hot(bar, median_volume, min_pct=0.1, vol_mult=500):
    """
    กำหนดว่า bar ต้องการ drill-down ลงสู่ระดับถัดไปหรือไม่
    Triggers อิสระสองตัว (OR logic):
      - ราคาเคลื่อนไหว >= min_pct ภายใน bar
      - ปริมาณเกิน median * vol_mult
    """
    price_move = (bar['high'] - bar['low']) / bar['open'] * 100
    return price_move >= min_pct or bar['volume'] >= median_volume * vol_mult

วิธีนี้จับสถานการณ์ที่มองไม่เห็นด้วยการตรวจสอบราคาอย่างเดียว: bar ที่มี open=3000, close=3001 แต่ปริมาณมากกว่าค่าปกติ 50,000 เท่า อาจแตะ 2950 และ 3050 ภายในมิลลิวินาที หากไม่มี volume-based drill-down แบ็คเทสต์จะไม่ตรวจสอบวินาทีนี้อย่างละเอียด

Raw Trades: ระดับที่สี่

ลำดับชั้นสามระดับเดิม (1m -> 1s -> 100ms) ยังมีช่องว่าง: ภายใน 100ms bucket เดียว การเทรดหลายรายการสามารถ execute ที่ราคาต่างกัน สำหรับ bucket ที่มี high=3060 และ low=2965 เรายังไม่รู้ลำดับที่แน่นอน

วิธีแก้: drill down ลงสู่ raw trades เป็นระดับที่สี่และสุดท้าย

แท่งเทียน 1m (ฐาน)
  └─> แท่งเทียน 1s    (เมื่อ 1s แสดง price_move >= min_pct หรือ volume >= median_1s * vol_mult)
      └─> แท่งเทียน 100ms  (เมื่อตรวจพบวินาที hot)
          └─> Raw trades     (เมื่อ 100ms แสดง price_move >= min_pct หรือ volume >= median_100ms * vol_mult)

ที่ระดับ raw trades ไม่มี ambiguity — การเทรดแต่ละรายการมีราคาและ 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

ระดับ raw trades ถูกเรียกน้อยมาก — น้อยกว่า 0.1% ของ bars ทั้งหมด — แต่เมื่อถูกเรียก มันให้ ground truth ที่การประมาณจากแท่งเทียนไม่สามารถเทียบได้

Thresholds แยกต่างหากต่อการเปลี่ยนผ่านแต่ละครั้ง

การเปลี่ยนผ่านความละเอียดที่ต่างกันมีลักษณะต่างกัน การเคลื่อนไหวของราคา 0.1% ภายในวินาทีมีนัยสำคัญ แต่ 0.1% เดียวกันภายใน 100ms bucket นั้นสุดขีด เช่นเดียวกัน การกระจายของปริมาณต่างกันในแต่ละ timescale

การเปลี่ยนผ่านแต่ละระดับตอนนี้มีพารามิเตอร์ 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 สามารถใช้เกณฑ์ที่เข้มงวดกว่า เนื่องจากต้นทุนการโหลด raw trades สำหรับ 100ms bucket เดียวนั้นน้อยมาก

@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

Median Statistics ถาวร

Volume-based drill-down ต้องการทราบ median volume ในแต่ละ timescale การคำนวณ medians แบบ on-the-fly สำหรับทุก backtest จะลบล้างประโยชน์ด้านประสิทธิภาพ วิธีแก้: คำนวณ medians ครั้งเดียวและ cache ไว้

สำหรับแต่ละ symbol median volumes ที่ granularity 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
  }
}

สถิติถูกคำนวณครั้งเดียวต่อ symbol เมื่อดาวน์โหลดข้อมูลครั้งแรก และนำมาใช้ซ้ำในทุก backtest ที่ตามมา ถ้าข้อมูลถูกอัปเดต (ดาวน์โหลดเดือนใหม่) สถิติจะถูกคำนวณใหม่แบบ incremental

def compute_median_stats(symbol, data_dir):
    """คำนวณและ cache สถิติ median volume สำหรับ 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 และ Bybit รวมเป็น unified granularity layers

รองรับหลาย Exchange: Bybit

ไม่ใช่ทุก symbol จะมีให้บน Binance สำหรับสินทรัพย์เช่น XAUTUSDT (ทอง) ข้อมูลต้องมาจาก exchange อื่น ระบบ drill-down ตอนนี้รองรับ Bybit เป็นแหล่งข้อมูลทางเลือก

สำหรับ symbols ของ Bybit ทุกระดับแท่งเทียน (1m, 1s, 100ms) และ raw trades ถูกสร้างจาก raw trade stream ของ Bybit กระบวนการเหมือนกัน — raw trades ถูก aggregate เป็นแท่งเทียนในแต่ละ timescale — แต่แหล่งข้อมูลต่างกัน

data/{SYMBOL}/
├── source.json              # {"exchange": "bybit"} หรือ {"exchange": "binance"}
├── klines_1m/
│   └── ...
├── klines_1s/
│   └── ...
├── klines_100ms_hot/
│   └── ...
└── trades_hot/              # Raw trades สำหรับ 100ms hot buckets
    └── ...

data loader ตรวจสอบ source.json และใช้ download pipeline ที่เหมาะสม จากมุมมองของ backtest engine รูปแบบข้อมูลเหมือนกันไม่ว่าจะเป็น source exchange ใด — drill-down logic ไม่ขึ้นกับ exchange

สิ่งนี้สำคัญอย่างยิ่งสำหรับกลยุทธ์ cross-exchange หรือ symbols ที่ซื้อขายเฉพาะบน venues บางแห่ง

บทสรุป

Adaptive drill-down คือการประยุกต์ใช้หลักการง่ายๆ: ใช้ทรัพยากรการคำนวณและพื้นที่จัดเก็บตามสัดส่วนกับความสำคัญของข้อมูล

สี่ระดับความละเอียด:

  1. 1m — ผ่านฐานสำหรับ 95% ของ bars
  2. 1s — drill-down ระหว่าง fill ambiguity หรือ volume spikes
  3. 100ms — drill-down สำหรับวินาที hot ที่มีการเคลื่อนไหวสุดขีดหรือปริมาณผิดปกติ
  4. Raw trades — drill-down สำหรับ 100ms buckets hot แก้ไข fills ในระดับการเทรดแต่ละรายการ

สี่ระดับการจัดเก็บ:

  1. ทั้งหมด 1m — archive สมบูรณ์, ~15 MB ใน 2 ปี
  2. ทั้งหมด 1s — archive สมบูรณ์หรือ adaptive, ~550 MB/เดือน
  3. เฉพาะ 100ms hot — <1% ของวินาที, ~50 MB/เดือน
  4. เฉพาะ trades hot — raw trades สำหรับ 100ms buckets ที่รุนแรงที่สุด

Drill-down triggers สอง trigger (OR logic):

  • ตามราคา: ช่วงราคาของ bar เกิน min_pct
  • ตามปริมาณ: ปริมาณของ bar เกิน median * vol_mult

ผลลัพธ์: แบ็คเทสต์ที่มีความแม่นยำระดับ tick simulator ด้วยความเร็วระดับนาที พื้นที่จัดเก็บที่เพิ่มขึ้นแบบ linear ไม่ใช่แบบ exponential และรองรับหลาย exchange — Binance และ Bybit — ด้วย drill-down logic ที่ไม่ขึ้นกับ exchange

สำหรับข้อมูลเพิ่มเติมเกี่ยวกับ precomputed cache สำหรับกลยุทธ์ multi-timeframe ดูบทความ Aggregated Parquet Cache เกี่ยวกับผลกระทบของ funding rates ต่อผลลัพธ์ด้วย leverage สูง — Funding rates kill your leverage


ลิงก์ที่มีประโยชน์

  1. Apache Parquet — รูปแบบการจัดเก็บข้อมูล
  2. Apache Arrow — BYTE_STREAM_SPLIT encoding
  3. Zstandard — อัลกอริทึมการบีบอัด
  4. Lopez de Prado — Advances in Financial Machine Learning
  5. Binance — Historical Market Data

การอ้างอิง

@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 = {วิธีที่ความละเอียดข้อมูลแบบปรับตัวได้ช่วยเร่งความเร็วแบ็คเทสต์และประหยัดพื้นที่จัดเก็บ: drill-down จาก 1m ลงสู่ 1s, 100ms และการเทรดดิบ เฉพาะในจุดที่ราคาเคลื่อนไหวอย่างมีนัยสำคัญหรือปริมาณการซื้อขายพุ่งสูง}
}
ข้อจำกัดความรับผิดชอบ: ข้อมูลที่ให้ไว้ในบทความนี้มีไว้เพื่อการศึกษาและให้ข้อมูลเท่านั้น และไม่ถือเป็นคำแนะนำทางการเงิน การลงทุน หรือการเทรด การเทรดสกุลเงินดิจิทัลมีความเสี่ยงสูงที่จะขาดทุน

ผู้เขียน

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 เฉพาะ การวิเคราะห์ตลาด และการอัปเดตแพลตฟอร์ม

เราเคารพความเป็นส่วนตัวของคุณ ยกเลิกการสมัครได้ทุกเมื่อ