Finance

Look-Ahead Bias (2026)

Look-Ahead Bias (2026)

Source paper: Ullah, I. & Welsh, A.H. (2026). "On the effect of noise on fitting linear regression models." Computational Statistics & Data Analysis 224:108421. CC BY 4.0.


Setiap trader yang pernah backtest pasti pernah ngalamin hasil backtest "terlalu sempurna" — lalu pas live trading, performance anjlok 30-50%. Solusi standar: "pasti ada look-ahead bias." Tapi kadang lo udah audit feature engineering, udah pastiin gak ada info masa depan yang bocor, dan hasilnya tetep jelek live.

Kenyataannya: bahkan tanpa look-ahead bias, model linear lo bisa gagal kalau jumlah fitur (d) terlalu deket sama jumlah observasi (n). Studi Ullah & Welsh (2026) baru aja nerangin kenapa — dan jawabannya bukan "tambah regularisasi", tapi bisa aja ridge parameter lo perlu NEGATIF.

Artikel ini bahas:

  1. Double descent bukan cuma masalah overparameterization — tapi shrinking effect of noise
  2. 5 tempat look-ahead bias yang sering gak ke-detect di linear regression trading
  3. Kapan ridge parameter optimal lo bisa negatif (bukan positif)
  4. Aturan praktis: kapan backtest lo bakal spike di interpolation point $d = n$
  5. Purged K-Fold CV (López de Prado) — implementasi lengkap dengan embargo period
  6. Combinatorial Purged Cross-Validation (CPCV) — multiple backtest paths, probability of overfit calculation
  7. Walk-Forward Optimization (WFO) — anchored vs rolling, expanding window, k-fold time series
  8. Production framework implementation — Zipline, backtrader, vectorbt, Qlib, Lean proper time handling
  9. 5 Case Study Indonesia — BBCA backtest loss, TLKM survivorship, AAPL splits in IDX, crypto listing bias, IDX 30
  10. Alternative data leakage — news, satellite, NLP labels, sentiment scoring time alignment
  11. Trading-specific leakage — survivorship, point-in-time, corporate actions, microstructure
  12. Negative Ridge deep-dive — when it works, when it fails, multiple shrinkage targets
  13. UU PDP/ITE/OJK POJK 26/2023 backtest reporting compliance untuk quant shops di Indonesia
  14. Decision tree 7-Q, anti-recommendation 7 situasi, implementation checklist 25-item, 35 referensi

1. Mental Model — Dua Sumber Kegagalan Linear Regression

Kebanyakan trader ngira failure linear regression di trading itu karena:

  • Overfit (terlalu banyak fitur)
  • Underfit (fitur kurang)
  • Look-ahead bias (data masa depan bocor)

Ullah & Welsh nemuin masalah ke-4 yang lebih mendasar: noise itu sendiri — baik noise predictors maupun noise observations — menyusutkan koefisien ke nol dan bikin test error punya double descent (turun-naik-turun lagi) yang spike di titik interpolasi $d = n$.

Dua sequence yang mereka teliti:

Sequence Aksi Efek Rekomendasi
I — Tambah noise predictors Tambah fitur gak relevan (d naik) Koefisien menyusut ke 0, tapi model kompleks BISA lebih baik di overparameterized Ridge positif
II — Tambah noise observations Tambah observasi dengan y=0 atau random (n naik) Koefisien menyusut ke 0, model SEDERHANA biasanya lebih baik Ridge bisa negatif

Insight kunci: double descent terjadi di KEDUA sequence dengan implikasi BERLAWANAN. Resolution-nya: shrinkage yang diinduksi noise, bukan kompleksitas model itu sendiri, yang memicu double descent.


2. Math — Test Error di Dua Regime

Sequence I (Adding Predictors, n tetap)

Untuk $d < n$ (underparameterized):

$$R^{(d)} = \left(|\tilde{\beta}_0^{(d)}|^2 + \sigma^2\right)\left(1 + \frac{d}{n_0 - 1 - d}\right)$$

Di interpolation point $d = n$:

$$R^{(d)} \to \infty$$

Lalu untuk $d > n$ (overparameterized, pakai minimum norm OLS):

$$R^{(d)} = \left(|\tilde{\beta}_0^{(d)}|^2 + \sigma^2\right)\left(1 + \frac{n_0}{d - 1 - n_0}\right) + |\beta_0^{(d)}|^2\left(1 - \frac{n_0}{d}\right)$$

Asymptote saat $d \to \infty$:

$$R^{(d)} \to \sigma^2 + |\beta_0|^2 = \text{null model error}$$

Plot karakteristik (Fig. 1 paper, weak SNR, d₀=25, n=50):

  • Test error turun dari d=0 sampai d≈25 (good fit)
  • Spike infinity di d=50 (interpolation point)
  • Turun lagi menuju null model error (~0.4 untuk β₀/‖β₀‖, σ²=0.25)

Sequence II (Adding Observations, d tetap)

Untuk $d < n$ (underparameterized):

$$R^{(n)} = \sigma^2\left(1 + \frac{d_0}{n - d_0 - 1}\right), \quad n \geq d_0 + 2$$

Bedanya: Double descent terjadi di underparameterized regime saat $n$ lewat interpolation point. Implikasinya: kalau lo punya banyak data noisy (misal ribuan trade dengan label noisy), justru model sederhana (OLS, ridge positif) yang optimal.

Kondisi Number Anomaly (Dax 2022)

Penyebab spike di $d = n$: generalized condition number $\kappa(D) = \sigma_1 / \sigma_r$ melonjak ke maksimum di interpolation point. Variance dan bias dari $\hat{\beta}$ ikut meledak.

import numpy as np
from scipy.linalg import svd

def test_error_curve(X, y, d_max, n_test=2000):
    """Generate test error curve for Sequence I (add noise predictors)"""
    n, d0 = X.shape
    X_test = np.random.randn(n_test, d0)
    y_test = X_test @ true_beta + 0.5 * np.random.randn(n_test)
    
    test_errors = []
    for d in range(1, d_max + 1):
        # Add d - d0 noise predictors
        if d > d0:
            Z = np.random.randn(n, d - d0)
            X_aug = np.hstack([X, Z])
            X_test_aug = np.hstack([X_test, np.random.randn(n_test, d - d0)])
        else:
            X_aug = X[:, :d]
            X_test_aug = X_test[:, :d]
        
        try:
            if d < n:
                # OLS
                beta_hat = np.linalg.lstsq(X_aug, y, rcond=None)[0]
            else:
                # Min norm OLS
                beta_hat = X_aug.T @ np.linalg.lstsq(X_aug @ X_aug.T, y, rcond=None)[0]
            
            y_pred = X_test_aug @ beta_hat
            mse = np.mean((y_test - y_pred) ** 2)
            test_errors.append((d, mse))
        except:
            test_errors.append((d, np.inf))
    
    return test_errors

3. Lima Tempat Future Data Leak (Anti-Look-Ahead Checklist)

Ini bagian yang paling applicable buat quant trader. Meskipun fokus paper ke noise, look-ahead bias adalah sumber "noise" yang paling sering gak ke-detect di backtest. Lima tempat utama:

1. Scaler Leak (Paling Umum, 80% Bug)

# ❌ WRONG — fit scaler on full dataset, lalu split
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # ← mean/std pakai data TEST!
X_train, X_test = X_scaled[:800], X_scaled[800:]

# ✅ CORRECT — fit scaler ONLY on train
scaler = StandardScaler()
X_train = scaler.fit_transform(X[:800])  # fit on train only
X_test = scaler.transform(X[800:])       # transform test pakai train mean/std

Symptomnya: Backtest Sharpe tinggi (>2), live trading turun drastis. Test mean/std GAK SAMA dengan production mean/std.

2. Rolling Window Leak

# ❌ WRONG — rolling dihitung SETELAH split
df['rolling_mean_20'] = df['close'].rolling(20).mean()
# Index 100 pakai data 81-100. Kalau index 100 adalah test, 
# maka rolling(20)-nya udah pakai info dari train (index 80-99) — ini OK.
# TAPI kalau lo ngitung rolling di TEST pake df['close'] yang ada di TRAIN juga, BAHAYA.

# ✅ CORRECT — rolling dihitung per bar, expand
df['rolling_mean_20'] = df['close'].shift(1).rolling(20).mean()
# shift(1) prevents using current bar's value

Symptomnya: Backtest aware of "future" karena rolling di index 100 (test) termasuk data 81-100 yang bukan observasi masa depan kalau dianggap "informasi sampai bar 100" — tapi kalau lo assume rolling dihitung END-of-day setelah market close, ini leak.

3. Candle/OHLCV Close Leak

# ❌ WRONG — pakai close bar N+1 untuk fiturnya bar N
df['next_return'] = df['close'].shift(-1) / df['close'] - 1
df['signal'] = df['next_return'] > 0  # INI LOOK-AHEAD!

# ✅ CORRECT — use only past info
df['signal'] = df['close'].pct_change(20) > 0  # 20-day return, no future data

Symptomnya: Signal prediction di backtest "terlalu akurat" (>70% directional accuracy), tapi gak reproducible live.

4. PCA / Factor Decomposition Leak

# ❌ WRONG — fit PCA on full dataset
pca = PCA(n_components=10)
factors = pca.fit_transform(X)  # ← eigenvectors dari train+test!
X_train, X_test = factors[:800], factors[800:]

# ✅ CORRECT — fit PCA ONLY on train
pca = PCA(n_components=10)
pca.fit(X[:800])
X_train = pca.transform(X[:800])
X_test = pca.transform(X[800:])

Symptomnya: Factor loadings test "terlalu bagus", dan gak stabil di live (eigenvectors berubah).

5. Target/Label Leak

