Skip to content

Signal 15 — Ornstein-Uhlenbeck Mean Reversion

Tại sao quan trọng

Trong khi z-score rolling cho ta "spread đang xa mean bao nhiêu σ", OU model cho thêm half-life — câu trả lời cho câu hỏi sống còn: spread sẽ mất bao lâu để về mean? Nếu half-life > horizon trade của bạn, signal vô nghĩa.

Thuộc tínhGiá trị
Phân loạiMean-Reversion / Stochastic Process
Khung thời gianDaily / 1H / 15min
Paper gốcUhlenbeck-Ornstein (1930); Avellaneda-Lee (2010)
Loại dữ liệuSpread series (pair, basis, etc.)
Hướng giao dịchMean-revert; long khi z<−2, short khi z>+2
CapacityTrung bình (phụ thuộc spread instrument)

Ý tưởng (Concept)

Stochastic process OU mô tả một biến động lực mean-reverting: $$ dX_t = \kappa(\theta - X_t), dt + \sigma, dW_t $$

trong đó:

  • θ: long-term mean (mức "công bằng").
  • κ: tốc độ mean reversion. Cao → revert nhanh.
  • σ: volatility.

Hệ quả quan trọng — half-life: $$ T_{1/2} = \frac{\ln 2}{\kappa} $$

Đây là expected time để gap về mean giảm một nửa. Nếu bạn entry tại |z| = 2σT_half = 5 days, expected exit ~5 ngày sau ở |z| ≈ 1σ.

Equilibrium distribution: X_∞ ~ N(θ, σ²/(2κ)). → standardized z-score: (X - θ) / sqrt(σ²/(2κ)).

Công thức (Formula)

Ước lượng OU bằng OLS trên discrete equation: $$ X_t - X_{t-1} = \alpha + \beta X_{t-1} + \varepsilon_t $$

trong đó:

  • β = -κ Δtκ = -β / Δt
  • α = κ θ Δtθ = α / κ Δt = -α / β
  • σ² = Var(ε) / Δt

Trading thresholds (Bertram 2010 — optimal entry/exit):

  • Entry: |z| > z_entry (thường 1.5-2.0).
  • Exit: |z| < z_exit (thường 0-0.5).
  • z = (X_t - θ) / sqrt(σ²/(2κ)).

Cách giao dịch (Entry/Exit Rules)

  • Pre-condition:
    • OU β phải < 0 và significant (t-stat < -2.5).
    • Half-life giữa 1-30 ngày (nếu daily). Quá nhanh = noise, quá chậm = trend không phải revert.
    • ADF test reject unit root (p < 0.05).
  • Entry LONG: z < -2.0 và half-life < horizon plan.
  • Entry SHORT: z > +2.0 và half-life < horizon plan.
  • Exit: |z| < 0.5 hoặc time = 2× T_half.
  • Stop loss: |z| > 3.5 (regime broken) hoặc holding > 3 × T_half.
  • Sizing: notional ∝ 1/σ_z để equalize risk across spreads.
  • Timeframe khuyến nghị: Daily cho equity spreads, 1H cho FX/crypto, 15min cho intraday VN30F basis.

Code Python (Python Implementation)

python
import numpy as np
import pandas as pd
from scipy import stats

def fit_ou(series: pd.Series, dt: float = 1.0) -> dict:
    """Estimate OU parameters via OLS on Δx = α + β x_{t-1} + ε."""
    x = series.dropna().values
    dx = np.diff(x)
    x_lag = x[:-1]

    slope, intercept, r_value, p_value, std_err = stats.linregress(x_lag, dx)
    beta = slope
    alpha = intercept
    if beta >= 0:
        return {'kappa': np.nan, 'theta': np.nan, 'sigma': np.nan,
                'half_life': np.inf, 'p_value': p_value,
                'mean_reverting': False}

    kappa = -beta / dt
    theta = -alpha / beta
    resid = dx - (alpha + beta * x_lag)
    sigma = resid.std() / np.sqrt(dt)
    half_life = np.log(2) / kappa

    return {
        'kappa': kappa, 'theta': theta, 'sigma': sigma,
        'half_life': half_life, 'p_value': p_value,
        'mean_reverting': p_value < 0.05 and half_life > 0,
    }

def ou_zscore(series: pd.Series, params: dict) -> pd.Series:
    """Z-score against OU equilibrium distribution."""
    eq_std = params['sigma'] / np.sqrt(2 * params['kappa'])
    return (series - params['theta']) / eq_std

def ou_signal(series: pd.Series, lookback: int = 252,
              entry: float = 2.0, exit_thresh: float = 0.5) -> pd.DataFrame:
    """Rolling OU fit + entry/exit signals."""
    out = []
    for t in range(lookback, len(series)):
        window = series.iloc[t - lookback:t]
        p = fit_ou(window)
        if not p['mean_reverting']:
            out.append((series.index[t], np.nan, 0))
            continue
        z = (series.iat[t] - p['theta']) / (p['sigma'] / np.sqrt(2 * p['kappa']))
        sig = -1 if z > entry else (1 if z < -entry else (0 if abs(z) < exit_thresh else np.nan))
        out.append((series.index[t], z, sig))
    df = pd.DataFrame(out, columns=['date', 'z', 'signal']).set_index('date')
    df['signal'] = df['signal'].ffill().fillna(0)
    return df

# Example: VN30F basis (futures - spot)
# basis = df['vn30f_close'] - df['vn30_spot']
# params = fit_ou(basis.iloc[-252:])
# print(f"Half-life: {params['half_life']:.1f} days, κ={params['kappa']:.3f}")
# signals = ou_signal(basis)

Ưu nhược điểm

Ưu điểm:

  • Cung cấp half-life — tham số quan trọng để chọn horizon (filter signals).
  • Bertram 2010 cho công thức optimal threshold maximize Sharpe.
  • Áp dụng đa năng: pairs spread, basis (futures-spot), yield curve, vol carry.
  • Kết hợp tốt với [[kalman-pairs-spread]] — dùng Kalman để tracking spread, OU để định cấp entry/exit.

Nhược điểm:

  • Giả định constant parameters trong lookback window — vi phạm khi regime change.
  • ADF test có low power với chuỗi ngắn (<252 obs).
  • Khi half-life > lookback, fit không ổn định.
  • Trên trending series (không cointegrated), OU fit cho giả mean-reversion → false signals. Phải pre-screen bằng [[hurst-exponent]] (H < 0.5).

Powered by dautu.tech