مهندسی اعتبارسنجی سریهای زمانی مالی و جلوگیری از نشت اطلاعات (Data Leakage)
کالبدشکافی خطاهای مرگبار Look-Ahead Bias و همپوشانی افق برچسبها در دادههای مالی، پیادهسازی الگوریتم Purged & Embargoed K-Fold و تفاضلگیری کسری (Fractional Differentiation).
چرا اکثر بکتستهای معاملاتی در بازار واقعی شکست میخورند؟
در یادگیری ماشین مالی (Financial ML)، بسیار رایج است که مدلی با نسبت شارپ (Sharpe Ratio) خیرهکننده در مرحله اعتبارسنجی، به محض اتصال به بازار واقعی سرمایه را نابود کند. علت اصلی این فاجعه، نقض فرض i.i.d (مستقل و با توزیع یکسان بودن دادهها) و وقوع نشت اطلاعات نامحسوس (Data Leakage) است.
در سریهای زمانی مالی، برچسب هر نمونه (مثلاً در روش Triple-Barrier Labeling که بررسی میکند آیا قیمت در ۲۰ کندل آینده به حد سود میرسد یا حد ضرر) یک نقطه زمانی واحد نیست، بلکه تابعی از تغییرات قیمت در یک بازه زمانی است.
کالبدشکافی نشت اطلاعات در همپوشانی برچسبها و راهحل Purging & Embargo
حتی اگر از TimeSeriesSplit استاندارد استفاده کنید، باز هم در مرز تفکیک دادههای آموزشی (Train) و آزمون (Test) نشت اطلاعات رخ میدهد:
- نشت ناشی از همپوشانی بازهها (Overlapping Horizon Leakage): فرض کنید نمونه آموزشی در ساعت
10:00باز شده و نتیجه برچسب آن در ساعت14:00مشخص میشود (). اگر مجموعه آزمون از ساعت11:00شروع شود ()، مسیر قیمتی بین ساعت ۱۱ تا ۱۴ هم در ساخت برچسب آموزشی حضور دارد و هم در مجموعه آزمون! برای حذف این نشت، باید فرآیند Purging (پاکسازی) را اجرا کنیم: حذف تمام نمونههای آموزشی که بازه ارزیابی آنها با بازه مجموعه آزمون همپوشانی دارد. - نشت ناشی از همبستگی خودکار ویژگیها (Serial Correlation & Embargo): به دلیل استفاده از میانگینهای متحرک و متغیرهای با تاخیر (Lag Features) روی کندلهای متوالی، نمونههای آموزشی بلافاصله بعد از مجموعه آزمون (در اعتبارسنجی متقاطع ترکیبی) حاوی اطلاعات نهفته از آخرین کندلهای آزمون هستند. اعمال یک حاشیه امن زمانی به نام Embargo ($h$) پس از پایان هر بلوک آزمون، این زنجیره همبستگی را به طور کامل قطع میکند.
معمای ایستایی در برابر حافظه: تفاضلگیری کسری (Fractional Differentiation)
سریهای زمانی قیمت () غیرایستا (Non-Stationary) هستند و میانگین و واریانس آنها در طول زمان تغییر میکند. راهحل سنتی اقتصادسنجی، محاسبه بازده اول ( یا تفاضل مرتبه ) است. اما تفاضل مرتبه اول، حافظه بلندمدت سری زمانی (Long-Term Memory / Trend) را به طور کامل پاک میکند!
با بسط دوجملهای عملگر تاخیر برای مقادیر کسری (مثلاً )، میتوانیم کمترین درجه تفاضلگیری را پیدا کنیم که سری زمانی از آزمون ایستایی ADF (Augmented Dickey-Fuller) عبور کند، در حالی که بیش از ۸۰ درصد همبستگی خود را با سطح قیمت اصلی حفظ کرده است:
پیادهسازی PurgedKFold همراه با Embargo و تفاضلگیری کسری در پایتون
در کد زیر، کلاس استاندارد و سازگار با Scikit-Learn برای اعتبارسنجی متقاطع Purged & Embargoed K-Fold به همراه تابع برداری محاسبه ویژگیهای ایستا با Fractional Differentiation (FFD) پیادهسازی شده است:
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 به صورت ایزوله انجام دهید.