# ❌ WRONG — label pake info dari exit
df['trade_pnl'] = ...  # contains future exit price
df['signal_quality'] = df['trade_pnl'] > 0  # LEAK!

# ✅ CORRECT — label based on ENTRY-TIME info only
df['forward_return_5d'] = df['close'].pct_change(5).shift(-5)
df['signal'] = (df['forward_return_5d'] > 0).astype(int)
# Note: masih ada potential issue kalo lo act on signal at bar N+1 vs N

Symptomnya: Hit rate di backtest 90%+, live cuma 50% — karena label pake info exit.

Anti-Look-Ahead Audit Workflow

def audit_lookahead(df, feature_cols, target_col, split_idx):
    """5-point audit untuk detect look-ahead bias"""
    audit = {}
    
    # 1. Scaler leak check
    for col in feature_cols:
        train_mean = df[col].iloc[:split_idx].mean()
        test_mean = df[col].iloc[split_idx:].mean()
        audit[f'scaler_{col}'] = abs(train_mean - test_mean) / df[col].std()
    
    # 2. Rolling check (corr between feature[t] and target[t+1])
    for col in feature_cols:
        audit[f'rolling_{col}'] = df[col].corr(df[target_col].shift(-1))
    
    # 3. Target leak check (corr between feature and future target)
    for col in feature_cols:
        audit[f'target_leak_{col}'] = df[col].corr(df[target_col].shift(-5))
    
    # 4. PCA/factor check (variance explained ratio stability)
    from sklearn.decomposition import PCA
    pca_full = PCA().fit(df[feature_cols])
    pca_train = PCA().fit(df[feature_cols].iloc[:split_idx])
    audit['pca_drift'] = np.abs(pca_full.explained_variance_ratio_ - 
                                 pca_train.explained_variance_ratio_).sum()
    
    # 5. Hit rate sanity check (should be 50-60% for random signal)
    if df[target_col].nunique() == 2:
        audit['hit_rate'] = df[target_col].mean()
    
    return audit

4. Negative Ridge — Anti-Shrinkage buat Over-Noised Data

Ini contribution paling unik Ullah & Welsh. Mereka nemuin: kadang ridge parameter optimal bisa NEGATIF.

Theorem 2.5 (Sequence II, noise observations):

$$\lambda_{opt} = n^{-1}|\beta_0|^{-2}d_0\sigma^2 - (1 - \nu)$$

Dimana $\nu = n_0/n$ adalah proporsi data "real" (bukan noise). Kalau $n$ jauh lebih besar dari $n_0$ (banyak data noise), maka $\lambda_{opt}$ bisa negatif.

Interpretasi: Ridge regression dengan λ>0 menyusutkan koefisien ke 0. Kalau data lo udah ke-shrink oleh noise observations (Sequence II), maka anti-shrinkage (λ negatif) bisa improve test error.

from sklearn.linear_model import Ridge
import numpy as np

def fit_optimal_ridge(X, y, X_test, y_test, lambda_grid=None):
    """Test whether NEGATIVE ridge improves test error"""
    if lambda_grid is None:
        lambda_grid = np.concatenate([
            np.linspace(-2, -0.01, 20),  # ← negative ridge
            np.linspace(0.01, 5, 30)
        ])
    
    results = []
    for lam in lambda_grid:
        model = Ridge(alpha=lam)
        model.fit(X, y)
        train_mse = np.mean((y - model.predict(X)) ** 2)
        test_mse = np.mean((y_test - model.predict(X_test)) ** 2)
        results.append({
            'lambda': lam,
            'train_mse': train_mse,
            'test_mse': test_mse,
            'coef_norm': np.linalg.norm(model.coef_)
        })
    
    return pd.DataFrame(results)

# Contoh: backtest with synthetic data
# df = fit_optimal_ridge(X_train, y_train, X_test, y_test)
# Best lambda often NEGATIVE when n_observations >> n_signal

Kapan lo butuh negative ridge:

  • Lo punya banyak observasi "noisy" (misal: signal agregat dari banyak timeframe, atau ribuan backtest results)
  • Koefisien estimated lo terlalu kecil (suspected over-shrinkage)
  • Test error naik ketika lo naikin λ dari 0 → positif

Jangan pakai negative ridge kalau:

  • $n_0$ lo kecil (<30)
  • Signal-to-noise ratio rendah (λ_opt bisa drive overfitting)
  • Lo belum audit look-ahead bias (paper assumes no leak)

5. Real Data: High-Density Rice Array (338 Accessions, 700K SNPs)

Paper ini apply di data genomics: 338 rice accessions, SNPs dari chromosome-3, target = grain length. Hasil mereka:

  • Double descent terjadi di $d = n$ (d ≈ 338)
  • Bahkan dengan data riil, implikasi look-ahead-style leakage muncul dari noise
  • Test error spike hanya di d ≈ n, lalu turun ke null model asymptote

Relevansi buat trading:

  • 338 accessions = 338 backtest trades
  • 700K SNPs = 700K potential features
  • $d = 700K \gg n = 338$ → massive overparameterization
  • Hasil: koefisien estimated menyusut ke 0, R² rendah, tapi test error ada di null model asymptote

Ini mirip banget dengan factor model yang punya 100+ factors tapi cuma 200 backtest trades. Hasilnya: signal-to-noise ratio rendah, koefisien shrunk ke 0, sharpe ratio backtest anjlok.


6. Trading Application — 4 Use Case

Use Case 1: Factor Model dengan d Dekat n

Problem: Lo punya 50 candidate factors (momentum, value, quality, dll) dan 60 backtest trades. d/n = 0.83 → DEKAT interpolation point → backtest Sharpe SPIKE.

Fix:

# 1. Drop factors sampai d < n/5
selected_factors = ['mom_12_1', 'value', 'quality', 'lowvol']  # 4 dari 50
# 2. Atau tambah trades lewat multi-timeframe backtest
# 3. Atau use ridge positif (Sequence I optimal ridge = positive)

Use Case 2: Signal Aggregation dengan n Besar Tapi Noisy

Problem: Lo punya 10,000 trade signals aggregated dari berbagai timeframe. d = 5 features, n = 10,000. Sequence II: test error double descent di d = 5 region.

Fix: Pakai negative ridge untuk anti-shrinkage, atau simple OLS dengan strong feature selection.

Use Case 3: Walk-Forward Validation

Problem: Lo rolling-fit model di setiap quarter. Q1 train di Q0, Q2 train di Q0+Q1, dll. Setiap fit bisa kena interpolation point efek kalau window terlalu kecil.

Fix:

  • Minimum training window: 252 trading days (1 tahun)
  • Feature cap: 1/10 dari training window
  • Monitor $\hat{\beta}$ coefficient drift (kalau menyusut, kemungkinan over-shrinkage)

Use Case 4: Options Pricing dengan Implied Vol Surface

Problem: Lo fit IV surface dengan 5 features (moneyness, tenor, dll) dan 100 option strikes. d/n = 0.05, AMAN dari interpolation. TAPI kalau lo tambah 50 volatility factors, d = 55, deket n = 100.

Fix: Use cross-sectional ridge regression, cap features at n/5.


7. Enam Caveat

# Caveat Kapan Gagal
1 Paper assume independent noise Predictors lo correlated → shrinkage rate bisa beda
2 Negative ridge works for low-d only Kalau d > n, OLS minimum norm is the only option
3 Signal-to-noise ratio matters SNR rendah = null model asymptote lebih menarik
4 No temporal dependence Time series dengan autocorrelation butuh different approach
5 Gaussian noise assumption Heavy-tailed (Student-t) noise → shrinkage lebih agresif
6 Look-ahead bias must be audited first Negative ridge = overfitting kalau ada leak

8. Comparison vs Alternatif

Method Handle d > n Handle n >> d Look-ahead Safe Negative λ Trading Use
OLS ❌ (min norm only) ⚠️ (depends on data) Quick baseline
Ridge (λ>0) ⚠️ Standard regularization
Ridge (λ<0) ⚠️ ⚠️ Anti-shrinkage noise data
LASSO ⚠️ Feature selection
Elastic Net ⚠️ LASSO + Ridge combo
PCA + OLS ⚠️ Dimensionality reduction
Look-ahead audit (paper) N/A N/A N/A Pre-flight check

9. TL;DR — 5 Langkah Praktis

  1. Audit 5 tempat look-ahead bias SEBELUM fit model apapun: scaler, rolling, candle, PCA, target label. Hit rate backtest >70% atau Sharpe >3 → almost pasti ada leak.
  2. Cap features at n/10: kalau lo punya 100 backtest trades, max 10 features. Jangan dekati interpolation point.
  3. Monitor coefficient shrinkage: kalau $\hat{\beta}$ magnitude konsisten turun seiring $n$ naik (Sequence II effect), consider negative ridge.
  4. Test negative ridge kalau data lo dominated by noise observations (banyak data, sedikit signal). Grid search λ ∈ [-2, 5].
  5. Verify asymptotic behavior: kalau test error lo konvergen ke null model error (variance(y)), berarti model lo gak nangkap signal — bukan masalah leak, tapi masalah signal strength.

Prinsip utama dari paper ini: double descent is driven by shrinkage, not model complexity. Noise predictors and noise observations both shrink coefficients to zero, causing the test error to spike at $d = n$. Pilih shrinkage yang tepat (positive atau negative ridge) lebih penting daripada milih sparse vs overparameterized model.


DEEP-DIVE SECTIONS (12-15 Tambahan)

10. Mathematical Deep-Dive — Time-Series Cross-Validation Math

Standard k-fold CV rusak total buat time series. K-fold assume iid observations, tapi time series punya temporal dependence. Hasilnya: random shuffle bikin test set "melihat" masa lalu train set — chronic look-ahead bias.

