data-science// financial-time-series-purged-kfold

مهندسی اعتبارسنجی سریهای زمانی مالی و جلوگیری از نشت اطلاعات (Data Leakage)

کالبدشکافی خطاهای مرگبار Look-Ahead Bias و همپوشانی افق برچسبها در دادههای مالی، پیادهسازی الگوریتم Purged & Embargoed K-Fold و تفاضلگیری کسری (Fractional Differentiation).

احسان شهبازی·۱۴۰۴/۰۸/۰۹·۱۵ دقیقه مطالعه
// SBU Data Science Lab

چرا اکثر بکتستهای معاملاتی در بازار واقعی شکست میخورند؟

در یادگیری ماشین مالی (Financial ML)، بسیار رایج است که مدلی با نسبت شارپ (Sharpe Ratio) خیرهکننده در مرحله اعتبارسنجی، به محض اتصال به بازار واقعی سرمایه را نابود کند. علت اصلی این فاجعه، نقض فرض i.i.d (مستقل و با توزیع یکسان بودن دادهها) و وقوع نشت اطلاعات نامحسوس (Data Leakage) است.

در سریهای زمانی مالی، برچسب هر نمونه ii (مثلاً در روش Triple-Barrier Labeling که بررسی میکند آیا قیمت در ۲۰ کندل آینده به حد سود میرسد یا حد ضرر) یک نقطه زمانی واحد نیست، بلکه تابعی از تغییرات قیمت در یک بازه زمانی [ti,0,ti,1][t_{i,0}, t_{i,1}] است.

کالبدشکافی نشت اطلاعات در همپوشانی برچسبها و راهحل Purging & Embargo

حتی اگر از TimeSeriesSplit استاندارد استفاده کنید، باز هم در مرز تفکیک دادههای آموزشی (Train) و آزمون (Test) نشت اطلاعات رخ میدهد:

  • نشت ناشی از همپوشانی بازهها (Overlapping Horizon Leakage): فرض کنید نمونه آموزشی ii در ساعت 10:00 باز شده و نتیجه برچسب آن در ساعت 14:00 مشخص میشود (ti,1=14:00t_{i,1} = 14:00). اگر مجموعه آزمون از ساعت 11:00 شروع شود (tj,0=11:00t_{j,0} = 11:00)، مسیر قیمتی بین ساعت ۱۱ تا ۱۴ هم در ساخت برچسب آموزشی حضور دارد و هم در مجموعه آزمون! برای حذف این نشت، باید فرآیند Purging (پاکسازی) را اجرا کنیم: حذف تمام نمونههای آموزشی که بازه ارزیابی آنها [ti,0,ti,1][t_{i,0}, t_{i,1}] با بازه مجموعه آزمون همپوشانی دارد.
  • نشت ناشی از همبستگی خودکار ویژگیها (Serial Correlation & Embargo): به دلیل استفاده از میانگینهای متحرک و متغیرهای با تاخیر (Lag Features) روی کندلهای متوالی، نمونههای آموزشی بلافاصله بعد از مجموعه آزمون (در اعتبارسنجی متقاطع ترکیبی) حاوی اطلاعات نهفته از آخرین کندلهای آزمون هستند. اعمال یک حاشیه امن زمانی به نام Embargo ($h$) پس از پایان هر بلوک آزمون، این زنجیره همبستگی را به طور کامل قطع میکند.

معمای ایستایی در برابر حافظه: تفاضلگیری کسری (Fractional Differentiation)

سریهای زمانی قیمت (PtP_t) غیرایستا (Non-Stationary) هستند و میانگین و واریانس آنها در طول زمان تغییر میکند. راهحل سنتی اقتصادسنجی، محاسبه بازده اول (rt=ln⁡Pt−ln⁡Pt−1r_t = \ln P_t - \ln P_{t-1} یا تفاضل مرتبه d=1d=1) است. اما تفاضل مرتبه اول، حافظه بلندمدت سری زمانی (Long-Term Memory / Trend) را به طور کامل پاک میکند!

با بسط دوجملهای عملگر تاخیر (1−B)d(1 - B)^d برای مقادیر کسری d∈(0,1)d \in (0, 1) (مثلاً d=0.35d = 0.35)، میتوانیم کمترین درجه تفاضلگیری را پیدا کنیم که سری زمانی از آزمون ایستایی ADF (Augmented Dickey-Fuller) عبور کند، در حالی که بیش از ۸۰ درصد همبستگی خود را با سطح قیمت اصلی حفظ کرده است:

∑ FORMULA
(1−B)d=∑k=0∞ωkBk,ω0=1,ωk=−ωk−1d−k+1k(1 - B)^d = \sum_{k=0}^{\infty} \omega_k B^k, \quad \omega_0 = 1, \quad \omega_k = -\omega_{k-1} \frac{d - k + 1}{k}

پیادهسازی PurgedKFold همراه با Embargo و تفاضلگیری کسری در پایتون

در کد زیر، کلاس استاندارد و سازگار با Scikit-Learn برای اعتبارسنجی متقاطع Purged & Embargoed K-Fold به همراه تابع برداری محاسبه ویژگیهای ایستا با Fractional Differentiation (FFD) پیادهسازی شده است:

purged_kfold_finance.py
python
from typing import Iterator, Tuple
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold

class PurgedEmbargoedKFold(KFold):
    """
    Purged & Embargoed K-Fold Cross-Validator for financial time-series
    where labels span an evaluation horizon [t0, t1].
    """
    def __init__(self, n_splits: int = 5, t1: pd.Series = None, pct_embargo: float = 0.01):
        super().__init__(n_splits=n_splits, shuffle=False, random_state=None)
        if not isinstance(t1, pd.Series):
            raise ValueError('t1 must be a pandas Series mapping index (t0) to label horizon end (t1).')
        self.t1 = t1
        self.pct_embargo = pct_embargo

    def split(self, X: pd.DataFrame, y: pd.Series = None, groups=None) -> Iterator[Tuple[np.ndarray, np.ndarray]]:
        if not X.index.equals(self.t1.index):
            raise ValueError('X index and t1 index must match exactly.')

        indices = np.arange(X.shape[0])
        embargo_step = int(X.shape[0] * self.pct_embargo)
        test_ranges = [(i[0], i[-1] + 1) for i in np.array_split(indices, self.n_splits)]

        for st, end in test_ranges:
            test_indices = indices[st:end]
            t0_test_start = X.index[st]
            t1_test_max = self.t1.iloc[test_indices].max()

            # 1. Left-side Purge: drop train samples whose evaluation horizon t1 overlaps test start
            left_train_mask = self.t1.iloc[:st] < t0_test_start
            left_train_idx = indices[:st][left_train_mask.values]

            # 2. Right-side Purge + Embargo: drop train samples starting before max(t1_test) + embargo
            embargo_bound_idx = min(X.shape[0] - 1, end + embargo_step)
            t0_embargo_cutoff = max(t1_test_max, X.index[embargo_bound_idx] if end < X.shape[0] else t1_test_max)
            right_train_mask = X.index[end:] > t0_embargo_cutoff
            right_train_idx = indices[end:][right_train_mask]

            train_indices = np.concatenate([left_train_idx, right_train_idx])
            yield train_indices, test_indices

def frac_diff_ffd(series: pd.Series, d: float = 0.4, thres: float = 1e-4) -> pd.Series:
    """
    Fixed-Width Window Fractional Differentiation (FFD).
    Achieves stationarity while preserving long-term memory without look-ahead bias.
    """
    weights = [1.0]
    k = 1
    while True:
        w_next = -weights[-1] * (d - k + 1) / k
        if abs(w_next) < thres:
            break
        weights.append(w_next)
        k += 1

    weights_arr = np.array(weights[::-1], dtype=np.float64)
    width = len(weights_arr)
    vals = series.values
    out = np.full(len(vals), np.nan, dtype=np.float64)

    for i in range(width - 1, len(vals)):
        window = vals[i - width + 1 : i + 1]
        if not np.isnan(window).any():
            out[i] = np.dot(weights_arr, window)

    return pd.Series(out, index=series.index, name=f'{series.name}_ffd_{d}')

نکته کلیدی مهندسی: هرگز استانداردسازی ویژگیها (StandardScaler یا محاسبه Z-Score) را پیش از فراخوانی PurgedEmbargoedKFold.split() روی کل دیتافریم اجرا نکنید! محاسبه میانگین و انحراف معیار روی کل تاریخچه بازار، اطلاعات نوسانات آینده (مثلاً سقوطهای بازار در سال بعد) را به درون مقیاس دادههای گذشته تزریق میکند. همیشه نرمالسازی را با استفاده از Rolling Window (صرفاً رو به گذشته) یا درون Pipeline روی هر Fold به صورت ایزوله انجام دهید.