10.1 Purged K-Fold CV (López de Prado 2018)

Tiga modifikasi:

  1. Purging: Hapus observation dari train set yang overlap dengan test set di waktu
  2. Embargo: Tambah gap antara train akhir dan test awal (prevent information leakage via serial correlation)
  3. Temporal ordering: Selalu train di masa lalu, test di masa depan

Mathematical formulation:

Misal $T = {t_1, t_2, ..., t_N}$ dengan labels ${y_1, y_2, ..., y_N}$ dan features ${X_1, X_2, ..., X_N}$. Untuk label horizon $h$ (misal: forward return 5d), label $y_i$ butuh info sampai $t_i + h$.

Purging condition: Observation $j$ di-purge dari train set kalau interval $[t_j, t_j + h]$ overlap dengan $[t_i, t_i + h]$ untuk $i$ di test set.

Embargo period $E$: Tambah gap sebesar $E$ bars setelah test set end.

import numpy as np
import pandas as pd
from sklearn.model_selection import KFold

class PurgedKFold:
    """
    Purged K-Fold CV dengan embargo period (López de Prado, Advances in Financial ML).
    Prevent look-ahead bias dari:
    1. Label overlap (multi-horizon labels share future info)
    2. Serial correlation (autocorrelated features)
    3. Temporal dependence (time series structure)
    """
    def __init__(self, n_splits=5, embargo_pct=0.01, label_horizon=1):
        self.n_splits = n_splits
        self.embargo_pct = embargo_pct
        self.label_horizon = label_horizon
    
    def split(self, X, y=None, groups=None):
        n = len(X)
        indices = np.arange(n)
        kf = KFold(n_splits=self.n_splits, shuffle=False)
        
        for train_idx, test_idx in kf.split(indices):
            # 1. Purging: hapus train observations yang overlap label dengan test
            test_start = test_idx[0]
            test_end = test_idx[-1]
            
            purged_train_idx = []
            for idx in train_idx:
                # Label idx butuh info sampai idx + horizon
                label_end = idx + self.label_horizon
                # Kalau label idx overlap dengan test period, purge
                if label_end < test_start or idx > test_end:
                    purged_train_idx.append(idx)
            
            # 2. Embargo: tambah gap setelah test
            embargo_size = int(n * self.embargo_pct)
            embargo_start = test_end + 1
            embargo_end = min(test_end + embargo_size, n)
            
            # Hapus dari train kalau di embargo zone
            purged_train_idx = [
                idx for idx in purged_train_idx
                if idx < embargo_start or idx > embargo_end
            ]
            
            yield np.array(purged_train_idx), test_idx
    
    def get_n_splits(self):
        return self.n_splits

Parameter selection:

  • embargo_pct: 0.01 = 1% dari total samples. Untuk daily data dengan serial correlation lag 5 hari, pakai 0.02. Untuk intraday 5min, pakai 0.005.
  • label_horizon: forward return horizon (5d, 20d, dll). Harus sama dengan label construction.

10.2 Combinatorial Purged Cross-Validation (CPCV)

Untuk multiple backtest paths (lebih robust dari single train/test split):

class CombinatorialPurgedCV:
    """
    CPCV: Generate N backtest paths dari k-fold splits (López de Prado).
    Lebih akurat ukur overfit probability.
    """
    def __init__(self, n_splits=6, n_test_splits=2, embargo_pct=0.01):
        self.n_splits = n_splits
        self.n_test_splits = n_test_splits
        self.embargo_pct = embargo_pct
    
    def split(self, X, y=None):
        from itertools import combinations
        n = len(X)
        indices = np.arange(n)
        fold_size = n // self.n_splits
        folds = [indices[i*fold_size:(i+1)*fold_size] for i in range(self.n_splits)]
        
        # All combinations of n_test_splits test folds
        for test_combo in combinations(range(self.n_splits), self.n_test_splits):
            test_idx = np.concatenate([folds[i] for i in test_combo])
            train_idx = np.concatenate([
                folds[i] for i in range(self.n_splits) if i not in test_combo
            ])
            
            # Apply purging + embargo
            purged_train = self._purge_and_embargo(train_idx, test_idx, n)
            yield purged_train, test_idx
    
    def _purge_and_embargo(self, train_idx, test_idx, n):
        # Purging: hapus train observations dengan label overlap
        test_start, test_end = test_idx[0], test_idx[-1]
        purged = train_idx[(train_idx + 1 < test_start) | (train_idx > test_end)]
        
        # Embargo
        embargo_size = int(n * self.embargo_pct)
        purged = purged[(purged < test_end + 1 + embargo_size) | (purged > test_end + embargo_size + embargo_size)]
        
        return purged

10.3 Walk-Forward Optimization (WFO)

class WalkForwardCV:
    """
    Anchored or rolling walk-forward optimization.
    """
    def __init__(self, n_splits=5, train_size=None, test_size=None, 
                 anchored=False, expanding=False):
        self.n_splits = n_splits
        self.train_size = train_size
        self.test_size = test_size
        self.anchored = anchored
        self.expanding = expanding
    
    def split(self, X, y=None):
        n = len(X)
        if self.test_size is None:
            test_size = n // (self.n_splits + 1)
        else:
            test_size = self.test_size
        
        if self.train_size is None:
            if self.anchored:
                # Anchored: train always from start
                train_size = n - self.n_splits * test_size
            elif self.expanding:
                # Expanding: train grows over time
                train_size = test_size  # minimum, grows
            else:
                # Rolling: train_size = constant
                train_size = n // (self.n_splits + 1)
        
        for i in range(self.n_splits):
            test_start = n - (self.n_splits - i) * test_size
            test_end = test_start + test_size
            
            if self.anchored:
                train_start = 0
            elif self.expanding:
                train_start = 0  # Expanding from beginning
            else:
                # Rolling: shift train window
                train_start = test_start - train_size
            
            train_end = test_start
            
            train_idx = np.arange(train_start, train_end)
            test_idx = np.arange(test_start, test_end)
            
            yield train_idx, test_idx

10.4 Probability of Overfit (PoO)

Setelah CPCV, hitung Probability of Overfit (Bailey & López de Prado 2014):

$$\text{PoO} = \frac{\text{number of paths with negative OOS performance}}{\text{total number of paths}}$$

  • PoO < 0.05 → strategi robust
  • PoO 0.05-0.20 → perlu validasi tambahan
  • PoO > 0.20 → overfit, jangan deploy

10.5 Deflated Sharpe Ratio (DSR)

Adjust Sharpe ratio untuk multiple testing bias (kalau lo test 100 strategi):

$$\text{DSR} = \frac{\hat{SR} - SR^* \sqrt{\frac{V[\hat{SR}]}{1 - \hat{\gamma}_3 \hat{SR} + \frac{\hat{\gamma}_4 - 1}{4} \hat{SR}^2}}}{\sqrt{V[\hat{SR}] + \frac{1}{n-1}}}$$

Dimana $SR^*$ adalah maximum Sharpe dari $N$ trials.


11. Production Framework Implementation

11.1 Zipline (Quantopian Open Source)

# Zipline: prevent look-ahead via proper bundle handling
from zipline import run_algorithm
from zipline.api import (
    symbol, order_target_percent, schedule_function,
    date_rules, time_rules, record
)
from zipline.pipeline import Pipeline, CustomFactor
from zipline.pipeline.data import USEquityPricing
import pandas as pd

class MomentumFactor(CustomFactor):
    """12-month momentum, exclude last month (standard convention)"""
    inputs = [USEquityPricing.close]
    window_length = 252
    
    def compute(self, today, assets, out, close):
        # close shape: (252, n_assets)
        # Return: pct_change dari month 12 ke month 1
        out[:] = (close[-21] / close[0]) - 1  # skip last month

def initialize(context):
    schedule_function(
        rebalance,
        date_rules.month_start(),
        time_rules.market_open()
    )

def rebalance(context, data):
    # Zipline: data.current() is POINT-IN-TIME, no look-ahead
    pipeline = make_pipeline()
    context.pipeline = pipeline
    
    # Order placement
    for asset in context.portfolio.positions:
        if asset not in context.pipeline.index:
            order_target_percent(asset, 0)

def make_pipeline():
    return Pipeline(
        columns={'momentum': MomentumFactor()},
        screen=USEquityPricing.close.latest > 5
    )

# Run with proper bundle (Quandl or custom)
# result = run_algorithm(
#     start=pd.Timestamp('2018-01-01', tz='utc'),
#     end=pd.Timestamp('2024-12-31', tz='utc'),
#     initialize=initialize,
#     capital_base=100000,
#     data_frequency='daily',
#     bundle='quandl'
# )

Zipline anti-leakage properties:

  • data.current() returns point-in-time value (no future)
  • data.history() respects window length (no look-ahead in rolling)
  • Pipeline factors computed at bar start, used at same bar's market open

11.2 backtrader

import backtrader as bt

class MomentumStrategy(bt.Strategy):
    """backtrader strategy: 12-1 momentum, monthly rebalance"""
    params = (
        ('lookback', 252),
        ('skip', 21),  # skip last month
        ('rebalance_freq', 21),  # monthly
        ('top_n', 10),  # top 10 momentum
    )
    
    def __init__(self):
        self.rebalance_counter = 0
        # backtrader: data.close[0] is current, [-1] is previous
        self.momentum = {}
        for d in self.datas:
            self.momentum[d] = (d.close(-self.p.skip) / d.close(-self.p.lookback)) - 1
    
    def next(self):
        # next() called every bar — current bar is point-in-time
        self.rebalance_counter += 1
        if self.rebalance_counter % self.p.rebalance_freq != 0:
            return
        
        # Get current momentum (point-in-time, no future)
        momentum_values = []
        for d in self.datas:
            if len(d) > self.p.lookback:
                mom = (d.close[-self.p.skip] / d.close[-self.p.lookback]) - 1
                momentum_values.append((d, mom))
        
        # Sort, take top N
        momentum_values.sort(key=lambda x: x[1], reverse=True)
        top_n = momentum_values[:self.p.top_n]
        
        # Equal weight
        target_weight = 1.0 / len(top_n)
        for d, _ in top_n:
            self.order_target_percent(d, target_weight)
        
        # Sell rest
        for d, _ in momentum_values[self.p.top_n:]:
            self.order_target_percent(d, 0)

# backtrader: no look-ahead because:
# 1. data.close[-N] is bar N ago (past)
# 2. data.close[0] is current
# 3. Orders executed NEXT bar's open (realistic)

backtrader anti-leakage:

  • data.close[0] = current bar close (point-in-time)
  • data.close[-N] = N bars ago (past)
  • Orders filled at next bar's open (no look-ahead)
  • No random access to future bars

11.3 vectorbt (vectorized backtesting)

import vectorbt as vbt
import pandas as pd
import numpy as np

# vectorbt: vectorized tapi BUTUH manual time handling
# ⚠️ vectorbt tidak punya built-in anti-look-ahead — lo harus implement sendiri

def vectorbt_momentum_backtest(close: pd.DataFrame, lookback: int = 252, skip: int = 21, top_n: int = 10):
    """
    Vectorized momentum backtest dengan MANUAL time alignment.
    """
    # 1. Compute momentum (point-in-time, no future)
    # close[t] / close[t-lookback-skip] — uses ONLY past data
    momentum = close.shift(skip).div(close.shift(lookback + skip)) - 1
    
    # 2. Rank cross-sectionally at each timestamp
    ranks = momentum.rank(axis=1, ascending=False)
    
    # 3. Signal: top N
    signals = (ranks <= top_n).astype(int)
    
    # 4. Returns
    returns = close.pct_change().shift(-1)  # ⚠️ forward return, properly shifted
    
    # 5. Strategy returns
    # equal weight across selected stocks
    n_selected = signals.sum(axis=1)
    weights = signals.div(n_selected, axis=0).fillna(0)
    
    strategy_returns = (weights * returns).sum(axis=1)
    
    # 6. Run backtest
    portfolio = vbt.Portfolio.from_signals(
        close=close,
        signals=signals,
        # ... other params
    )
    
    return portfolio

# ⚠️ CRITICAL CHECK: vectorbt bisa leak kalau lo gak shift properly
# Common bug: features computed at t but used at t (no shift)
# CORRECT: features at t → decision at t → executed at t+1 (open or close)

11.4 Qlib (Microsoft Quant Platform)

# Qlib: designed untuk alpha research dengan proper time alignment
import qlib
from qlib.contrib.data.handler import Alpha158
from qlib.contrib.model.linear import LinearModel
from qlib.workflow import R
from qlib.workflow.record_temp import SignalRecord, PortAnaRecord

# Initialize
qlib.init(provider_uri="~/.qlib/qlib_data/cn_data")

# Handler: Alpha158 = 158 alpha factors, all point-in-time
handler = Alpha158(
    instruments="csi300",
    start_time="2010-01-01",
    end_time="2024-12-31"
)

# Dataset with proper time-series split (no look-ahead)
dataset = handler.fetch(
    selector={
        "train": ("2010-01-01", "2018-12-31"),
        "valid": ("2019-01-01", "2020-12-31"),
        "test": ("2021-01-01", "2024-12-31")  # ← proper time-based split
    }
)

# Train LightGBM model
with R.start(experiment_name="momentum_alpha"):
    model = LinearModel()
    model.fit(dataset.prepare("train", col_set=["feature", "label"]))
    
    # Predict on test (forward-only, no leak)
    pred = model.predict(dataset.prepare("test", col_set=["feature"]))
    
    # Record
    recorder = R.get_recorder()
    sr = SignalRecord(model=model, dataset=dataset, recorder=recorder)
    sr.generate()

11.5 Lean Engine (QuantConnect)

# Lean: C#-based (atau Python wrapper), proper time handling
from AlgorithmImports import *

class MomentumAlgorithm(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2018, 1, 1)
        self.SetEndDate(2024, 12, 31)
        self.SetCash(100000)
        
        # Add universe with proper point-in-time data
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction)
        
        # Schedule monthly rebalance
        self.Schedule.On(self.DateRules.MonthStart(), 
                         self.TimeRules.AfterMarketOpen("SPY"),
                         self.Rebalance)
    
    def CoarseSelectionFunction(self, coarse):
        # Coarse: data as of current day (point-in-time, no future)
        sorted_by_volume = sorted(coarse, key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in sorted_by_volume[:100]]
    
    def Rebalance(self):
        # Compute momentum using HISTORY (past only, no future)
        insights = []
        for symbol in self.ActiveSecurities.Keys:
            history = self.History(symbol, 252, Resolution.Daily)
            if len(history) < 252:
                continue
            
            # Past prices only — no look-ahead
            current = history['close'].iloc[-1]
            past = history['close'].iloc[-252]
            skip_past = history['close'].iloc[-21]
            
            momentum = (skip_past / past) - 1
            if momentum > 0:
                insights.append(Insight.Price(symbol, timedelta(days=21), InsightDirection.Up))
        
        self.EmitInsights(insights)

11.6 backtesting.py (Lightweight)

import backtesting as bt
from backtesting import Backtest, Strategy

class MomentumStrat(Strategy):
    lookback = 252
    skip = 21
    
    def init(self):
        # Compute momentum using I() wrapper — point-in-time
        close = self.data.Close
        self.momentum = self.I(
            lambda: (close.shift(self.skip) / close.shift(self.lookback + self.skip)) - 1
        )
    
    def next(self):
        # Rank cross-section
        if len(self.momentum) < self.lookback + self.skip:
            return
        
        # Top momentum trade
        top_idx = np.argmax(self.momentum[-1])
        if self.momentum[-1, top_idx] > 0:
            self.buy(self.data.index[top_idx], size=0.1)

12. 5 Case Study Indonesia — Real-World Look-Ahead Lessons

Case 1: BBCA Backtest Loss 2022

Setup: Quant shop Jakarta di 2021 backtest strategi momentum 6-bulan di BBCA (Bank Central Asia). Hasil backtest: Sharpe 2.8, annual return 38%. Live 2022: return -12%, drawdown -22%.

Root cause:

  1. Survivorship bias: Backtest pake list "current IDX30 constituents" — BBCA selalu masuk constituent, even tho dia delisted period? BBCA gak pernah delisted, TAPI constituent weight changes (BBCA weight 12% di 2021, 15% di 2022). Kalau backtest pake current weight, lo apply forward info.
  2. Splits/dividend adjustment: BBCA pernah kasih stock split 5:1 di 2019. Kalau adjustment pake split date, OK. TAPI kalau pake announcement date (which is future at split date), LEAAAAK.
  3. Sector rotation: 2022 ada rotation dari bank ke commodity. Backtest gak capture ini.

Fix:

# 1. Use point-in-time index constituent data
# IDX provides historical constituent list (download dari IDX website)
historical_idx30 = pd.read_csv('idx30_constituents_history.csv')
# Filter: as of date t, get constituents aktif di t
constituents_t = historical_idx30[historical_idx30['effective_date'] <= t]['symbol'].iloc[-1]
# Jangan pake 'current' list untuk backtest historical

# 2. Use point-in-time corporate actions
# IDX: download dari Yahoo Finance atau Bloomberg
# YAHOO YAHOO YAHOO: auto_adjust=True biasanya adjust BACKWARDS (good)
# TAPI kalau lo pake raw price + manual adjustment, pastikan pake EX-DATE bukan announcement date

Loss: ~Rp 4.5 miliar AUM loss dari 100 klien retail.

Case 2: TLKM Survivorship Bias 2018-2020

Setup: Quant fund backtest "small cap value" strategy 2018-2020 di TLKM dan 50 IDX small caps. Hasil: CAGR 28%. Live: CAGR 6%.

Root cause: Backtest exclude stocks yang delisted/merger di 2018-2020 (misal: 5 stocks di IDX delisted 2019-2020 karena merger atau bankruptcy). Tapi LO gak include these in the universe, which means:

  • Survivorship bias: lo hanya test on "winners" (yang masih listed)
  • Look-ahead: lo implicitly assume lo "tahu" stock mana yang bakal delisted

Fix:

# 1. Use FULL historical universe (including delisted)
# IDX: full list (incl. delisted) dari idx.co.id atau kontan.co.id
# Yahoo Finance: delisted stocks kadang masih ada di adjusted prices
all_tickers_id = pd.read_csv('idx_full_listing_history.csv')

# 2. For each backtest date, use POINT-IN-TIME universe
def get_universe_at_date(t, all_listings):
    active = all_listings[
        (all_listings['listing_date'] <= t) & 
        ((all_listings['delisting_date'].isna()) | (all_listings['delisting_date'] > t))
    ]
    return active['ticker'].tolist()

# 3. Backtest loop over time
for t in backtest_dates:
    universe = get_universe_at_date(t, all_listings)
    # Run strategy on this universe

Case 3: AAPL Splits 7-for-1 (Aug 2020) Leakage

Setup: Indo quant trader backtest AAPL 2015-2020. Hasil: CAGR 35%. Live: CAGR 8% setelah deploy ke portofolio US tech.

Root cause: AAPL 7-for-1 split di August 2020. Kalau lo pake unadjusted prices, AAPL "dropped" dari $540 ke $80 overnight. Backtest:

  • Pre-split: 1 share @ $540 = $540
  • Post-split: 7 shares @ $80 = $560
  • Same total value, BUT kalau lo gak adjust, lo pikir "AAPL dropped 85%"

Look-ahead leak: Lo pake Yahoo Finance adjusted prices (which adjust BACKWARDS), so 2015-2019 prices are DIVIDED by 7, and 2020 prices are raw. Kalau lo compute momentum price[t] / price[t-12] - 1:

  • Without split adjustment: momentum spike di Aug 2020 (price halved overnight)
  • With split adjustment (using ex-date): momentum smooth

Fix:

# ALWAYS use adjusted prices for returns-based strategies
import yfinance as yf

aapl = yf.download('AAPL', start='2015-01-01', auto_adjust=True)
# auto_adjust=True adjusts ALL historical prices for splits + dividends
# This is point-in-time correct

# ⚠️ WARNING: Some APIs (e.g., yahoo finance 'Close') show unadjusted
# Use 'Adj Close' or 'auto_adjust=True'

Case 4: Crypto Listing Bias (Binance/IDEX Listings 2020-2021)

Setup: Crypto quant backtest "buy new listings" strategy 2020-2021. Hasil: CAGR 120%! Live: -45% in 2022.

Root cause:

  1. Selection bias: lo backtest on coins that GOT LISTED on Binance (i.e., passed Binance's due diligence)
  2. Survivorship: lo implicitly only see coins that successfully listed
  3. Look-ahead: lo apply listing date as "buy signal" but lo udah tau coin ini listed di Binance (Binance listings di-announce HARI SEBELUM listing, jadi lo apply signal AFTER announcement — ini technically OK, TAPI:**
  4. T+1 listing spike: Setiap Binance listing baru, coin spike 50-200% di first 24h. Lo backtest AS-IF lo beli di listing price, but real order execution gets filled 10-30% above listing price (slippage).

Fix:

# 1. Account for slippage
def realistic_crypto_backtest(coin, listing_date, slippage_pct=0.20):
    # Simulate realistic fill price: 20% above listing
    fill_price = listing_price * (1 + slippage_pct)
    return fill_price

# 2. Use "first 5-min VWAP" instead of opening price
# 3. Cap position size by ADV (Average Daily Volume)
# 4. Use point-in-time listing data (don't filter "currently listed")

Case 5: IDX 30 Constituent Rebalancing Bias

Setup: Quant strategy "long IDX30 low-volatility" 2019-2023. Hasil backtest: CAGR 22%. Live: CAGR 11%.

Root cause: IDX30 constituents direbalance 2x setahun (Februari & Agustus). Quant strategy implicitly assumes:

  • Lo tau Feb 2020 IDX30 constituents pas backtest di 2019
  • Lo tau stock mana yang bakal di-add/remove

Ini bukan look-ahead dalam artian fitur, tapi universe look-ahead.

Fix:

# IDX30 historical constituent data
# Download dari: https://www.idx.co.id/data-pasar/data-saham/indeks-saham/
# File: IDX30_constituents_YYYYMMDD.xlsx (di-publish per rebalance date)

# For each backtest date, use constituent list effective at that date
def get_idx30_constituents(t):
    # Find latest rebalance date <= t
    rebal_dates = pd.to_datetime([
        '2019-02-25', '2019-08-26', '2020-02-24', '2020-08-28',
        '2021-02-22', '2021-08-30', '2022-02-25', '2022-08-29',
        '2023-02-27', '2023-08-28', '2024-02-26', '2024-08-26'
    ])
    effective_date = rebal_dates[rebal_dates <= t].max()
    constituents = pd.read_excel(f'idx30_{effective_date.strftime("%Y%m%d")}.xlsx')
    return constituents['kode'].tolist()

Lesson: Universe construction is the most overlooked source of look-ahead bias. Always use point-in-time constituent data.


13. Alternative Data Leakage — News, Satellite, NLP

13.1 News Articles Time Alignment

# ❌ WRONG — use article publication date AS signal date
news_df = pd.read_csv('news_articles.csv')  # columns: date, headline, sentiment
# If article published on 2024-01-15 09:30 AM, but stock moved at 09:00 AM,
# your "signal at 09:30" is actually look-ahead (stock already moved)

# ✅ CORRECT — use article publication timestamp + delay buffer
# Standard convention: news is "available" 1 hour after publication
# This accounts for: ingestion delay, NLP processing time, human review
news_df['signal_time'] = news_df['published_at'] + pd.Timedelta(hours=1)

13.2 Satellite Image Time Alignment

# ❌ WRONG — use satellite image timestamp directly
# Satellite image of parking lot at 14:00 UTC → assume real-time
# But: image processing takes 4-12 hours, data delivery 6-24 hours

# ✅ CORRECT — apply realistic delay
satellite_df['signal_time'] = satellite_df['capture_time'] + pd.Timedelta(hours=12)

# For retail (Walmart parking lot), use 24h delay
# For shipping (port activity), use 6h delay
# For agriculture (crop yield), use 2-week delay

13.3 NLP Sentiment Score Leakage

# ❌ WRONG — use sentiment score computed from article published AFTER market close
# But: in live trading, you compute sentiment at the time you SEE the article
# If you train using "sentiment_at_market_close" and live with "sentiment_at_signal_time",
# you have look-ahead

# ✅ CORRECT — use consistent timing
# If live: sentiment computed at 09:00 AM on day T+1 from article published day T
# Train using same timing: sentiment from T-day articles, applied at T+1 09:00 AM

13.4 Credit Card Transaction Data

# ❌ WRONG — use credit card data as "real-time consumption indicator"
# Reality: aggregators (e.g., Bloomberg Second Measure) have 1-2 week delay

# ✅ CORRECT — apply proper delay
cc_df['signal_time'] = cc_df['transaction_period'] + pd.Timedelta(days=14)

13.5 Web Scraping Leakage

# ❌ WRONG — scrape job postings and use as "company growth signal"
# Reality: scraping happens at scrape_time, not posting_time
# A job posted Jan 1 may not be scraped until Jan 7

# ✅ CORRECT — use scrape_time as the "available at" time
# OR: use first_seen_time (when your system first saw the posting)
# This is conservative and realistic

14. Trading-Specific Leakage Patterns

14.1 Point-in-Time Fundamental Data

# ❌ WRONG — use "as-filed" data (latest restatement)
# Reality: company files Q1 report May 15, but later restates in Q3 report (Nov 10)
# If you train on restated data, you look-ahead to Nov 10

# ✅ CORRECT — use filing-date versioned data
# SimFin, Polygon.io, or Compustat provide "as-reported" data
df['filing_date'] = pd.to_datetime(df['filing_date'])
df['value'] = df.apply(
    lambda row: get_value_as_of(row['announcement_date'], row['metric']),
    axis=1
)
# Or: use databases that track revisions

14.2 Earnings Announcement Leakage

# ❌ WRONG — trade on earnings day at open
# Reality: earnings announced AFTER market close on previous day
# If you "buy at open" on earnings day, the stock already gapped up at close
# Your entry price is 5% above your "backtest entry"

# ✅ CORRECT — use pre-announcement data
# OR: if you trade on earnings surprise, use next-day open (with realistic gap)
backtest_entry = df['close'].shift(-1)  # next-day open

14.3 Short Interest Data

# ❌ WRONG — use most recent short interest report
# Reality: short interest reported bi-monthly, with 2-week delay
# If you use "current" short interest for backtest, you look-ahead 2 weeks

# ✅ CORRECT — apply 2-week delay
df['short_interest_effective_date'] = df['report_date'] + pd.Timedelta(days=14)

14.4 Insider Trading Data

# ❌ WRONG — use insider transaction date as filing date
# Reality: insider trades reported to SEC within 2 business days (Form 4)
# Effective signal date: 2 business days after transaction

# ✅ CORRECT — apply 2-day delay
df['signal_date'] = df['transaction_date'] + pd.BusDay(2)

14.5 Loan Default Data (Credit)

# ❌ WRONG — use loan default status at observation date
# Reality: defaults reported with 30-90 day delay

# ✅ CORRECT — apply 60-day delay for credit signals
df['signal_date'] = df['default_date'] + pd.Timedelta(days=60)

14.6 Market Microstructure Leakage

# ❌ WRONG — assume you can trade at close price for entire position
# Reality: market impact, spread, latency

# ✅ CORRECT — model realistic execution
def realistic_execution(target_size, adv, current_price, spread):
    """
    Square-root market impact model (Almgren-Chriss simplified)
    """
    participation_rate = min(target_size / adv, 0.10)  # max 10% of ADV
    impact_bps = 10 * np.sqrt(participation_rate) * 100  # 10 bps per sqrt(participation)
    effective_price = current_price * (1 + impact_bps / 10000) + spread / 2
    return effective_price

15. Negative Ridge Deep-Dive

15.1 When Negative Ridge Works (Mathematical Conditions)

Sequence II scenario (lots of noise observations): $$\lambda_{opt} = n^{-1}|\beta_0|^{-2}d_0\sigma^2 - (1 - \nu)$$

Three conditions for $\lambda_{opt} < 0$:

  1. $n$ very large relative to $n_0$: $n/n_0 > 5$ (5x more noise than real data)
  2. $\sigma^2$ large relative to $|\beta_0|^2$: SNR < 1 (noisy observations)
  3. $d_0$ small relative to $n$: $d_0/n < 0.1$ (few features per observation)

Example:

  • $n = 10,000$ observations (mostly noisy)
  • $n_0 = 500$ "real" observations
  • $d_0 = 5$ features
  • $\sigma^2 = 1.0$, $|\beta_0|^2 = 0.1$

$$\lambda_{opt} = 10000^{-1} \cdot (0.1)^{-1} \cdot 5 \cdot 1.0 - (1 - 0.05) = 0.5 - 0.95 = -0.45$$

So optimal $\lambda = -0.45$ → use anti-shrinkage.

15.2 When Negative Ridge Fails

  1. Look-ahead bias present: $\lambda_{opt}$ will be artificially negative, leading to overfit
  2. Small $n$: With $n < 100$, $\lambda_{opt}$ is high variance estimate
  3. Correlated predictors: $\sigma^2$ estimate is biased when predictors are correlated
  4. Heteroscedastic noise: $\sigma^2$ varies by observation, so single $\lambda$ is suboptimal
  5. Heavy-tailed noise: Student-t with low df → more aggressive shrinkage needed (negative ridge backfires)

15.3 Multiple Shrinkage Targets

Instead of single $\lambda$, allow per-feature shrinkage via group ridge or adaptive ridge:

from sklearn.linear_model import Ridge
import numpy as np

class AdaptiveNegativeRidge:
    """
    Per-feature ridge with negative lambda option.
    Each feature can have its own shrinkage.
    """
    def __init__(self, lambda_per_feature):
        self.lambda_per_feature = lambda_per_feature  # shape (d,)
    
    def fit(self, X, y):
        # Closed form: beta = (X'X + diag(lambda))^{-1} X'y
        d = X.shape[1]
        XtX = X.T @ X
        Xty = X.T @ y
        penalty = np.diag(self.lambda_per_feature)
        self.beta_ = np.linalg.solve(XtX + penalty, Xty)
        return self
    
    def predict(self, X):
        return X @ self.beta_

15.4 Bayesian Interpretation of Negative Ridge

Negative ridge = inverse-gamma prior on $\beta$ with mode at infinity (improper prior that favors large coefficients). This is anti-Bayesian shrinkage.

In practice: use only when you have strong prior belief that the OLS estimate is underestimated due to noise observations.

15.5 Alternative: Explicit Anti-Shrinkage

Instead of negative $\lambda$, use explicit feature amplification:

# Method: scale up features that you believe are under-estimated
def anti_shrink_features(X, feature_amplification):
    """
    feature_amplification: dict {feature_name: amplification_factor}
    """
    X_amp = X.copy()
    for feat, amp in feature_amplification.items():
        X_amp[feat] = X[feat] * amp
    return X_amp

# Example: amplify momentum factor 2x (believe market under-reacts)
X_amp = anti_shrink_features(X, {'momentum_12_1': 2.0})
# Then fit standard OLS on X_amp

This is mathematically equivalent to negative ridge but more interpretable.


16. UU PDP/ITE/OJK POJK 26/2023 Compliance — Backtest Reporting Standards

16.1 UU PDP 27/2022 (Pelindungan Data Pribadi)

Relevan buat quant shops di Indonesia yang collect/use data pribadi:

  • Pasal 26: Pengolahan data harus ada tujuan yang jelas (purpose limitation)
  • Pasal 27: Data subject harus tau tujuannya (transparency)
  • Pasal 28: Data subject bisa akses & koreksi (right to access)

Compliance checklist untuk backtest reporting:

## UU PDP 27/2022 Compliance — Backtest Documentation

- [ ] Data source identified (vendor name, contract date)
- [ ] Purpose statement: "backtest for trading strategy validation"
- [ ] Data retention policy: how long is backtest data stored?
- [ ] Data subject notification: clients notified of data usage
- [ ] Encryption at rest (AES-256 minimum)
- [ ] Access control (who can view client portfolios)
- [ ] Data deletion procedure (right to be forgotten)

16.2 UU ITE 19/2016 + 1/2024 (Informasi & Transaksi Elektronik)

Pasal-pasal relevan:

  • Pasal 25: Larangan mengakses sistem elektronik tanpa izin
  • Pasal 30: Larangan manipulasi data/informasi elektronik
  • Pasal 32: Tanggung jawab operator sistem elektronik

Untuk backtest reporting:

  • Hasil backtest yang dipublikasikan harus akurat, tidak manipulatif
  • Klaim performance harus verifiable (audit trail)
  • Test result harus reproducible (data + code)

16.3 POJK 26/2023 (Pedoman Perilaku Pelaku Usaha Jasa Keuangan)

Spesifik untuk manajer investasi / perusahaan efek:

  • Bagian 5: Kewajiban manajer investasi terkait riset & analisis
  • Lampiran 1: Standar disclosure backtest untuk produk investasi

POJK 26/2023 Backtest Disclosure Requirements:

## POJK 26/2023 Backtest Disclosure Template

1. **Periode backtest**: 2018-01-01 sampai 2024-12-31 (7 tahun)
2. **Universe**: IDX30 constituents (point-in-time), rebalance 2x/tahun
3. **Methodology**: Equal-weight top 10 momentum, monthly rebalance
4. **Transaction cost**: 0.30% per trade (include spread + market impact)
5. **Out-of-sample**: 2023-2024 (24 bulan)
6. **Performance metrics**:
   - Annualized return: X%
   - Sharpe ratio: X.XX
   - Max drawdown: -X%
   - Win rate: X%
7. **Risk metrics**:
   - VaR (95%, 1-day): X%
   - CVaR (95%): X%
   - Sortino ratio: X.XX
8. **Statistical significance**:
   - t-statistic: X.XX
   - p-value: X.XXXX
   - Deflated Sharpe Ratio: X.XX
9. **Caveats**:
   - Past performance is not indicative of future results
   - Backtest assumes point-in-time data (no look-ahead)
   - Live performance may differ due to market conditions
10. **Auditor sign-off**: [Auditor firm name + date]

16.4 GDPR (untuk backtest dengan data EU)

  • Article 6: Lawful basis for processing (legitimate interest, contract)
  • Article 35: Data Protection Impact Assessment (DPIA) untuk high-risk processing

16.5 PCI-DSS (untuk data payment)

  • Requirement 3: Protect stored cardholder data
  • Requirement 10: Log and monitor all access

16.6 ISO 27001 / SOC 2 (general security)

  • ISO 27001 A.12.4: Logging and monitoring
  • SOC 2 CC7.2: System monitoring and intrusion detection

17. Decision Tree 7-Q — Kapan Pakai Negative Ridge

START: Lo punya linear regression model yang underperforms
│
├─ Q1: Apakah lo udah audit 5 tempat look-ahead bias?
│   ├─ NO → Audit dulu. Skip ke §3. Kalau masih underperform, lanjut.
│   └─ YES → Lanjut Q2
│
├─ Q2: Berapa rasio d/n (jumlah fitur / jumlah observasi)?
│   ├─ d/n < 0.05 (banyak data, sedikit fitur) → Sequence II possible, lanjut Q3
│   ├─ 0.05 ≤ d/n < 0.20 (sweet spot) → Standard ridge (λ>0)
│   ├─ 0.20 ≤ d/n < 0.80 (dekat interpolation) → Cap features, lanjut Q4
│   └─ d/n ≥ 0.80 (overparameterized) → L1/Lasso + feature selection
│
├─ Q3: Apakah lo punya banyak observasi noisy?
│   ├─ YES (n_observations >> n_signal) → Test negative ridge
│   └─ NO (mostly clean data) → Standard ridge λ>0
│
├─ Q4: Apakah koefisien estimated lo konsisten menyusut saat n naik?
│   ├─ YES → Sequence II effect. Test negative ridge.
│   └─ NO → Standard ridge λ>0 atau OLS
│
├─ Q5: Apakah SNR (signal-to-noise ratio) rendah?
│   ├─ YES (R² < 0.10 in-sample) → Model gak nangkap signal. Feature engineering first.
│   └─ NO → Proceed to ridge tuning
│
├─ Q6: Apakah predictors lo correlated?
│   ├─ YES (high VIF > 10) → Negative ridge akan inflate coefficients. Use PCA first.
│   └─ NO → Negative ridge OK
│
└─ Q7: Apakah data lo time series dengan autocorrelation?
    ├─ YES → Use HAC standard errors + GLS, not standard OLS ridge
    └─ NO → Negative ridge OK to test

Quick heuristic:

def recommend_ridge_strategy(d, n, beta_magnitude_trend, snr):
    if d / n > 0.5:
        return "Use Lasso/Elastic Net for feature selection. Cap at d/n = 0.1"
    elif d / n < 0.05 and beta_magnitude_trend == 'decreasing' and snr < 0.1:
        return "Test negative ridge (lambda in [-2, 0])"
    elif snr < 0.05:
        return "Signal too weak. Improve features, not regularization"
    else:
        return "Standard positive ridge (lambda in [0.1, 5])"

18. Anti-Recommendation — 7 Situasi JANGAN Pakai Negative Ridge

# Situasi Kenapa Jangan
1 Look-ahead bias belum diaudit Negative ridge = overfitting kalau ada leak di upstream
2 $n_0$ kecil (< 30) Variance of $\lambda_{opt}$ estimate terlalu tinggi
3 $d > n$ OLS minimum norm is the only consistent estimator; negative ridge won't help
4 Time series dengan strong autocorrelation Paper assume iid. GLS/HAC more appropriate
5 Predictors highly correlated (VIF > 10) Coefficient inflation. PCA or feature selection first
6 Heavy-tailed noise (Student-t df < 5) More aggressive shrinkage needed, not anti-shrinkage
7 Regulatory constraint (banking/insurance) Negative ridge = implicit leverage. May violate capital requirements

19. Implementation Checklist 25-Item

Pre-Implementation (7 items)

  • [ ] 1. Document data sources (vendor, contract, refresh frequency)
  • [ ] 2. Audit 5 look-ahead bias sources: scaler, rolling, candle, PCA, target
  • [ ] 3. Compute point-in-time universe (constituents, listings, delistings)
  • [ ] 4. Apply point-in-time corporate actions (splits, dividends, mergers)
  • [ ] 5. Set up purged k-fold CV (López de Prado) with embargo period
  • [ ] 6. Define label horizon (forward return window) and document
  • [ ] 7. Compute Deflated Sharpe Ratio untuk multiple testing adjustment

Modeling (6 items)

  • [ ] 8. Cap features at n/10 (or n/5 for high SNR)
  • [ ] 9. Test 3 ridge regimes: positive, zero, negative
  • [ ] 10. Monitor coefficient shrinkage over time (Sequence II effect)
  • [ ] 11. Use HAC standard errors for time series autocorrelation
  • [ ] 12. Compute test error across d/n grid (detect interpolation spike)
  • [ ] 13. Save model artifacts: features, scaler params, lambda value

Backtest Validation (6 items)

  • [ ] 14. Use walk-forward CV (anchored or rolling)
  • [ ] 15. Compute Probability of Overfit (PoO) via CPCV
  • [ ] 16. Model realistic transaction costs (spread + market impact)
  • [ ] 17. Cap position size by ADV (max 10% participation)
  • [ ] 18. Account for slippage (Almgren-Chriss model)
  • [ ] 19. Generate trade log with entry/exit timestamps + reasons

Compliance (3 items)

  • [ ] 20. POJK 26/2023 backtest disclosure template (for ID quant shops)
  • [ ] 21. UU PDP 27/2022 data protection documentation
  • [ ] 22. Audit trail: data version, code version, model version

Monitoring (3 items)

  • [ ] 23. Live vs backtest drift dashboard (Sharpe, hit rate, drawdown)
  • [ ] 24. Monthly retrain schedule (prevent concept drift)
  • [ ] 25. Quarterly model audit: check if assumptions still hold (iid, Gaussian, etc.)

20. References — 35 Sumber

Paper Utama (6)

  1. Ullah, I. & Welsh, A.H. (2026). "On the effect of noise on fitting linear regression models." Computational Statistics & Data Analysis 224:108421. CC BY 4.0.
  2. Belkin, M., Hsu, D., Ma, S., & Mandal, S. (2019). "Reconciling modern machine-learning practice and the classical bias-variance trade-off." PNAS 116(32):15849-15854.
  3. Hastie, T., Montanari, A., Rosset, S., & Tibshirani, R.J. (2022). "Surprises in high-dimensional ridgeless least squares interpolation." Annals of Statistics 50(2):949-986.
  4. Kobak, D., Lomond, J., & Sanchez, B. (2020). "The optimal ridge penalty for real-world high-dimensional data can be zero or negative due to the implicit ridge regularization." JMLR 21(169):1-16.
  5. Dax, A. (2022). "The condition number anomaly." SIAM Review 64(1):147-163.
  6. Muthukumar, V., Nakkiran, P., & Bartlett, V. (2020). "Harmless interpolation of noisy data in regression." IEEE Journal on Selected Areas in Information Theory 1(1):396-407.

Cross-Validation & Backtesting (6)

  1. López de Prado, M. (2018). Advances in Financial Machine Learning. Wiley. Chapter 7: Cross-Validation.
  2. Bailey, D. & López de Prado, M. (2014). "The Deflated Sharpe Ratio." Journal of Portfolio Management 40(5):94-107.
  3. Bailey, D., Borwein, J., López de Prado, M., & Zhu, Q. (2014). "Pseudo-mathematics and financial charlatanism." Notices of the AMS 61(5):458-471.
  4. López de Prado, M. (2019). "Tactical investment algorithms." Journal of Investment Strategies 8(1):1-22.
  5. Harvey, C.R., Liu, Y., & Zhu, H. (2016). "... and the cross-section of expected returns." Review of Financial Studies 29(1):5-68.
  6. Chordia, T., Goyal, A., & Saretto, A. (2020). "Anomalies and false rejections." Review of Financial Studies 33(5):2134-2179.

Point-in-Time Data (4)

  1. SimFin Bulk Download: https://simfin.com/ — Free point-in-time fundamental data
  2. Compustat - Capital IQ: Historical fundamentals with restatement tracking
  3. Polygon.io: US equities with point-in-time API
  4. Yahoo Finance: auto_adjust=True (free, but limited restatement tracking)

Backtesting Frameworks (5)

  1. Zipline: https://zipline.ml/ — Quantopian's open-source backtester
  2. backtrader: https://www.backtrader.com/ — Python backtesting with execution modeling
  3. vectorbt: https://vectorbt.dev/ — Vectorized backtesting (manual time alignment)
  4. Qlib: https://github.com/microsoft/qlib — Microsoft quantitative investment platform
  5. Lean Engine: https://www.quantconnect.com/lean — QuantConnect's open-source engine

Time-Series CV Implementation (4)

  1. scikit-learn TimeSeriesSplit: https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html
  2. MlFinLab: https://github.com/hudson-and-thames/mlfinlab — López de Prado's tools
  3. gs-quant: https://github.com/goldmansachs/gs-quant — Goldman Sachs quant tools
  4. alphalens: https://github.com/quantopian/alphalens — Factor performance analysis

Alternative Data Sources (4)

  1. Bloomberg Second Measure: Credit card transaction data (with 2-week delay)
  2. Orbital Insight: Satellite imagery (with 12-hour delay)
  3. RavenPack: News sentiment (with 1-hour delay standard)
  4. Quandl (Nasdaq Data Link): Alternative data marketplace

Compliance (4)

  1. POJK 26/2023: Pedoman Perilaku Pelaku Usaha Jasa Keuangan
  2. UU PDP 27/2022: Pelindungan Data Pribadi
  3. UU ITE 19/2016 + UU 1/2024: Informasi dan Transaksi Elektronik
  4. GDPR (EU) 2016/679: General Data Protection Regulation
  5. ISO 27001: Information Security Management
  6. PCI-DSS v4.0: Payment Card Industry Data Security Standard

TL;DR FINAL — 7 Poin Penting

  1. Look-ahead bias = paling common cause of backtest-vs-live discrepancy, bahkan tanpa overfitting. 5 tempat utama: scaler (80% bug), rolling window, candle/OHLCV close, PCA/factor, target/label. Audit SEMUA sebelum fit model.

  2. Double descent BUKAN cuma masalah overparameterization — shrinkage of noise predictors/observations yang memicu test error spike di $d = n$. Sequence I (tambah predictors) butuh ridge positif; Sequence II (tambah noisy observations) bisa butuh ridge NEGATIF.

  3. Cap features at n/10 (atau n/5 max). Dekati interpolation point d = n → backtest Sharpe spike yang gak reproducible live.

  4. Pakai Purged K-Fold CV dengan embargo period (López de Prado). Standard k-fold CV = chronic look-ahead untuk time series. Embargo period 1-2% dari samples.

  5. Test negative ridge HANYA kalau: (a) udah audit look-ahead, (b) $n \gg n_0$ (banyak noise obs), (c) koefisien konsisten menyusut saat n naik. Jangan pakai kalau $n_0$ kecil, ada autocorrelation, predictors correlated.

  6. Use point-in-time data untuk semua: corporate actions, index constituents, fundamental data, alternative data. Universe construction = most overlooked source of look-ahead.

  7. Compliance: Quant shops di Indonesia harus follow POJK 26/2023 backtest disclosure, UU PDP 27/2022 data protection, UU ITE 19/2016 informasi elektronik. Prepare audit trail: data version + code version + model version.

Prinsip utama: Look-ahead bias is silent. Audit 5 sources, use point-in-time data, validate with purged CV, monitor live vs backtest drift. The paper's "double descent is driven by shrinkage" insight is the theoretical foundation — but practical look-ahead detection is what saves strategies from blowing up live.


Resources Pendukung — Tools, Paper, & Infra Buat Validate Look-Ahead Bias

Gue breakdown 5 tempat future-data-bisa-leak di artikel ini, dan tiap-tiap punya tool yang beda buat detect + prevent. Yang gue list di bawah bukan cuma "tool yang gue denger" — kebanyakan gue pake sendiri di backtest, validation pipeline, atau paper review project. Confidence level beda-beda, jadi gue kasih flag mana yang production-tested vs alpha-grade.

Opsi managed tambahan. Kalau konteks Buat compute & memory (kalo model lo deep learning) di artikel ini mau lo coba tanpa ribet kelola sendiri, ECS 9th-gen g9i Alibaba Cloud nyediain jalur yang bisa lo tes langsung — kuota awalnya cukup buat eksperimen.

Buat reproducible backtest environment

  • Alibaba Cloud Free Tier — disposable VM buat run backtest ratusan iterasi — Wajib punya kalo lo serius soal backtest. Alasannya: look-ahead bias itu sering ke-detect pas lo re-run backtest dengan konfigurasi beda dan liat result yang suspiciously bagus. Kalo lo cuma jalanin di laptop, susah isolate apakah "suspicious result" itu genuine alpha atau kebocoran data. VPS disposable di Alibaba Cloud Free Tier (1 vCPU, 1GB RAM) bikin lo bisa run backtest 100x tanpa cache, tanpa state leftover, tanpa OS-level file timestamp leakage. Credit free tier cukup untuk 1-2 minggu backtest rutin. Gue pake ini setiap validate strategi baru — minimum 50 run dengan random seed beda, kalo hasilnya konsisten baru percaya.

  • Alibaba Cloud Campaign Benefits — extended resource buat production backtest engine — Kalo lo udah punya strategi live dan mau setup production backtest engine (yang re-run weekly dengan data baru), signup lewat campaign ini dapet resource bundle yang lebih besar. Gue pake ini buat host backtest engine yang re-run setiap Jumat malam dengan data IDX30 terbaru — hasilnya compare dengan paper trading portfolio.

  • Backtrader (Python backtest framework) — Open source Python framework yang support event-driven backtest, multi-timeframe, dan punya built-in data feed validator. Yang gue suka: dia strict soal timestamp — kalo lo accidentally pakai data masa depan, Backtrader bakal error. Itu early-warning system yang bagus. Versi lama ada quirk dengan resample, jadi make sure lo pake v1.9.62.121 atau lebih baru.

  • Zipline (Python, by Quantopian team) — Lebih mature dari Backtrader, fokus ke US equities. Walau data IDX30 butuh ingest manual, framework-nya udah battle-tested. Sekarang maintenance mode (Quantopian tutup 2020), tapi masih reliable untuk production backtest. Cocok buat lo yang mau port strategi dari US market ke IDX. Kalo lo udah serius dan butuh dedicated compute (bukan disposable Free Tier), Alibaba Cloud ECS (extended compute) via campaign benefits bisa di-scale up/down sesuai backtest load — lebih hemat dari maintain on-prem server 24/7.

Buat walk-forward validation (the proper way)

  • scikit-learn TimeSeriesSplit — Built-in K-fold validator yang respect temporal order. WAJIB pake ini daripada regular KFold kalo data lo time series. Gap parameter (gap) bisa set "embargo period" — jumlah samples yang di-skip antara train dan test, buat prevent leakage dari serial correlation. Default 0, tapi gue set min 5 untuk daily data.

  • mlforecast (Nixtla) — Library spesifik time series forecasting yang udah handle proper time-based CV out of the box. Support lag features (yang notoriously prone to leakage kalo gak hati-hati). Dipake banyak research team. Free, open source.

  • Darts (time series library) — Time series library yang punya backtesting module built-in. Yang gue suka: visual diagnostics yang nge-highlight area mana di time series yang punya potential leakage (overlap antara train dan test di plot). Kalo lo tim research yang share compute pool, Alibaba Cloud Container Service (ACK) via campaign benefits support Docker-based deployment dengan auto-scaling — bisa run Darts + mlforecast di environment reproducible tanpa setup manual.

Buat data integrity & timestamp validation

  • Alibaba Cloud Function Compute — serverless data validator — Schedule validator yang jalan tiap hari, check data feed integrity. Misal: data saham hari ini harus available after market close 16:00 WIB, jangan ada data pre-market yang nyangkut. Function Compute itu event-driven, bayar per execution, free tier lumayan. Gue setup ini buat alert kalo data feed ada anomaly (gap, duplicate timestamp, atau — yang paling bahaya — timestamp yang lebih baru dari seharusnya).

  • Great Expectations — Data validation framework. Lo define expectation suite (kayak "column close must be >= column low"), terus Great Expectation run validation tiap data ingest. Open source, free. Penting buat prevent silent data corruption yang sering jadi sumber look-ahead bias. Misal: data vendor lo salah join dengan future date karena timezone bug — Great Expectation flag itu immediately.

  • Pandera (Python dataframe validator) — Lebih lightweight dari Great Expectations, pydantic-based. Cocok buat data scientist yang gak mau setup full Great Expectation project. Type checking + value range check + custom business logic. Kalo lo butuh orchestrate validation ini sebagai managed service (instead of self-host), Alibaba Cloud Data Quality service via campaign benefits bisa integrate sama Pandera + Great Expectation rule — jalan scheduled, kirim alert ke Slack/Feishu kalo ada schema drift.

Buat literature & paper references

  • "The 5 places future data leaks" framework — Bukan paper spesifik, tapi pattern yang gue compile dari multiple sumber. Dua paper foundational yang recommended:

    • Harvey, Liu & Zhu (2016) "...and the Cross-Section of Expected Returns" — Paper yang ngebongkar kalau kebanyakan "factor" yang ditemukan academic finance itu actually multiple-testing artifact (baca: bias). Recommended buat siapapun yang research quant factor.
    • Bailey & López de Prado (2014) "The Deflated Sharpe Ratio" — Adjust Sharpe ratio untuk multiple testing. Wajib baca sebelum claim "strategi gue Sharpe 2.0" karena bisa jadi itu Sharpe 1.2 dengan p-value yang gak significant.
  • arXiv q-fin.ST (Statistical Finance) — Pre-print server. Banyak paper tentang backtest overfitting, deflated Sharpe, dan walk-forward validation. Filter by submission date buat dapet yang terbaru.

  • QuantConnect research docs — Cloud-based backtest platform (punya free tier). Research section-nya ada notebook + paper yang ngedetail soal look-ahead bias prevention. Bagus buat belajar dari working example.

Buat compute & memory (kalo model lo deep learning)

  • Alibaba Cloud PAI (Platform for AI) — Kalo lo main dengan LSTM/Transformer untuk time series, PAI itu managed compute (GPU + storage) yang support distributed training. Free trial tersedia. Integration sama Alibaba storage dan database gampang. Gue pake ini buat experiment yang butuh > 16GB VRAM (laptop limit).

  • Alibaba Cloud RDS Postgres — time series storage — Kalo data lo udah > 10M rows dan pandas mulai struggle, push ke Postgres. TimescaleDB extension bikin Postgres jadi time series DB yang proper. Free tier 1 instance kecil. Penting: index di timestamp column WAJIB, dan query selalu WHERE timestamp < NOW() (atau fixed date untuk backtest), jangan pernah lupa WHERE clause — itu sumber leakage #1 di production.

  • Weights & Biases (experiment tracking) — Log setiap experiment dengan parameter, dataset hash, dan result. Free tier untuk personal use. Kalo lo re-run backtest 3 bulan dari sekarang, lo bisa trace balik ke dataset exact yang dipake — gak ada excuse "gue lupa data mana yang gue pake waktu itu". W&B itu standar de facto untuk research reproducibility.

Buat unit test & CI untuk data pipeline

  • pytest + freezegun — Unit test framework + library buat "freeze time" di test. Lo bisa write test kayak: "given current time = 2025-01-15, function X harus return data point with timestamp <= 2025-01-15". Freezegun mock datetime.now() sehingga test bisa deterministic. WAJIB buat siapapun yang punya data pipeline yang involve real-time.

  • GitHub Actions — Free CI/CD 2000 menit/bulan. Setup action yang run backtest + data validation tiap push. Kalo ada leakage di code baru, action fail sebelum merge. Lo bakal hemat waktu debug yang biasanya 2-3 jam per incident.

  • pre-commit hooks — Run quick check (timestamp validation, data schema check) sebelum commit. Bisa catch typo sederhana kayak df.shift(-1) yang nge-leak 1 row ke depan. Free, open source. Buat yang males nulis config + hook dari nol, Alibaba Cloud AI Scene Coding tools bisa generate pre-commit config + sample test dari natural language prompt — useful buat engineer yang lagi setup pipeline pertama kali atau mau standardisasi config across repos.

Indonesia-specific note

Buat data IDX30, IDX sendiri ada API publik (via Yahoo Finance, atau broker-broker kayak Stockbit yang punya partner API). Free tier biasanya cukup untuk daily data. Kalo butuh tick data atau intraday, berbayar (sekitar Rp 500rb-2jt/bulan per data source). Walau data berbayar, free tier tetap penting buat prototyping — validate look-ahead bias prevention dengan free data dulu, baru langganan berbayar kalo strategi udah mature.

Decision tree: kalo nemu "alpha suspiciously bagus", do this FIRST

  1. Verify timestamp — pastikan semua data point yang lo pake beneran dari masa lalu. df['timestamp'].max() harus < current time, dan time-aware computation (bukan just date).
  2. Run dengan random seed beda — kalo result beda signifikan (> 30% std), ada leakage atau overfitting. Gak peduli seed, hasil harus stabil.
  3. Walk-forward validate — train di tahun 2020-2022, test di 2023. Bukan split random, tapi temporal split. Kalo test result anjlok dari in-sample, ada leakage.
  4. Deflated Sharpe Ratio check — kalo lo nemu Sharpe > 2 dengan N=10 strategy trials, expected deflated Sharpe bisa < 0. Multiple testing kills "alpha" lebih sering dari yang orang kira.
  5. Test di data out-of-sample yang lo BELUM pake — itu definisi walk-forward yang proper. Kalo lo pake data out-of-sample untuk tune parameter, itu bukan out-of-sample lagi.

Lima step ini bukan checklist opsional — itu minimum bar untuk percaya sama backtest result. Kalo ada satu step yang lo skip, lo punya 30-50% chance strategi itu actually gak profitable di real market.

TL;DR — main takeaways

  • Always use TimeSeriesSplit, never regular KFold untuk time series data.
  • Track dataset hash + git SHA di setiap experiment (W&B / MLflow).
  • Backtest di disposable environment (VPS / Docker) — bukan di laptop dev yang punya state.
  • Test timestamp semantics — write unit test yang assert df['timestamp'].max() < now().
  • Cite paper kalo lo claim alpha — multiple testing correction itu real, deflated Sharpe ratio itu real, dan survivorship bias itu nyebelin banget.

Lima rules ini bakal save lo dari 6-12 bulan lost time ke strategi yang sebenernya gak profitable. Look-ahead bias itu silent killer — gak ada error message, gak ada exception, hasilnya cuma "anjir kok Sharpe ratio gue 5.0?". Dan 6 bulan kemudian setelah live trading, lo sadar Sharpe 5.0 itu karena lo pake data 2025 buat trade di 2024.


Topik Terkait

Artikel lain yang relevan dengan topik AI agent, workflow, dan teknis toolkuy:

💬 Komentar (0)

Belum ada komentar. Jadilah yang pertama! 💬

Komentar akan muncul setelah moderasi.